Skip to content

erichare/aeroza

Repository files navigation

Aeroza

Aeroza

Weather, but queryable.

Real-time radar, predictive nowcasting with calibrated confidence, and geospatial queries — for applications that need to understand and react to weather in real time. Every forecast scored against reality, in public.

Live demo  ·  Map  ·  Storm Replay  ·  Calibration  ·  Docs  ·  Swagger

CI Python 3.12+ FastAPI Next.js License: MIT

The Aeroza landing page: live CONUS radar next to the public calibration scorecard

Try it in ten seconds — nothing to install

The API is live at https://api.aeroza.app. These run right now, from your terminal:

# What is the radar reflectivity over downtown Houston, this minute?
curl -s "https://api.aeroza.app/v1/mrms/grids/sample?lat=29.76&lng=-95.37"
{
  "type": "MrmsGridSample",
  "product": "MergedReflectivityComposite",
  "level": "00.50",
  "validAt": "2026-07-25T16:44:38Z",
  "value": 14.199999809265137,
  "requestedLatitude": 29.76,
  "requestedLongitude": -95.37,
  "matchedLatitude": 29.75500000000422,
  "matchedLongitude": -95.36500098959021,
  "toleranceDeg": 0.05
}

A real response, minus the fileKey and variable fields. You asked for a point; you get back the grid cell that actually answered, how far off it was, and when it was valid.

# Is anything above 40 dBZ inside this polygon? (lng,lat,lng,lat,… — GeoJSON order)
curl -s "https://api.aeroza.app/v1/mrms/grids/polygon\
?polygon=-95.7,29.5,-95.0,29.5,-95.0,30.0,-95.7,30.0&reducer=count_ge&threshold=40"

# Every active NWS alert, as a GeoJSON FeatureCollection you can drop straight into a map
curl -s "https://api.aeroza.app/v1/alerts?limit=5"

# New alerts, streamed as they are issued (Server-Sent Events)
curl -N "https://api.aeroza.app/v1/alerts/stream"

# How good were the last 24h of forecasts? (this is the whole point)
curl -s "https://api.aeroza.app/v1/calibration"
You want to know… Call
Radar value at a point GET /v1/mrms/grids/sample?lat=&lng=
Max / mean / min / count over a region GET /v1/mrms/grids/polygon?polygon=&reducer=
Active watches & warnings (GeoJSON) GET /v1/alerts
Alerts as they happen GET /v1/alerts/stream (SSE)
Radar as map tiles GET /v1/mrms/tiles/{z}/{x}/{y}.png
Predicted grids, 10 / 30 / 60 min out GET /v1/nowcasts
Whether those forecasts were any good GET /v1/calibration
Surface observations GET /v1/metar, GET /v1/metar/{station}/latest
System freshness GET /v1/stats, GET /health

Coverage is CONUS-first, because that's where MRMS and NWS coverage is dense. Times are ISO-8601 UTC; the wire is camelCase; bbox/polygon use lng,lat (GeoJSON), point uses lat,lng.

See it running

Live map — MRMS reflectivity tiles under active NWS polygons, with a 1-hour loop scrubber. The tiles come from the same public /v1/mrms/tiles endpoint your app would use.

Live CONUS radar map with active alert polygons and a time scrubber

Storm Replay — autoplay through your local archive, or through curated historical events (Houston derecho, Mayfield quad-state tornado, the 2011 Super Outbreak) with commentary.

Storm Replay scrubbing through archived radar frames

Calibration — every nowcast is scored against the observation grid that lands after it. MAE / bias / RMSE, POD / FAR / CSI, Brier / CRPS, sample-weighted by cell count. Published, not claimed.

Calibration matrix showing MAE, bias and RMSE per algorithm and horizon

Dev console — a live test harness over the API: SSE stream, alert list, MRMS catalog, point sampler, webhook and alert-rule CRUD. Every panel is driven through the @aeroza/sdk client.

Dev console showing the live alert stream beside system health and stats

Run the whole stack with one command

Requires Docker, Node 20+, and uv.

make start

That runs make doctor (preflight), make bootstrap (writes .env with a real signing salt, syncs Python deps, starts Postgres / Redis / NATS, applies migrations), then brings up the full stack under honcho: the API, the console, the alerts / MRMS / METAR ingest workers, and the webhook dispatcher. Ctrl+C tears it all down. Re-running is idempotent.

Service Where it lands
API http://localhost:8000 — health at /health, Swagger at /docs
Console http://localhost:3000 — landing, /map, /replay, /calibration, /console, /docs

Your dashboard fills itself in. On first make start, scripts/seed-historical.sh backfills ~3 hours of MRMS in the background so you aren't staring at empty panels while live ingest catches up. Tail it with tail -f .seed.log, or run it explicitly with make seed. Idempotent — it short-circuits once the catalog has data.

With the [grib] extra installed (make extras-grib), make start also runs the GRIB → Zarr materialiser, the lagged-ensemble nowcaster, and the verifier, so radar replay and /calibration light up out of the box. make stop brings the Docker layer down.

