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:
parent
b142dfeefd
commit
864a91fc5c
5 changed files with 536 additions and 0 deletions
160
lib/microwaveprop/propagation/inversion.ex
Normal file
160
lib/microwaveprop/propagation/inversion.ex
Normal 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
|
||||
84
lib/microwaveprop/weather/theta_e.ex
Normal file
84
lib/microwaveprop/weather/theta_e.ex
Normal 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
|
||||
91
lib/mix/tasks/hrrr_native_derive.ex
Normal file
91
lib/mix/tasks/hrrr_native_derive.ex
Normal 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
|
||||
144
test/microwaveprop/propagation/inversion_test.exs
Normal file
144
test/microwaveprop/propagation/inversion_test.exs
Normal 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
|
||||
57
test/microwaveprop/weather/theta_e_test.exs
Normal file
57
test/microwaveprop/weather/theta_e_test.exs
Normal 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
|
||||
Loading…
Add table
Reference in a new issue