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)
146 lines
3.9 KiB
Elixir
146 lines
3.9 KiB
Elixir
defmodule Microwaveprop.Terrain.TerrainAnalysis do
|
|
@moduledoc false
|
|
|
|
@earth_radius_m 6_371_000.0
|
|
|
|
@doc """
|
|
Analyse an elevation profile for LOS clearance, Fresnel zone penetration,
|
|
and knife-edge diffraction loss.
|
|
|
|
K=4/3 standard atmosphere. Antenna heights default to 0m (conservative).
|
|
"""
|
|
def analyse(profile, dist_km, freq_ghz, ant_ht_a_m \\ 0.0, ant_ht_b_m \\ 0.0) do
|
|
lambda_m = 0.3 / freq_ghz
|
|
n = length(profile) - 1
|
|
|
|
first = hd(profile)
|
|
last = List.last(profile)
|
|
elev1 = first.elev + ant_ht_a_m
|
|
elev2 = last.elev + ant_ht_b_m
|
|
|
|
points =
|
|
profile
|
|
|> Enum.with_index()
|
|
|> Enum.map(fn {p, i} ->
|
|
frac = i / n
|
|
d1_m = frac * dist_km * 1000
|
|
d2_m = (1 - frac) * dist_km * 1000
|
|
|
|
beam = beam_height(frac, elev1, elev2)
|
|
bulge = earth_bulge(frac, dist_km)
|
|
eff_terrain = p.elev + bulge
|
|
r1 = fresnel_radius(d1_m, d2_m, lambda_m)
|
|
clearance = beam - eff_terrain
|
|
f1_clear = if r1 > 0, do: clearance - r1, else: clearance
|
|
|
|
is_endpoint = i == 0 or i == n
|
|
|
|
%{
|
|
lat: p.lat,
|
|
lon: p.lon,
|
|
d: p.d,
|
|
elev: p.elev,
|
|
dist_km: p.dist_km,
|
|
beam: beam,
|
|
eff_terrain: eff_terrain,
|
|
bulge: bulge,
|
|
r1: r1,
|
|
clearance: clearance,
|
|
f1_clear: f1_clear,
|
|
obstructed: not is_endpoint and clearance < 0,
|
|
fresnel_penetrated: not is_endpoint and r1 > 0 and f1_clear < 0 and clearance >= 0
|
|
}
|
|
end)
|
|
|
|
interior = Enum.slice(points, 1..-2//1)
|
|
obstructed = Enum.filter(interior, & &1.obstructed)
|
|
fresnel_hit = Enum.filter(interior, & &1.fresnel_penetrated)
|
|
|
|
{diffraction_db, verdict} = compute_verdict(obstructed, fresnel_hit)
|
|
|
|
elev_vals = Enum.map(profile, & &1.elev)
|
|
clearance_vals = Enum.map(interior, & &1.clearance)
|
|
max_elevation_m = Enum.max(elev_vals, fn -> 0.0 end)
|
|
min_clearance_m = if clearance_vals == [], do: 999.0, else: Enum.min(clearance_vals)
|
|
|
|
%{
|
|
points: points,
|
|
diffraction_db: diffraction_db,
|
|
verdict: verdict,
|
|
max_elevation_m: max_elevation_m,
|
|
min_clearance_m: min_clearance_m,
|
|
obstructed_count: length(obstructed),
|
|
fresnel_hit_count: length(fresnel_hit)
|
|
}
|
|
end
|
|
|
|
def fresnel_radius(d1_m, d2_m, lambda_m) do
|
|
if d1_m <= 0 or d2_m <= 0 do
|
|
0
|
|
else
|
|
:math.sqrt(lambda_m * d1_m * d2_m / (d1_m + d2_m))
|
|
end
|
|
end
|
|
|
|
def earth_bulge(frac, dist_km, k \\ 4 / 3) do
|
|
d1 = frac * dist_km * 1000
|
|
d2 = (1 - frac) * dist_km * 1000
|
|
d1 * d2 / (2 * k * @earth_radius_m)
|
|
end
|
|
|
|
def knife_edge_loss(v) when v <= -0.7787, do: 0
|
|
|
|
def knife_edge_loss(v) when v <= 0 do
|
|
max(0, -20 * :math.log10(0.5 - 0.62 * v))
|
|
end
|
|
|
|
def knife_edge_loss(v) when v <= 1 do
|
|
max(0, -20 * :math.log10(0.5 * :math.exp(-0.95 * v)))
|
|
end
|
|
|
|
def knife_edge_loss(v) when v <= 2.4 do
|
|
inner = max(0, 0.1184 - (0.38 - 0.1 * v) ** 2)
|
|
max(0, -20 * :math.log10(0.4 - :math.sqrt(inner)))
|
|
end
|
|
|
|
def knife_edge_loss(v) do
|
|
# Asymptotic: loss = 20·log10(v) + 13 dB
|
|
max(0, 20 * :math.log10(v) + 13.0)
|
|
end
|
|
|
|
defp beam_height(frac, elev1, elev2) do
|
|
elev1 + frac * (elev2 - elev1)
|
|
end
|
|
|
|
defp compute_verdict(obstructed, _fresnel_hit) when obstructed != [] do
|
|
worst = Enum.min_by(obstructed, & &1.clearance)
|
|
|
|
v_blocked =
|
|
if worst.r1 > 0 do
|
|
abs(worst.clearance) / worst.r1 + 0.5
|
|
else
|
|
2.0
|
|
end
|
|
|
|
{max(0, knife_edge_loss(v_blocked)), "BLOCKED"}
|
|
end
|
|
|
|
defp compute_verdict(_obstructed, fresnel_hit) when fresnel_hit != [] do
|
|
worst = Enum.min_by(fresnel_hit, & &1.f1_clear)
|
|
|
|
v_fresnel =
|
|
if worst.r1 > 0 do
|
|
-worst.f1_clear / worst.r1
|
|
else
|
|
0
|
|
end
|
|
|
|
db = max(0, knife_edge_loss(v_fresnel))
|
|
verdict = if db > 3, do: "FRESNEL_PARTIAL", else: "FRESNEL_MINOR"
|
|
{db, verdict}
|
|
end
|
|
|
|
defp compute_verdict(_obstructed, _fresnel_hit) do
|
|
{0, "CLEAR"}
|
|
end
|
|
end
|