Driving the pieces by hand
make bootstrap                # one-time setup (idempotent)
make dev                      # FastAPI on :8000 — terminal 1
make web-dev                  # Next console on :3000 — terminal 2
make ingest-alerts            # NWS active alerts → /v1/alerts*
make ingest-mrms              # MRMS file catalog → /v1/mrms/files
make ingest-metar             # METAR observations → /v1/metar
make materialise-mrms         # MRMS GRIB2 → Zarr → /v1/mrms/grids* (needs [grib])
make nowcast-persistence      # persistence baseline
make nowcast-lagged-ensemble  # probabilistic baseline (Brier/CRPS) — no extras needed
make seed                     # backfill ~3h of history (idempotent)
make check                    # lint + format check + mypy + unit tests

Every entry is also a top-level aeroza-* console script. make help lists all targets.

Optional extras: [grib] and [nowcast]

make start installs only the core extras the live stack needs. Two heavy, system-dependency-bearing ones stay opt-in.

Why make extras-* and not plain uv sync --extra X? uv sync --extra X replaces the installed extra-set rather than adding to it — running uv sync --extra grib on a working stack silently uninstalls db / cache / stream / ingest / verify and breaks everything else. The make extras-* targets re-list the bootstrap extras so adding one stays additive.

[grib]cfgrib, for decoding MRMS GRIB2. Required by aeroza-materialise-mrms, which fast-fails at startup with an install hint if it's missing.

# macOS
brew install eccodes && make extras-grib

# Debian/Ubuntu
sudo apt-get install -y libeccodes-dev && make extras-grib

[nowcast]pysteps, for the optical-flow forecaster. Opt in with aeroza-nowcast-mrms --algorithm pysteps; without it the worker keeps running the persistence baseline.

# Linux: pysteps' setup.py just works.
make extras-nowcast

# macOS: pysteps' setup.py passes raw -fopenmp, which Apple clang rejects.
brew install libomp
CFLAGS="-Xpreprocessor -fopenmp -I$(brew --prefix libomp)/include" \
LDFLAGS="-L$(brew --prefix libomp)/lib -lomp" \
make extras-nowcast

For both at once, make install runs uv sync --all-extras — fine when eccodes and libomp are already in place.

Build against it

TypeScript SDK

@aeroza/sdk pins every wire shape and is what the dev console drives every panel through — so the contract is dogfooded on every change. In-tree today (workspace dep), not yet published to npm.

import { AerozaClient } from "@aeroza/sdk";

const client = new AerozaClient({ apiBase: "https://api.aeroza.app" });

// Point sample
const sample = await client.sampleGrid({ lat: 29.76, lng: -95.37 });
//    → { type: "MrmsGridSample", value: 14.2, matchedLatitude: 29.755, … }

// "How many cells in this region are at or above 40 dBZ?"
const region = await client.reduceGridOverPolygon({
  polygon: "-95.7,29.5,-95.0,29.5,-95.0,30.0,-95.7,30.0",
  reducer: "count_ge",
  threshold: 40,
});

// Live alerts — the SDK builds the URL, you own reconnection
const source = new EventSource(client.alertsStreamUrl());
source.addEventListener("alert", (e) => {
  const alert = JSON.parse((e as MessageEvent).data);
  console.log(alert.event, alert.severity);
});

Non-2xx responses throw AerozaApiError carrying status and FastAPI's detail.

Webhooks with a geospatial rule DSL

Subscribe a URL, then attach rules that fire on the data itself — not just on "an alert was issued".

# 1. A subscription. The response carries the signing secret exactly once.
curl -s -X POST http://localhost:8000/v1/webhooks \
  -H 'content-type: application/json' \
  -d '{"url":"https://example.com/hooks/aeroza","events":["aeroza.mrms.grids.new"]}'

# 2. A rule: tell me when reflectivity over my warehouse crosses 45 dBZ.
curl -s -X POST http://localhost:8000/v1/alert-rules \
  -H 'content-type: application/json' \
  -d '{
        "subscriptionId": "<id from step 1>",
        "name": "Warehouse hail watch",
        "config": {
          "type": "point", "lat": 29.76, "lng": -95.37,
          "predicate": { "op": ">=", "threshold": 45 }
        }
      }'

Polygon rules take the same shape with "type": "polygon", a polygon string, and a reducer (max / mean / min / count_ge). A rule fires on the not-firing → firing transition and delivers aeroza.alert_rules.fired to its bound subscription — bound implies subscribed, so that event doesn't need to be in the subscription's events array. The subscribable stream events are aeroza.alerts.nws.new, aeroza.mrms.files.new, aeroza.mrms.grids.new, and aeroza.nowcast.grids.new.

Every delivery is signed Stripe-style — Aeroza-Timestamp plus Aeroza-Signature: v1=<hex>, an HMAC-SHA256 over timestamp + "." + payload, with a 5-minute replay window. Delivery attempts are auditable at GET /v1/webhooks/{id}/deliveries.

