defmodule Microwaveprop.Terrain.TerrainAnalysisPropertyTest do @moduledoc """ StreamData property tests for ITU-R P.526-16 diffraction math. Each property encodes a single invariant of the pure-math core of `Microwaveprop.Terrain.TerrainAnalysis`. The scenarios are constructed so the expected bound is physically meaningful — e.g. knife-edge loss is never negative regardless of ν, diffraction loss vanishes on an unobstructed path, fresnel radius is symmetric in its distance arguments, and the P.526 piecewise threshold at ν = −0.78 is a true zero floor. """ use ExUnit.Case, async: true use ExUnitProperties alias Microwaveprop.Terrain.TerrainAnalysis describe "knife_edge_loss/1" do property "is non-negative for every real ν" do check all(nu <- float(min: -50.0, max: 50.0)) do assert TerrainAnalysis.knife_edge_loss(nu) >= 0 end end property "is exactly 0 for ν ≤ −0.78 (P.526 piecewise branch)" do check all(nu <- float(min: -50.0, max: -0.78)) do assert TerrainAnalysis.knife_edge_loss(nu) == 0 end end property "is monotonically non-decreasing in ν above the cutoff" do check all( a <- float(min: -0.7, max: 20.0), delta <- float(min: 0.0001, max: 5.0) ) do loss_a = TerrainAnalysis.knife_edge_loss(a) loss_b = TerrainAnalysis.knife_edge_loss(a + delta) assert loss_b >= loss_a end end end describe "diffraction_param/4" do property "sign of ν matches sign of h when distances and λ are positive" do check all( h <- float(min: -500.0, max: 500.0), d1 <- float(min: 1.0, max: 100_000.0), d2 <- float(min: 1.0, max: 100_000.0), lambda <- float(min: 0.001, max: 1.0) ) do nu = TerrainAnalysis.diffraction_param(h, d1, d2, lambda) cond do h > 0 -> assert nu > 0 h < 0 -> assert nu < 0 true -> assert nu == 0.0 end end end property "degenerate inputs (d ≤ 0 or λ ≤ 0) return 0.0" do check all( h <- float(min: -500.0, max: 500.0), bad <- float(min: -10.0, max: 0.0), good <- float(min: 1.0, max: 1_000.0), lambda <- float(min: 0.001, max: 1.0) ) do assert TerrainAnalysis.diffraction_param(h, bad, good, lambda) == 0.0 assert TerrainAnalysis.diffraction_param(h, good, bad, lambda) == 0.0 assert TerrainAnalysis.diffraction_param(h, good, good, bad) == 0.0 end end end describe "fresnel_radius/3" do property "is non-negative and symmetric in d1 and d2" do check all( d1 <- float(min: 1.0, max: 100_000.0), d2 <- float(min: 1.0, max: 100_000.0), lambda <- float(min: 0.001, max: 1.0) ) do r1 = TerrainAnalysis.fresnel_radius(d1, d2, lambda) r2 = TerrainAnalysis.fresnel_radius(d2, d1, lambda) assert r1 >= 0 assert_in_delta r1, r2, 1.0e-9 end end property "returns 0 when either distance is non-positive" do check all( bad <- float(min: -10.0, max: 0.0), good <- float(min: 1.0, max: 1_000.0), lambda <- float(min: 0.001, max: 1.0) ) do assert TerrainAnalysis.fresnel_radius(bad, good, lambda) == 0 assert TerrainAnalysis.fresnel_radius(good, bad, lambda) == 0 end end end describe "earth_bulge/3" do property "is non-negative for fractions in [0, 1] and positive k" do check all( frac <- float(min: 0.0, max: 1.0), dist_km <- float(min: 0.0, max: 500.0), k <- float(min: 0.5, max: 10.0) ) do assert TerrainAnalysis.earth_bulge(frac, dist_km, k) >= 0 end end property "is zero at both endpoints regardless of distance or k" do check all( dist_km <- float(min: 0.0, max: 500.0), k <- float(min: 0.5, max: 10.0) ) do assert TerrainAnalysis.earth_bulge(0.0, dist_km, k) == 0.0 assert TerrainAnalysis.earth_bulge(1.0, dist_km, k) == 0.0 end end property "is symmetric around the midpoint" do check all( frac <- float(min: 0.0, max: 0.5), dist_km <- float(min: 1.0, max: 500.0), k <- float(min: 0.5, max: 10.0) ) do left = TerrainAnalysis.earth_bulge(frac, dist_km, k) right = TerrainAnalysis.earth_bulge(1.0 - frac, dist_km, k) assert_in_delta left, right, 1.0e-9 end end end describe "k_factor/1" do property "returns a positive value for any dN/dh" do check all(dn_dh <- float(min: -500.0, max: 500.0)) do assert TerrainAnalysis.k_factor(dn_dh) > 0 end end property "sub-refractive gradients (dN/dh > 0) yield k < 1" do check all(dn_dh <- float(min: 1.0, max: 500.0)) do assert TerrainAnalysis.k_factor(dn_dh) < 1.0 end end property "super-refractive gradients (dN/dh < 0, above ducting cap) yield k > 1" do # Stay well above the -157 ducting threshold so the 100.0 cap doesn't kick in. check all(dn_dh <- float(min: -100.0, max: -1.0)) do assert TerrainAnalysis.k_factor(dn_dh) > 1.0 end end end describe "analyse/4 invariants" do # Flat sea-level terrain with both antennas raised high enough to # clear the midpoint earth bulge *and* the first Fresnel zone: the # beam must be unobstructed and produce zero diffraction loss. # Antenna floor is chosen from the worst-case (max dist, max freq) # so every generated case is guaranteed physically clear. property "clear LoS (flat terrain, high antennas) produces 0 diffraction loss" do max_dist_km = 50.0 min_freq_ghz = 5.0 # Midpoint earth bulge + first-Fresnel clearance with margin. # bulge(0.5, 50km) ≈ 36.8m; r1(25km, 25km, 0.06m) ≈ 27.4m → ~64m. ant_floor_m = 120.0 check all( n_segs <- integer(4..20), dist_km <- float(min: 5.0, max: max_dist_km), freq_ghz <- float(min: min_freq_ghz, max: 50.0), extra_h <- float(min: 0.0, max: 400.0) ) do profile = flat_profile(n_segs, dist_km) ant_h = ant_floor_m + extra_h result = TerrainAnalysis.analyse(profile, dist_km, freq_ghz, ant_ht_a: ant_h, ant_ht_b: ant_h) assert result.diffraction_db == 0 assert result.obstructed_count == 0 assert result.verdict in ["CLEAR", "FRESNEL_MINOR"] end end property "diffraction loss is always non-negative" do check all( n_segs <- integer(4..12), dist_km <- float(min: 5.0, max: 100.0), freq_ghz <- float(min: 1.0, max: 50.0), peak_elev <- float(min: 0.0, max: 2_000.0) ) do profile = ridge_profile(n_segs, dist_km, peak_elev) result = TerrainAnalysis.analyse(profile, dist_km, freq_ghz) assert result.diffraction_db >= 0 end end # Deygout degenerate case: when only one interior point rises above # the direct ray, the three-edge method collapses to a single # knife-edge and the total loss must equal the principal edge's loss. property "single-obstacle path: total loss equals the principal knife-edge loss" do check all( dist_km <- float(min: 20.0, max: 80.0), freq_ghz <- float(min: 2.0, max: 24.0), peak_elev <- float(min: 200.0, max: 1_500.0) ) do # 3-segment profile: endpoints at 0, single interior peak. profile = [ %{lat: 32.9, lon: -97.0, d: 0.0, elev: 0.0, dist_km: 0.0}, %{lat: 33.0, lon: -97.0, d: 0.5, elev: peak_elev, dist_km: dist_km * 0.5}, %{lat: 33.1, lon: -97.0, d: 1.0, elev: 0.0, dist_km: dist_km} ] result = TerrainAnalysis.analyse(profile, dist_km, freq_ghz) # Hand-compute the knife-edge loss for the single interior point. lambda_m = 0.3 / freq_ghz d1_m = dist_km * 0.5 * 1000 d2_m = dist_km * 0.5 * 1000 # h = peak above direct ray = peak_elev + earth_bulge - 0 (endpoints) bulge = TerrainAnalysis.earth_bulge(0.5, dist_km) h = peak_elev + bulge nu = TerrainAnalysis.diffraction_param(h, d1_m, d2_m, lambda_m) expected = TerrainAnalysis.knife_edge_loss(nu) assert_in_delta result.diffraction_db, expected, 0.001 end end end # ── helpers ──────────────────────────────────────────────────── defp flat_profile(n_segs, dist_km) do 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 end defp ridge_profile(n_segs, dist_km, peak_elev) do mid = div(n_segs, 2) for i <- 0..n_segs do f = i / n_segs elev = if i == mid, do: peak_elev, else: 0.0 %{lat: 32.9 + f * 0.1, lon: -97.0, d: f, elev: elev, dist_km: f * dist_km} end end end