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 "effective_reach_km/3 boundary cases" do test "BLOCKED with score=0 and diffraction_db=0 uses the mild-terrain 0.8 tier" do # db=0 falls into the `db <= 3` terrain band (0.8) and score=0 gives # a 0.05 ducting floor — terrain wins cleanly. analysis = %{verdict: "BLOCKED", diffraction_db: 0.0} assert Viewshed.effective_reach_km(analysis, 50.0, 0) == 40.0 end test "BLOCKED at exactly the terrain-tier boundary (3 dB) still wins the 0.8 factor" do analysis = %{verdict: "BLOCKED", diffraction_db: 3.0} assert Viewshed.effective_reach_km(analysis, 100.0, 0) == 80.0 end test "CLEAR verdict ignores max_range_km of zero (returns 0)" do analysis = %{verdict: "CLEAR", diffraction_db: 0.0} assert Viewshed.effective_reach_km(analysis, 0.0, 100) == 0.0 end end describe "find_reach_km/2 boundary cases" do test "max_range_km of zero is returned verbatim when no obstructions exist" do points = [ %{obstructed: false, dist_km: 0.0}, %{obstructed: false, dist_km: 0.0}, %{obstructed: false, dist_km: 0.0} ] assert Viewshed.find_reach_km(points, 0.0) == 0.0 end test "single-obstruction-in-middle returns the preceding dist_km" do points = [ %{obstructed: false, dist_km: 0.0}, %{obstructed: false, dist_km: 5.0}, %{obstructed: false, dist_km: 10.0}, %{obstructed: true, dist_km: 15.0}, %{obstructed: false, dist_km: 20.0}, %{obstructed: false, dist_km: 25.0} ] assert Viewshed.find_reach_km(points, 25.0) == 10.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