prop/lib/microwaveprop/propagation/duct.ex
Graham McIntire 51e390959c Add dialyzer specs and types across the codebase
277 @spec/@type annotations added to 58 files covering all public
APIs: contexts (propagation, radio, weather, terrain, beacons,
commercial), GRIB2 decoders, terrain analysis, duct detection,
rain scatter, CSV/ADIF import, weather clients, and all Ecto
schemas. Dialyzer passes with 0 errors.
2026-04-12 08:55:04 -05:00

170 lines
5.3 KiB
Elixir

defmodule Microwaveprop.Propagation.Duct do
@moduledoc """
Detects atmospheric ducts from a native-level HRRR profile by
computing the modified refractivity M-profile and finding regions
where dM/dh < 0.
This replaces the scalar `min_refractivity_gradient` with physical
duct geometry: base height, top height, thickness, M-deficit, and
the minimum frequency the duct can trap. The per-band trapped
frequency is the key improvement — a 50 m duct can trap 24 GHz but
not 3 GHz, and the old scalar approach had no way to express that.
References:
- ITU-R P.453-14: refractivity formula N = 77.6*P/T + 3.73e5*e/T²
- ITU-R P.834-9: ducting defined by regions where dM/dh < 0
- Bean & Dutton (1966): minimum trapped wavelength λ_min ≈ ...
"""
@doc """
Compute the refractivity N at each level of a native profile.
Returns `[{height_m, N}, ...]` sorted ascending by height.
Uses ITU-R P.453 with water vapor pressure derived from specific
humidity.
"""
@spec refractivity_profile(map()) :: [{float(), float()}]
def refractivity_profile(%{heights_m: heights, temp_k: temps, spfh: spfhs, pressure_pa: pressures}) do
[heights, temps, spfhs, pressures]
|> Enum.zip()
|> Enum.map(fn {h, t, q, p} ->
# Water vapor pressure: e = q*P / (0.622 + 0.378*q) [Pa]
e = q * p / (0.622 + 0.378 * q)
# Convert to hPa for the P.453 formula
p_hpa = p / 100.0
e_hpa = e / 100.0
n = 77.6 * p_hpa / t + 3.73e5 * e_hpa / (t * t)
{h, n}
end)
end
@doc """
Convert an N-profile to the modified refractivity M-profile.
M = N + 157 * h (where h is in km)
In a standard atmosphere M always increases with height. A duct
exists wherever M decreases.
"""
@spec m_profile([{float(), float()}]) :: [{float(), float()}]
def m_profile(n_profile) do
Enum.map(n_profile, fn {h, n} ->
{h, n + 157.0 * h / 1000.0}
end)
end
@doc """
Detect ducts from an M-profile.
A duct is a contiguous region where M decreases with height (dM/dh < 0).
Returns a list of `%{base_m, top_m, thickness_m, m_deficit}` maps,
where `m_deficit` is the total M decrease (positive number) across the
duct — the strength of the trapping.
"""
@spec detect_ducts([{float(), float()}]) ::
[%{base_m: float(), top_m: float(), thickness_m: float(), m_deficit: float()}]
def detect_ducts(m_profile) when length(m_profile) < 2, do: []
def detect_ducts(m_profile) do
m_profile
|> Enum.chunk_every(2, 1, :discard)
|> Enum.reduce({[], nil}, fn [{h1, m1}, {h2, m2}], {ducts, current_duct} ->
if m2 < m1 do
# M is decreasing — we're in a duct
case current_duct do
nil ->
{ducts, %{base_m: h1, base_m_val: m1, top_m: h2, min_m_val: m2}}
duct ->
{ducts, %{duct | top_m: h2, min_m_val: min(duct.min_m_val, m2)}}
end
else
# M is increasing — close any open duct
case current_duct do
nil ->
{ducts, nil}
duct ->
finished = finalize_duct(duct)
{[finished | ducts], nil}
end
end
end)
|> then(fn {ducts, current} ->
case current do
nil -> Enum.reverse(ducts)
duct -> Enum.reverse([finalize_duct(duct) | ducts])
end
end)
end
defp finalize_duct(%{base_m: base, top_m: top, base_m_val: m_base, min_m_val: m_min}) do
%{
base_m: base,
top_m: top,
thickness_m: top - base,
m_deficit: m_base - m_min
}
end
@doc """
Minimum trapped frequency (GHz) for a duct of given thickness and M-deficit.
Uses the waveguide approximation from Bean & Dutton:
λ_max = 2.5 * d * sqrt(ΔM * 1e-6) (meters)
f_min = c / λ_max
where d is duct thickness in meters, ΔM is the M-deficit.
Returns a very high frequency (999 GHz) for degenerate ducts.
"""
@spec min_trapped_frequency_ghz(%{thickness_m: float(), m_deficit: float()}) :: float()
def min_trapped_frequency_ghz(%{thickness_m: d, m_deficit: delta_m}) when d > 0 and delta_m > 0 do
# Maximum trapped wavelength (meters)
lambda_max = 2.5 * d * :math.sqrt(delta_m * 1.0e-6)
if lambda_max > 1.0e-6 do
# f = c / λ, convert to GHz
3.0e8 / lambda_max / 1.0e9
else
999.0
end
end
def min_trapped_frequency_ghz(_), do: 999.0
@doc """
Full analysis pipeline: native profile → ducts with trapped frequencies.
Returns `%{ducts: [duct_map], best_duct_band_ghz: float | nil}`
where each duct_map includes `:min_freq_ghz` and
`best_duct_band_ghz` is the lowest min_freq across all detected ducts.
"""
@spec analyze(map()) ::
%{
ducts: [
%{
base_m: float(),
top_m: float(),
thickness_m: float(),
m_deficit: float(),
min_freq_ghz: float()
}
],
best_duct_band_ghz: float() | nil
}
def analyze(profile) do
ducts =
profile
|> refractivity_profile()
|> m_profile()
|> detect_ducts()
|> Enum.map(fn duct ->
Map.put(duct, :min_freq_ghz, min_trapped_frequency_ghz(duct))
end)
best = ducts |> Enum.map(& &1.min_freq_ghz) |> Enum.min(fn -> nil end)
%{ducts: ducts, best_duct_band_ghz: best}
end
end