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.
164 lines
6.3 KiB
Elixir
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
|