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.
251 lines
9.1 KiB
Elixir
251 lines
9.1 KiB
Elixir
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
|