thermograph/backend
Emi Griffith 0f34594ce0
All checks were successful
PR build (required check) / changes (pull_request) Successful in 12s
secrets-guard / encrypted (pull_request) Successful in 11s
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-backend (pull_request) Successful in 1m40s
PR build (required check) / gate (pull_request) Successful in 4s
ERA5 lake: bucket-hosted history primary + SQL indexer service
Move climate history to ERA5 served from our own object storage, with a
prod-only query service in front:

- data/era5lake.py: lake layout (per-point whole-record 1940+ serving files,
  a tile/year/month hive table, an Iceberg-style manifest) plus grid math and
  the read client (local dir -> lake service -> bucket).
- gen_era5_lake.py: tile-aligned extractor from the Earthmover Icechunk ERA5
  archive (one 86-year pull covers all 144 points of a chunk tile; resumable
  from the manifest; --cities / --tiles / --land).
- lake_app.py + THERMOGRAPH_ROLE=lake: the lake service. /history serves a
  point's parquet off a disk cache (11ms cold / 3ms warm in rehearsal);
  /query runs SELECT-only SQL on DuckDB over the hive table (79ms pruned
  aggregate, 88ms full scan of a 4.5M-row tile). httpfs is baked at image
  build.
- climate.py: history chain is now era5-lake -> NASA POWER -> Open-Meteo; the
  lake slice starts at START_DATE so grading windows are unchanged, and an
  unconfigured lake costs nothing (beta/LAN unchanged).
- stack: lake service (1..2 replicas behind the VIP, own cache volume) plus a
  second autoscaler instance (autoscale.sh gains TARGET_SERVICE); web/worker
  get THERMOGRAPH_LAKE_URL.
- seed_era5.py: constants corrected against the live store (icechunkV2,
  single/temporal, valid_time, ECMWF short names, pcodec).

Bucket creds (THERMOGRAPH_LAKE_S3_ACCESS_KEY/_SECRET_KEY) go in the prod
vault; until they land the lake stays healthy and everything falls through to
NASA exactly as before.
2026-07-23 14:17:50 -07:00
..
accounts Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
alembic Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
api Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
core Migrate off the Open-Meteo weather API (Phases 0-4) 2026-07-23 14:34:51 +00:00
data ERA5 lake: bucket-hosted history primary + SQL indexer service 2026-07-23 14:17:50 -07:00
deploy ERA5 lake: bucket-hosted history primary + SQL indexer service 2026-07-23 14:17:50 -07:00
notifications Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
scripts Subtree-sync backend to split main a4d7fcd (dev->main promotion: reconciled bot + hardening, in-image CI); subtree workflow copies stay deleted (CI lives at root) 2026-07-22 22:37:21 -07:00
tests ERA5 lake: bucket-hosted history primary + SQL indexer service 2026-07-23 14:17:50 -07:00
web Migrate off the Open-Meteo weather API (Phases 0-4) 2026-07-23 14:34:51 +00:00
.gitignore Subtree-sync backend to split main a4d7fcd (dev->main promotion: reconciled bot + hardening, in-image CI); subtree workflow copies stay deleted (CI lives at root) 2026-07-22 22:37:21 -07:00
alembic.ini Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
app.py Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
cities.json Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
cities_flavor.json Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
CLAUDE.md Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
docker-compose.test.yml Subtree-sync backend to split main a4d7fcd (dev->main promotion: reconciled bot + hardening, in-image CI); subtree workflow copies stay deleted (CI lives at root) 2026-07-22 22:37:21 -07:00
Dockerfile ERA5 lake: bucket-hosted history primary + SQL indexer service 2026-07-23 14:17:50 -07:00
drift_check.py Migrate off the Open-Meteo weather API (Phases 0-4) 2026-07-23 14:34:51 +00:00
gen_cities.py Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
gen_era5_lake.py ERA5 lake: bucket-hosted history primary + SQL indexer service 2026-07-23 14:17:50 -07:00
gen_flavor.py Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
indexnow.py Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
lake_app.py ERA5 lake: bucket-hosted history primary + SQL indexer service 2026-07-23 14:17:50 -07:00
Makefile Subtree-sync backend to split main a4d7fcd (dev->main promotion: reconciled bot + hardening, in-image CI); subtree workflow copies stay deleted (CI lives at root) 2026-07-22 22:37:21 -07:00
migrate.py Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
migrate_accounts_to_pg.py Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
migrate_cache_to_pg.py Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
paths.py Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
README.md backend: monorepo cutover marker (path-filter probe) 2026-07-23 03:18:03 -07:00
requirements-dev.txt Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
requirements-seed.txt ERA5 lake: bucket-hosted history primary + SQL indexer service 2026-07-23 14:17:50 -07:00
requirements.txt ERA5 lake: bucket-hosted history primary + SQL indexer service 2026-07-23 14:17:50 -07:00
seed_era5.py ERA5 lake: bucket-hosted history primary + SQL indexer service 2026-07-23 14:17:50 -07:00
warm_cities.py Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00

