prop/test/microwaveprop/propagation/inversion_test.exs
Graham McIntire 864a91fc5c 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.
2026-04-10 08:21:23 -05:00

144 lines
4.9 KiB
Elixir

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