diff --git a/lib/microwaveprop/propagation/inversion.ex b/lib/microwaveprop/propagation/inversion.ex new file mode 100644 index 00000000..fd9ff782 --- /dev/null +++ b/lib/microwaveprop/propagation/inversion.ex @@ -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 diff --git a/lib/microwaveprop/weather/theta_e.ex b/lib/microwaveprop/weather/theta_e.ex new file mode 100644 index 00000000..aff6844e --- /dev/null +++ b/lib/microwaveprop/weather/theta_e.ex @@ -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 diff --git a/lib/mix/tasks/hrrr_native_derive.ex b/lib/mix/tasks/hrrr_native_derive.ex new file mode 100644 index 00000000..518a797f --- /dev/null +++ b/lib/mix/tasks/hrrr_native_derive.ex @@ -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 diff --git a/test/microwaveprop/propagation/inversion_test.exs b/test/microwaveprop/propagation/inversion_test.exs new file mode 100644 index 00000000..dd300d92 --- /dev/null +++ b/test/microwaveprop/propagation/inversion_test.exs @@ -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 diff --git a/test/microwaveprop/weather/theta_e_test.exs b/test/microwaveprop/weather/theta_e_test.exs new file mode 100644 index 00000000..a0bfd153 --- /dev/null +++ b/test/microwaveprop/weather/theta_e_test.exs @@ -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