147 lines
7 KiB
Markdown
147 lines
7 KiB
Markdown
|
|
# 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).
|