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.
170 lines
5.3 KiB
Elixir
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
|