Phase 2 tasks 2.1-2.4: BL turbulence feature computations

Inversion detection module (Propagation.Inversion):
- find_inversion_top/1 walks the native profile to locate the first
  temperature inversion (surface-based or elevated)
- bulk_richardson/3 computes the Richardson number across the
  inversion layer (Ri < 0.25 = turbulent, > 1 = laminar/good)
- shear_magnitude/3 computes the wind shear vector magnitude
- potential_temperature/2 for θ = T*(P0/P)^0.286

Theta-e module (Weather.ThetaE):
- Bolton (1980) equivalent potential temperature
- dewpoint_from_spfh/2 via Magnus-Tetens inversion
- theta_e_jump/3 for the thermodynamic decoupling metric

mix hrrr_native_derive_fields populates inversion_top_m,
bulk_richardson, theta_e_jump_k, and shear_at_top_ms on existing
hrrr_native_profiles rows.

First real data: 2022-08-20 12Z TX profile shows inversion at
186 m, Ri = 0.16 (turbulent), θ_e jump = 0.33 K — consistent with
marginal propagation conditions at that hour.
This commit is contained in:
Graham McIntire 2026-04-10 08:21:22 -05:00
parent b142dfeefd
commit 864a91fc5c
5 changed files with 536 additions and 0 deletions

View file

