thermograph/backend/README.md
Emi Griffith df409f88b3
All checks were successful
PR build (required check) / changes (pull_request) Successful in 6s
secrets-guard / encrypted (pull_request) Successful in 4s
shell-lint / shellcheck (pull_request) Successful in 6s
PR build (required check) / validate-observability (pull_request) Successful in 20s
PR build (required check) / build-frontend (pull_request) Successful in 37s
PR build (required check) / build-backend (pull_request) Successful in 51s
PR build (required check) / gate (pull_request) Successful in 1s
registry: move image and repo references to the Jinemi namespace
Repos moved to the Jinemi org; the container packages did not follow, since
Forgejo does not transfer packages with a repo. The deploy path still resolved
`emi/thermograph/*` — a user_redirect to admin_emi — while build-push.yml
derives its push path from ${github.repository}, now jinemi/thermograph. The
next backend or frontend build would have published somewhere no deploy looks.

Point the image paths at jinemi/thermograph/* (lowercase: OCI references admit
no uppercase, which is why build-push.yml already pipes through tr), the clone
URLs at Jinemi/thermograph, and the registry logins at admin_emi — the account
that actually owns the tokens, rather than the redirect.

The live tags and both ci-runner tags were copied into the Jinemi namespace
first, so the switch has something to pull. thermograph-infra,
thermograph-observability and the retired */app packages stay under admin_emi;
they did not move.
2026-08-01 09:25:02 -07:00

106 lines
5.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 `Jinemi/thermograph` monorepo — this repo owns
the API/DB/accounts/notifications layer only; it renders no HTML/CSS/JS of its
own.
See [`CLAUDE.md`](CLAUDE.md) for the full split topology, deploy flow, and
API version contract, and [`thermograph-docs`](../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](https://open-meteo.com) 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/admin_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
```bash
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`](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:
```bash
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.
<!-- monorepo cutover 2026-07-23 -->