# 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 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.org` resolves publicly to vps1's IP, but vps1'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`. - **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](09-observability.md)), and on vps2 you additionally have to say *which* environment: `deploy.sh` there routes on the explicit `THERMOGRAPH_ENV` a 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](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).