Some checks failed
Build and Push / Build and Push Docker Image (push) Failing after 4m38s
- F-level (228): replace length/1 with Enum.count_until/2 or pattern matching; convert Enum.flat_map+if to Enum.filter+Enum.map; fix identity case in narr_client - W-level (46): normalize dual atom/string key access in weather_layers, beacon_measurements, surface, skewt_live, contact_live/show; add :data_provider and weather-map assigns to ignored_assigns in credo config (consumed by child components credo can't trace); remove weak is_list assertion; remove explicit assert_receive timeout - R-level (6): replace 'This module provides...' moduledocs with meaningful descriptions - Also fix 4 compile-connected xref issues by deferring BandConfig.band_options() from module attribute to runtime Co-Authored-By: Claude <noreply@anthropic.com>
169 lines
5.3 KiB
Elixir
169 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) do
|
|
m_profile
|
|
|> Enum.chunk_every(2, 1, :discard)
|
|
|> Enum.reduce({[], nil}, fn [{h1, m1}, {h2, m2}], acc ->
|
|
process_m_pair(acc, h1, m1, h2, m2)
|
|
end)
|
|
|> close_trailing_duct()
|
|
end
|
|
|
|
def detect_ducts(_), do: []
|
|
|
|
defp process_m_pair({ducts, current_duct}, h1, m1, h2, m2) when m2 < m1 do
|
|
# M is decreasing — we're in a duct
|
|
duct = extend_or_start_duct(current_duct, h1, m1, h2, m2)
|
|
{ducts, duct}
|
|
end
|
|
|
|
defp process_m_pair({ducts, nil}, _h1, _m1, _h2, _m2), do: {ducts, nil}
|
|
|
|
defp process_m_pair({ducts, duct}, _h1, _m1, _h2, _m2) do
|
|
{[finalize_duct(duct) | ducts], nil}
|
|
end
|
|
|
|
defp extend_or_start_duct(nil, h1, m1, h2, m2) do
|
|
%{base_m: h1, base_m_val: m1, top_m: h2, min_m_val: m2}
|
|
end
|
|
|
|
defp extend_or_start_duct(duct, _h1, _m1, h2, m2) do
|
|
%{duct | top_m: h2, min_m_val: min(duct.min_m_val, m2)}
|
|
end
|
|
|
|
defp close_trailing_duct({ducts, nil}), do: Enum.reverse(ducts)
|
|
defp close_trailing_duct({ducts, duct}), do: Enum.reverse([finalize_duct(duct) | ducts])
|
|
|
|
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
|