prop/test/microwaveprop/terrain/viewshed_property_test.exs
Graham McIntire dc8353a9e9
test: coverage round 4 (84.39% → 84.44%) + 6 new property tests
30 unit tests + 6 property tests across two parallel agents.

- ContactLive.Show + HrrrNativeClient + NexradClient: sort_observations
  + sort_soundings actual ordering per field, closest_observations
  capped-at-5 proximity, nil-pos2 half_dist=0 path, HrrrNativeClient
  scrambled-level density + surface finiteness, NexradClient cache
  population + zero-byte + year-boundary URL rounding. Properties:
  sort_observations preservation, haversine symmetry, build_native
  surface_temp_k finiteness.

- PathLive + Viewshed + GefsFetchWorker + SnmpClient: GPS source
  URL preservation, QRZ 404 surfacing, propagation_updated same-midpoint
  no-op; Viewshed effective_reach_km BLOCKED boundaries + find_reach_km
  zero max_range; GefsFetchWorker 502/400/410 + wind_u/wind_v aliases
  + nil profile; SnmpClient fully-qualified OID + double-dot drop +
  empty poll + unknown radio type. Properties: destination_point
  round-trip < 0.5 km, valid_time = run_time + fh*3600 invariant,
  parse_snmpget_output totality.
2026-04-24 10:32:05 -05:00

164 lines
6.3 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 "destination_point/4 round-trip distance" do
property "the generated point is ~dist_km from the origin via the haversine" do
# Regardless of bearing, the great-circle distance between the
# origin and destination_point's result must match the requested
# distance. Allow a loose absolute tolerance for earth-radius
# rounding at longer distances (the module uses 6371 km exactly).
check all(
lat <- float(min: -60.0, max: 60.0),
lon <- float(min: -170.0, max: 170.0),
bearing <- float(min: 0.0, max: 359.999),
dist_km <- float(min: 1.0, max: 500.0)
) do
{lat2, lon2} = Viewshed.destination_point(lat, lon, bearing, dist_km)
measured = Microwaveprop.Geo.haversine_km(lat, lon, lat2, lon2)
assert_in_delta measured, dist_km, 0.5
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