prop/lib/microwaveprop/terrain/terrain_analysis.ex
Graham McIntire a0065e2f44
refactor: deduplicate hrrr download, simplify radio/mqtt, fix credo/perf issues
- hrrr_client: extract shared fetch_grib_ranges/2 to eliminate ~80% duplicated code
- mqtt: encode_varint/1 uses IO list accumulator instead of O(n^2) binary concat
- commercial: single-pass reduce for aggregate_degradation, remove unused average/1
- scorer: single-pass reduce for safe_avg and build_path_conditions
- terrain_analysis: split 76-line do_analyse/4 into 3 focused functions
- radio: build_position_changes/3 simplified from then-chains to Enum.reduce
- recalibrator_test: fix credo warning (length > 0 -> != [])
2026-05-07 11:50:29 -05:00

288 lines
8.7 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.TerrainAnalysis do
@moduledoc """
Terrain diffraction analysis per ITU-R P.526-16.
Implements:
- Knife-edge diffraction loss (Eq. 31)
- Deygout 3-edge method for multiple obstacles (Section 6)
- Dynamic k-factor from refractivity gradient (Section 2)
"""
@type elevation_point :: %{lat: float(), lon: float(), elev: float(), d: float(), dist_km: float()}
@type analysis_point :: %{
lat: float(),
lon: float(),
d: float(),
elev: float(),
dist_km: float(),
idx: non_neg_integer(),
beam: float(),
eff_terrain: float(),
bulge: float(),
r1: float(),
d1_m: float(),
d2_m: float(),
clearance: float(),
f1_clear: float(),
obstructed: boolean(),
fresnel_penetrated: boolean()
}
@type analysis_result :: %{
points: [analysis_point()],
diffraction_db: number(),
verdict: String.t(),
k_factor: float(),
max_elevation_m: float(),
min_clearance_m: float(),
obstructed_count: non_neg_integer(),
fresnel_hit_count: non_neg_integer()
}
@earth_radius_m 6_371_000.0
@earth_radius_km 6_371.0
@doc """
Analyse an elevation profile for LOS clearance, Fresnel zone penetration,
and diffraction loss per ITU-R P.526-16.
## Options
* `:ant_ht_a` - antenna height A in meters (default 0.0)
* `:ant_ht_b` - antenna height B in meters (default 0.0)
* `:k_factor` - effective earth radius factor (default 4/3, compute with `k_factor/1`)
"""
@spec analyse([elevation_point()], float(), float(), keyword()) :: analysis_result()
def analyse(profile, dist_km, freq_ghz, opts \\ []) do
Microwaveprop.Instrument.span(
[:terrain, :analyse],
%{point_count: length(profile), dist_km: dist_km, freq_ghz: freq_ghz},
fn -> do_analyse(profile, dist_km, freq_ghz, opts) end
)
end
defp do_analyse(profile, dist_km, freq_ghz, opts) do
ant_ht_a = Keyword.get(opts, :ant_ht_a, 0.0)
ant_ht_b = Keyword.get(opts, :ant_ht_b, 0.0)
k = Keyword.get(opts, :k_factor, 4 / 3)
lambda_m = 0.3 / freq_ghz
n = length(profile) - 1
elev1 = hd(profile).elev + ant_ht_a
elev2 = List.last(profile).elev + ant_ht_b
points = build_analysis_points(profile, n, dist_km, lambda_m, k, elev1, elev2)
interior = Enum.slice(points, 1..-2//1)
obstructed = Enum.filter(interior, & &1.obstructed)
fresnel_hit = Enum.filter(interior, & &1.fresnel_penetrated)
diffraction_db = deygout_diffraction(interior, lambda_m)
verdict = classify_verdict(diffraction_db, obstructed, fresnel_hit)
{max_elevation_m, min_clearance_m} = compute_terrain_summary(profile, interior)
%{
points: points,
diffraction_db: diffraction_db,
verdict: verdict,
k_factor: k,
max_elevation_m: max_elevation_m,
min_clearance_m: min_clearance_m,
obstructed_count: length(obstructed),
fresnel_hit_count: length(fresnel_hit)
}
end
defp build_analysis_points(profile, n, dist_km, lambda_m, k, elev1, elev2) do
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, k)
r1 = fresnel_radius(d1_m, d2_m, lambda_m)
clearance = beam - (p.elev + bulge)
is_endpoint = i == 0 or i == n
f1_clear = if r1 > 0, do: clearance - r1, else: clearance
%{
lat: p.lat,
lon: p.lon,
d: p.d,
elev: p.elev,
dist_km: p.dist_km,
idx: i,
beam: beam,
eff_terrain: p.elev + bulge,
bulge: bulge,
r1: r1,
d1_m: d1_m,
d2_m: d2_m,
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)
end
defp compute_terrain_summary(profile, interior) do
max_elevation_m = Enum.reduce(profile, 0.0, fn p, acc -> max(p.elev, acc) end)
min_clearance_m =
case interior do
[] -> 999.0
_ -> Enum.reduce(interior, :infinity, fn p, acc -> min(p.clearance, acc) end)
end
{max_elevation_m, min_clearance_m}
end
@doc "First Fresnel zone radius at a point between two antennas."
@spec fresnel_radius(number(), number(), number()) :: number()
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
@doc "Earth bulge correction in meters at fractional distance along path."
@spec earth_bulge(float(), float(), float()) :: float()
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
@doc """
ITU-R P.526-16 Eq. 31 — knife-edge diffraction loss in dB.
J(ν) = 6.9 + 20·log10(√((ν0.1)² + 1) + ν 0.1) for ν > 0.78
J(ν) = 0 for ν0.78
"""
@spec knife_edge_loss(number()) :: number()
def knife_edge_loss(v) when v <= -0.78, do: 0
def knife_edge_loss(v) do
inner = :math.sqrt((v - 0.1) * (v - 0.1) + 1) + v - 0.1
max(0, 6.9 + 20 * :math.log10(inner))
end
@doc """
ITU-R P.526-16 diffraction parameter ν.
ν = h · √(2·(d1+d2) / (λ·d1·d2))
where h is height above direct ray (positive = above/blocked, negative = below/clear).
"""
@spec diffraction_param(number(), number(), number(), number()) :: float()
def diffraction_param(h, d1_m, d2_m, lambda_m) do
if d1_m <= 0 or d2_m <= 0 or lambda_m <= 0 do
0.0
else
h * :math.sqrt(2 * (d1_m + d2_m) / (lambda_m * d1_m * d2_m))
end
end
@doc """
Compute effective earth radius factor k from refractivity gradient dN/dh (N-units/km).
k = 1 / (1 + a·(dN/dh)·10⁻⁶)
Standard atmosphere: dN/dh ≈ 39 → k ≈ 4/3.
Returns 4/3 for nil input.
"""
@spec k_factor(number() | nil) :: float()
def k_factor(nil), do: 4 / 3
def k_factor(dn_dh) do
denominator = 1 + @earth_radius_km * dn_dh * 1.0e-6
if denominator <= 0.01 do
# Near or beyond ducting — cap at very large k
100.0
else
1.0 / denominator
end
end
# ── Deygout 3-edge diffraction (P.526-16 Section 6) ─────────────
defp deygout_diffraction(interior, lambda_m) do
if interior == [] do
0
else
# Find principal edge: point with highest ν on full T→R path
principal = find_principal_edge(interior, lambda_m)
if principal == nil or principal.nu <= -0.78 do
0
else
loss_main = knife_edge_loss(principal.nu)
# Find subsidiary edges on each sub-path
{before_points, after_points} = split_at_edge(interior, principal.idx)
loss_t = subsidiary_loss(before_points, lambda_m)
loss_r = subsidiary_loss(after_points, lambda_m)
max(0, loss_main + loss_t + loss_r)
end
end
end
defp find_principal_edge(points, lambda_m) do
points
|> Enum.map(fn p ->
# h = height above direct ray = -clearance (clearance negative when above)
h = -p.clearance
nu = diffraction_param(h, p.d1_m, p.d2_m, lambda_m)
%{idx: p.idx, nu: nu, d1_m: p.d1_m, d2_m: p.d2_m}
end)
|> Enum.max_by(& &1.nu, fn -> nil end)
end
defp split_at_edge(points, edge_idx) do
before = Enum.filter(points, fn p -> p.idx < edge_idx end)
after_pts = Enum.filter(points, fn p -> p.idx > edge_idx end)
{before, after_pts}
end
defp subsidiary_loss([], _lambda_m), do: 0
defp subsidiary_loss(points, lambda_m) do
# Recompute ν relative to the sub-path endpoints
# For sub-path T→principal or principal→R, the beam line is different
# Use the existing d1_m/d2_m which are relative to the full path endpoints
# This is a simplified Deygout where we use the same reference line
sub_edge = find_principal_edge(points, lambda_m)
if sub_edge == nil or sub_edge.nu <= -0.78 do
0
else
knife_edge_loss(sub_edge.nu)
end
end
# ── Verdict classification ──────────────────────────────────────
defp classify_verdict(diffraction_db, obstructed, fresnel_hit) do
cond do
obstructed != [] -> "BLOCKED"
fresnel_hit != [] and diffraction_db > 3 -> "FRESNEL_PARTIAL"
fresnel_hit != [] -> "FRESNEL_MINOR"
true -> "CLEAR"
end
end
defp beam_height(frac, elev1, elev2) do
elev1 + frac * (elev2 - elev1)
end
end