feat(weather): add 700 mb T/Td and mid-layer lapse rate for cap diagnostics
850 mb T alone shows that warm air is present aloft but doesn't reveal
whether it's the textbook cap structure — a warm 700 mb above an EML
plume. The three new layers expose the canonical capping diagnostics
visually:
* T @ 700 mb — ≥10 °C is the southern Plains "moderate cap" threshold
* Td @ 700 mb — wide T-Td depression at 700 mb signals the EML plume
* Lapse 850→700 — steep mid-layer lapse rate (≥7 °C/km) over a moist
boundary layer is the cap mechanism itself
All three derive purely from the existing pressure-level profile (Rust
already fetches up through 700 mb), so no Rust pipeline changes needed.
This commit is contained in:
parent
f6ddf38071
commit
3ac6963c74
4 changed files with 154 additions and 1 deletions
|
|
@ -48,7 +48,10 @@ interface WeatherPoint {
|
|||
pwat: number | null
|
||||
temp_850mb: number | null
|
||||
dewpoint_850mb: number | null
|
||||
temp_700mb: number | null
|
||||
dewpoint_700mb: number | null
|
||||
lapse_rate: number | null
|
||||
mid_lapse_rate: number | null
|
||||
inversion_strength: number | null
|
||||
inversion_base_m: number | null
|
||||
ducting: boolean | null
|
||||
|
|
@ -229,6 +232,35 @@ const COLOR_SCALES = {
|
|||
label: "Dewpoint at 850mb",
|
||||
unit: "°C"
|
||||
},
|
||||
// 700 mb T is the canonical cap indicator over the southern Plains.
|
||||
// Cool greens (no cap) → yellow at ~8°C → orange at the cap edge
|
||||
// (10°C) → red for strong caps (≥12°C). Warm caps stand out clearly
|
||||
// against the cooler "no cap" base map.
|
||||
temp_700mb: {
|
||||
breakpoints: [
|
||||
{ value: -10, r: 0, g: 100, b: 200 },
|
||||
{ value: 0, r: 100, g: 180, b: 230 },
|
||||
{ value: 5, r: 200, g: 230, b: 200 },
|
||||
{ value: 8, r: 255, g: 229, b: 102 },
|
||||
{ value: 10, r: 255, g: 144, b: 68 },
|
||||
{ value: 14, r: 200, g: 0, b: 0 }
|
||||
],
|
||||
format: (v: number | null): string => v != null ? `${v.toFixed(1)} °C` : "N/A",
|
||||
label: "Temperature at 700mb",
|
||||
unit: "°C"
|
||||
},
|
||||
dewpoint_700mb: {
|
||||
breakpoints: [
|
||||
{ value: -30, r: 139, g: 90, b: 43 },
|
||||
{ value: -15, r: 194, g: 165, b: 116 },
|
||||
{ value: -5, r: 173, g: 216, b: 230 },
|
||||
{ value: 5, r: 65, g: 105, b: 225 },
|
||||
{ value: 15, r: 0, g: 0, b: 180 }
|
||||
],
|
||||
format: (v: number | null): string => v != null ? `${v.toFixed(1)} °C` : "N/A",
|
||||
label: "Dewpoint at 700mb",
|
||||
unit: "°C"
|
||||
},
|
||||
lapse_rate: {
|
||||
breakpoints: [
|
||||
{ value: 0, r: 0, g: 255, b: 163 },
|
||||
|
|
@ -241,6 +273,21 @@ const COLOR_SCALES = {
|
|||
label: "Lapse Rate",
|
||||
unit: "°C/km"
|
||||
},
|
||||
// EML signature: a steep lapse rate across the 850→700 layer above
|
||||
// a moist surface = elevated mixed layer = textbook cap. Red end of
|
||||
// the scale (≥7 °C/km) is the cap-mechanism trigger.
|
||||
mid_lapse_rate: {
|
||||
breakpoints: [
|
||||
{ value: 0, r: 0, g: 100, b: 200 },
|
||||
{ value: 4, r: 100, g: 200, b: 200 },
|
||||
{ value: 6, r: 255, g: 229, b: 102 },
|
||||
{ value: 7, r: 255, g: 144, b: 68 },
|
||||
{ value: 9, r: 200, g: 0, b: 0 }
|
||||
],
|
||||
format: (v: number | null): string => v != null ? `${v.toFixed(1)} °C/km` : "N/A",
|
||||
label: "Lapse 850→700",
|
||||
unit: "°C/km"
|
||||
},
|
||||
inversion_strength: {
|
||||
breakpoints: [
|
||||
{ value: 0, r: 80, g: 80, b: 80 },
|
||||
|
|
@ -326,7 +373,10 @@ function buildDetailHTML(point: WeatherPoint): string {
|
|||
{ separator: true },
|
||||
{ label: "T @ 850mb", value: COLOR_SCALES.temp_850mb.format(point.temp_850mb), color: "#ff9044" },
|
||||
{ label: "Td @ 850mb", value: COLOR_SCALES.dewpoint_850mb.format(point.dewpoint_850mb), color: "#4169e1" },
|
||||
{ label: "T @ 700mb", value: COLOR_SCALES.temp_700mb.format(point.temp_700mb), color: "#ff9044" },
|
||||
{ label: "Td @ 700mb", value: COLOR_SCALES.dewpoint_700mb.format(point.dewpoint_700mb), color: "#4169e1" },
|
||||
{ label: "Lapse Rate", value: COLOR_SCALES.lapse_rate.format(point.lapse_rate), color: "#ffe566" },
|
||||
{ label: "Lapse 850→700", value: COLOR_SCALES.mid_lapse_rate.format(point.mid_lapse_rate), color: "#ffe566" },
|
||||
{ label: "Inversion", value: COLOR_SCALES.inversion_strength.format(point.inversion_strength), color: point.inversion_strength != null && point.inversion_strength > 0 ? "#00ffa3" : "#666" },
|
||||
{ label: "Inv. Base", value: COLOR_SCALES.inversion_base_m.format(point.inversion_base_m), color: point.inversion_base_m != null ? "#7dffd4" : "#666" },
|
||||
{ separator: true },
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ defmodule Microwaveprop.Weather.WeatherLayers do
|
|||
`:surface_pressure_mb`, `:surface_refractivity`, `:profile`, `:duct_characteristics`.
|
||||
|
||||
Returns a map with derived fields: `:surface_rh`, `:surface_refractivity`,
|
||||
`:temp_850mb`, `:dewpoint_850mb`, `:lapse_rate`, `:inversion_strength`,
|
||||
`:temp_850mb`, `:dewpoint_850mb`, `:temp_700mb`, `:dewpoint_700mb`,
|
||||
`:lapse_rate`, `:mid_lapse_rate`, `:inversion_strength`,
|
||||
`:inversion_base_m`, `:duct_base_m`, `:duct_strength`.
|
||||
"""
|
||||
@spec derive(map()) :: map()
|
||||
|
|
@ -22,7 +23,10 @@ defmodule Microwaveprop.Weather.WeatherLayers do
|
|||
surface_refractivity: row.surface_refractivity,
|
||||
temp_850mb: level_value(sorted, 850, "tmpc"),
|
||||
dewpoint_850mb: level_value(sorted, 850, "dwpc"),
|
||||
temp_700mb: level_value(sorted, 700, "tmpc"),
|
||||
dewpoint_700mb: level_value(sorted, 700, "dwpc"),
|
||||
lapse_rate: compute_lapse_rate(sorted),
|
||||
mid_lapse_rate: compute_layer_lapse_rate(sorted, 850, 700),
|
||||
inversion_strength: inversion_strength(sorted),
|
||||
inversion_base_m: inversion_base_m(sorted),
|
||||
duct_base_m: duct_field(row.duct_characteristics, "base"),
|
||||
|
|
@ -72,6 +76,31 @@ defmodule Microwaveprop.Weather.WeatherLayers do
|
|||
end
|
||||
end
|
||||
|
||||
# Lapse rate over a specific pressure layer (e.g. 850→700 mb), used as
|
||||
# a cap-mechanism diagnostic. A steep lapse rate above the boundary
|
||||
# layer indicates an elevated mixed layer (EML), which is the classic
|
||||
# cap structure: warm boundary layer → very stable inversion → steep
|
||||
# dry-adiabatic layer aloft. Returns nil if either bracketing level
|
||||
# is missing from the profile.
|
||||
defp compute_layer_lapse_rate(sorted, lower_pres, upper_pres) when lower_pres > upper_pres do
|
||||
lower = level_entry(sorted, lower_pres)
|
||||
upper = level_entry(sorted, upper_pres)
|
||||
|
||||
if lower == nil or upper == nil do
|
||||
nil
|
||||
else
|
||||
dh_km = (upper["hght"] - lower["hght"]) / 1000.0
|
||||
if dh_km > 0, do: (lower["tmpc"] - upper["tmpc"]) / dh_km
|
||||
end
|
||||
end
|
||||
|
||||
defp level_entry([], _target_pres), do: nil
|
||||
|
||||
defp level_entry(sorted, target_pres) do
|
||||
nearest = Enum.min_by(sorted, fn p -> abs(p["pres"] - target_pres) end)
|
||||
if abs(nearest["pres"] - target_pres) <= 25.0, do: nearest
|
||||
end
|
||||
|
||||
defp inversion_strength([]), do: 0.0
|
||||
|
||||
defp inversion_strength(sorted) do
|
||||
|
|
|
|||
|
|
@ -85,6 +85,22 @@ defmodule MicrowavepropWeb.WeatherMapLive do
|
|||
desc:
|
||||
"Dewpoint at 850 mb. A sharp moisture drop between the surface and 850mb creates an elevated refractivity gradient that can form ducts."
|
||||
},
|
||||
%{
|
||||
id: "temp_700mb",
|
||||
label: "T @ 700mb",
|
||||
unit: "°C",
|
||||
group: "Upper Air",
|
||||
desc:
|
||||
"Temperature at 700 mb (~3000m altitude). The classic capping diagnostic over the southern Plains: ≥10°C indicates a moderate cap, ≥12°C strong. Combined with warm 850 mb, signals a 'loaded gun' setup that suppresses convection until forcing arrives."
|
||||
},
|
||||
%{
|
||||
id: "dewpoint_700mb",
|
||||
label: "Td @ 700mb",
|
||||
unit: "°C",
|
||||
group: "Upper Air",
|
||||
desc:
|
||||
"Dewpoint at 700 mb. Wide depressions (T-Td > 10°C) signal the elevated mixed layer (EML) plume — a hallmark cap mechanism off the Mexican plateau."
|
||||
},
|
||||
%{
|
||||
id: "lapse_rate",
|
||||
label: "Lapse Rate",
|
||||
|
|
@ -93,6 +109,14 @@ defmodule MicrowavepropWeb.WeatherMapLive do
|
|||
desc:
|
||||
"Temperature decrease per km from surface to 700mb. Low rates (< 5 °C/km) mean stable air that preserves inversions. High rates (> 8) mean convective mixing that destroys them."
|
||||
},
|
||||
%{
|
||||
id: "mid_lapse_rate",
|
||||
label: "Mid Lapse 850-700",
|
||||
unit: "°C/km",
|
||||
group: "Upper Air",
|
||||
desc:
|
||||
"Lapse rate across the 850→700 mb layer. Steep values (≥7 °C/km) above a warm moist boundary layer indicate an elevated mixed layer — the textbook cap structure that holds back convection until the inversion is broken."
|
||||
},
|
||||
%{
|
||||
id: "inversion_strength",
|
||||
label: "Inversion",
|
||||
|
|
|
|||
|
|
@ -63,6 +63,50 @@ defmodule Microwaveprop.Weather.WeatherLayersTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "derive/1 700mb extraction (cap diagnostics)" do
|
||||
test "extracts temperature at 700mb from profile" do
|
||||
result = WeatherLayers.derive(sample_row())
|
||||
# 700 mb level in @sample_profile sits at 4.0 °C
|
||||
assert result.temp_700mb == 4.0
|
||||
end
|
||||
|
||||
test "extracts dewpoint at 700mb from profile" do
|
||||
result = WeatherLayers.derive(sample_row())
|
||||
assert result.dewpoint_700mb == -4.0
|
||||
end
|
||||
|
||||
test "returns nil when profile does not reach 700 mb" do
|
||||
shallow = [
|
||||
%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 15.0, "hght" => 100.0},
|
||||
%{"pres" => 925.0, "tmpc" => 14.0, "dwpc" => 8.0, "hght" => 800.0}
|
||||
]
|
||||
|
||||
result = WeatherLayers.derive(sample_row(%{profile: shallow}))
|
||||
assert result.temp_700mb == nil
|
||||
assert result.dewpoint_700mb == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "derive/1 mid-layer lapse rate (850-700 cap mechanism)" do
|
||||
test "computes lapse rate over the 850-700 mb layer in C/km" do
|
||||
result = WeatherLayers.derive(sample_row())
|
||||
# 850 mb: 16.0 °C @ 1640 m, 700 mb: 4.0 °C @ 3550 m
|
||||
# dT/dh = (16.0 - 4.0) / ((3550 - 1640) / 1000) = 12.0 / 1.91 ≈ 6.28
|
||||
expected = (16.0 - 4.0) / ((3550.0 - 1640.0) / 1000.0)
|
||||
assert_in_delta result.mid_lapse_rate, expected, 0.01
|
||||
end
|
||||
|
||||
test "returns nil when one of the bracketing levels is missing" do
|
||||
shallow = [
|
||||
%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 15.0, "hght" => 100.0},
|
||||
%{"pres" => 925.0, "tmpc" => 14.0, "dwpc" => 8.0, "hght" => 800.0}
|
||||
]
|
||||
|
||||
result = WeatherLayers.derive(sample_row(%{profile: shallow}))
|
||||
assert result.mid_lapse_rate == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "derive/1 lapse rate" do
|
||||
test "computes lapse rate in C/km from surface to top of profile" do
|
||||
result = WeatherLayers.derive(sample_row())
|
||||
|
|
@ -118,7 +162,10 @@ defmodule Microwaveprop.Weather.WeatherLayersTest do
|
|||
|
||||
assert result.temp_850mb == nil
|
||||
assert result.dewpoint_850mb == nil
|
||||
assert result.temp_700mb == nil
|
||||
assert result.dewpoint_700mb == nil
|
||||
assert result.lapse_rate == nil
|
||||
assert result.mid_lapse_rate == nil
|
||||
assert result.inversion_strength == 0.0
|
||||
assert result.inversion_base_m == nil
|
||||
end
|
||||
|
|
@ -128,7 +175,10 @@ defmodule Microwaveprop.Weather.WeatherLayersTest do
|
|||
|
||||
assert result.temp_850mb == nil
|
||||
assert result.dewpoint_850mb == nil
|
||||
assert result.temp_700mb == nil
|
||||
assert result.dewpoint_700mb == nil
|
||||
assert result.lapse_rate == nil
|
||||
assert result.mid_lapse_rate == nil
|
||||
assert result.inversion_strength == 0.0
|
||||
assert result.inversion_base_m == nil
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue