# 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 1. **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 by `cos(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. 2. **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. 3. **Recent + forecast** — a separate bundle of recent observations plus ~8 forward days, refreshed on its own TTL. This is what "today" is graded from. 4. **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 onto `TEMP_BANDS` / `RAIN_BANDS`. 5. **Payloads** (`backend/api/payloads.py`) — pure "inputs → response dict" assembly. One definition per payload shape, shared by the HTTP routes, the `/cell` bundle and offline tooling. 6. **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 one `PAYLOAD_VER` bump 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](06-contracts.md). ## The estate Four machines on a WireGuard mesh (`10.10.0.0/24`): | Host | Mesh IP | Public | Runs | |---|---|---|---| | **prod** | `10.10.0.1` | `169.58.46.181`, thermograph.org | Docker **Swarm** app stack, TimescaleDB, Postfix, backups, Centralis | | **beta** | `10.10.0.2` | `75.119.132.91`, beta.thermograph.org | **compose** app stack, Forgejo (git + CI + registry), Grafana + Loki | | **desktop** | `10.10.0.3` | — | LAN dev stack, the Forgejo Actions runner, your editor | | phone | — | — | alerts | Two consequences you will hit within the first week: - **Forgejo is mesh-only.** `git.thermograph.org` resolves publicly to beta's IP, but beta's Caddy rejects `/v2/*` (the registry API) from outside the mesh. Any host that pulls images needs `10.10.0.2 git.thermograph.org` in `/etc/hosts`. - **Prod and beta run different orchestrators.** Prod is Swarm (`infra/deploy/stack/`), beta and LAN dev are compose (`infra/docker-compose.yml`). `deploy.sh` routes between them by reading `/etc/thermograph/deploy-mode` on the box. Most of the time you don't care — but when you're reading logs or naming containers, you do (see [observability](09-observability.md)). ## How a change travels ``` PR ──(required check: `gate`)──▶ dev ──▶ LAN dev box │ promotion PR ▼ main ──▶ beta.thermograph.org │ promotion PR ← the owner's call ▼ release ──▶ thermograph.org ``` `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](07-ci-and-release.md). ## The four rules that are not negotiable These come from the operator, via Centralis's own onboarding. They override convenience. 1. **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). 2. **Boring prod.** Anything clever proves itself on `dev` or beta first. 3. **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. 4. **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`](../../CUTOVER-NOTES.md) at the repo root | Next: [Local setup](02-setup.md).