All checks were successful
PR build (required check) / changes (pull_request) Successful in 8s
secrets-guard / encrypted (pull_request) Successful in 6s
PR build (required check) / build-frontend (pull_request) Has been skipped
shell-lint / shellcheck (pull_request) Successful in 10s
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-backend (pull_request) Successful in 1m2s
PR build (required check) / gate (pull_request) Successful in 3s
Content SEO pages keyed derived-payload cache validity on
history_token = PAYLOAD_VER:hist_end, computed from the fully-loaded
~45-year archive. Every hourly tail top-up advanced hist_end and
invalidated the whole content cache for a cell, and computing the token
at all required loading the full history first — so even cache hits paid
the full load.
Add content_token(cell_id) = PAYLOAD_VER:CONTENT_VER:max_date, keyed on
the archive's newest DATE read cheaply without loading history:
climate_store.history_max_date does an indexed MAX(date) over the
(cell_id, date) PK on Postgres; climate.history_max_date dispatches to it
or to a single-column scan of the cached parquet on the dev backend. The
token survives intra-day top-ups and turns over only when the last
archived day advances (~1x/day), keeping content pages <=1 day stale.
Fail-soft: a store/DB error buckets to 'none' rather than raising.
CONTENT_VER ("c1") is a separate content-shape version so a content-only
change need not orphan every other kind's cache.
342 lines
15 KiB
Python
342 lines
15 KiB
Python
"""Payload builders — pure "inputs → response dict" assembly for every view.
|
||
|
||
This is the layer between the data modules (climate/grading/grid) and any
|
||
delivery mechanism: the FastAPI endpoints, the /cell bundle, and the offline
|
||
migrate script all build payloads here, so a payload's shape has exactly one
|
||
definition and offline callers never have to import the web stack. HTTP
|
||
semantics (audit runs, derived-store lookups, ETags) stay with the callers;
|
||
``run`` is an audit.RunAudit or None.
|
||
"""
|
||
import contextlib
|
||
import datetime
|
||
import time
|
||
|
||
import polars as pl
|
||
|
||
from data import climate
|
||
from data import grading
|
||
from data import scoring
|
||
|
||
# Observed values pulled from a daily record row for grading. Includes the
|
||
# temperature-scale metrics (tmax/tmin/feels/wind/gust) plus precip; a column may
|
||
# be absent on an older cache, so only carry the ones present.
|
||
OBS_COLS = ("tmax", "tmin", "precip", "feels", "humid", "wind", "gust")
|
||
|
||
# Bump when any response payload shape changes (new metrics, renamed fields…).
|
||
# The version is part of every derived-store validity token, so one bump
|
||
# atomically orphans all pre-upgrade cached payloads instead of letting a stale
|
||
# row whose history_end happens to match keep serving the old shape.
|
||
PAYLOAD_VER = "p2" # p2: place labels now carry the trailing country component
|
||
|
||
CAL_MAX_SPAN_DAYS = 732 # ~2 years — the most a single calendar request will grade
|
||
# (the frontend splits longer spans into 2-year chunks)
|
||
|
||
|
||
class NullRun:
|
||
"""audit.RunAudit stand-in for offline callers (the migrate script)."""
|
||
|
||
def set(self, **kw):
|
||
return self
|
||
|
||
def phase(self, name):
|
||
return contextlib.nullcontext()
|
||
|
||
|
||
def _months_before(d: datetime.date, months: int) -> datetime.date:
|
||
"""`d` shifted back `months` calendar months, landing on the same day-of-month
|
||
(clamped to the last valid day, e.g. Mar 31 → Feb 28). Mirrors pandas
|
||
``DateOffset(months=...)`` for the default calendar-range start."""
|
||
m = d.month - 1 - months
|
||
year, month = d.year + m // 12, m % 12 + 1
|
||
nxt = datetime.date(year + 1, 1, 1) if month == 12 else datetime.date(year, month + 1, 1)
|
||
last_day = (nxt - datetime.timedelta(days=1)).day
|
||
return datetime.date(year, month, min(d.day, last_day))
|
||
|
||
|
||
def hist_end(history) -> str:
|
||
"""Last day in the cell's archive record — the freshness token for everything
|
||
derived purely from history. Advances via the hourly tail top-up inside
|
||
climate.get_history, which cached payloads follow automatically."""
|
||
return history["date"].max().isoformat()
|
||
|
||
|
||
# --- cache identity ------------------------------------------------------------
|
||
# The derived store caches payloads under (kind, cell, key) guarded by a validity
|
||
# token (see store.py). These functions are the ONLY definitions of each kind's
|
||
# key/token format: the per-view endpoints, the /cell bundle and the migrate
|
||
# script all derive cache identity here. (Formats drifting apart would silently
|
||
# split the cache — endpoints missing rows the bundle wrote, migrate
|
||
# materializing rows nobody reads.)
|
||
|
||
def history_token(history) -> str:
|
||
"""Validity for payloads derived from the archive record alone."""
|
||
return f"{PAYLOAD_VER}:{hist_end(history)}"
|
||
|
||
|
||
# The content-shape version for the SEO content pages (/climate/<city>[/month|
|
||
# /records]). Kept separate from PAYLOAD_VER so a content-only shape change need
|
||
# not orphan every other kind's cache, and vice-versa. Bump on change.
|
||
CONTENT_VER = "c1"
|
||
|
||
|
||
def content_token(cell_id: str) -> str:
|
||
"""Validity for the SEO content-page payloads — cheap AND stable.
|
||
|
||
Unlike history_token, this never loads the ~45-year archive: it keys on the
|
||
cell's newest archived DATE (climate.history_max_date — an indexed
|
||
``MAX(date)`` on Postgres, a single-column read on the parquet backend), so it
|
||
survives the hourly tail top-ups (which only refresh intra-day freshness) and
|
||
turns over only when the archive's last day genuinely advances (≈1×/day). That
|
||
keeps content pages ≤1 day stale — acceptable for SEO — while sparing every
|
||
request the full-history load the old token forced even on a cache hit.
|
||
Fail-soft: a store/DB error yields a 'none' bucket rather than raising."""
|
||
try:
|
||
max_date = climate.history_max_date(cell_id)
|
||
except Exception: # noqa: BLE001 - token computation must never fail a request
|
||
max_date = None
|
||
return f"{PAYLOAD_VER}:{CONTENT_VER}:{max_date or 'none'}"
|
||
|
||
|
||
def recent_token(history, cell_id: str) -> str:
|
||
"""Validity for payloads that also grade the hourly recent/forecast bundle."""
|
||
return f"{history_token(history)}:{climate.recent_stamp(cell_id)}"
|
||
|
||
|
||
def grade_key(target, days: int, after: int) -> str:
|
||
return f"{target.isoformat()}:{days}:{after}"
|
||
|
||
|
||
def calendar_key(start_ts, end_ts, months: int) -> str:
|
||
return f"{start_ts.isoformat()}:{end_ts.isoformat()}:{months}"
|
||
|
||
|
||
def day_key(target) -> str:
|
||
return target.isoformat()
|
||
|
||
|
||
def day_token(history, target) -> str:
|
||
"""A fully-archived day stays valid until the record itself advances; a newer
|
||
day's observation comes from the hourly recent bundle, so its payload expires
|
||
on that bundle's own cadence (hourly)."""
|
||
token = history_token(history)
|
||
if target > history["date"].max():
|
||
token += f":h{int(time.time() // 3600)}"
|
||
return token
|
||
|
||
|
||
def forecast_key(today, days: int) -> str:
|
||
return f"{today.isoformat()}:{days}"
|
||
|
||
|
||
# The score is derived purely from the full archive record, so its validity token
|
||
# is the plain `history_token` (expires when the archive tail advances). The key
|
||
# just carries the scoring-math version, so a math change invalidates only score
|
||
# rows without disturbing any other kind.
|
||
SCORE_VER = "s5" # s5: overall is an equal average of metrics (was weighted)
|
||
|
||
|
||
def score_key() -> str:
|
||
return SCORE_VER
|
||
|
||
|
||
def _obs_from_row(row: dict) -> dict:
|
||
return {k: row[k] for k in OBS_COLS if k in row}
|
||
|
||
|
||
def _grade_rows(history, rows_df) -> list[dict]:
|
||
"""Grade each daily row against its own ±7-day climatology window."""
|
||
return [grading.grade_day(history, row["date"], _obs_from_row(row))
|
||
for row in rows_df.iter_rows(named=True)]
|
||
|
||
|
||
def _attach_dry_streaks(graded: list[dict], *precip_frames) -> None:
|
||
"""Attach `dsr` (days since last measurable rain) to each graded day, computed
|
||
over a combined, de-duplicated precip series so streaks stay continuous across
|
||
the history / recent / forecast sources."""
|
||
frames = [f.select("date", "precip") for f in precip_frames
|
||
if f is not None and f.height]
|
||
if not frames:
|
||
return
|
||
# Sources are passed archive-first, recent/forecast-last, so `keep="last"`
|
||
# prefers the fresher source's row for any shared date (before the final sort).
|
||
combined = (pl.concat(frames, how="vertical_relaxed")
|
||
.unique(subset="date", keep="last", maintain_order=True)
|
||
.sort("date"))
|
||
dsr_map = grading.dry_streaks(combined["date"].to_list(), combined["precip"].to_numpy())
|
||
for g in graded:
|
||
g["dsr"] = dsr_map.get(g["date"])
|
||
|
||
|
||
def _graded_window(history, rows, precip_frames, doy) -> tuple[list[dict], dict]:
|
||
"""The shared tail of the /grade and /forecast payloads: grade `rows` against
|
||
history, attach dry streaks over `precip_frames`, order newest-first, and pair
|
||
the result with the target day-of-year's climatology summary. The window
|
||
*assembly* (recent+archive merge vs future-only filter) differs per builder and
|
||
stays there; only this post-processing is shared."""
|
||
graded = _grade_rows(history, rows)
|
||
_attach_dry_streaks(graded, *precip_frames)
|
||
graded.reverse() # newest-first for display (the chart reverses to L→R chronological)
|
||
climo = grading.climatology(history, doy)
|
||
return graded, climo
|
||
|
||
|
||
def build_grade(cell, target, days, history, recent, cache_meta, place, run=None, after=14) -> dict:
|
||
"""/grade payload: a window of days centered on the target — `days` of history
|
||
before it, the target itself, then up to `after` days after it — plus the
|
||
target day's climatology summary. Days after the target are observed when they
|
||
are already in the past and forecast when they run into the future, so the
|
||
weekly view can frame two weeks of history around the orange target marker and
|
||
trail off into up to 14 days of observations / forecast after it. The forecast
|
||
only reaches ~7 days out, so a recent target naturally yields some observed days
|
||
plus 1-7 forecast days, while an older target fills the whole 14 with real obs."""
|
||
run = run or NullRun()
|
||
with run.phase("grading"):
|
||
lo = target - datetime.timedelta(days=days)
|
||
hi = target + datetime.timedelta(days=after)
|
||
# Build the window from both sources. The recent+forecast bundle covers the
|
||
# last few weeks plus the forward forecast (the only source for future days);
|
||
# the archive reaches decades back for targets older than that bundle. Prefer
|
||
# the bundle row for any given date, filling the rest from the archive.
|
||
rwin = recent.filter((pl.col("date") >= lo) & (pl.col("date") <= hi))
|
||
hwin = history.filter((pl.col("date") >= lo) & (pl.col("date") <= hi))
|
||
hwin = hwin.join(rwin.select("date"), on="date", how="anti")
|
||
# diagonal_relaxed aligns by column name and null-fills gaps, matching the
|
||
# old pandas concat when the two sources' columns differ (e.g. a reduced
|
||
# recent bundle); grading treats an absent column's value as None.
|
||
window = pl.concat([rwin, hwin], how="diagonal_relaxed").sort("date")
|
||
graded, climo = _graded_window(history, window, (history, recent),
|
||
target.timetuple().tm_yday)
|
||
|
||
# Summarize what this run actually covered. A fresh history fetch pulls the
|
||
# full ~45-year archive (run_type "full"); a cache hit only fetched the
|
||
# recent window (run_type "partial").
|
||
years = history["date"].dt.year()
|
||
full = not cache_meta.get("cached", False)
|
||
run.set(
|
||
run_type="full" if full else "partial",
|
||
history_source="fetch" if full else "cache",
|
||
history_rows=int(len(history)),
|
||
history_years=int(years.max() - years.min() + 1),
|
||
history_span=[int(years.min()), int(years.max())],
|
||
cache_age_days=cache_meta.get("cache_age_days"),
|
||
graded_days=len(graded),
|
||
place_found=place is not None,
|
||
)
|
||
return {
|
||
"cell": cell,
|
||
"place": place,
|
||
"target_date": target.isoformat(),
|
||
"cache": cache_meta,
|
||
"climatology": climo,
|
||
"recent": graded,
|
||
}
|
||
|
||
|
||
def cal_span(history, start, end, months) -> tuple[datetime.date, datetime.date]:
|
||
"""Clamp a requested calendar range to the available record (≤ ~2 years).
|
||
|
||
Without a start, defaults to whole months back landing on the SAME
|
||
day-of-month as the end, so the two range endpoints line up
|
||
(e.g. 2024-06-29 → 2026-06-29). `start`/`end` are ISO strings or None."""
|
||
first = history["date"].min()
|
||
last = history["date"].max()
|
||
end_ts = min(datetime.date.fromisoformat(end), last) if end else last
|
||
if start:
|
||
start_ts = datetime.date.fromisoformat(start)
|
||
else:
|
||
start_ts = _months_before(end_ts, months)
|
||
# Cap the graded span at ~2 years, and keep it inside the available record.
|
||
min_start = end_ts - datetime.timedelta(days=CAL_MAX_SPAN_DAYS - 1)
|
||
start_ts = max(start_ts, min_start, first)
|
||
start_ts = min(start_ts, end_ts)
|
||
return start_ts, end_ts
|
||
|
||
|
||
def build_calendar(cell, history, start_ts, end_ts, months, place, run=None) -> dict:
|
||
"""/calendar payload: every day in [start_ts, end_ts] graded, compact shape."""
|
||
run = run or NullRun()
|
||
with run.phase("grading"):
|
||
days = grading.grade_range(history, start_ts, end_ts)
|
||
run.set(graded_days=len(days))
|
||
return {
|
||
"api_version": "v2",
|
||
"cell": cell,
|
||
"place": place,
|
||
"range": {"start": start_ts.isoformat(), "end": end_ts.isoformat()},
|
||
"months": months,
|
||
"days": days,
|
||
# Per-month climatology over the full history — drives the plain-language
|
||
# insight strip and cross-month rain comparisons in the calendar UI.
|
||
"months_climate": grading.month_climate(history),
|
||
}
|
||
|
||
|
||
def build_day(cell, history, target, place, run=None, recent=None) -> dict:
|
||
"""/day payload: the full percentile breakdown for one day. Observed values
|
||
prefer the archive record; a date newer than it comes from the recent window
|
||
(passed pre-loaded by the bundle path, fetched here otherwise)."""
|
||
run = run or NullRun()
|
||
last = history["date"].max()
|
||
|
||
obs = None
|
||
hit = history.filter(pl.col("date") == target)
|
||
if hit.height:
|
||
obs = _obs_from_row(hit.row(0, named=True))
|
||
elif target > last:
|
||
try:
|
||
if recent is None:
|
||
with run.phase("recent"):
|
||
recent = climate.get_recent_forecast(cell)
|
||
rr = recent.filter(pl.col("date") == target)
|
||
if rr.height:
|
||
obs = _obs_from_row(rr.row(0, named=True))
|
||
except Exception: # noqa: BLE001 - detail page still works from climatology alone
|
||
obs = None
|
||
|
||
with run.phase("detail"):
|
||
detail = grading.day_detail(history, target, obs)
|
||
run.set(has_observation=obs is not None)
|
||
return {
|
||
"api_version": "v2",
|
||
"cell": cell,
|
||
"place": place,
|
||
"latest": last.isoformat(),
|
||
"detail": detail,
|
||
}
|
||
|
||
|
||
def build_forecast(cell, days, history, fc, today, place, run=None) -> dict:
|
||
"""/forecast payload: the next `days` forecast days graded — same shape as
|
||
/grade so the frontend renders it identically, furthest-out day first."""
|
||
run = run or NullRun()
|
||
# Strictly future days (tomorrow onward), earliest→latest, capped at `days`.
|
||
future = fc.filter(pl.col("date") > today).sort("date").head(days)
|
||
with run.phase("grading"):
|
||
graded, climo = _graded_window(history, future, (history, fc),
|
||
today.timetuple().tm_yday)
|
||
run.set(graded_days=len(graded), place_found=place is not None)
|
||
return {
|
||
"api_version": "v2",
|
||
"cell": cell,
|
||
"place": place,
|
||
"target_date": today.isoformat(),
|
||
"forecast": True,
|
||
"climatology": climo,
|
||
"recent": graded,
|
||
}
|
||
|
||
|
||
def build_score(cell, history, place, run=None) -> dict:
|
||
"""/score payload: how far the cell's last 6 years have drifted from its full
|
||
45-year baseline, per metric and season (see scoring.build_scores)."""
|
||
run = run or NullRun()
|
||
with run.phase("scoring"):
|
||
scores = scoring.build_scores(history)
|
||
run.set(place_found=place is not None)
|
||
return {
|
||
"api_version": "v2",
|
||
"cell": cell,
|
||
"place": place,
|
||
"latest": hist_end(history),
|
||
"scores": scores,
|
||
}
|