diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index fd43883..d1b720c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -113,7 +113,7 @@ jobs: echo "primed $job: $(cat "$out")" } pids=() - for job in alerts temp windtex refc; do prime "$job" & pids+=($!); done + for job in alerts temp windtex refc cape; do prime "$job" & pids+=($!); done fail=0 for pid in "${pids[@]}"; do wait "$pid" || fail=1; done [ "$fail" -eq 0 ] || { echo "one or more prime jobs failed"; exit 1; } diff --git a/README.md b/README.md index ed3cd46..ea270f4 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Live weather on a deck.gl map, served almost entirely from free tiers: the whole bill is thirteen dollars a year of vanity domain plus a few cents a month for Route 53 and SES email. -OpenStreetMap basemap tiles come from [martin](https://github.com/maplibre/martin) running **inside AWS Lambda**, reading [PMTiles](https://docs.protomaps.com/pmtiles/) extracts straight from a private S3 bucket. A scheduled Rust lambda ([cargo-lambda](https://www.cargo-lambda.info/)) snapshots US-wide NWS alerts and decodes NOAA GFS GRIB2 straight from NOAA's open-data S3 bucket into global 2 m temperatures (a planet-wide lattice when zoomed out, per-city point forecasts when zoomed in), animated global wind, and a global precipitation forecast (GFS composite reflectivity) — city list from GeoNames. Precipitation shows the live RainViewer global radar composite (IEM NEXRAD as fallback) while the timeline is parked at *now*, then swaps to the GFS forecast raster as you scrub forward, so the whole map moves on one timeline. The web app is React + deck.gl + MapLibre, served from the same CloudFront distribution as the tiles and weather: one origin, no CORS, hashed assets cached immutable at the edge. Map views mirror into the URL hash, so any view is a link. +OpenStreetMap basemap tiles come from [martin](https://github.com/maplibre/martin) running **inside AWS Lambda**, reading [PMTiles](https://docs.protomaps.com/pmtiles/) extracts straight from a private S3 bucket. A scheduled Rust lambda ([cargo-lambda](https://www.cargo-lambda.info/)) snapshots US-wide NWS alerts and decodes NOAA GFS GRIB2 straight from NOAA's open-data S3 bucket into global 2 m temperatures (a planet-wide lattice when zoomed out, per-city point forecasts when zoomed in), animated global wind, a global precipitation forecast (GFS composite reflectivity), and a global storm-potential overlay (GFS surface CAPE) — city list from GeoNames. Precipitation shows the live RainViewer global radar composite (IEM NEXRAD as fallback) while the timeline is parked at *now*, then swaps to the GFS forecast raster as you scrub forward, so the whole map moves on one timeline. Storm potential (off by default) shades where the atmosphere is primed for thunderstorms, alongside the live NWS alerts. The web app is React + deck.gl + MapLibre, served from the same CloudFront distribution as the tiles and weather: one origin, no CORS, hashed assets cached immutable at the edge. Map views mirror into the URL hash, so any view is a link. > **Not an official weather source.** This is a hobby map on shoestring infrastructure: alerts refresh on a schedule, live radar lags by several minutes, the forecast precipitation is coarse 0.25° model output (it smooths over individual cells), zone-based NWS alerts (no polygon geometry) are not shown, and any piece can fail silently with no on-map indication. For decisions involving life or property, use [weather.gov](https://www.weather.gov/) and local emergency guidance. @@ -35,7 +35,7 @@ flowchart LR sched -->|"invokes"| ingest nws --> ingest gfs --> ingest - ingest -->|"PUT JSON + wind/precip PNGs"| bucket + ingest -->|"PUT JSON + wind/precip/cape PNGs"| bucket ``` ## What it costs @@ -135,19 +135,19 @@ One piece lives outside CloudFormation: the stormdeck.live certificate was reque | Lattice ↔ city temp switch | `GRID_ZOOM_SPLIT` in `web/src/config.ts` | z6.5 | | Map start view | `web/src/config.ts` (URL hash wins) | world, z0 | | World context detail | `WORLD_MAXZOOM` env for `just tiles extract` | z0-6 | -| Schedules | `cdk/lib/stormdeck-stack.ts` | alerts 5 min, temp 6 h, windtex 6 h | +| Schedules | `cdk/lib/stormdeck-stack.ts` | alerts 5 min; temp / windtex / refc / cape 6 h | -The `bbox` sets the full-detail basemap region; outside it the map shows the coarse world tiles. Temperature, wind, and the precipitation forecast are global (GFS), so they ignore it. +The `bbox` sets the full-detail basemap region; outside it the map shows the coarse world tiles. Temperature, wind, the precipitation forecast, and storm potential are global (GFS), so they ignore it. ## Notes - **martin-in-Lambda**: martin ≥ v0.14 detects `AWS_LAMBDA_RUNTIME_API` and serves Lambda events natively. The zip is just the upstream `aarch64-musl` binary plus a two-line `bootstrap`. The function URL is IAM-auth; only CloudFront (OAC SigV4) may invoke it. - **No aws-sdk in the ingester**: it only PUTs a handful of small objects, JSON snapshots plus the GFS wind PNGs, so it signs the request itself (SigV4, ~80 lines, test vector included). As of June 2026 the SDK also doesn't compile (aws-runtime 1.7.4 vs aws-smithy-runtime-api 1.12.3 skew). Check back later if you need more S3 surface. - **Zone-based NWS alerts** (no polygon geometry) are dropped; rendering them would mean shipping zone shapefiles. Counted in the lambda logs. -- **GFS straight from GRIB2**: temperature, wind, and the precipitation forecast all come from NOAA GFS with no per-point API metering: the ingester pulls 0.25° UGRD/VGRD/TMP/REFC fields from NOAA's public `noaa-gfs-bdp-pds` S3 bucket and decodes the GRIB2 itself, so one ~0.9 MB field covers the whole planet (1440×721) and any number of points sample for free. One pass writes a whole-planet `lattice.json` (the zoomed-out grid) plus per-city tiles (zoomed in), sampled from the same TMP fields, so the grid costs no extra fetches; the wind u/v PNGs (±40 m/s); and the precipitation PNGs (composite reflectivity, a grayscale dBZ texture over −20…75 dBZ — GFS floors clear sky at ~−20 dBZ, so the web renders that transparent). All carry the model run's snapshot so a new run refetches cleanly, and all share one forecast-hour axis so the timeline scrubs grid, cities, wind, and precipitation together. +- **GFS straight from GRIB2**: temperature, wind, the precipitation forecast, and storm potential all come from NOAA GFS with no per-point API metering: the ingester pulls 0.25° UGRD/VGRD/TMP/REFC/CAPE fields from NOAA's public `noaa-gfs-bdp-pds` S3 bucket and decodes the GRIB2 itself, so one ~0.9 MB field covers the whole planet (1440×721) and any number of points sample for free. One pass writes a whole-planet `lattice.json` (the zoomed-out grid) plus per-city tiles (zoomed in), sampled from the same TMP fields, so the grid costs no extra fetches; the wind u/v PNGs (±40 m/s); the precipitation PNGs (composite reflectivity, a grayscale dBZ texture over −20…75 dBZ — GFS floors clear sky at ~−20 dBZ, so the web renders that transparent); and the storm-potential PNGs (surface CAPE, a grayscale texture over 0…5000 J/kg, faded out below ~250 J/kg). All carry the model run's snapshot so a new run refetches cleanly, and all share one forecast-hour axis so the timeline scrubs grid, cities, wind, precipitation, and storm potential together. ## Attribution -Map data © [OpenStreetMap](https://openstreetmap.org/copyright) contributors, tiles via [Protomaps](https://protomaps.com) builds (ODbL). Radar: [RainViewer](https://www.rainviewer.com/) global composite (free tier, attribution required), falling back to NOAA NEXRAD via the [Iowa Environmental Mesonet](https://mesonet.agron.iastate.edu/). Alerts: [National Weather Service](https://www.weather.gov/) (public domain). Temperatures, wind, and the precipitation forecast (composite reflectivity): [NOAA GFS](https://registry.opendata.aws/noaa-gfs-bdp-pds/) via NOAA Open Data Dissemination (public domain). City list: [GeoNames](https://www.geonames.org/) (CC-BY 4.0). +Map data © [OpenStreetMap](https://openstreetmap.org/copyright) contributors, tiles via [Protomaps](https://protomaps.com) builds (ODbL). Radar: [RainViewer](https://www.rainviewer.com/) global composite (free tier, attribution required), falling back to NOAA NEXRAD via the [Iowa Environmental Mesonet](https://mesonet.agron.iastate.edu/). Alerts: [National Weather Service](https://www.weather.gov/) (public domain). Temperatures, wind, the precipitation forecast (composite reflectivity), and storm potential (surface CAPE): [NOAA GFS](https://registry.opendata.aws/noaa-gfs-bdp-pds/) via NOAA Open Data Dissemination (public domain). City list: [GeoNames](https://www.geonames.org/) (CC-BY 4.0). MIT. diff --git a/cdk/lib/stormdeck-stack.ts b/cdk/lib/stormdeck-stack.ts index 8075ff4..7169126 100644 --- a/cdk/lib/stormdeck-stack.ts +++ b/cdk/lib/stormdeck-stack.ts @@ -55,14 +55,15 @@ export class StormdeckStack extends Stack { enforceSSL: true, removalPolicy: RemovalPolicy.DESTROY, autoDeleteObjects: true, - // citytile + windtex + refctex each write a fresh {snapshot}/ tree per - // run; expire old snapshots so they don't accumulate. Their latest.json - // pointers are rewritten each run, so they stay under the age cutoff and - // never age out. + // citytile + windtex + refctex + capetex each write a fresh {snapshot}/ + // tree per run; expire old snapshots so they don't accumulate. Their + // latest.json pointers are rewritten each run, so they stay under the age + // cutoff and never age out. lifecycleRules: [ { prefix: 'weather/citytile/', expiration: Duration.days(2) }, { prefix: 'weather/windtex/', expiration: Duration.days(2) }, { prefix: 'weather/refctex/', expiration: Duration.days(2) }, + { prefix: 'weather/capetex/', expiration: Duration.days(2) }, ], }); @@ -102,12 +103,12 @@ export class StormdeckStack extends Stack { manifestPath: CRATES_MANIFEST, binaryName: 'weather-ingest', architecture: lambda.Architecture.ARM_64, - // temp + windtex + refc decode GFS GRIB fields (~4 MB grids, several in - // flight; windtex also holds a u/v pair while its PNG encodes); alerts is - // lighter. + // temp + windtex + refc + cape decode GFS GRIB fields (~4 MB grids, + // several in flight; windtex also holds a u/v pair while its PNG encodes); + // alerts is lighter. memorySize: 512, // temp samples ~57 GFS TMP fields (cities + lattice); windtex decodes u/v - // pairs and refc single REFC fields into PNGs — all well within 600s. + // pairs, refc/cape single REFC/CAPE fields into PNGs — all within 600s. timeout: Duration.seconds(600), environment: { BUCKET: bucket.bucketName, @@ -298,12 +299,13 @@ export class StormdeckStack extends Stack { // EventBridge Scheduler triggers (14M invocations/month free tier). Every // weather job is free GFS GRIB from NODD (no per-call limit) plus the public // NWS alerts feed, so the cadence is bounded by freshness, not API quotas; - // temp + windtex + refc refresh once per GFS cycle window. + // temp + windtex + refc + cape refresh once per GFS cycle window. const jobs: Array<[string, Duration]> = [ ['alerts', Duration.minutes(5)], ['temp', Duration.hours(6)], ['windtex', Duration.hours(6)], ['refc', Duration.hours(6)], + ['cape', Duration.hours(6)], ]; for (const [job, every] of jobs) { new scheduler.Schedule(this, `Schedule-${job}`, { diff --git a/crates/weather-ingest/src/contract.rs b/crates/weather-ingest/src/contract.rs index 73f25da..ea4d0ad 100644 --- a/crates/weather-ingest/src/contract.rs +++ b/crates/weather-ingest/src/contract.rs @@ -205,6 +205,31 @@ pub struct RefcTexIndex { pub dbz_max: f32, } +/// The `capetex/latest.json` pointer: the current snapshot, its forecast-hour +/// axis, the equirectangular texture dimensions, and the CAPE (J/kg) bounds the +/// grayscale PNG was normalized over (so the web denormalizes identically). The +/// per-step textures live at `capetex/{snapshotMs}/{hour}.png` (immutable); the +/// web's storm-potential overlay loads whichever step the timeline is on. Same +/// axis as windtex/refctex/citytile, so the one timeline scrubs it with the rest. +#[derive(Serialize)] +#[cfg_attr(feature = "ts", derive(specta::Type))] +pub struct CapeTexIndex { + #[serde(rename = "snapshotMs")] + #[cfg_attr(feature = "ts", specta(type = specta_typescript::Number))] + pub snapshot_ms: u64, + pub hours: Vec, + pub width: u32, + pub height: u32, + // Always-present reals; the `Number` override avoids the `number | null` + // that a bare f32 exports (same trick as CityForecast::t). + #[serde(rename = "capeMin")] + #[cfg_attr(feature = "ts", specta(type = specta_typescript::Number))] + pub cape_min: f32, + #[serde(rename = "capeMax")] + #[cfg_attr(feature = "ts", specta(type = specta_typescript::Number))] + pub cape_max: f32, +} + #[cfg(all(test, feature = "ts"))] mod export { use specta::Types; @@ -221,7 +246,8 @@ mod export { .register::() .register::() .register::() - .register::(); + .register::() + .register::(); Typescript::default() .header("// Generated from crates/weather-ingest/src/contract.rs by `just build types`. Do not edit.\n") .export_to( diff --git a/crates/weather-ingest/src/gfs.rs b/crates/weather-ingest/src/gfs.rs index 68ae11b..34c6fdf 100644 --- a/crates/weather-ingest/src/gfs.rs +++ b/crates/weather-ingest/src/gfs.rs @@ -31,6 +31,14 @@ pub const WIND_MS_MAX: f32 = 40.0; pub const REFC_DBZ_MIN: f32 = -20.0; pub const REFC_DBZ_MAX: f32 = 75.0; +/// Surface CAPE (convective available potential energy, J/kg) normalization +/// range for the storm-potential texture, same scheme as [`REFC_DBZ_MIN`]. 0 is +/// a stable atmosphere; the web renders below its display threshold (~250 J/kg, +/// marginal instability) transparent. 5000 J/kg caps all but the most extreme +/// setups (observed global max ~4800), which clamp. +pub const CAPE_JKG_MIN: f32 = 0.0; +pub const CAPE_JKG_MAX: f32 = 5000.0; + /// A decoded 0.25° global field, row-major from (90N, 0E). pub struct Field { values: Vec, diff --git a/crates/weather-ingest/src/main.rs b/crates/weather-ingest/src/main.rs index bf5df62..1525b8a 100644 --- a/crates/weather-ingest/src/main.rs +++ b/crates/weather-ingest/src/main.rs @@ -3,14 +3,15 @@ //! Sources: //! - NWS active alerts (api.weather.gov, public domain) //! - NOAA GFS via NODD (public S3, no auth): 2 m temperature as a whole-planet -//! lattice + per-city tiles, 10 m wind as u/v textures, and composite -//! reflectivity (REFC) as precip textures — all decoded from GRIB2 in -//! `gfs.rs`, so one ~0.9 MB field covers the planet for free. +//! lattice + per-city tiles, 10 m wind as u/v textures, composite +//! reflectivity (REFC) as precip textures, and surface CAPE as +//! storm-potential textures — all decoded from GRIB2 in `gfs.rs`, so one +//! ~0.9 MB field covers the planet for free. //! //! Runs in two modes: //! - AWS Lambda (AWS_LAMBDA_RUNTIME_API set): writes to s3://$BUCKET/weather/, //! invoked by EventBridge Scheduler with -//! {"job": "alerts" | "temp" | "windtex" | "refc" | "all"} +//! {"job": "alerts" | "temp" | "windtex" | "refc" | "cape" | "all"} //! - CLI (`cargo run -p weather-ingest -- all`): writes to $LOCAL_OUT //! (default web/public/weather/) so `just dev` has live data. @@ -24,8 +25,8 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use anyhow::{bail, Context, Result}; use contract::{ - AlertProps, CityForecast, CityTile, CityTileIndex, LatticeForecast, RefcTexIndex, Severity, - Snapshot, WindTexIndex, + AlertProps, CapeTexIndex, CityForecast, CityTile, CityTileIndex, LatticeForecast, RefcTexIndex, + Severity, Snapshot, WindTexIndex, }; use lambda_runtime::LambdaEvent; use serde::Deserialize; @@ -398,51 +399,52 @@ async fn fetch_windtex(http: &reqwest::Client, sink: &Sink) -> Result<()> { Ok(()) } -// --- precipitation: composite-reflectivity textures (the forecast precip raster) - - -/// Forecast horizon for the REFC precip textures — the same axis as citytile and -/// windtex, so the one map-wide timeline scrubs precip with everything else. -const REFCTEX_FHOUR_MAX: u16 = 168; // 7 days -const REFCTEX_STEP_H: u16 = 3; // 3-hourly - -/// Composite-reflectivity (REFC) textures, one per forecast step: an -/// equirectangular 8-bit grayscale PNG of dBZ normalized over -/// [`gfs::REFC_DBZ_MIN`, `gfs::REFC_DBZ_MAX`] — the model's depiction of -/// precipitation that the web colormaps and overlays when the timeline is -/// scrubbed into the future (live radar stays the truth for "now"). GFS packs -/// no-echo at a ~−20 dBZ floor, so the clear majority maps to byte 0 (rendered -/// transparent). Snapshot-addressed like windtex (`refctex/{snapshot}/{fhour}.png`, -/// immutable) with a short-lived `refctex/latest.json` index carrying the hour -/// axis, texture dims, and the dBZ bounds the web denormalizes with. One ~0.9 MB -/// GFS field covers the whole planet, free. -async fn fetch_refctex(http: &reqwest::Client, sink: &Sink) -> Result<()> { +// --- scalar GFS textures: one field per step, colormapped client-side ---------- + +/// Forecast horizon for the scalar GFS textures (REFC precip, CAPE storm +/// potential) — the same axis as citytile and windtex, so the one map-wide +/// timeline scrubs them all together. +const SCALARTEX_FHOUR_MAX: u16 = 168; // 7 days +const SCALARTEX_STEP_H: u16 = 3; // 3-hourly + +/// Fetch one GFS scalar field per forecast step, encode each to an +/// equirectangular grayscale PNG over `[min, max]`, and write +/// `{prefix}/{snapshot}/{fhour}.png` (immutable). Returns the snapshot epoch-ms +/// and the forecast-hour axis for the caller's typed `{prefix}/latest.json` +/// index. One ~0.9 MB GFS field covers the whole planet, free; concurrency is +/// capped so peak memory stays well under the function's 512 MB (one ~1M-point +/// f32 grid resident per in-flight step while its PNG encodes). +async fn fetch_scalar_tex( + http: &reqwest::Client, + sink: &Sink, + var: &str, + level: &str, + prefix: &str, + min: f32, + max: f32, +) -> Result<(u64, Vec)> { let started = Instant::now(); let (date, cyc) = gfs::latest_cycle(now_ms() / 1000); let snapshot_ms = gfs::cycle_ms(&date, cyc)?; - let fhours: Vec = (0..=REFCTEX_FHOUR_MAX) - .step_by(REFCTEX_STEP_H as usize) + let fhours: Vec = (0..=SCALARTEX_FHOUR_MAX) + .step_by(SCALARTEX_STEP_H as usize) .collect(); - // One ~1M-point f32 grid resident per in-flight step while its PNG encodes; - // cap concurrency so peak memory stays well under the function's 512 MB. const CONC: usize = 6; for chunk in fhours.chunks(CONC) { let mut set = tokio::task::JoinSet::new(); for &fh in chunk { - let (http, date) = (http.clone(), date.clone()); + let (http, date, var, level) = + (http.clone(), date.clone(), var.to_owned(), level.to_owned()); set.spawn(async move { - let field = - gfs::fetch_field(&http, &date, cyc, fh, "REFC", "entire atmosphere").await?; - anyhow::Ok(( - fh, - gfs::encode_scalar_png(&field, gfs::REFC_DBZ_MIN, gfs::REFC_DBZ_MAX)?, - )) + let field = gfs::fetch_field(&http, &date, cyc, fh, &var, &level).await?; + anyhow::Ok((fh, gfs::encode_scalar_png(&field, min, max)?)) }); } while let Some(res) = set.join_next().await { let (fh, png) = res??; sink.publish_bytes( - &format!("refctex/{snapshot_ms}/{fh}.png"), + &format!("{prefix}/{snapshot_ms}/{fh}.png"), png, "image/png", 31_536_000, @@ -450,7 +452,31 @@ async fn fetch_refctex(http: &reqwest::Client, sink: &Sink) -> Result<()> { .await?; } } + info!( + "{prefix}: {} steps → PNGs ({}×{}, GFS {date}/{cyc:02}z) in {:.1?}", + fhours.len(), + gfs::TEX_WIDTH, + gfs::TEX_HEIGHT, + started.elapsed() + ); + Ok((snapshot_ms, fhours)) +} +/// Composite-reflectivity (REFC) precip textures — the model's depiction of +/// precipitation the web overlays when the timeline is scrubbed into the future +/// (live radar stays the truth for "now"). GFS floors no-echo at ~−20 dBZ → byte +/// 0, which the web renders transparent. +async fn fetch_refctex(http: &reqwest::Client, sink: &Sink) -> Result<()> { + let (snapshot_ms, fhours) = fetch_scalar_tex( + http, + sink, + "REFC", + "entire atmosphere", + "refctex", + gfs::REFC_DBZ_MIN, + gfs::REFC_DBZ_MAX, + ) + .await?; let index = RefcTexIndex { snapshot_ms, hours: fhours.iter().map(|&f| u32::from(f)).collect(), @@ -460,15 +486,34 @@ async fn fetch_refctex(http: &reqwest::Client, sink: &Sink) -> Result<()> { dbz_max: gfs::REFC_DBZ_MAX, }; sink.publish("refctex/latest.json", &serde_json::to_value(index)?, 300) - .await?; - info!( - "refctex: {} steps → PNGs ({}×{}, GFS {date}/{cyc:02}z) in {:.1?}", - fhours.len(), - gfs::TEX_WIDTH, - gfs::TEX_HEIGHT, - started.elapsed() - ); - Ok(()) + .await +} + +/// Surface-CAPE storm-potential textures — where the atmosphere is primed for +/// convection (J/kg). Complementary to precip + alerts, global on the shared +/// axis; stable air is 0 J/kg → byte 0, and the web renders below its display +/// threshold transparent. +async fn fetch_capetex(http: &reqwest::Client, sink: &Sink) -> Result<()> { + let (snapshot_ms, fhours) = fetch_scalar_tex( + http, + sink, + "CAPE", + "surface", + "capetex", + gfs::CAPE_JKG_MIN, + gfs::CAPE_JKG_MAX, + ) + .await?; + let index = CapeTexIndex { + snapshot_ms, + hours: fhours.iter().map(|&f| u32::from(f)).collect(), + width: gfs::TEX_WIDTH, + height: gfs::TEX_HEIGHT, + cape_min: gfs::CAPE_JKG_MIN, + cape_max: gfs::CAPE_JKG_MAX, + }; + sink.publish("capetex/latest.json", &serde_json::to_value(index)?, 300) + .await } #[derive(Clone)] @@ -540,8 +585,12 @@ async fn run_job(job: &str, cfg: &Config, http: &reqwest::Client, sink: &Sink) - fetch_refctex(http, sink).await?; done.push("refc"); } + if matches!(job, "cape" | "all") { + fetch_capetex(http, sink).await?; + done.push("cape"); + } if done.is_empty() { - bail!("unknown job '{job}' (expected alerts | temp | windtex | refc | all)"); + bail!("unknown job '{job}' (expected alerts | temp | windtex | refc | cape | all)"); } Ok(json!({ "ok": true, "jobs": done })) } diff --git a/web/src/App.tsx b/web/src/App.tsx index 1f70cc7..3b74546 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -116,7 +116,7 @@ export default function App() { attributionControl={{ compact: false, customAttribution: - 'Radar: RainViewer / NOAA · Alerts: NWS · Temps, wind & precip forecast: NOAA GFS · Cities: GeoNames', + 'Radar: RainViewer / NOAA · Alerts: NWS · Temps, wind, precip & storm forecast: NOAA GFS · Cities: GeoNames', }} > diff --git a/web/src/generated/weather.ts b/web/src/generated/weather.ts index 506b4ec..f57bd63 100644 --- a/web/src/generated/weather.ts +++ b/web/src/generated/weather.ts @@ -12,6 +12,23 @@ export type AlertProps = { expires: string | null; }; +/** + * The `capetex/latest.json` pointer: the current snapshot, its forecast-hour + * axis, the equirectangular texture dimensions, and the CAPE (J/kg) bounds the + * grayscale PNG was normalized over (so the web denormalizes identically). The + * per-step textures live at `capetex/{snapshotMs}/{hour}.png` (immutable); the + * web's storm-potential overlay loads whichever step the timeline is on. Same + * axis as windtex/refctex/citytile, so the one timeline scrubs it with the rest. + */ +export type CapeTexIndex = { + snapshotMs: number; + hours: number[]; + width: number; + height: number; + capeMin: number; + capeMax: number; +}; + /** * One city's point forecast: its name and a temperature series (°F) aligned * element-for-element with the enclosing tile's `hours` axis. diff --git a/web/src/layers/cape.tsx b/web/src/layers/cape.tsx new file mode 100644 index 0000000..3742b1f --- /dev/null +++ b/web/src/layers/cape.tsx @@ -0,0 +1,54 @@ +import { Slider } from '@/components/ui/slider'; +import { WEATHER_BASE } from '../config'; +import type { CapeTexIndex } from '../generated/weather'; +import { nearestStep } from '../Timeline'; +import { age } from '../weather'; +import { CapeRasterLayer } from './capeRasterLayer'; +import { Swatch } from './swatch'; +import type { WeatherLayer } from './types'; + +/** + * Storm potential: GFS surface CAPE (convective available potential energy) as a + * scrubbing raster — where the atmosphere is primed for thunderstorms. It's + * always a forecast (no live analog like radar has), so it renders the step + * nearest the map-wide time at every position. Off by default: a specialist + * overlay beside the live NWS alerts, which stay the authoritative "now" hazard + * layer. The texture swaps via the `image` prop as the timeline scrubs. + */ +export const cape: WeatherLayer = { + id: 'cape', + label: () => 'storm potential', + legend: ( + + ), + defaultVisible: false, + initialUi: { opacity: 0.5 }, + select: (w) => w.capeTex, + build: (idx, ctx) => { + const step = nearestStep(idx.hours, ctx.time); + return [ + new CapeRasterLayer({ + id: 'cape-raster', + image: `${WEATHER_BASE}/weather/capetex/${idx.snapshotMs}/${step}.png`, + capeMin: idx.capeMin, + capeMax: idx.capeMax, + opacity: ctx.ui.opacity ?? 0.5, + }), + ]; + }, + controls: (ctx, idx) => ( +
+
+ CAPE · GFS · {age(idx?.snapshotMs)} +
+ ctx.setUi({ opacity: v })} + aria-label="storm potential opacity" + /> +
+ ), +}; diff --git a/web/src/layers/capeRasterLayer.ts b/web/src/layers/capeRasterLayer.ts new file mode 100644 index 0000000..a2c237c --- /dev/null +++ b/web/src/layers/capeRasterLayer.ts @@ -0,0 +1,152 @@ +// The GFS storm-potential raster: the same full-world lng/lat mesh as the wind +// backdrop (projected through deck's `project32`), but the fragment shader samples +// the single-channel `capetex` surface-CAPE texture, fades out stable air below a +// display threshold (→ transparent), and colormaps the rest on the severe-weather +// instability scale. Always a forecast (CAPE has no live analog). Mirrors +// RefcRasterLayer / WindRasterLayer. + +import { + Layer, + type LayerContext, + type LayerProps, + project32, + type UpdateParameters, +} from '@deck.gl/core'; +import { Geometry, Model } from '@luma.gl/engine'; +import { + CAPE_RAMP_GLSL, + EQUIRECT_RASTER_VS, + equirectGridMesh, + loadEquirectTexture, +} from './rasterShared'; + +// std140 UBO (luma v9 has no setUniforms): the J/kg bounds to denormalize the +// texture byte with, and the fill opacity. +const RASTER_UNIFORM_BLOCK = /* glsl */ `\ +layout(std140) uniform capeUniforms { + float capeMin; + float capeMax; + float opacity; +} raster; +`; + +// Typed `any`: luma's ShaderModule generic isn't worth threading for a local UBO. +// `name` MUST match the UBO block prefix: luma derives the block it binds as +// `${name}Uniforms` (shadertools getShaderModuleUniformBlockName), so a 'raster' +// name with a `capeUniforms` block silently fails to bind (uniforms read 0). +const capeUniforms: any = { + name: 'cape', + vs: RASTER_UNIFORM_BLOCK, + fs: RASTER_UNIFORM_BLOCK, + uniformTypes: { + capeMin: 'f32', + capeMax: 'f32', + opacity: 'f32', + }, +}; + +const FS = /* glsl */ `#version 300 es +#define SHADER_NAME cape-raster-fragment +precision highp float; +uniform sampler2D u_cape; +in vec2 v_uv; +out vec4 fragColor; +${CAPE_RAMP_GLSL} +void main() { + // Grayscale texture: CAPE (J/kg) packed in the red channel over [capeMin, capeMax]. + float cape = mix(raster.capeMin, raster.capeMax, texture(u_cape, v_uv).r); + // Stable / weakly-unstable air (below ~250 J/kg) fades out; only air primed for + // convection paints. Fade in across the threshold band so edges aren't a hard cut. + float a = smoothstep(250.0, 800.0, cape) * raster.opacity; + if (a <= 0.0) discard; + fragColor = vec4(capeRamp(cape), a); +}`; + +export type CapeRasterLayerProps = LayerProps & { + image: string; + capeMin: number; + capeMax: number; + opacity?: number; +}; + +const defaultProps = { + image: '', + capeMin: 0, + capeMax: 5000, + opacity: 0.5, +}; + +export class CapeRasterLayer extends Layer { + static layerName = 'CapeRasterLayer'; + static defaultProps = defaultProps as never; + + // Typed `any`: deck's layer state is loosely typed; the GPU resources are local. + declare state: any; + + initializeState(): void { + const { device } = this.context; + const mesh = equirectGridMesh(90, 140); + const model = new Model(device, { + id: `${this.props.id}-mesh`, + vs: EQUIRECT_RASTER_VS, + fs: FS, + modules: [project32, capeUniforms], + geometry: new Geometry({ + topology: 'triangle-list', + vertexCount: mesh.length / 2, + attributes: { a_lnglat: { size: 2, value: mesh } }, + }), + parameters: { + blend: true, + blendColorSrcFactor: 'src-alpha', + blendColorDstFactor: 'one-minus-src-alpha', + blendAlphaSrcFactor: 'one', + blendAlphaDstFactor: 'one-minus-src-alpha', + depthWriteEnabled: false, + depthCompare: 'always', + }, + disableWarnings: true, + }); + this.setState({ model }); + } + + updateState(params: UpdateParameters): void { + const { props, oldProps } = params; + if (props.image && props.image !== oldProps.image) { + void this._loadCape(props.image); + } + } + + async _loadCape(url: string): Promise { + const tex = await loadEquirectTexture(this.context.device, url).catch( + () => null, + ); + if (!tex || this.props.image !== url) { + tex?.destroy(); + return; + } + this.state.capeTexture?.destroy(); + this.setState({ capeTexture: tex }); + this.setNeedsRedraw(); + } + + draw(): void { + const { model, capeTexture } = this.state; + if (!capeTexture) return; + model.setBindings({ u_cape: capeTexture }); + model.shaderInputs.setProps({ + cape: { + capeMin: this.props.capeMin, + capeMax: this.props.capeMax, + opacity: this.props.opacity ?? 0.5, + }, + }); + model.draw(this.context.renderPass); + } + + finalizeState(context: LayerContext): void { + super.finalizeState(context); + this.state.capeTexture?.destroy(); + this.state.model?.destroy(); + } +} diff --git a/web/src/layers/index.ts b/web/src/layers/index.ts index b597e05..69f9033 100644 --- a/web/src/layers/index.ts +++ b/web/src/layers/index.ts @@ -1,4 +1,5 @@ import { alerts } from './alerts'; +import { cape } from './cape'; import { precip } from './precip'; import { temp } from './temp'; import type { WeatherLayer } from './types'; @@ -7,8 +8,8 @@ import { wind } from './wind'; export type { LayerCtx, WeatherLayer } from './types'; /** - * The layer registry. Array order is paint order (precipitation at the bottom, - * temps on top). Adding a layer is a one-line change here plus its module — `App` - * never has to learn the layer exists. + * The layer registry. Array order is paint order (the filled rasters at the + * bottom, vector/point layers on top). Adding a layer is a one-line change here + * plus its module — `App` never has to learn the layer exists. */ -export const LAYERS: WeatherLayer[] = [precip, alerts, wind, temp]; +export const LAYERS: WeatherLayer[] = [precip, cape, alerts, wind, temp]; diff --git a/web/src/layers/rasterShared.ts b/web/src/layers/rasterShared.ts index 3c95c82..ce1180c 100644 --- a/web/src/layers/rasterShared.ts +++ b/web/src/layers/rasterShared.ts @@ -115,3 +115,21 @@ vec3 refcRamp(float dbz) { if (s < 3.0) return mix(c2, c3, s - 2.0); return mix(c3, c4, s - 3.0); }`; + +/** Surface-CAPE (J/kg) colormap: green → yellow → orange → red → magenta across + * ~500→4500 J/kg, the severe-weather instability scale. The storm-potential + * layer fades out stable/weak air (< ~250 J/kg) so the overlay only paints where + * the atmosphere is primed for convection. */ +export const CAPE_RAMP_GLSL = /* glsl */ ` +vec3 capeRamp(float cape) { + const vec3 c0 = vec3(0.30, 0.66, 0.36); // ~500 green (weak) + const vec3 c1 = vec3(0.93, 0.86, 0.32); // ~1500 yellow (moderate) + const vec3 c2 = vec3(0.95, 0.55, 0.20); // ~2500 orange (strong) + const vec3 c3 = vec3(0.86, 0.24, 0.24); // ~3500 red (severe) + const vec3 c4 = vec3(0.72, 0.26, 0.66); // ~4500 magenta (extreme) + float s = clamp((cape - 500.0) / 1000.0, 0.0, 4.0); + if (s < 1.0) return mix(c0, c1, s); + if (s < 2.0) return mix(c1, c2, s - 1.0); + if (s < 3.0) return mix(c2, c3, s - 2.0); + return mix(c3, c4, s - 3.0); +}`; diff --git a/web/src/layers/refcRasterLayer.ts b/web/src/layers/refcRasterLayer.ts index 94d5bff..5694685 100644 --- a/web/src/layers/refcRasterLayer.ts +++ b/web/src/layers/refcRasterLayer.ts @@ -31,8 +31,11 @@ layout(std140) uniform refcUniforms { `; // Typed `any`: luma's ShaderModule generic isn't worth threading for a local UBO. +// `name` MUST match the UBO block prefix: luma derives the block it binds as +// `${name}Uniforms` (shadertools getShaderModuleUniformBlockName), so a 'raster' +// name with a `refcUniforms` block silently fails to bind (uniforms read 0). const refcUniforms: any = { - name: 'raster', + name: 'refc', vs: RASTER_UNIFORM_BLOCK, fs: RASTER_UNIFORM_BLOCK, uniformTypes: { @@ -133,7 +136,7 @@ export class RefcRasterLayer extends Layer { if (!refcTexture) return; model.setBindings({ u_refc: refcTexture }); model.shaderInputs.setProps({ - raster: { + refc: { dbzMin: this.props.dbzMin, dbzMax: this.props.dbzMax, opacity: this.props.opacity ?? 0.65, diff --git a/web/src/weather.ts b/web/src/weather.ts index 739d5dd..7905b03 100644 --- a/web/src/weather.ts +++ b/web/src/weather.ts @@ -9,6 +9,7 @@ import { import type { FeatureCollection, Geometry, Point } from './generated/geojson'; import type { AlertProps, + CapeTexIndex, CityTileIndex, LatticeForecast, RefcTexIndex, @@ -88,6 +89,11 @@ export const useWindTex = () => export const useRefcTex = () => useFeed('refctex/latest.json', 600_000); +/** The surface-CAPE texture index (snapshot + forecast hours + J/kg bounds). The + * storm-potential overlay loads the per-step PNG nearest the map-wide timeline. */ +export const useCapeTex = () => + useFeed('capetex/latest.json', 600_000); + /** * Latest worldwide radar frame from RainViewer. Falls back to the IEM * NEXRAD composite (US only) until — or unless — the API answers. @@ -136,6 +142,8 @@ export interface WeatherData { windTex: WindTexIndex | null; /** REFC precip texture index (snapshot + forecast hours + dBZ bounds). */ refcTex: RefcTexIndex | null; + /** Surface-CAPE texture index (snapshot + forecast hours + J/kg bounds). */ + capeTex: CapeTexIndex | null; } /** One hook, all feeds — keeps the layer registry itself hook-free. The @@ -148,5 +156,6 @@ export function useWeatherData(): WeatherData { const cityTiles = useCityTiles(); const windTex = useWindTex(); const refcTex = useRefcTex(); - return { alerts, radar, lattice, cityTiles, windTex, refcTex }; + const capeTex = useCapeTex(); + return { alerts, radar, lattice, cityTiles, windTex, refcTex, capeTex }; }