prop/test/microwaveprop/terrain/terrain_analysis_property_test.exs
Graham McIntire 4c1013cdbd
test(property): add property tests for terrain + atmospheric math
Cover the pure-math invariants of the ITU-R P.526-16 diffraction
helpers, the haversine metric on Geo, and the Magnus / dry-adiabat
helpers in the skew-T renderer. 29 properties, one invariant each.
2026-04-21 13:56:39 -05:00

251 lines
9.1 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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