@ -0,0 +1,160 @@
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 = Enum.zip(heights, 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

View file

@ -0,0 +1,84 @@
defmodule Microwaveprop.Weather.ThetaE do
@moduledoc """
Equivalent potential temperature (θ) computation using the Bolton
(1980) approximation.
θ combines temperature and moisture into a single conserved variable
that characterizes how "juicy" an air parcel is. A sharp vertical
θ gradient across the inversion top means strong thermodynamic
decoupling between the boundary layer and the free atmosphere which
the meteorologist identified as a first-class propagation predictor.
All inputs are SI: T in Kelvin, specific humidity in kg/kg, P in Pa.
"""
@doc """
Compute equivalent potential temperature (K) from temperature,
specific humidity, and pressure.
Uses the Bolton (1980) formulation:
θ = θ_dry × exp((Lv × r) / (cp × T_lcl))
where r is mixing ratio, T_lcl is the lifting condensation level
temperature, and θ_dry is the dry potential temperature.
"""
@spec compute(float, float, float) :: float
def compute(temp_k, spfh, pressure_pa) do
# Mixing ratio from specific humidity: r = q / (1 - q)
r = spfh / (1.0 - spfh)
# Water vapor pressure (Pa): e = q * P / (0.622 + 0.378 * q)
e = spfh * pressure_pa / (0.622 + 0.378 * spfh)
# Dry potential temperature: θ = T * (P0/P_dry)^(Rd/cp)
# where P_dry = P - e
p_dry = pressure_pa - e
theta_dry = temp_k * :math.pow(100_000.0 / p_dry, 0.2854)
# Bolton (1980) LCL temperature (Eq. 15)
t_lcl = 2840.0 / (3.5 * :math.log(temp_k) - :math.log(e / 100.0) - 4.805) + 55.0
# Bolton (1980) θₑ (Eq. 38, simplified)
lv_cp = 2.5e6 / 1005.7
theta_dry * :math.exp(lv_cp * r / t_lcl)
end
@doc """
Dewpoint temperature (K) from specific humidity and pressure.
Computes water vapor pressure from (q, P), then inverts the
Magnus-Tetens formula to get Td.
"""
@spec dewpoint_from_spfh(float, float) :: float
def dewpoint_from_spfh(spfh, pressure_pa) do
# e = q * P / (0.622 + 0.378 * q) in Pa
e = spfh * pressure_pa / (0.622 + 0.378 * spfh)
e_hpa = e / 100.0
# Invert Magnus-Tetens: Td(°C) = (243.04 * ln(e/6.1078)) / (17.625 - ln(e/6.1078))
ln_ratio = :math.log(e_hpa / 6.1078)
td_c = 243.04 * ln_ratio / (17.625 - ln_ratio)
td_c + 273.15
end
@doc """
θ jump (K) between two levels in a profile.
Returns θ(top) θ(base). A large positive value means the
inversion top is much warmer+drier (thermodynamically decoupled)
than the air below which is what the meteorologist says we need.
"""
@spec theta_e_jump(map, non_neg_integer, non_neg_integer) :: float | nil
def theta_e_jump(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),
q_base when is_float(q_base) <- Enum.at(profile.spfh, base_idx),
q_top when is_float(q_top) <- Enum.at(profile.spfh, 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) do
compute(t_top, q_top, p_top) - compute(t_base, q_base, p_base)
else
_ -> nil
end
end
end

View file

@ -0,0 +1,91 @@
defmodule Mix.Tasks.HrrrNativeDeriveFields do
@shortdoc "Compute derived turbulence fields on hrrr_native_profiles"
@moduledoc """
Walks all `hrrr_native_profiles` rows that are missing derived
fields (bulk_richardson is nil) and populates them from the raw
level arrays using the Inversion and ThetaE modules.
Idempotent: rows that already have derived fields are skipped.
mix hrrr_native_derive_fields # process all pending
mix hrrr_native_derive_fields --limit 100
"""
use Mix.Task
import Ecto.Query
alias Microwaveprop.Propagation.Inversion
alias Microwaveprop.Repo
alias Microwaveprop.Weather.HrrrNativeProfile
alias Microwaveprop.Weather.ThetaE
@impl Mix.Task
def run(argv) do
Mix.Task.run("app.start")
{opts, _, _} = OptionParser.parse(argv, switches: [limit: :integer])
limit = Keyword.get(opts, :limit, 10_000)
profiles =
HrrrNativeProfile
|> where([p], is_nil(p.bulk_richardson) and p.level_count > 2)
|> limit(^limit)
|> Repo.all()
Mix.shell().info("Deriving fields for #{length(profiles)} profiles...")
count =
Enum.count(profiles, fn profile ->
derived = derive(profile)
if derived do
{1, _} =
HrrrNativeProfile
|> where([p], p.id == ^profile.id)
|> Repo.update_all(set: derived)
true
else
false
end
end)
Mix.shell().info("Updated #{count} profiles with derived turbulence fields.")
end
defp derive(profile) do
p = %{
heights_m: profile.heights_m,
temp_k: profile.temp_k,
spfh: profile.spfh,
pressure_pa: profile.pressure_pa,
u_wind_ms: profile.u_wind_ms,
v_wind_ms: profile.v_wind_ms,
tke_m2s2: profile.tke_m2s2,
level_count: profile.level_count
}
case Inversion.find_inversion_top(p) do
{:ok, %{height_m: top_h, level_idx: top_idx, base_idx: base_idx}} ->
ri = Inversion.bulk_richardson(p, base_idx, top_idx)
shear = Inversion.shear_magnitude(p, base_idx, top_idx)
theta_e_jump = ThetaE.theta_e_jump(p, base_idx, top_idx)
[
inversion_top_m: top_h,
bulk_richardson: ri,
shear_at_top_ms: shear,
theta_e_jump_k: theta_e_jump
]
:none ->
# No inversion — still fill nulls so we know the row was processed
[
inversion_top_m: nil,
bulk_richardson: nil,
shear_at_top_ms: nil,
theta_e_jump_k: nil
]
end
end
end

View file

@ -0,0 +1,144 @@
defmodule Microwaveprop.Propagation.InversionTest do
use ExUnit.Case, async: true
alias Microwaveprop.Propagation.Inversion
# Helper: build a profile struct-like map from level tuples.
# Each level is {height_m, temp_k, spfh, pressure_pa, u_ms, v_ms, tke}
defp profile(levels) do
%{
level_count: length(levels),
heights_m: Enum.map(levels, &elem(&1, 0)),
temp_k: Enum.map(levels, &elem(&1, 1)),
spfh: Enum.map(levels, &elem(&1, 2)),
pressure_pa: Enum.map(levels, &elem(&1, 3)),
u_wind_ms: Enum.map(levels, &elem(&1, 4)),
v_wind_ms: Enum.map(levels, &elem(&1, 5)),
tke_m2s2: Enum.map(levels, &elem(&1, 6))
}
end
describe "find_inversion_top/1" do
test "returns :none for a monotonically cooling profile (no inversion)" do
# Standard atmosphere: T decreases with height, no inversion
p =
profile([
{10.0, 295.0, 0.010, 101_000.0, 2.0, 1.0, 0.5},
{100.0, 294.0, 0.009, 100_000.0, 2.5, 1.0, 0.4},
{300.0, 292.0, 0.007, 97_000.0, 3.0, 1.5, 0.3},
{500.0, 289.0, 0.005, 95_000.0, 4.0, 2.0, 0.2},
{1000.0, 283.0, 0.003, 90_000.0, 5.0, 2.5, 0.1}
])
assert Inversion.find_inversion_top(p) == :none
end
test "detects a surface-based temperature inversion" do
# Cool at surface, warm layer at 100-300m (classic radiation inversion)
p =
profile([
{10.0, 288.0, 0.008, 101_000.0, 1.0, 0.5, 0.1},
{50.0, 289.0, 0.008, 100_500.0, 1.0, 0.5, 0.1},
{100.0, 291.0, 0.007, 100_000.0, 1.5, 0.5, 0.1},
{200.0, 293.0, 0.006, 99_000.0, 2.0, 1.0, 0.2},
{300.0, 292.0, 0.006, 98_000.0, 3.0, 1.5, 0.3},
{500.0, 289.0, 0.005, 95_000.0, 4.0, 2.0, 0.5}
])
{:ok, top} = Inversion.find_inversion_top(p)
# The inversion top is where temperature stops increasing and starts decreasing
# Between 200m (293K) and 300m (292K) — so the top is at the 200m level
assert top.height_m == 200.0
assert top.level_idx == 3
assert top.strength_k > 0
end
test "detects an elevated inversion" do
# Normal lapse below 500m, inversion between 500-1000m
p =
profile([
{10.0, 295.0, 0.010, 101_000.0, 2.0, 1.0, 0.5},
{100.0, 294.0, 0.009, 100_000.0, 2.5, 1.0, 0.4},
{300.0, 292.0, 0.007, 97_000.0, 3.0, 1.5, 0.3},
{500.0, 290.0, 0.005, 95_000.0, 4.0, 2.0, 0.2},
{700.0, 292.0, 0.004, 93_000.0, 5.0, 3.0, 0.1},
{1000.0, 294.0, 0.003, 90_000.0, 6.0, 3.5, 0.1},
{1500.0, 291.0, 0.002, 85_000.0, 8.0, 4.0, 0.2}
])
{:ok, top} = Inversion.find_inversion_top(p)
# T increases from 500m to 1000m then drops — top at 1000m
assert top.height_m == 1000.0
assert top.level_idx == 5
end
test "returns :none for a profile with too few levels" do
p = profile([{10.0, 295.0, 0.010, 101_000.0, 2.0, 1.0, 0.5}])
assert Inversion.find_inversion_top(p) == :none
end
end
describe "bulk_richardson/3" do
test "returns large Ri for a strong inversion with weak shear (laminar)" do
# Strong warming (5 K) with minimal wind change → Ri >> 1
p =
profile([
{10.0, 288.0, 0.008, 101_000.0, 2.0, 1.0, 0.1},
{200.0, 293.0, 0.006, 99_000.0, 2.1, 1.1, 0.1}
])
ri = Inversion.bulk_richardson(p, 0, 1)
assert ri > 1.0
end
test "returns small Ri for an inversion with strong shear (turbulent)" do
# Mild warming (1 K) with strong wind change (20 m/s) → Ri < 0.25
p =
profile([
{10.0, 290.0, 0.008, 101_000.0, 2.0, 1.0, 0.1},
{200.0, 291.0, 0.006, 99_000.0, 22.0, 1.0, 0.5}
])
ri = Inversion.bulk_richardson(p, 0, 1)
assert ri < 0.25
end
test "clamps at 100.0 when shear is near zero" do
p =
profile([
{10.0, 290.0, 0.008, 101_000.0, 2.0, 1.0, 0.1},
{200.0, 295.0, 0.006, 99_000.0, 2.0, 1.0, 0.1}
])
ri = Inversion.bulk_richardson(p, 0, 1)
assert ri == 100.0
end
end
describe "shear_magnitude/3" do
test "computes the vector magnitude of wind difference" do
p =
profile([
{10.0, 290.0, 0.008, 101_000.0, 0.0, 0.0, 0.1},
{200.0, 290.0, 0.008, 101_000.0, 3.0, 4.0, 0.1}
])
shear = Inversion.shear_magnitude(p, 0, 1)
assert_in_delta shear, 5.0, 0.001
end
end
describe "potential_temperature/2" do
test "computes theta correctly at standard sea-level conditions" do
# At P = 100000 Pa (exactly), theta should equal T
theta = Inversion.potential_temperature(300.0, 100_000.0)
assert_in_delta theta, 300.0, 0.01
end
test "theta > T when pressure is above 1000 hPa" do
# At P = 101325 Pa, theta should be slightly less than T
theta = Inversion.potential_temperature(300.0, 101_325.0)
assert theta < 300.0
end
end
end

View file

@ -0,0 +1,57 @@
defmodule Microwaveprop.Weather.ThetaETest do
use ExUnit.Case, async: true
alias Microwaveprop.Weather.ThetaE
describe "compute/3" do
test "returns a plausible theta-e for standard summer conditions" do
# T=300K, q=0.015, P=101325 Pa → theta-e should be ~340-360 K
theta_e = ThetaE.compute(300.0, 0.015, 101_325.0)
assert theta_e > 330.0
assert theta_e < 370.0
end
test "dry air has theta-e close to potential temperature" do
# q ≈ 0 → theta-e ≈ theta ≈ T * (100000/P)^0.286
theta_e = ThetaE.compute(300.0, 0.0001, 100_000.0)
assert_in_delta theta_e, 300.0, 5.0
end
test "theta-e increases with moisture at constant T and P" do
dry = ThetaE.compute(295.0, 0.005, 100_000.0)
moist = ThetaE.compute(295.0, 0.015, 100_000.0)
assert moist > dry
end
end
describe "dewpoint_from_spfh/2" do
test "returns a plausible dewpoint for typical specific humidity" do
# q=0.01, P=101325 Pa → Td should be around 14°C (287 K)
td = ThetaE.dewpoint_from_spfh(0.01, 101_325.0)
assert td > 280.0
assert td < 295.0
end
test "very dry air has a low dewpoint" do
td = ThetaE.dewpoint_from_spfh(0.001, 100_000.0)
assert td < 265.0
end
end
describe "theta_e_jump/3" do
test "returns the difference in theta-e across two levels" do
profile = %{
temp_k: [295.0, 300.0],
spfh: [0.015, 0.005],
pressure_pa: [101_000.0, 95_000.0]
}
jump = ThetaE.theta_e_jump(profile, 0, 1)
assert is_float(jump)
# The dry upper level should have lower theta-e despite higher T
# because moisture dominates. So the jump could be negative.
# Just verify it's computed.
assert abs(jump) > 0.0
end
end
end