prop/test/microwaveprop/terrain/terrain_analysis_test.exs
Graham McIntire 45e2e69361
Add SRTM terrain path profiles for QSOs
Fetch elevation data along the path between two stations via the
Open-Meteo Elevation API (with Open-Topo-Data fallback), compute
Fresnel zone clearance, earth bulge, and knife-edge diffraction loss,
and store the results per QSO.

- terrain_profiles table with migration and TerrainProfile schema
- ElevationClient with batched API calls and fallback
- TerrainAnalysis with Fresnel/diffraction physics (ITU-R P.526-15)
- TerrainProfileWorker on Oban :terrain queue
- QsoWeatherEnqueueWorker enqueues terrain jobs automatically
- QSO show page displays verdict badge and collapsible elevation table
- Reorder show page: terrain, soundings, solar, HRRR, surface obs
- Fix Dockerfile wgrib2 build (add cmake dependency)
2026-03-29 16:22:29 -05:00

175 lines
5.8 KiB
Elixir

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
end
describe "knife_edge_loss/1" do
test "returns 0 for v <= -0.7787 (clear path)" do
assert TerrainAnalysis.knife_edge_loss(-1.0) == 0
assert TerrainAnalysis.knife_edge_loss(-0.8) == 0
end
test "returns ~6 dB for v = 0 (grazing)" do
loss = TerrainAnalysis.knife_edge_loss(0.0)
assert_in_delta loss, 6.0, 0.5
end
test "loss increases with v > 0" do
loss_05 = TerrainAnalysis.knife_edge_loss(0.5)
loss_10 = TerrainAnalysis.knife_edge_loss(1.0)
loss_20 = TerrainAnalysis.knife_edge_loss(2.0)
assert loss_05 > 0
assert loss_10 > loss_05
assert loss_20 > loss_10
end
test "asymptotic formula for v > 2.4" do
loss = TerrainAnalysis.knife_edge_loss(3.0)
assert_in_delta loss, 22.5, 0.5
end
end
describe "analyse/5" do
test "returns CLEAR for flat terrain with antenna heights" do
# 10 km path, flat terrain at 0m, antennas at 30m each
# Earth bulge at midpoint ~ 1.5m, beam at 30m, plenty of clearance
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, 30.0, 30.0)
assert result.verdict == "CLEAR"
assert result.obstructed_count == 0
assert result.diffraction_db == 0
end
test "returns BLOCKED for high obstacle" do
# 50 km path, endpoints at 100m, 500m peak in middle
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.obstructed_count >= 1
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
# 20 km path, terrain at 0m, endpoints at 200m (hilltops)
# Earth bulge midpoint ~ 5.9m, beam at 200m, terrain + bulge = 5.9m
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
# Use antenna heights to represent elevated positions
result = TerrainAnalysis.analyse(profile, 20.0, 1.296, 200.0, 200.0)
assert result.verdict == "CLEAR"
end
test "returns fresnel verdict for moderate ridge" do
# 20 km path, endpoints at 200m elevation, small ridge at midpoint
# Beam at midpoint = 200m, earth bulge ~ 5.9m
# Ridge at 160m + bulge 5.9m = 165.9m effective -> clearance = 200 - 165.9 = 34.1m
# Fresnel r1 at midpoint 1296 MHz 20km = sqrt(0.2315*10000*10000/20000) ~ 34m
# f1_clear = 34.1 - 34 = 0.1m -> just barely clear
# Need ridge slightly higher: 165m + 5.9 = 170.9 -> clearance 29.1, f1_clear = -4.9 -> penetrated
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, 200.0, 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
# 10 km path, 30m hill, no antenna height -> blocked (beam at 0m, terrain+bulge > 0)
# With 50m antennas -> clear
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, 0.0, 0.0)
result_high = TerrainAnalysis.analyse(profile, 10.0, 1.296, 100.0, 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
end
end