Changes under lib/ (source):
- iemre_fetch_worker: replace `if transient_failure?/1` boolean predicate
+ `if/else` with a classifier that pattern-matches the error reason
directly, returning `:transient | :permanent`, and dispatch via
`case`. Three-clause head is clearer than the boolean predicate.
- scores_file.extract_points and nexrad_client.extract_box: force `//1`
step on the row/col comprehensions so an image box whose centre
lands outside the raster collapses to an empty iteration instead
of firing Elixir 1.19's ambiguous-range warning. Adds a regression
test for the right-edge box.
Test coverage added:
- Feature wrappers in Backtest.Features (theta_e_jump, shear_at_top,
duct_thickness, best_duct_freq, duct_usable_for_band,
distance_to_front / parallel_to_front stubs, all_features/0).
- NotifyListener handle_info tolerance for malformed payloads +
unknown messages, plus the PubSub broadcast side effect.
- Viewshed.effective_reach_km across every verdict / diffraction
tier / ducting-score tier combination.
- SnmpClient parse_af60_output unit conversions + unknown-column
handling; parse_snmpget_output OID normalisation and junk-line
tolerance.
- HrrrNativeClient native_level_count, native_variables,
native_messages shape, duct_messages / duct_byte_ranges.
- Changeset coverage for GeomagneticObservation, SolarFluxObservation,
SolarXrayObservation, Ionosphere.Observation, HrrrClimatology, and
Metar5minObservation — lifted each from ~50% / 0% to 100%.
- Property tests: GefsClient.dewpoint_from_rh invariants (Td ≤ T,
monotonic in RH, finite across the operating envelope) + idempotent
nearest_run. NexradClient.pixel_to_dbz monotonicity + bounded
output; latlon_to_pixel round-trip for every grid cell.
Also cleared several unrelated compiler/linter warnings:
- Three private test helpers (create_contact, insert_contact,
current_hour) had `\\ %{}` / `\\ 0` defaults that no caller used.
- profiles_file_test bound `dir` in a pattern match without using it.
Suite: 2,359 tests + 146 properties pass (was 2,300 / 139); coverage
70.38% → 70.89%; credo strict clean.
162 lines
5.8 KiB
Elixir
162 lines
5.8 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 "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
|