thermograph-backend

The Thermograph API service: grades recent local weather against ~45 years of climate history, and hosts the accounts, notification, and SSR-content back-end that the rest of the split Thermograph stack (thermograph-frontend) talks to over HTTP. Split from the emi/thermograph monorepo — this repo owns the API/DB/accounts/notifications layer only; it renders no HTML/CSS/JS of its own.

See CLAUDE.md for the full split topology, deploy flow, and API version contract, and thermograph-docs (a sibling repo) for cross-cutting architecture decisions and operator runbooks.

How it works

  1. Grid — a lat/lon is snapped to a stable ~4 sq mi cell (data/grid.py); longitude spacing is scaled by cos(latitude) so cells stay roughly square at any latitude. The cell id is the cache key, so the same spot always resolves to the same data.
  2. Data (on-demand, cached to parquet) — the first request for a cell fetches the full 1980present daily record (max/min temp, precip) from the free Open-Meteo archive (ERA5) and writes it to data/cache/<cell_id>.parquet (zstd, ~200 KB for 45 years). Later requests read the parquet directly. Recent days come from Open-Meteo's forecast API (past_days), so history and grading share one source.
  3. Percentiles & grading (data/grading.py) — each day is graded against every historical day within ±7 days of it (a 15-day seasonal window, wrapping year-end), as an empirical mid-rank percentile. Temperature uses a symmetric tier ladder (TEMP_BANDS: Near Record / High / Above Normal / Normal / Below Normal / Low / Near Record); precipitation is graded separately (RAIN_BANDS) since most days are dry — a rainy day is ranked only among rain days in its window, dry days are colored by dry-streak length instead.
  4. Caching & ETags — every derived payload (grade/calendar/day/SSR content) is cached in SQLite (data/store.py) under (kind, cell_id, key), validated by a token that only advances when the cell's history actually changes. That token doubles as a weak ETag, so an unchanged request costs a 304 with no payload rebuild (api/payloads.py, web/app.py).

Layout

accounts/       fastapi-users models/schemas/db + api_accounts routes
alembic/        Postgres schema migrations (alembic upgrade head on boot)
api/            versioned payload builders (payloads.py, content_payloads.py)
                + route wiring (content_routes.py, sitemap.py, homepage.py)
core/           metrics, audit/access logging, a singleton helper
data/           grid snapping, climate fetch/cache, grading/scoring,
                places/cities, the derived-payload store
notifications/  push (VAPID), email, Discord bot (interactions + linking),
                monthly digest, the in-process scheduler (city warming, IndexNow)
web/app.py      the FastAPI app (routes, CORS, ETag/versioning, middleware)
deploy/         container entrypoint.sh (alembic migrate, then serve)
app.py          shim re-exporting web.app:app — keeps the launch target
                `app:app` stable regardless of internal package layout
scripts/        one-off admin scripts (Discord slash-command registration)
tests/          pytest suite, hermetic (see tests/conftest.py)
cities.json,
cities_flavor.json   bundled reference data (generated by gen_cities.py /
                     gen_flavor.py), not runtime state

How it fits the split

  • thermograph-frontend calls this service's GET /api/v2/... endpoints (grade, geocode, calendar) and the SSR content endpoints under /content/...; it negotiates compatibility via GET /api/version.
  • thermograph-infra owns the deploy/Compose/Terraform layer — this repo only builds and publishes its own container image (git.thermograph.org/emi/thermograph-backend/app) and hands infra a tag to roll out (SERVICE=backend + BACKEND_IMAGE_TAG into infra's deploy/deploy.sh).
  • thermograph-docs holds the cross-repo architecture/runbook docs; this README only covers what's local to this service.

Build & run

docker build -t thermograph-backend .
docker run -p 8137:8137 --env-file .env thermograph-backend

The image runs deploy/entrypoint.sh: alembic upgrade head against THERMOGRAPH_DATABASE_URL (retried, since a fresh Postgres volume can still be starting up), then uvicorn app:app on $PORT (default 8137) with $WORKERS workers (default 4). /healthz is an I/O-free liveness probe.

For local development without a container, see the "Run / test locally" section of CLAUDE.md — there is no Makefile in this repo yet, so it's a plain venv + pytest/uvicorn invocation.

Notifications: Discord slash commands

notifications/discord_interactions.py answers Discord's HTTP Interactions endpoint (/discord/interactions) for the /grade <city> slash command. Registering (or updating) the command definition with Discord's REST API is a one-off admin action, not part of the running app:

THERMOGRAPH_DISCORD_APP_ID=... THERMOGRAPH_DISCORD_BOT_TOKEN=... \
    python3 scripts/register_discord_commands.py

Global command changes can take up to an hour to propagate.