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)
175 lines
5.8 KiB
Elixir
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
|