Auth

API keys (aza_live_…) are bearer tokens; GET /v1/me introspects one. Auth is opt-in per deployment via AEROZA_AUTH_REQUIRED, so local development stays frictionless.

curl -s https://api.aeroza.app/v1/me -H "Authorization: Bearer aza_live_…"

Architecture

A modular monolith (FastAPI) with the long-running work extracted into workers.

flowchart LR
  subgraph sources[Public data]
    NWS[NWS alerts API]
    MRMS[NOAA MRMS on S3]
    METAR[METAR / AWC]
  end

  subgraph workers[Ingest workers]
    ING[alerts · mrms · metar]
    MAT[GRIB2 to Zarr materialiser]
    NOW[nowcasters]
    VER[verifier]
    DISP[webhook dispatcher]
  end

  subgraph storage[Storage]
    PG[(Postgres + PostGIS)]
    S3[(S3 / R2 · Zarr)]
    REDIS[(Redis)]
  end

  NWS --> ING
  MRMS --> ING
  METAR --> ING
  ING --> PG
  ING --> NATS{{NATS JetStream}}
  NATS --> MAT --> S3
  MAT --> NOW --> S3
  NOW --> VER --> PG
  NATS --> DISP --> HOOK[your endpoint]

  PG --> API[FastAPI · /v1]
  S3 --> API
  REDIS --> API
  API --> SDK["@aeroza/sdk"]
  API --> WEB[Next.js console]
  SDK --> WEB
Loading
aeroza/
  cli/        Long-running workers + one-shot CLIs (the aeroza-* scripts).
  ingest/     NWS alerts + MRMS reflectivity + METAR observations.
  query/      REST read-side — alerts, MRMS files/grids/tiles, METAR,
              nowcasts, calibration, stats. Split per domain under query/v1/.
  nowcast/    Forecaster Protocol + persistence, pySTEPS Lucas–Kanade,
              and lagged-ensemble forecasters.
  verify/     Continuous verification + sample-weighted aggregates:
              MAE/bias/RMSE, POD/FAR/CSI, Brier/CRPS.
  webhooks/   Signed delivery, subscription CRUD, alert-rule DSL, dispatcher.
  tiles/      XYZ raster tiles for MRMS reflectivity.
  auth/       Bearer-token API keys (aza_live_*), opt-in.
  push/       APNs device registration + severe-weather push.
  stream/     NATS publishers/subscribers + the SSE gateway.
  admin/      Dev-console-only seed-event endpoints (gated).
  shared/     DB session helpers, common schemas, HTTP client.

25 public /v1 paths (32 operations), plus /health. Postgres 16 + PostGIS, Redis, and S3-compatible object storage for Zarr; NATS JetStream for streaming. The sdk-ts/ client pins the wire shapes.

Development

make check              # lint + format check + mypy + unit tests — what CI runs
make test               # unit tests (DB-dependent tests auto-skip)
make test-integration   # integration tests (needs `make up`)
make test-cov           # coverage report

872 tests, 543 of which run without infrastructure. CI runs ruff, ruff-format, mypy, and the unit suite with a coverage floor on every push and PR. CONTRIBUTING.md covers the branch / PR / merge flow.

The README screenshots above are generated, not hand-cropped — refresh them with:

node scripts/capture-screenshots.mjs

Status

Phases 0–6f shipped. Live ingest (NWS alerts, MRMS reflectivity, METAR), queryable point / polygon / tile reads, three nowcasting algorithms (persistence baseline, pySTEPS Lucas–Kanade, lagged-ensemble), continuous verification across continuous / categorical / probabilistic skill scores, signed webhook delivery with the point + polygon alert-rule DSL, opt-in bearer-token auth, APNs severe-weather push, and a six-route web surface driven entirely through the TypeScript SDK.

Next: a STEPS ensemble forecaster with real stochastic perturbations, reliability diagrams on /calibration, per-key rate limiting, and more ingest sources (NEXRAD Level II, HRRR / NBM). Details in docs/ROADMAP.md.

Project meta

Doc What's in it
Roadmap docs/ROADMAP.md — phase plan, shipped vs. next
Changelog CHANGELOG.md — release-style notes per phase
Contributing CONTRIBUTING.md — branch / PR / merge flow
Deploy docs/DEPLOY-RAILWAY.md — Railway + Supabase + Vercel
SDK sdk-ts/README.md@aeroza/sdk reference

Radar and alert data courtesy of NOAA/NSSL MRMS and the National Weather Service. Basemap by OpenFreeMap / OpenMapTiles, data © OpenStreetMap contributors.

License

MIT — see LICENSE.

About

Aeroza is a developer-first weather platform with real-time radar, geospatial queries, streaming APIs, and predictive nowcasting for modern applications.

Resources

Contributing

Stars

Watchers

Forks

Releases

Packages

Used by

Contributors

Languages