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.
144 lines
4.9 KiB
Elixir
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
|