prop/test/microwaveprop/weather/skewt_params_test.exs
Graham McIntire be1174914f
feat(skewt): NOAA SPC line colors + sounding-parameter parity
Two changes that bring /skewt visually and analytically into parity
with the NOAA SPC sounding viewer
(https://www.spc.noaa.gov/exper/soundings/):

NOAA-style line set
-------------------

Updated the SkewtSvg style block to match the public SPC chart:

  * Temperature trace ........ pure red (#ff0000) at 2.5 px stroke
  * Dewpoint trace ........... pure green (#00aa00) at 2.5 px stroke
  * Isotherms ................ dashed teal (#14b8a6)
  * Dry adiabats ............. solid orange (#f59e0b)
  * Moist (saturated) adiabats dashed sky-blue (#38bdf8)
  * Mixing-ratio lines ....... dotted darker green (#16a34a), with
                               labels at 700 mb showing the g/kg
                               value, drawn only below 600 mb (matches
                               the SPC chart's lower-band number row)
  * Parcel ascent ............ new dashed grey (#475569) line, drawn
                               whenever SkewtParams.derive returns a
                               non-empty parcel_trace

The mixing-ratio set is the SPC standard (0.4 / 0.7 / 1 / 2 / 3 / 5 /
8 / 12 / 16 / 20 g/kg) instead of the prior arbitrary set.

SkewtParams: SPC parcel-theory + indices module
-----------------------------------------------

New `Microwaveprop.Weather.SkewtParams` derives the parameter set that
ships in the SPC `*.txt` companion file:

  * LCL pressure / temperature / height (Bolton 1980 eq 22)
  * LFC pressure / height
  * EL pressure / height
  * SBCAPE, SBCIN (J/kg, virtual-temperature integration with
    surface parcel)
  * 3 km CAPE
  * Lifted Index (parcel ↔ environment T at 500 mb)
  * Freezing level (T = 0 °C height) and Wet-Bulb Zero
  * DCAPE (downdraft CAPE — picks the min-θₑ source level in
    400–700 mb, descends moist-adiabatically to surface)
  * Layer lapse rates: sfc–3 km, 700–500 mb, 850–500 mb
  * `parcel_trace` — pressure/temperature trajectory used by
    SkewtSvg to draw the dashed parcel-ascent overlay

Wind-derived indices (SRH, BWD, Bunkers motion, SCP, STP, SHIP) are
deliberately *not* produced and the LiveView surfaces a one-line note
explaining why: HRRR persists wind only at 10 m AGL, so per-pressure-
level shear can't be computed from this data source.

LiveView panel restructured into grouped sections (Surface, Convective
parcel, Levels, Lapse rates, Moisture/stability, Refractivity) so the
layout matches the SPC viewer's order. The Levels rows show pressure
*and* height ("872 mb · 1,140 m") for quick cross-reference between
the diagram axis and the readout.

Tests: 8/8 new SkewtParamsTest cases (LCL Bolton check, saturated-air
edge case, full field-set presence, summer profile produces SBCAPE > 0
and LI < 0, capped profile produces SBCAPE = 0 and LI > 0, freezing
level interpolated correctly, sfc–3 km lapse rate, empty profile no
crash). 25/25 across all skewt-related test files. mix test green
(2,902 / 2,902 + 221 properties), mix credo --strict clean, mix
assets.build clean.
2026-04-25 13:26:41 -05:00

118 lines
4.9 KiB
Elixir

defmodule Microwaveprop.Weather.SkewtParamsTest do
use ExUnit.Case, async: true
alias Microwaveprop.Weather.SkewtParams
# A moderately unstable summer-like profile. Surface ≈ 30 °C / 22 °C
# dewpoint, gradually drier and colder aloft. Used as a baseline for
# LCL/LFC/EL/CAPE assertions.
@summer_profile [
%{"pres" => 1000.0, "hght" => 100.0, "tmpc" => 30.0, "dwpc" => 22.0},
%{"pres" => 925.0, "hght" => 800.0, "tmpc" => 24.0, "dwpc" => 19.0},
%{"pres" => 850.0, "hght" => 1500.0, "tmpc" => 18.0, "dwpc" => 14.0},
%{"pres" => 700.0, "hght" => 3100.0, "tmpc" => 8.0, "dwpc" => -2.0},
%{"pres" => 500.0, "hght" => 5800.0, "tmpc" => -10.0, "dwpc" => -25.0},
%{"pres" => 300.0, "hght" => 9300.0, "tmpc" => -38.0, "dwpc" => -55.0},
%{"pres" => 200.0, "hght" => 12_000.0, "tmpc" => -60.0, "dwpc" => -80.0}
]
# A capped / stable profile: subsidence inversion above the surface
# mixed layer. Should produce 0 CAPE.
@capped_profile [
%{"pres" => 1000.0, "hght" => 100.0, "tmpc" => 25.0, "dwpc" => 18.0},
%{"pres" => 925.0, "hght" => 800.0, "tmpc" => 28.0, "dwpc" => 5.0},
%{"pres" => 850.0, "hght" => 1500.0, "tmpc" => 25.0, "dwpc" => -5.0},
%{"pres" => 700.0, "hght" => 3100.0, "tmpc" => 12.0, "dwpc" => -15.0},
%{"pres" => 500.0, "hght" => 5800.0, "tmpc" => -8.0, "dwpc" => -30.0}
]
describe "lcl/3" do
test "returns LCL pressure and temperature consistent with Bolton (1980)" do
# Surface 30°C / 22°C dewpoint at 1000 mb → LCL ~880-920 mb,
# T_LCL ~17-22°C. Tolerances stay loose because Bolton is an
# approximation and the surface RH already drives most of the
# answer.
{p_lcl, t_lcl} = SkewtParams.lcl(30.0, 22.0, 1000.0)
assert p_lcl >= 850.0 and p_lcl <= 940.0
assert t_lcl >= 16.0 and t_lcl <= 23.0
end
test "LCL pressure equals surface pressure when air is saturated" do
{p_lcl, t_lcl} = SkewtParams.lcl(20.0, 20.0, 1000.0)
assert_in_delta p_lcl, 1000.0, 1.0
assert_in_delta t_lcl, 20.0, 0.2
end
end
describe "derive/1" do
test "produces every field documented in the public spec for a non-trivial profile" do
params = SkewtParams.derive(@summer_profile)
# Surface-based parcel parameters present.
assert is_map(params)
assert is_number(params[:lcl_pressure_mb])
assert is_number(params[:lcl_height_m])
assert is_number(params[:sbcape])
assert is_number(params[:sbcin])
assert is_number(params[:lifted_index])
# LFC/EL may be nil if no positive area exists — but for this
# unstable summer profile they must resolve.
assert is_number(params[:lfc_pressure_mb])
assert is_number(params[:el_pressure_mb])
# Cold-side milestones.
assert is_number(params[:freezing_level_m])
assert is_number(params[:wbz_m])
# Layer lapse rates.
assert is_number(params[:lapse_rate_700_500_c_per_km])
assert is_number(params[:lapse_rate_sfc_3km_c_per_km])
# Parcel trajectory used by SkewtSvg to draw the dashed grey line.
assert is_list(params[:parcel_trace])
assert length(params[:parcel_trace]) >= 2
end
test "summer profile is unstable: SBCAPE > 0, SBCIN <= 0, LFC < surface" do
params = SkewtParams.derive(@summer_profile)
assert params[:sbcape] > 0
# CIN by convention is negative (or zero).
assert params[:sbcin] <= 0
assert params[:lfc_pressure_mb] < 1000.0
assert params[:el_pressure_mb] < params[:lfc_pressure_mb]
# Lifted index < 0 for an unstable summer atmosphere.
assert params[:lifted_index] < 0
end
test "capped profile is stable: SBCAPE = 0, no LFC" do
params = SkewtParams.derive(@capped_profile)
assert params[:sbcape] == 0.0
assert params[:lfc_pressure_mb] == nil
assert params[:el_pressure_mb] == nil
# Lifted index > 0 in a stable atmosphere.
assert params[:lifted_index] > 0
end
test "freezing level is the height where T crosses 0 °C" do
params = SkewtParams.derive(@summer_profile)
# Profile goes 30→24→18→8→-10°C between 100 m and 5800 m.
# 0°C falls between 700 mb (8°C, 3100 m) and 500 mb (-10°C, 5800 m).
# Linear interpolation puts it ~4300 m.
assert params[:freezing_level_m] >= 3700
assert params[:freezing_level_m] <= 4900
end
test "lapse rate sfc-3km is the (T_sfc - T_3km) / 3 km gradient" do
params = SkewtParams.derive(@summer_profile)
# 30 °C at 100 m, ~9 °C at 3100 m → ~7 °C/km drop.
assert params[:lapse_rate_sfc_3km_c_per_km] >= 5.5
assert params[:lapse_rate_sfc_3km_c_per_km] <= 9.0
end
test "empty profile returns a fully-nil parameter map (no crash)" do
params = SkewtParams.derive([])
assert is_map(params)
assert params[:sbcape] == nil
assert params[:lcl_pressure_mb] == nil
assert params[:parcel_trace] == []
end
end
end