defmodule Microwaveprop.Terrain.TerrainAnalysisTest do use ExUnit.Case, async: true alias Microwaveprop.Terrain.TerrainAnalysis describe "fresnel_radius/3" do test "returns 0 when d1 or d2 is 0" do assert TerrainAnalysis.fresnel_radius(0, 1000, 0.023) == 0 assert TerrainAnalysis.fresnel_radius(1000, 0, 0.023) == 0 end test "computes correct Fresnel radius for known values" do # 1296 MHz -> lambda = 0.2315 m lambda = 0.3 / 1.296 d1 = 50_000.0 d2 = 50_000.0 r = TerrainAnalysis.fresnel_radius(d1, d2, lambda) assert_in_delta r, 76.1, 1.0 end end describe "earth_bulge/3" do test "returns 0 at endpoints" do assert TerrainAnalysis.earth_bulge(0.0, 100.0) == 0.0 assert TerrainAnalysis.earth_bulge(1.0, 100.0) == 0.0 end test "maximum bulge at midpoint of 100 km path" do bulge = TerrainAnalysis.earth_bulge(0.5, 100.0) assert_in_delta bulge, 147.0, 2.0 end test "bulge increases with path length" do bulge_50 = TerrainAnalysis.earth_bulge(0.5, 50.0) bulge_100 = TerrainAnalysis.earth_bulge(0.5, 100.0) assert bulge_100 > bulge_50 end test "bulge decreases with larger k-factor (super-refraction)" do bulge_std = TerrainAnalysis.earth_bulge(0.5, 100.0, 4 / 3) bulge_super = TerrainAnalysis.earth_bulge(0.5, 100.0, 2.0) assert bulge_super < bulge_std end end # P.526-16 Eq. 31: J(ν) = 6.9 + 20*log10(sqrt((ν-0.1)² + 1) + ν - 0.1) describe "knife_edge_loss/1 (P.526-16 Eq. 31)" do test "returns 0 for ν ≤ -0.78 (well clear)" do assert TerrainAnalysis.knife_edge_loss(-1.0) == 0 assert TerrainAnalysis.knife_edge_loss(-0.8) == 0 end test "returns ~6 dB at grazing (ν = 0)" do loss = TerrainAnalysis.knife_edge_loss(0.0) assert_in_delta loss, 6.0, 0.1 end test "returns ~13.9 dB at ν = 1" do loss = TerrainAnalysis.knife_edge_loss(1.0) assert_in_delta loss, 13.9, 0.2 end test "returns ~19.0 dB at ν = 2" do loss = TerrainAnalysis.knife_edge_loss(2.0) assert_in_delta loss, 19.0, 0.2 end test "returns ~22.5 dB at ν = 3" do loss = TerrainAnalysis.knife_edge_loss(3.0) assert_in_delta loss, 22.5, 0.3 end test "monotonically increases with ν" do values = [-0.5, 0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 5.0] losses = Enum.map(values, &TerrainAnalysis.knife_edge_loss/1) losses |> Enum.chunk_every(2, 1, :discard) |> Enum.each(fn [a, b] -> assert b >= a end) end end describe "diffraction_param/4" do test "returns positive ν for obstacle above beam (blocked)" do # h = 50m above beam, d1 = d2 = 25km, 10 GHz (λ = 0.03m) nu = TerrainAnalysis.diffraction_param(50.0, 25_000.0, 25_000.0, 0.03) assert nu > 0 end test "returns negative ν for obstacle below beam (clear)" do nu = TerrainAnalysis.diffraction_param(-50.0, 25_000.0, 25_000.0, 0.03) assert nu < 0 end test "returns 0 at grazing" do assert TerrainAnalysis.diffraction_param(0.0, 25_000.0, 25_000.0, 0.03) == 0.0 end test "ν increases with frequency (shorter wavelength = sharper shadow)" do nu_10ghz = TerrainAnalysis.diffraction_param(50.0, 25_000.0, 25_000.0, 0.03) nu_24ghz = TerrainAnalysis.diffraction_param(50.0, 25_000.0, 25_000.0, 0.0125) assert nu_24ghz > nu_10ghz end end describe "k_factor/1" do test "returns 4/3 for standard atmosphere (-39 N/km)" do k = TerrainAnalysis.k_factor(-39.0) assert_in_delta k, 4 / 3, 0.01 end test "returns ~1.0 for no refraction (0 N/km)" do k = TerrainAnalysis.k_factor(0.0) assert_in_delta k, 1.0, 0.01 end test "returns large value approaching ducting (-157 N/km)" do k = TerrainAnalysis.k_factor(-150.0) assert k > 5 end test "returns 4/3 for nil input" do assert_in_delta TerrainAnalysis.k_factor(nil), 4 / 3, 0.001 end end describe "analyse/4" do test "returns CLEAR 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: 0.0, dist_km: f * 10.0} end result = TerrainAnalysis.analyse(profile, 10.0, 1.296, ant_ht_a: 30.0, ant_ht_b: 30.0) assert result.verdict == "CLEAR" assert result.obstructed_count == 0 assert result.diffraction_db == 0 end test "returns BLOCKED for high obstacle" do profile = for i <- 0..10 do f = i / 10 elev = if i == 5, do: 500.0, else: 100.0 %{lat: 32.9 + f, lon: -97.0, d: f, elev: elev, dist_km: f * 50.0} end result = TerrainAnalysis.analyse(profile, 50.0, 1.296) assert result.verdict == "BLOCKED" assert result.diffraction_db > 0 end test "max_elevation_m reflects the terrain peak" do profile = for i <- 0..4 do f = i / 4 elev = if i == 2, do: 500.0, else: 100.0 %{lat: 32.9 + f, lon: -97.0, d: f, elev: elev, dist_km: f * 50.0} end result = TerrainAnalysis.analyse(profile, 50.0, 1.296) assert_in_delta result.max_elevation_m, 500.0, 0.1 end test "elevated endpoints clear over flat terrain" do profile = for i <- 0..10 do f = i / 10 %{lat: 32.9 + f * 0.18, lon: -97.0, d: f, elev: 0.0, dist_km: f * 20.0} end result = TerrainAnalysis.analyse(profile, 20.0, 1.296, ant_ht_a: 200.0, ant_ht_b: 200.0) assert result.verdict == "CLEAR" end test "returns FRESNEL verdict for moderate ridge" do profile = for i <- 0..10 do f = i / 10 elev = if i == 5, do: 165.0, else: 0.0 %{lat: 32.9 + f * 0.18, lon: -97.0, d: f, elev: elev, dist_km: f * 20.0} end result = TerrainAnalysis.analyse(profile, 20.0, 1.296, ant_ht_a: 200.0, ant_ht_b: 200.0) assert result.verdict in ["FRESNEL_MINOR", "FRESNEL_PARTIAL"] assert result.fresnel_hit_count >= 1 end test "antenna heights raise beam above terrain" do profile = for i <- 0..10 do f = i / 10 elev = if i == 5, do: 30.0, else: 0.0 %{lat: 32.9 + f * 0.09, lon: -97.0, d: f, elev: elev, dist_km: f * 10.0} end result_low = TerrainAnalysis.analyse(profile, 10.0, 1.296) result_high = TerrainAnalysis.analyse(profile, 10.0, 1.296, ant_ht_a: 100.0, ant_ht_b: 100.0) assert result_low.verdict == "BLOCKED" assert result_high.verdict == "CLEAR" end test "min_clearance_m can be negative for obstructed paths" do profile = for i <- 0..4 do f = i / 4 elev = if i == 2, do: 500.0, else: 100.0 %{lat: 32.9 + f, lon: -97.0, d: f, elev: elev, dist_km: f * 50.0} end result = TerrainAnalysis.analyse(profile, 50.0, 1.296) assert result.min_clearance_m < 0 end test "accepts k_factor option for atmospheric correction" do profile = for i <- 0..10 do f = i / 10 elev = if i == 5, do: 100.0, else: 0.0 %{lat: 32.9 + f * 0.09, lon: -97.0, d: f, elev: elev, dist_km: f * 50.0} end # Super-refraction (k=2) reduces earth bulge → less total obstruction → less loss result_std = TerrainAnalysis.analyse(profile, 50.0, 10.0, k_factor: 4 / 3) result_super = TerrainAnalysis.analyse(profile, 50.0, 10.0, k_factor: 2.0) assert result_super.diffraction_db <= result_std.diffraction_db end test "Deygout: two obstacles produce more loss than single worst" do # Two 300m peaks at 1/3 and 2/3 of a 60km path, endpoints at 0m profile = for i <- 0..6 do f = i / 6 elev = case i do 2 -> 300.0 4 -> 300.0 _ -> 0.0 end %{lat: 32.9 + f, lon: -97.0, d: f, elev: elev, dist_km: f * 60.0} end result = TerrainAnalysis.analyse(profile, 60.0, 10.0) # Single 300m peak at midpoint of 60km path for comparison single_profile = for i <- 0..6 do f = i / 6 elev = if i == 3, do: 300.0, else: 0.0 %{lat: 32.9 + f, lon: -97.0, d: f, elev: elev, dist_km: f * 60.0} end single_result = TerrainAnalysis.analyse(single_profile, 60.0, 10.0) # Two obstacles should produce MORE diffraction loss than a single one assert result.diffraction_db > single_result.diffraction_db end test "higher frequency produces more diffraction loss for same obstacle" do profile = for i <- 0..10 do f = i / 10 elev = if i == 5, do: 200.0, else: 0.0 %{lat: 32.9 + f, lon: -97.0, d: f, elev: elev, dist_km: f * 30.0} end result_10ghz = TerrainAnalysis.analyse(profile, 30.0, 10.0) result_24ghz = TerrainAnalysis.analyse(profile, 30.0, 24.0) assert result_24ghz.diffraction_db > result_10ghz.diffraction_db end end end