Commit graph

405 commits

Author SHA1 Message Date
Emi Griffith
abf37e6376 Add value-drift check comparing NASA vs Open-Meteo (ERA5)
All checks were successful
PR build (required check) / changes (pull_request) Successful in 7s
secrets-guard / encrypted (pull_request) Successful in 5s
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-backend (pull_request) Successful in 44s
PR build (required check) / gate (pull_request) Successful in 2s
backend/drift_check.py quantifies the migration's data impact by fetching both
live history sources (NASA POWER primary + the Open-Meteo/ERA5 fallback) for a
sample of cells and reporting, per variable: mean-absolute-difference / bias / max
(raw drift) and grade-band divergence (the share of days whose graded band actually
changes). Since Open-Meteo is ERA5, this doubles as a fidelity check for the ERA5
seed.

Because grades are percentiles within each source's own distribution, a uniform
bias moves MAD but not grades — so the two numbers are read together. Open-Meteo is
kept as a dormant fallback (not deleted), so both sources stay fetchable for this
comparison. The comparison logic is unit-tested; the per-cell fetch runs on a
networked box (python drift_check.py [--limit N] [--days N]).
2026-07-23 06:33:54 -07:00
Emi Griffith
5569bcbc0a Flip recent/forecast leg off Open-Meteo (NASA past + MET Norway forward)
All checks were successful
secrets-guard / encrypted (pull_request) Successful in 7s
The recent-observations + forward-forecast bundle is now built without Open-Meteo:
the recent observed window comes from a NASA POWER range (measured, via the range
fetch added for the history flip) and the forward days from MET Norway, merged by
date. Meteostat fills the gusts neither source carries — measured for the observed
days, estimated from wind for the forecast days.

Open-Meteo's forecast API is demoted to the fallback, still gated by its existing
_forecast_cooldown_until rate-limit cooldown; then a stale cache. MET Norway
switched to /compact (same fields, smaller payload) and the bundle TTL relaxed
1h -> 4h. NASA's near-real-time lag leaves a 1-2 day recent-edge gap that grades
as missing, bracketed by history behind and forecast ahead.

