Skip to content

Commit ed792f1

Browse files
Add a GFS storm-potential (surface CAPE) overlay
A new default-off layer shading where the atmosphere is primed for thunderstorms, alongside the live NWS alerts. Surface CAPE (J/kg) is decoded from the same GFS GRIB2 source as temperature/wind/precip and shipped as a grayscale equirectangular texture (0..5000 J/kg) that the web colormaps on the GPU and scrubs on the shared forecast timeline. - ingest: generic fetch_scalar_tex helper (shared by refc + cape) writes capetex/{snapshot}/{hour}.png + capetex/latest.json; CapeTexIndex contract type; CAPE bounds in gfs.rs; cape/all job dispatch. - web: CapeRasterLayer (mirrors RefcRasterLayer) + cape layer with a CAPE colormap and opacity control; useCapeTex feed. - cdk: cape schedule (6 h) + capetex/ lifecycle (2-day expiry). - deploy: prime cape after stack deploy so the feed never 404s. - docs: README + on-map attribution cover storm potential / GFS CAPE. Also fix the equirect raster UBO binding: luma derives the bound block name as `${module.name}Uniforms`, so a 'raster' module name with a refcUniforms/capeUniforms block silently never binds (uniforms read 0 → everything discards → blank). Name the modules 'refc'/'cape' to match. This also repairs the precip GFS-forecast raster, which had the same latent mismatch (live radar was unaffected).
1 parent dcfadb0 commit ed792f1

14 files changed

Lines changed: 410 additions & 71 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ jobs:
113113
echo "primed $job: $(cat "$out")"
114114
}
115115
pids=()
116-
for job in alerts temp windtex refc; do prime "$job" & pids+=($!); done
116+
for job in alerts temp windtex refc cape; do prime "$job" & pids+=($!); done
117117
fail=0
118118
for pid in "${pids[@]}"; do wait "$pid" || fail=1; done
119119
[ "$fail" -eq 0 ] || { echo "one or more prime jobs failed"; exit 1; }

README.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
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.
88

9-
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.
9+
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.
1010

