Task 9.3 - Weight recalibration via gradient descent:
- Recalibrator module fits logistic regression weights using Nx
- Trains on QSO positives vs random baseline negatives
- Cross-validates by month, normalizes weights to sum to 1.0
- Mix task: mix recalibrate_scorer --sample 5000 --epochs 2000
Task 9.4 - Side-by-side scorer comparison:
- ScorerDiff.compare/3 re-scores grid with old vs new weights
- Reports mean diff, regressions, improvements, per-band breakdown
- Mix task: mix scorer_diff --new-weights '{...}'
Phase 3 - NEXRAD ingestion pipeline:
- NexradClient fetches IEM n0q composite PNGs, extracts per-point
box statistics (mean/max dBZ, texture variance)
- NexradObservation schema with unique (lat, lon, observed_at)
- NexradWorker on :nexrad queue for background processing
- nexrad_texture backtest feature in Features module
- mix nexrad_backfill --limit 200
All tasks added to AdminTaskWorker and Release for production use.
1116 tests, 0 failures.
162 lines
5.7 KiB
Elixir
162 lines
5.7 KiB
Elixir
defmodule Microwaveprop.Propagation.Inversion do
|
|
@moduledoc """
|
|
Detects temperature inversions in a native HRRR vertical profile.
|
|
|
|
An inversion exists where temperature increases with height (dT/dz > 0),
|
|
violating the normal atmospheric lapse rate. The "inversion top" is the
|
|
level where temperature peaks before resuming its normal decrease — this
|
|
is the boundary that acts as a mirror for RF propagation when it's
|
|
smooth enough (see the meteorologist's April 2026 review).
|
|
|
|
This module also computes the stability and turbulence properties at
|
|
the inversion top that Phase 2 needs for scoring:
|
|
|
|
- **Bulk Richardson number** — ratio of thermal stratification to
|
|
wind shear; Ri < 0.25 → turbulent, Ri > 1 → laminar (good for
|
|
propagation).
|
|
- **θₑ jump** — change in equivalent potential temperature across
|
|
the inversion; sharper jump = stronger decoupling.
|
|
- **Wind shear magnitude** at the inversion top.
|
|
"""
|
|
|
|
@g 9.80665
|
|
|
|
@doc """
|
|
Find the first (lowest) temperature inversion in a native profile.
|
|
|
|
Returns `{:ok, %{height_m, level_idx, strength_k, base_idx}}` where
|
|
`strength_k` is the total temperature increase from the inversion base
|
|
to its top, or `:none` if no inversion is found.
|
|
|
|
The profile map must have at least `:heights_m` and `:temp_k` arrays.
|
|
"""
|
|
def find_inversion_top(%{level_count: n}) when n < 2, do: :none
|
|
|
|
def find_inversion_top(%{heights_m: heights, temp_k: temps}) do
|
|
levels = heights |> Enum.zip(temps) |> Enum.with_index()
|
|
|
|
# Walk upward looking for the first level where T stops increasing.
|
|
# "Inversion base" is the level where dT/dz first turns positive.
|
|
# "Inversion top" is the level where dT/dz turns negative again.
|
|
case find_inversion_region(levels) do
|
|
nil ->
|
|
:none
|
|
|
|
{base_idx, top_idx} ->
|
|
{:ok,
|
|
%{
|
|
height_m: Enum.at(heights, top_idx),
|
|
level_idx: top_idx,
|
|
base_idx: base_idx,
|
|
strength_k: Enum.at(temps, top_idx) - Enum.at(temps, base_idx)
|
|
}}
|
|
end
|
|
end
|
|
|
|
def find_inversion_top(_), do: :none
|
|
|
|
defp find_inversion_region(levels) do
|
|
pairs = Enum.chunk_every(levels, 2, 1, :discard)
|
|
|
|
# Find the first transition from cooling to warming (inversion base),
|
|
# then the first transition back to cooling (inversion top).
|
|
find_region(pairs, nil)
|
|
end
|
|
|
|
defp find_region([], _base), do: nil
|
|
|
|
defp find_region([pair | rest], base) do
|
|
[{{_h1, t1}, _i1}, {{_h2, t2}, i2}] = pair
|
|
|
|
if is_nil(base) do
|
|
if t2 > t1 do
|
|
find_region_top(rest, i2 - 1, i2)
|
|
else
|
|
find_region(rest, nil)
|
|
end
|
|
else
|
|
if t2 < t1 do
|
|
{base, i2 - 1}
|
|
else
|
|
find_region(rest, base)
|
|
end
|
|
end
|
|
end
|
|
|
|
# Inside the inversion, looking for dT < 0 (= the top)
|
|
defp find_region_top([], base_idx, last_warm_idx), do: {base_idx, last_warm_idx}
|
|
|
|
defp find_region_top([pair | rest], base_idx, _last_warm_idx) do
|
|
[{{_h1, t1}, _i1}, {{_h2, t2}, i2}] = pair
|
|
|
|
if t2 < t1 do
|
|
# Temperature dropping — previous level is the top
|
|
{base_idx, i2 - 1}
|
|
else
|
|
find_region_top(rest, base_idx, i2)
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Bulk Richardson number across a layer from `base_idx` to `top_idx`.
|
|
|
|
Ri = (g / θ_ref) * Δθ * Δz / (ΔU² + ΔV²)
|
|
|
|
where Δθ is the potential temperature difference, ΔU/ΔV are wind
|
|
component differences, and θ_ref is the mean potential temperature.
|
|
|
|
Returns `nil` if inputs are missing or shear is zero (infinite Ri
|
|
is clamped to 100.0 for practical use).
|
|
"""
|
|
def bulk_richardson(profile, base_idx, top_idx) do
|
|
with t_base when is_float(t_base) <- Enum.at(profile.temp_k, base_idx),
|
|
t_top when is_float(t_top) <- Enum.at(profile.temp_k, top_idx),
|
|
p_base when is_float(p_base) <- Enum.at(profile.pressure_pa, base_idx),
|
|
p_top when is_float(p_top) <- Enum.at(profile.pressure_pa, top_idx),
|
|
h_base when is_float(h_base) <- Enum.at(profile.heights_m, base_idx),
|
|
h_top when is_float(h_top) <- Enum.at(profile.heights_m, top_idx),
|
|
u_base when is_float(u_base) <- Enum.at(profile.u_wind_ms, base_idx),
|
|
u_top when is_float(u_top) <- Enum.at(profile.u_wind_ms, top_idx),
|
|
v_base when is_float(v_base) <- Enum.at(profile.v_wind_ms, base_idx),
|
|
v_top when is_float(v_top) <- Enum.at(profile.v_wind_ms, top_idx) do
|
|
theta_base = potential_temperature(t_base, p_base)
|
|
theta_top = potential_temperature(t_top, p_top)
|
|
theta_ref = (theta_base + theta_top) / 2.0
|
|
|
|
delta_theta = theta_top - theta_base
|
|
delta_z = h_top - h_base
|
|
delta_u_sq = :math.pow(u_top - u_base, 2) + :math.pow(v_top - v_base, 2)
|
|
|
|
if delta_u_sq < 1.0e-6 or delta_z < 1.0 do
|
|
# No shear or degenerate layer → effectively infinite stability
|
|
100.0
|
|
else
|
|
ri = @g / theta_ref * delta_theta * delta_z / delta_u_sq
|
|
min(ri, 100.0)
|
|
end
|
|
else
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Wind shear magnitude (m/s) across a layer from `base_idx` to `top_idx`.
|
|
"""
|
|
def shear_magnitude(profile, base_idx, top_idx) do
|
|
with u_base when is_float(u_base) <- Enum.at(profile.u_wind_ms, base_idx),
|
|
u_top when is_float(u_top) <- Enum.at(profile.u_wind_ms, top_idx),
|
|
v_base when is_float(v_base) <- Enum.at(profile.v_wind_ms, base_idx),
|
|
v_top when is_float(v_top) <- Enum.at(profile.v_wind_ms, top_idx) do
|
|
:math.sqrt(:math.pow(u_top - u_base, 2) + :math.pow(v_top - v_base, 2))
|
|
else
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Potential temperature θ = T * (P₀/P)^(R/cp) where P₀ = 100000 Pa.
|
|
"""
|
|
def potential_temperature(temp_k, pressure_pa) do
|
|
temp_k * :math.pow(100_000.0 / pressure_pa, 0.286)
|
|
end
|
|
end
|