Tests updated to the new source order (NASA+MET merge, Open-Meteo fallback, and
the forecast cooldown now gating that fallback); deletion of Open-Meteo is not
done — it stays as the dormant fallback.
2026-07-23 06:32:31 -07:00
Emi Griffith
9567c51783 Subtree-merge thermograph-backend era5-seed into backend/ 2026-07-22 22:26:27 -07:00
Emi Griffith
ffcb25885f Subtree-merge thermograph-backend nasa-primary-flip into backend/
Conflict resolved (UNVERIFIED -- tests not runnable locally, docker blocked):
tests/data/test_climate.py keeps BOTH appended test blocks (dev's
forecast-cooldown tests + this branch's NASA-primary tests). Same conflict
exists merging this branch into the split repo's dev.

# Conflicts:
#	backend/tests/data/test_climate.py
2026-07-22 22:26:17 -07:00
Emi Griffith
8301d5c7d9 Subtree-merge thermograph-backend history-meteostat-gusts into backend/
Conflict resolved (UNVERIFIED -- tests not runnable locally, docker blocked):
data/climate.py combines dev's hardened _request signature
(ARCHIVE_FETCH_TIMEOUTS + phase) with this branch's Meteostat gust-fill wrap.
Same conflict exists merging this branch into the split repo's dev.

# Conflicts:
#	backend/data/climate.py
2026-07-22 22:26:03 -07:00
Emi Griffith
6eff3ba3a7 Subtree-merge thermograph-backend geocode-local into backend/ 2026-07-22 22:24:31 -07:00
Emi Griffith
a9ceb8f03f CI: port the dev-branch in-image test step into the reusable build check
Backend runs its full hermetic suite, frontend its unit tier, inside the
just-built image -- the dev-only feature both app repos carried in their own
build.yml (deleted here in favor of the root workflow).
2026-07-22 22:24:31 -07:00
Emi Griffith
2b775abe6f Subtree-merge thermograph-frontend origin/dev into frontend/ (two-tier test suite; CI workflow ported to root)
# Conflicts:
#	frontend/.forgejo/workflows/build.yml
2026-07-22 22:23:50 -07:00
Emi Griffith
6bee541d66 Subtree-merge thermograph-backend origin/dev into backend/ (CI-in-image feature; workflows ported to root)
# Conflicts:
#	backend/.forgejo/workflows/build.yml
#	backend/.forgejo/workflows/deploy.yml
2026-07-22 22:23:50 -07:00
Emi Griffith
210627f040 docs: monorepo README, cutover runbook, root agent instructions 2026-07-22 22:11:33 -07:00
Emi Griffith
2e753f2c6f deploy: adapt scripts + compose to the monorepo host checkout
/opt/thermograph becomes a monorepo checkout: deploy.sh gains INFRA_DIR (git
ops at the root, compose work cd'd into infra/), lock/tags/render paths move
under infra/deploy/, image-path defaults become emi/thermograph/backend|
frontend across compose, stack, and the tag-prune. docker-compose.yml pins
name: thermograph -- without it compose run from .../infra derives project
"infra" and recreates the whole stack beside the running one;
deploy-dev.sh pins COMPOSE_PROJECT_NAME=thermograph-dev (env wins over the
file key) to keep LAN dev's separate project.
2026-07-22 22:11:33 -07:00
Emi Griffith
7b2db07722 CI: port the split repos' workflows to per-domain path-filtered monorepo pipelines
One root workflow set replaces the four repos' copies (deleted -- root-only
is where Forgejo reads them, and dead copies are a trap): per-domain
build-push with explicit image paths (emi/thermograph/backend|frontend; the
old github.repository-derived path collides in a monorepo), path-filtered
per-domain beta/prod/dev deploys, a domain-input reusable build check, a
single always-reporting PR gate (path-filtered required checks deadlock
auto-merge), a new infra-sync pipeline (host checkout + secrets render on
infra/** pushes), and ports of secrets-guard / ops-cron /
observability-validate to monorepo paths.
2026-07-22 22:11:33 -07:00
emi
f0e87b78ba Merge pull request 'Merge main into dev: absorb responsiveness hardening; migrate its new tests to the unit tier' (#6) from reconcile-dev-with-main into dev 2026-07-23 05:07:43 +00:00
emi
017997a656 Merge pull request 'Merge main into dev: absorb hardening line + resolve the duplicate bot port' (#11) from reconcile-dev-with-main into dev 2026-07-23 05:07:40 +00:00
Emi Griffith
24d8cd9ba1 Add one-time ERA5 seed for curated-city cells (keyless Icechunk)
seed_era5.py backfills the curated-city cells with true ERA5 data from the
Earthmover Icechunk ERA5 archive on AWS Open Data (anonymous, keyless), so
high-traffic pages keep ERA5 fidelity now that NASA POWER is the live primary.
It aggregates the hourly ERA5 point series to the daily store schema in polars
(no pandas), derives feels-like via the existing heat-index/wind-chill path, and
writes straight to the history store via climate._write_history_backed, bypassing
the live fetch. ERA5 carries real gusts (i10fg), so seeded cells keep measured
gusts rather than the Meteostat fallback.

The icechunk/xarray/zarr stack is isolated in requirements-seed.txt (not the app
image or CI) and lazy-imported, so the module and its unit-tested hourly->daily
transform load without those deps. The S3 access layer targets a young API and is
verified via `--dry-run` on the seed box, not in tests. Idempotent by default
(skips already-cached cells; --overwrite to reseed).
2026-07-22 22:07:00 -07:00
Emi Griffith
30153dbc64 Flip history primary to NASA POWER, Open-Meteo to fallback
The live history path now tries NASA POWER first (keyless, independent of
Open-Meteo) and falls back to the Open-Meteo archive only when NASA is
unavailable — the reverse of before. Gusts NASA lacks are filled from Meteostat
inside _fetch_history_nasa (added in the prior change). Both sources must still
return a plausibly-full span (MIN_ARCHIVE_DAYS) before being cached as complete;
a short/partial response is rejected and the other source is tried.

_fetch_history_nasa now accepts a start/end range so it also serves the recent
tail top-up: _topup_tail fetches NASA-first via the new _fetch_history_tail
helper (Open-Meteo range as fallback, skipped during its rate-limit cooldown),
removing the last per-active-cell Open-Meteo call from the history path. The
Open-Meteo cooldown no longer gates the top-up, since NASA is not subject to it.

Open-Meteo remains wired as the fallback (deletion is a later step). Tests
updated to the new source order plus tail-fetch coverage.
2026-07-22 22:02:30 -07:00
Emi Griffith
f2fd8f6835 Subtree-merge thermograph-observability (origin/main) into observability/
git-subtree-dir: observability
git-subtree-mainline: ae1d9bb534
git-subtree-split: 19e74af9ca
2026-07-22 22:01:11 -07:00
Emi Griffith
ae1d9bb534 Subtree-merge thermograph-infra (origin/main) into infra/
git-subtree-dir: infra
git-subtree-mainline: d6df04eab2
git-subtree-split: 99b4b3f78d
2026-07-22 22:01:11 -07:00
Emi Griffith
d6df04eab2 Subtree-merge thermograph-frontend (origin/main) into frontend/
git-subtree-dir: frontend
git-subtree-mainline: a4be7066e5
git-subtree-split: 3a98146da4
2026-07-22 22:01:11 -07:00
Emi Griffith
a4be7066e5 Subtree-merge thermograph-backend (origin/main) into backend/
git-subtree-dir: backend
git-subtree-mainline: 6723fc0326
git-subtree-split: 83c2e05b96
2026-07-22 22:01:11 -07:00
Emi Griffith
6723fc0326 Monorepo root: reunify the split app repos (docs stays separate) 2026-07-22 22:01:11 -07:00
Emi Griffith
9afc19eb2f Merge main into dev: absorb responsiveness hardening (#4)
# Conflicts:
#	tests/test_content.py
2026-07-22 21:56:11 -07:00
Emi Griffith
6c3d7b8215 Merge main into dev: absorb notifier/PG hardening + the hardened bot port
main received the perf hardening line (#5, #8) and its own landing of the
Discord gateway bot (#7) while dev carried a parallel port of the same bot
(#3). The two implementations are near-identical ports of the same archived
source; main's includes one extra hardening (grading calls moved off the
gateway event loop via asyncio.to_thread) and broader tests, so bot files
resolve wholesale to main's side. dev keeps its test-runner/smoke tooling and
the run-tests-in-image CI, which main lacks.

# Conflicts:
#	notifications/discord_bot.py
#	tests/notifications/test_discord_bot.py
#	web/app.py
2026-07-22 21:54:18 -07:00
emi
99b4b3f78d Mail docs: stack-mode gateway + postfix umbrella-unit gotcha (#12) 2026-07-23 04:45:15 +00:00
emi
6f75762c89 Document THERMOGRAPH_DISCORD_BOT (gateway-bot enable flag) in env example + key-gaps (#11) 2026-07-23 04:41:49 +00:00
emi
3a98146da4 Harden top pages against backend blips, pool the content-API client (#4) 2026-07-23 04:41:43 +00:00
emi
83c2e05b96 Land the Discord gateway bot (port + hardening) (#7) 2026-07-23 04:41:32 +00:00
emi
215c46cea7 Bound Postgres connections, add rate-limit/timeout guards, move revgeo off the threadpool (#8) 2026-07-23 04:41:26 +00:00
emi
d67476ebbf notifier: bound push/Discord sends, cap pass duration, fix key-gen race (#5) 2026-07-23 04:41:19 +00:00
Emi Griffith
b4d8d67825 Add Meteostat gust supplier for the gust-less backup sources
NASA POWER (history) and MET Norway (forecast) carry no wind gusts, but gust is
a graded metric. This adds data/meteostat.py, which finds the nearest Meteostat
station to a cell and reads its daily peak gust (wpgt) from the keyless gzipped
bulk endpoints, filling the gust column on the NASA POWER history frame. Where no
station is within ~100 km it estimates from sustained wind (wind * GUST_FACTOR).

Bulk files (station list + per-station daily) are cached on disk so steady-state
network IO is near zero, and any lookup/fetch failure degrades to pure estimation
so a history fetch never fails. A constant estimate factor makes an estimated
gust redundant with wind, so real signal comes only where a station backs it.

Activates once NASA POWER becomes the primary history source; Open-Meteo already
carries its own gusts. New history_gust / meteostat_stations metrics phases map
to the meteostat source.
2026-07-22 21:39:09 -07:00
Emi Griffith
db86277103 Move forward geocoding off Open-Meteo to local index + Nominatim
/suggest is now served purely from the local GeoNames index — no external
geocoder call per keystroke. /geocode answers from the local index first and
falls back to OSM Nominatim /search only on a miss (or while the index is still
loading), covering the neighbourhood/postcode/tiny-village/native-name long tail
the cities dump lacks.

Nominatim shares the reverse geocoder's lock and ~1/sec pacing since both hit
the same host, and only the low-volume /geocode miss path reaches it. Removes
the Open-Meteo geocoding dependency entirely; the "geocode" metrics phase now
maps to the nominatim source.
2026-07-22 21:29:26 -07:00
emi
f2270100bb Stack rehearsal fixes: interpolation, network ownership, plain-CMD images (#10) 2026-07-23 04:21:10 +00:00
emi
9cd24387f2 Swarm stack for prod: autoscaled web tier (1-3), worker split, loopback LB (#9) 2026-07-23 04:13:12 +00:00
emi
d9d72e4e8d CI: run the test suite inside the built image (#3) 2026-07-23 00:59:06 +00:00
emi
90a7148165 CI: run the test suite inside the built image (#4) 2026-07-23 00:59:03 +00:00
emi
b412b7352a test: self-contained two-tier suite + pull-the-backend-image harness (#2) 2026-07-23 00:40:18 +00:00
emi
c2ce93fad6 test: reproducible local runner + image boot-smoke (#2) 2026-07-23 00:40:13 +00:00
emi
d02c0f719f render-secrets: chown rendered env to the deploy user on the sudo-install path (#7) 2026-07-23 00:38:15 +00:00
emi
97d0e53d77 Port the Discord gateway bot from the archived app repo (its PR #23) (#3) 2026-07-22 23:58:49 +00:00
emi
f5b8b26590 thermograph.env.example: document THERMOGRAPH_DISCORD_BOT (#8) 2026-07-22 23:58:02 +00:00
emi
e44d1603b6 deploy.sh: serialize concurrent deploys with flock; GC old app-image tags (#6) 2026-07-22 23:39:29 +00:00
emi
e929f4606f Docs pass after the app-repo archive; sync key-gaps skill; add CLAUDE.md (#5) 2026-07-22 23:36:21 +00:00
emi
5fd96552f7 Port mail provisioning from the archived app repo (+ mesh-listener knobs) (#4) 2026-07-22 23:31:33 +00:00
emi
8f98bab89f terraform: per-service backend/frontend image tags (two-image contract) (#2) 2026-07-22 23:27:13 +00:00
emi
dde342f01b Port the fixed ops-cron: prod backups died with the app-repo archive (#3) 2026-07-22 23:26:35 +00:00
Emi Griffith
59e7517747 deploy.sh: incoming image tag wins over .image-tags.env; health via container healthcheck
Two bugs surfaced wiring dev auto-deploy:
1. Sourcing .image-tags.env clobbered the caller's incoming *_IMAGE_TAG. The
   first backend-only deploy persists FRONTEND_IMAGE_TAG=local (sibling unknown);
   the next frontend deploy then sourced that and pulled :local -> 'manifest
   unknown'. Now the incoming env is captured before sourcing and re-applied.
2. Health-checked services via a host-port curl, but the dev overlay leaves the
   frontend port unpublished (reached via the backend proxy) -> false failure.
   Now polls each container's own HEALTHCHECK status via docker inspect.
2026-07-22 15:48:23 -07:00
Emi Griffith
418f4e0631 deploy.sh: make registry login conditional on REGISTRY_TOKEN
The CI/SSH deploy paths (deploy.yml/deploy-prod.yml over SSH, deploy-dev on the
LAN runner) don't pass REGISTRY_TOKEN and rely on the host already being
docker-logged-in; an unconditional login with an empty token aborts deploy.sh
under set -e. Skip the login when no token is set and trust the host cred;
pull still fails loudly on a real auth problem.
2026-07-22 15:27:30 -07:00
Emi Griffith
c202eb45a0 compose: mount appdata at /state (not /app/data) to stop shadowing the data/ package
The split backend's Python package data/ lives at /app/data; mounting the
appdata runtime volume there erased data/*.py and broke import at boot. Point
THERMOGRAPH_DATA_DIR + the appdata mount + the singleton lock at /state,
outside the code tree. Pairs with thermograph-backend paths.py making
DATA_DIR/LOGS_DIR env-overridable.
2026-07-22 14:46:22 -07:00
Emi Griffith
7b7727f234 paths.py: make DATA_DIR/LOGS_DIR env-overridable to avoid /app/data volume collision
After the repo split the code lives at the image root, so the Python package
data/ and the runtime data dir <root>/data are the same path (/app/data). The
deploy compose mounts the appdata volume there, shadowing data/*.py so
'import data.climate' fails at container boot. THERMOGRAPH_DATA_DIR (and
_LOGS_DIR) now let the container point runtime state at a path outside the code
tree; local dev is unchanged (defaults to <root>/data).
2026-07-22 14:45:26 -07:00
Emi Griffith
86c8e6905c Reconcile render-secrets.sh: sudo-read age key + in-place-write /etc/thermograph.env
The split branch carried the #34-era render-secrets.sh, missing two fixes
required for prod/beta deploy: (1) sudo-read the root-owned 0400 age key into
SOPS_AGE_KEY when the deploy user can't read it directly; (2) in-place-write
/etc/thermograph.env when it's group-writable (beta's non-root 'deploy' user)
instead of only install(1). Without these, deploy.sh's render fails on both
hosts. Flagged by the terraform-layer workstream.
2026-07-22 12:56:15 -07:00