77 unit tests + 23 property tests across four parallel agents. - ContactLive.Show (72.01% → ~75%): every factor-note branch (humidity/time/td/refractivity/pressure/season/pwat), all propagation_mechanism classifications, apply_admin_edit/owner_edit/ submit_user_edit error paths, internal_network? IP shapes, expanded sounding skew-T, composite-score [0,100] property. - PathLive + BeaconLive.Show + Weather: coordinate-pair resolver, invalid grid, empty-DB edge cases for iemre_for_*, narr_for_*, nearest_native_duct_*, find_nearest_rtma, recent_surface_obs; properties for round_to_hrrr_grid/round_to_iemre_grid/band round-trip/beacon-id URL round-trip. - Viewshed + Duct + SoundingParams: find_reach_km edge cases, destination_point, effective_reach_km fractions, detect_ducts trailing-duct + guard branches; 13 properties including flat- terrain-fully-visible, thicker-duct-lower-freq, M-profile monotonicity, feature-vector deterministic length. - Workers + Mix tasks: GefsFetchWorker 500/429/403 + malformed ISO8601, HrrrNativeGridWorker snap-to-3-decimals + empty DB, IonosphereFetchWorker 404 + non-tabular body, RadarBackfill --dry-run + --year + --limit, NexradBackfill --limit 0, Backtest --feature + --all; year-filter property covering 2015..2026. 170 → 205 properties, 2666 → 2743 tests, 0 failures.
144 lines
5.4 KiB
Elixir
144 lines
5.4 KiB
Elixir
defmodule Microwaveprop.Terrain.ViewshedPropertyTest do
|
|
@moduledoc """
|
|
StreamData property tests for the pure-math portions of
|
|
`Microwaveprop.Terrain.Viewshed`.
|
|
|
|
Each property exercises one physical invariant of the viewshed's
|
|
math helpers without touching SRTM tiles or `Task.async_stream` — the
|
|
scenarios are constructed so the expected bound is physically
|
|
meaningful (reach never exceeds max range, flat terrain is always
|
|
visible, destination_point is a no-op for zero distance, and the
|
|
BLOCKED ducting-vs-terrain `max/2` is monotonic in score).
|
|
"""
|
|
|
|
use ExUnit.Case, async: true
|
|
use ExUnitProperties
|
|
|
|
alias Microwaveprop.Terrain.Viewshed
|
|
|
|
describe "find_reach_km/2" do
|
|
property "never returns a value greater than max_range_km" do
|
|
check all(
|
|
n <- integer(2..20),
|
|
max_range <- float(min: 1.0, max: 500.0),
|
|
obstruction_idx <- integer(0..25)
|
|
) do
|
|
points =
|
|
for i <- 0..n do
|
|
%{obstructed: i == obstruction_idx and i > 0 and i < n, dist_km: i / n * max_range}
|
|
end
|
|
|
|
reach = Viewshed.find_reach_km(points, max_range)
|
|
assert reach <= max_range
|
|
assert reach >= 0.0
|
|
end
|
|
end
|
|
|
|
property "all-clear profiles always return max_range_km" do
|
|
check all(
|
|
n <- integer(2..30),
|
|
max_range <- float(min: 0.1, max: 1_000.0)
|
|
) do
|
|
points =
|
|
for i <- 0..n do
|
|
%{obstructed: false, dist_km: i / n * max_range}
|
|
end
|
|
|
|
assert Viewshed.find_reach_km(points, max_range) == max_range
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "destination_point/4" do
|
|
property "zero distance always returns (approximately) the origin" do
|
|
check all(
|
|
lat <- float(min: -80.0, max: 80.0),
|
|
lon <- float(min: -180.0, max: 180.0),
|
|
bearing <- float(min: 0.0, max: 359.9)
|
|
) do
|
|
{lat2, lon2} = Viewshed.destination_point(lat, lon, bearing, 0.0)
|
|
assert_in_delta lat2, lat, 1.0e-9
|
|
assert_in_delta lon2, lon, 1.0e-9
|
|
end
|
|
end
|
|
|
|
property "north/south bearings preserve longitude and move latitude in the right sign" do
|
|
# `destination_point/4` is great-circle: due-east travel at non-zero
|
|
# latitude slightly bends a tiny bit toward the pole/equator, so
|
|
# we only assert the exact-meridian preservation for N/S bearings,
|
|
# which is a pure math identity regardless of latitude.
|
|
check all(
|
|
lat <- float(min: -60.0, max: 60.0),
|
|
lon <- float(min: -170.0, max: 170.0),
|
|
dist_km <- float(min: 1.0, max: 500.0)
|
|
) do
|
|
{lat_n, lon_n} = Viewshed.destination_point(lat, lon, 0.0, dist_km)
|
|
{lat_s, lon_s} = Viewshed.destination_point(lat, lon, 180.0, dist_km)
|
|
|
|
assert_in_delta lon_n, lon, 1.0e-6
|
|
assert_in_delta lon_s, lon, 1.0e-6
|
|
# Far enough from the poles (|lat| ≤ 60) no wraparound occurs at 500 km.
|
|
assert lat_n > lat
|
|
assert lat_s < lat
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "effective_reach_km/3" do
|
|
property "BLOCKED verdicts: reach is non-decreasing as score rises" do
|
|
# terrain_reach_factor is constant in score, so max(terrain, ducting)
|
|
# can only rise as ducting_reach_factor rises. ducting_reach_factor
|
|
# is a non-decreasing step function of score.
|
|
check all(
|
|
dif_db <- float(min: 0.0, max: 60.0),
|
|
max_range <- float(min: 1.0, max: 500.0),
|
|
score_a <- integer(0..100),
|
|
bump <- integer(0..50)
|
|
) do
|
|
score_b = min(100, score_a + bump)
|
|
analysis = %{verdict: "BLOCKED", diffraction_db: dif_db}
|
|
r_a = Viewshed.effective_reach_km(analysis, max_range, score_a)
|
|
r_b = Viewshed.effective_reach_km(analysis, max_range, score_b)
|
|
assert r_b >= r_a
|
|
end
|
|
end
|
|
|
|
property "non-BLOCKED verdicts return deterministic fractions of max_range" do
|
|
check all(max_range <- float(min: 0.0, max: 1_000.0), score <- integer(0..100)) do
|
|
clear = %{verdict: "CLEAR", diffraction_db: 0.0}
|
|
minor = %{verdict: "FRESNEL_MINOR", diffraction_db: 1.0}
|
|
partial = %{verdict: "FRESNEL_PARTIAL", diffraction_db: 4.0}
|
|
|
|
assert Viewshed.effective_reach_km(clear, max_range, score) == max_range
|
|
assert_in_delta Viewshed.effective_reach_km(minor, max_range, score), max_range * 0.9, 1.0e-9
|
|
assert_in_delta Viewshed.effective_reach_km(partial, max_range, score), max_range * 0.7, 1.0e-9
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "analyse_ray/5 over generated flat profiles" do
|
|
property "flat terrain with high antennas always reaches the full distance" do
|
|
# Antenna floor chosen to beat the worst-case (max dist, max freq)
|
|
# earth-bulge + first-Fresnel clearance with margin, matching the
|
|
# pattern used in TerrainAnalysis property tests.
|
|
ant_h = 150.0
|
|
|
|
check all(
|
|
n_segs <- integer(4..16),
|
|
dist_km <- float(min: 2.0, max: 40.0),
|
|
freq_ghz <- float(min: 5.0, max: 50.0)
|
|
) do
|
|
profile =
|
|
for i <- 0..n_segs do
|
|
f = i / n_segs
|
|
%{lat: 32.9 + f * 0.1, lon: -97.0, d: f, elev: 0.0, dist_km: f * dist_km}
|
|
end
|
|
|
|
result = Viewshed.analyse_ray(profile, dist_km, freq_ghz, ant_h, ant_h)
|
|
|
|
assert result.reach_km == dist_km
|
|
assert result.verdict in ["CLEAR", "FRESNEL_MINOR"]
|
|
end
|
|
end
|
|
end
|
|
end
|