1111
> **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.
1212
@@ -35,7 +35,7 @@ flowchart LR
3535
sched -->|"invokes"| ingest
3636
nws --> ingest
3737
gfs --> ingest
38-
ingest -->|"PUT JSON + wind/precip PNGs"| bucket
38+
ingest -->|"PUT JSON + wind/precip/cape PNGs"| bucket
3939
```
4040

4141
## What it costs
@@ -135,19 +135,19 @@ One piece lives outside CloudFormation: the stormdeck.live certificate was reque
135135
| Lattice ↔ city temp switch | `GRID_ZOOM_SPLIT` in `web/src/config.ts` | z6.5 |
136136
| Map start view | `web/src/config.ts` (URL hash wins) | world, z0 |
137137
| World context detail | `WORLD_MAXZOOM` env for `just tiles extract` | z0-6 |
138-
| Schedules | `cdk/lib/stormdeck-stack.ts` | alerts 5 min, temp 6 h, windtex 6 h |
138+
| Schedules | `cdk/lib/stormdeck-stack.ts` | alerts 5 min; temp / windtex / refc / cape 6 h |
139139

140-
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.
140+
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.
141141

142142
## Notes
143143

144144
- **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.
145145
- **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.
146146
- **Zone-based NWS alerts** (no polygon geometry) are dropped; rendering them would mean shipping zone shapefiles. Counted in the lambda logs.
147-
- **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.
147+
- **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.
148148

149149
## Attribution
150150

151-
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).
151+
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).
152152

153153
MIT.

cdk/lib/stormdeck-stack.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -55,14 +55,15 @@ export class StormdeckStack extends Stack {
5555
enforceSSL: true,
5656
removalPolicy: RemovalPolicy.DESTROY,
5757
autoDeleteObjects: true,
58-
// citytile + windtex + refctex each write a fresh {snapshot}/ tree per
59-
// run; expire old snapshots so they don't accumulate. Their latest.json
60-
// pointers are rewritten each run, so they stay under the age cutoff and
61-
// never age out.
58+
// citytile + windtex + refctex + capetex each write a fresh {snapshot}/
59+
// tree per run; expire old snapshots so they don't accumulate. Their
60+
// latest.json pointers are rewritten each run, so they stay under the age
61+
// cutoff and never age out.
6262
lifecycleRules: [
6363
{ prefix: 'weather/citytile/', expiration: Duration.days(2) },
6464
{ prefix: 'weather/windtex/', expiration: Duration.days(2) },
6565
{ prefix: 'weather/refctex/', expiration: Duration.days(2) },
66+
{ prefix: 'weather/capetex/', expiration: Duration.days(2) },
6667
],
6768
});
6869

@@ -102,12 +103,12 @@ export class StormdeckStack extends Stack {
102103
manifestPath: CRATES_MANIFEST,
103104
binaryName: 'weather-ingest',
104105
architecture: lambda.Architecture.ARM_64,
105-
// temp + windtex + refc decode GFS GRIB fields (~4 MB grids, several in
106-
// flight; windtex also holds a u/v pair while its PNG encodes); alerts is
107-
// lighter.
106+
// temp + windtex + refc + cape decode GFS GRIB fields (~4 MB grids,
107+
// several in flight; windtex also holds a u/v pair while its PNG encodes);
108+
// alerts is lighter.
108109
memorySize: 512,
109110
// temp samples ~57 GFS TMP fields (cities + lattice); windtex decodes u/v
110-
// pairs and refc single REFC fields into PNGs — all well within 600s.
111+
// pairs, refc/cape single REFC/CAPE fields into PNGs — all within 600s.
111112
timeout: Duration.seconds(600),
112113
environment: {
113114
BUCKET: bucket.bucketName,
@@ -298,12 +299,13 @@ export class StormdeckStack extends Stack {
298299
// EventBridge Scheduler triggers (14M invocations/month free tier). Every
299300
// weather job is free GFS GRIB from NODD (no per-call limit) plus the public
300301
// NWS alerts feed, so the cadence is bounded by freshness, not API quotas;
301-
// temp + windtex + refc refresh once per GFS cycle window.
302+
// temp + windtex + refc + cape refresh once per GFS cycle window.
302303
const jobs: Array<[string, Duration]> = [
303304
['alerts', Duration.minutes(5)],
304305
['temp', Duration.hours(6)],
305306
['windtex', Duration.hours(6)],
306307
['refc', Duration.hours(6)],
308+
['cape', Duration.hours(6)],
307309
];
308310
for (const [job, every] of jobs) {
309311
new scheduler.Schedule(this, `Schedule-${job}`, {

crates/weather-ingest/src/contract.rs

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,31 @@ pub struct RefcTexIndex {
205205
pub dbz_max: f32,
206206
}
207207

208+
/// The `capetex/latest.json` pointer: the current snapshot, its forecast-hour
209+
/// axis, the equirectangular texture dimensions, and the CAPE (J/kg) bounds the
210+
/// grayscale PNG was normalized over (so the web denormalizes identically). The
211+
/// per-step textures live at `capetex/{snapshotMs}/{hour}.png` (immutable); the
212+
/// web's storm-potential overlay loads whichever step the timeline is on. Same
213+
/// axis as windtex/refctex/citytile, so the one timeline scrubs it with the rest.
214+
#[derive(Serialize)]
215+
#[cfg_attr(feature = "ts", derive(specta::Type))]
216+
pub struct CapeTexIndex {
217+
#[serde(rename = "snapshotMs")]
218+
#[cfg_attr(feature = "ts", specta(type = specta_typescript::Number))]
219+
pub snapshot_ms: u64,
220+
pub hours: Vec<u32>,
221+
pub width: u32,
222+
pub height: u32,
223+
// Always-present reals; the `Number` override avoids the `number | null`
224+
// that a bare f32 exports (same trick as CityForecast::t).
225+
#[serde(rename = "capeMin")]
226+
#[cfg_attr(feature = "ts", specta(type = specta_typescript::Number))]
227+
pub cape_min: f32,
228+
#[serde(rename = "capeMax")]
229+
#[cfg_attr(feature = "ts", specta(type = specta_typescript::Number))]
230+
pub cape_max: f32,
231+
}
232+
208233
#[cfg(all(test, feature = "ts"))]
209234
mod export {
210235
use specta::Types;
@@ -221,7 +246,8 @@ mod export {
221246
.register::<super::CityForecast>()
222247
.register::<super::CityTileIndex>()
223248
.register::<super::WindTexIndex>()
224-
.register::<super::RefcTexIndex>();
249+
.register::<super::RefcTexIndex>()
250+
.register::<super::CapeTexIndex>();
225251
Typescript::default()
226252
.header("// Generated from crates/weather-ingest/src/contract.rs by `just build types`. Do not edit.\n")
227253
.export_to(

crates/weather-ingest/src/gfs.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,14 @@ pub const WIND_MS_MAX: f32 = 40.0;
3131
pub const REFC_DBZ_MIN: f32 = -20.0;
3232
pub const REFC_DBZ_MAX: f32 = 75.0;
3333

34+
/// Surface CAPE (convective available potential energy, J/kg) normalization
35+
/// range for the storm-potential texture, same scheme as [`REFC_DBZ_MIN`]. 0 is
36+
/// a stable atmosphere; the web renders below its display threshold (~250 J/kg,
37+
/// marginal instability) transparent. 5000 J/kg caps all but the most extreme
38+
/// setups (observed global max ~4800), which clamp.
39+
pub const CAPE_JKG_MIN: f32 = 0.0;
40+
pub const CAPE_JKG_MAX: f32 = 5000.0;
41+
3442
/// A decoded 0.25° global field, row-major from (90N, 0E).
3543
pub struct Field {
3644
values: Vec<f32>,

0 commit comments

Comments
 (0)