thermograph/migrate.py
Emi Griffith 7989f142a1 Single-source cache identity; shared fetch preamble and cache flow (#43)
The derived store's key/token formats existed in three places — each
endpoint, api_cell's slice assembly, and migrate.py — where any drift
would silently split the cache (endpoints missing rows the bundle wrote,
migrate materializing rows nobody reads). They are now defined once in
views.py (grade_key/calendar_key/day_key/forecast_key, history_token/
recent_token/day_token) and consumed everywhere, with a pinning test so
a format change is always deliberate.

The four data endpoints shared two copy-pasted sequences, now helpers:

- _fetch_history: history (+ optional recent bundle) fetch with upstream
  failures mapped to clean HTTP errors and an empty record to 404.
- _cached_response: If-None-Match 304 / token-valid store replay /
  build + persist + serve, with calendar's dont-persist-placeless rule
  as an explicit flag.

Each endpoint is now its audit run + identity + a build callback (~10
lines); api_day's hourly-token special case moved into day_token. New
tests: identity format pins, rate-limit 503 parametrized across all five
data routes, prefetch=1 never touching upstream (cold 204, warm
history-only slices), and calendar's placeless-payload retry behavior.
2026-07-11 19:49:15 +00:00

91 lines
3.6 KiB
Python

"""Backfill the SQLite derived store from the existing parquet cell caches.
For every cell with a cached archive record, materialize the payloads that are
derivable offline — the default 2-year calendar and the latest-day detail — plus
its reverse-geocode label, so a server restarted (or freshly deployed) onto an
existing parquet cache serves those views from the database immediately instead
of regrading each cell on first touch.
Idempotent and resumable: cells whose derived rows already match the current
validity token are skipped, so re-running after an interruption (or after new
archive days arrive) only does the missing work. Never fetches weather data —
history comes strictly from the parquet cache (cells without one are skipped and
will materialize lazily on first request, as always). Reverse geocoding makes at
most one Nominatim call per unlabeled cell, throttled to ~1/s per the service's
policy. Safe to run alongside a live server: writes go through the same WAL
store the server reads, and readers fall back to recomputing on any miss.
Run via `make migrate`.
"""
import os
import sys
import time
import pandas as pd
import climate
import grid
import store
import views
def migrate() -> int:
if not os.path.isdir(climate.CACHE_DIR):
print("No parquet cache directory yet — nothing to migrate.")
return 0
cells = sorted(
f[: -len(".parquet")]
for f in os.listdir(climate.CACHE_DIR)
if f.endswith(".parquet") and not f.endswith("_rf.parquet")
)
built = current = skipped = 0
for cell_id in cells:
try:
cell = grid.from_id(cell_id)
except ValueError:
print(f" {cell_id}: unrecognized cache filename — skipped")
skipped += 1
continue
history = climate.load_cached_history(cell)
if history is None or history.empty:
# Pre-current-schema record; the server refetches it lazily on first
# touch (see climate.NEW_COLS) — nothing to materialize offline.
print(f" {cell_id}: no schema-complete record — skipped")
skipped += 1
continue
token = views.history_token(history)
start_ts, end_ts = views.cal_span(history, None, None, 24)
cal_key = views.calendar_key(start_ts, end_ts, 24)
last = pd.Timestamp(history["date"].max()).normalize()
day_key = views.day_key(last)
have_cal = store.get_payload("calendar", cell_id, cal_key, token) is not None
have_day = store.get_payload("day", cell_id, day_key, token) is not None
if have_cal and have_day:
current += 1
continue
found, place = climate.reverse_geocode_cached(cell["center_lat"], cell["center_lon"])
if not found:
place = climate.reverse_geocode(cell["center_lat"], cell["center_lon"])
time.sleep(1.1) # Nominatim usage policy: at most ~1 request/second
if not have_cal:
store.put_payload("calendar", cell_id, cal_key, token,
views.build_calendar(cell, history, start_ts, end_ts, 24, place))
if not have_day:
store.put_payload("day", cell_id, day_key, token,
views.build_day(cell, history, last, place))
built += 1
print(f" {cell_id}: materialized ({place or 'no label'})")
s = store.stats()
print(f"{built} cell(s) materialized, {current} already current, {skipped} skipped"
f" · derived rows: {s['derived']} · revgeo: {s['revgeo']}"
f" · db: {s['bytes'] / 1e6:.1f} MB → {s['db_path']}")
return 0
if __name__ == "__main__":
sys.exit(migrate())