prop/test/microwaveprop/terrain/viewshed_test.exs
Graham McIntire bbf51544e1
test: coverage round 2 (82.05% → 83.96%) + 23 new property tests
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.
2026-04-24 10:15:37 -05:00

221 lines
7.7 KiB
Elixir

defmodule Microwaveprop.Terrain.ViewshedTest do
use ExUnit.Case, async: true
alias Microwaveprop.Terrain.Viewshed
describe "destination_point/4" do
test "north bearing increases latitude, holds longitude" do
{lat, lon} = Viewshed.destination_point(32.0, -97.0, 0, 100.0)
assert_in_delta lat, 32.899, 0.01
assert_in_delta lon, -97.0, 0.01
end
test "east bearing increases longitude, holds latitude" do
{lat, lon} = Viewshed.destination_point(32.0, -97.0, 90, 100.0)
assert_in_delta lat, 32.0, 0.01
assert lon > -97.0
end
test "south bearing decreases latitude" do
{lat, lon} = Viewshed.destination_point(32.0, -97.0, 180, 50.0)
assert lat < 32.0
assert_in_delta lon, -97.0, 0.01
end
test "zero distance returns origin" do
{lat, lon} = Viewshed.destination_point(32.0, -97.0, 45, 0.0)
assert_in_delta lat, 32.0, 0.001
assert_in_delta lon, -97.0, 0.001
end
end
describe "find_reach_km/2" do
test "returns max range when no points are obstructed" do
points = [
%{obstructed: false, dist_km: 0.0},
%{obstructed: false, dist_km: 10.0},
%{obstructed: false, dist_km: 20.0},
%{obstructed: false, dist_km: 30.0},
%{obstructed: false, dist_km: 50.0}
]
assert Viewshed.find_reach_km(points, 50.0) == 50.0
end
test "returns distance of point before first obstruction" do
points = [
%{obstructed: false, dist_km: 0.0},
%{obstructed: false, dist_km: 10.0},
%{obstructed: false, dist_km: 20.0},
%{obstructed: true, dist_km: 30.0},
%{obstructed: false, dist_km: 40.0},
%{obstructed: false, dist_km: 50.0}
]
assert Viewshed.find_reach_km(points, 50.0) == 20.0
end
test "returns first interior point distance when obstruction is at second point" do
points = [
%{obstructed: false, dist_km: 0.0},
%{obstructed: true, dist_km: 5.0},
%{obstructed: false, dist_km: 10.0}
]
# First point is endpoint (excluded), second is first interior and is obstructed
# No clear interior point before it, return minimum
assert Viewshed.find_reach_km(points, 10.0) == 0.0
end
test "ignores endpoint obstruction flags" do
# Endpoints (first/last) are never counted as obstructed by TerrainAnalysis
# but just to be safe, find_reach_km skips them
points = [
%{obstructed: true, dist_km: 0.0},
%{obstructed: false, dist_km: 25.0},
%{obstructed: true, dist_km: 50.0}
]
assert Viewshed.find_reach_km(points, 50.0) == 50.0
end
end
describe "effective_reach_km/3" do
test "CLEAR path always gets the full range regardless of score" do
analysis = %{verdict: "CLEAR", diffraction_db: 0.0}
assert Viewshed.effective_reach_km(analysis, 50.0, 0) == 50.0
assert Viewshed.effective_reach_km(analysis, 50.0, 100) == 50.0
end
test "FRESNEL_MINOR scales range to 90%" do
analysis = %{verdict: "FRESNEL_MINOR", diffraction_db: 2.0}
assert Viewshed.effective_reach_km(analysis, 50.0, 50) == 45.0
end
test "FRESNEL_PARTIAL scales range to 70%" do
analysis = %{verdict: "FRESNEL_PARTIAL", diffraction_db: 4.0}
assert Viewshed.effective_reach_km(analysis, 50.0, 50) == 35.0
end
test "BLOCKED with mild diffraction (<=3 dB) keeps 80% via terrain factor" do
analysis = %{verdict: "BLOCKED", diffraction_db: 2.0}
# score=0 so ducting factor is 0.05; terrain factor 0.8 wins.
assert Viewshed.effective_reach_km(analysis, 50.0, 0) == 40.0
end
test "BLOCKED reduces monotonically as diffraction_db climbs" do
ranges =
Enum.map([2.0, 5.0, 10.0, 15.0, 30.0], fn db ->
analysis = %{verdict: "BLOCKED", diffraction_db: db}
Viewshed.effective_reach_km(analysis, 100.0, 0)
end)
assert ranges == Enum.sort(ranges, :desc)
assert List.first(ranges) > List.last(ranges)
end
test "BLOCKED with high ducting score overrides terrain factor" do
analysis = %{verdict: "BLOCKED", diffraction_db: 25.0}
# At 25 dB, terrain factor is 0.05. A score of 85 gives ducting
# factor 0.7 — that should be the dominant term.
assert Viewshed.effective_reach_km(analysis, 100.0, 85) == 70.0
end
test "BLOCKED ducting-score tiers: 80+ / 65+ / 50+ / 33+ / <33" do
analysis = %{verdict: "BLOCKED", diffraction_db: 40.0}
# terrain_reach_factor(40) = 0.05 in every row, so max() == ducting factor.
assert Viewshed.effective_reach_km(analysis, 100.0, 90) == 70.0
assert Viewshed.effective_reach_km(analysis, 100.0, 65) == 50.0
assert Viewshed.effective_reach_km(analysis, 100.0, 50) == 30.0
assert Viewshed.effective_reach_km(analysis, 100.0, 33) == 15.0
assert Viewshed.effective_reach_km(analysis, 100.0, 10) == 5.0
end
end
describe "find_reach_km/2 edge cases" do
test "empty interior (two-point profile) returns max range" do
# With only endpoints and no interior points, there's nothing to
# obstruct — reach is the full range.
points = [
%{obstructed: false, dist_km: 0.0},
%{obstructed: false, dist_km: 50.0}
]
assert Viewshed.find_reach_km(points, 50.0) == 50.0
end
test "three-point profile with obstructed middle returns 0.0" do
# One interior point, obstructed at idx 0 of interior → special case.
points = [
%{obstructed: false, dist_km: 0.0},
%{obstructed: true, dist_km: 25.0},
%{obstructed: false, dist_km: 50.0}
]
assert Viewshed.find_reach_km(points, 50.0) == 0.0
end
test "obstruction at the last interior index returns preceding distance" do
points = [
%{obstructed: false, dist_km: 0.0},
%{obstructed: false, dist_km: 10.0},
%{obstructed: false, dist_km: 20.0},
%{obstructed: false, dist_km: 30.0},
%{obstructed: true, dist_km: 40.0},
%{obstructed: false, dist_km: 50.0}
]
assert Viewshed.find_reach_km(points, 50.0) == 30.0
end
end
describe "effective_reach_km/3 terrain-factor tiers" do
test "BLOCKED at score=0 walks through every terrain-factor tier" do
# With score=0 the ducting factor is 0.05 so the terrain factor
# dominates in every band except the worst.
tiers = [
# (db, expected terrain factor)
{2.0, 0.8},
{5.0, 0.5},
{10.0, 0.3},
{18.0, 0.15},
{50.0, 0.05}
]
for {db, factor} <- tiers do
analysis = %{verdict: "BLOCKED", diffraction_db: db}
expected = 100.0 * factor
# For the worst tier ducting_factor and terrain_factor tie at 0.05.
assert_in_delta Viewshed.effective_reach_km(analysis, 100.0, 0), expected, 1.0e-9
end
end
end
describe "analyse_ray/5" do
test "returns full range for flat terrain with antenna heights" do
profile =
for i <- 0..10 do
f = i / 10
%{lat: 32.9 + f * 0.09, lon: -97.0, d: f, elev: 200.0, dist_km: f * 10.0}
end
result = Viewshed.analyse_ray(profile, 10.0, 10.0, 2.4, 2.4)
assert result.reach_km == 10.0
end
test "detects obstruction and returns reduced reach" do
# Use 10km total so earth bulge is negligible (~0.7m) and
# the 500m peak at index 5 is the only obstruction.
profile =
for i <- 0..10 do
f = i / 10
elev = if i == 5, do: 500.0, else: 200.0
%{lat: 32.9 + f * 0.009, lon: -97.0, d: f, elev: elev, dist_km: f * 10.0}
end
result = Viewshed.analyse_ray(profile, 10.0, 10.0, 2.4, 2.4)
assert result.reach_km < 10.0
assert result.reach_km > 0.0
end
end
end