From 3ac6963c741a7f0697ef0463fab43247f3c0a67d Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 24 Apr 2026 19:27:19 -0500 Subject: [PATCH] feat(weather): add 700 mb T/Td and mid-layer lapse rate for cap diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- assets/js/weather_map_hook.ts | 50 +++++++++++++++++++ lib/microwaveprop/weather/weather_layers.ex | 31 +++++++++++- .../live/weather_map_live.ex | 24 +++++++++ .../weather/weather_layers_test.exs | 50 +++++++++++++++++++ 4 files changed, 154 insertions(+), 1 deletion(-) diff --git a/assets/js/weather_map_hook.ts b/assets/js/weather_map_hook.ts index 601f9d7b..2c62db6a 100644 --- a/assets/js/weather_map_hook.ts +++ b/assets/js/weather_map_hook.ts @@ -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 }, diff --git a/lib/microwaveprop/weather/weather_layers.ex b/lib/microwaveprop/weather/weather_layers.ex index 3b2bb904..b9628cba 100644 --- a/lib/microwaveprop/weather/weather_layers.ex +++ b/lib/microwaveprop/weather/weather_layers.ex @@ -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 diff --git a/lib/microwaveprop_web/live/weather_map_live.ex b/lib/microwaveprop_web/live/weather_map_live.ex index 10a3fb8b..a044fa5e 100644 --- a/lib/microwaveprop_web/live/weather_map_live.ex +++ b/lib/microwaveprop_web/live/weather_map_live.ex @@ -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", diff --git a/test/microwaveprop/weather/weather_layers_test.exs b/test/microwaveprop/weather/weather_layers_test.exs index 923c9f0b..29ba8409 100644 --- a/test/microwaveprop/weather/weather_layers_test.exs +++ b/test/microwaveprop/weather/weather_layers_test.exs @@ -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