8.6 KiB
1. Orientation
What the product actually claims
Thermograph answers one question: how unusual is the weather here, right now, compared to this exact place's own history?
That framing is not decoration — it constrains the code, the copy and the colour scales:
- A grade is a percentile against ~45 years of that grid cell's own record, not an absolute reading. 60 °F is Above Normal on a cool coast and Below Normal inland on the same afternoon.
- Tier names are therefore relative words — Near Record, High, Above Normal, Normal, Below Normal, Low, Near Record — never "hot", "warm", "cold". This rule binds prose, alt text, commit messages and API fields alike.
- Precipitation is graded on its own ladder, because most days are dry: a rainy day is ranked only among the rain days in its seasonal window, and dry days are coloured by dry-streak length instead.
Public since 2026-07-16. Free. Run by one operator plus automation, which is why so much of this repo is about making irreversible things hard.
The domain model, in six steps
- Grid (
backend/data/grid.py) — an arbitrary lat/lon snaps to a stable ~4 sq mi cell. Latitude rows are ~2 miles tall; longitude spacing scales bycos(latitude)so cells stay roughly square at any latitude. The cell id is deterministic and is the cache key for everything downstream. Worldwide coverage, no gaps. - History (
backend/data/climate.py) — the full 1980 → present daily record (max/min temp, precip, wind, gust, humidity, feels-like) for that cell, fetched once and stored durably. Expensive to obtain, so it is treated as source-of-truth data, not cache. - Recent + forecast — a separate bundle of recent observations plus ~8 forward days, refreshed on its own TTL. This is what "today" is graded from.
- Grading (
backend/data/grading.py) — for a given day of year, the reference distribution is every historical day within ±7 days of it (a 15-day seasonal window that wraps the year end). The observed value is placed on that distribution as an empirical mid-rank percentile, then mapped ontoTEMP_BANDS/RAIN_BANDS. - Payloads (
backend/api/payloads.py) — pure "inputs → response dict" assembly. One definition per payload shape, shared by the HTTP routes, the/cellbundle and offline tooling. - Caching (
backend/data/store.py) — every derived payload is stored against a validity token that encodes everything the payload depends on. A token mismatch is simply a miss. Freshness is driven by the token advancing, never by clock expiry — so stale data cannot be served, and onePAYLOAD_VERbump atomically orphans every pre-upgrade row.
If you internalise one thing: the token is the cache-correctness mechanism,
and PAYLOAD_VER is the lever that moves it. See
contracts.
The estate
Hosts are named by role, not by environment, plus the operator's desktop —
all on a WireGuard mesh (10.10.0.0/24):
| Host | Mesh IP | Public | Runs |
|---|---|---|---|
| vps1 | 10.10.0.2 |
75.119.132.91, git.thermograph.org, dashboard.thermograph.org |
Forgejo (git + CI + registry), Grafana + Loki + Alloy, the emigriffith.dev portfolio, and dev — its own Postgres, plain compose, mesh-only (no public DNS, no Caddy site, no TLS) |
| vps2 | 10.10.0.1 |
169.58.46.181, thermograph.org, beta.thermograph.org |
prod and beta as two separate Docker Swarm stacks, Centralis, Postfix, backups |
| desktop | 10.10.0.3 |
— | AI-model hosting (voice-to-text, an upcoming-feature LLM) + flex Swarm capacity. Hosts no Thermograph environment — make dev-up there is a laptop convenience only |
| phone | — | — | alerts |
vps2 runs prod and beta co-resident on one box: one TimescaleDB instance
serves both, on separate databases and separate roles (thermograph /
thermograph_beta, the latter NOSUPERUSER/NOCREATEDB, CONNECT revoked
from PUBLIC); separate checkouts, env files and loopback LB ports; beta's
Swarm service names are prefixed (beta-web, not web) because Swarm
registers a service's short name as a DNS alias on every network it joins, and
beta shares prod's network to reach the database. Beta rehearses prod's actual
orchestrator this way — that's the point of putting it next to prod rather
than next to dev.
The mesh IPs did not move when this topology landed — vps1 is the box that used
to be called "beta", vps2 the one that used to be called "prod". What moved is
which environment lives where. infra/deploy/env-topology.sh is the single
source of truth for every per-environment path, port and role; THERMOGRAPH_ENV
(dev/beta/prod) is the input a deploy leg passes, since vps2 can no longer
tell beta and prod apart by which host it's running on.
Two consequences you will hit within the first week:
- Forgejo is mesh-only.
git.thermograph.orgresolves publicly to vps1's IP, but vps1's Caddy rejects/v2/*(the registry API) from outside the mesh. Any host that pulls images needs10.10.0.2 git.thermograph.orgin/etc/hosts. - Beta and prod run the same orchestrator now — Swarm, as two stacks on one
box. Dev is the only environment on compose (
infra/docker-compose.yml), and it lives alone on vps1. Most of the time you don't care which stack you're reading — but when you're reading logs or naming containers, you do (see observability), and on vps2 you additionally have to say which environment:deploy.shthere routes on the explicitTHERMOGRAPH_ENVa caller passes, not on which host it's running on.
How a change travels
PR ──(required check: `gate`)──▶ dev ──▶ dev (vps1, mesh-only)
│
promotion PR
▼
main ──▶ beta.thermograph.org (vps2)
│
promotion PR ← the owner's call
▼
release ──▶ thermograph.org (vps2)
dev, main and release are protected — no direct pushes, for humans or
agents. Promotion is by pull request, continuously and per-decision; there is
no release calendar. Full detail in CI and release.
The four rules that are not negotiable
These come from the operator, via Centralis's own onboarding. They override convenience.
- Everything is a PR. If it didn't go through the pipeline, it didn't happen. This includes Grafana dashboards (provisioned from repo JSON — a UI edit is silently overwritten) and secrets (SOPS vault only).
- Boring prod. Anything clever proves itself on
devor beta first. - Privacy is a feature. No tracking cookies, no per-visitor IDs,
allowlisted anonymous counters only (
backend/core/metrics.py,POST /api/v2/event). Cheap to keep, expensive to retrofit. - Grades are relative. Never describe a percentile as hot or cold.
And a fifth, local to the repo
Commits and PRs are concise and technical, and never mention AI, assistants
or automated authorship. Every domain CLAUDE.md says this; it applies to
you too.
Quota discipline
Thermograph runs on free upstream data sources. Two hard rules protect them:
- Anything named "prefetch" or "warm" must never spend the Open-Meteo
quota. The warming paths call
climate.load_cached_history/load_cached_recent_forecast— the cache-only loaders — and skip cells that aren't warm. A cell self-heals on first real request instead. - Nominatim gets at most ~1 request/second. All reverse geocoding funnels
through a single dedicated worker thread
(
climate._revgeo_worker) that paces itself; never call it from a request thread.
Breaking either one gets the whole service rate-limited, which is why
singleton.claim_leader() exists: a background sweep running in N uvicorn
workers × M hosts multiplies quota use by N×M. That failure has already
happened once, in production, at 3 workers.
Where the written record lives
| Question | Where |
|---|---|
| How does this repo work? | The domain CLAUDE.md files, then this set |
| Why is the architecture like this? | thermograph-docs (ADRs) — docs_search |
| How do I operate the fleet? | thermograph-docs runbooks, plus the thermograph-ops Centralis skill |
| What did someone find out last Tuesday? | notes_search |
| What is and isn't live yet? | CUTOVER-NOTES.md at the repo root |
Next: Local setup.