prop/lib/microwaveprop/weather/theta_e.ex
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

84 lines
3.2 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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