* Migrate backend dataframe layer from pandas to polars Replace pandas with polars across the backend, dropping both pandas and its pyarrow parquet engine from the dependency set. numpy stays (the grading percentile math is unchanged). - climate.py: parquet IO, source→frame mappings, cache read/topup on polars. New _normalize_read casts the cached `date` column to pl.Date (older files were written by pandas as datetime64[ns]); frames now unify missing values as null so the grading boundary drops them consistently across sources. - grading.py: keep the numpy percentile core; swap the frame→numpy bridge to .to_numpy()/.drop_nulls(), day-of-year/year to polars dt expressions, and the per-row loop to iter_rows(named=True). - views.py: filter/anti-join/concat replace boolean-mask, isin and pd.concat; scalar dates are stdlib datetime.date; a local _months_before helper replaces DateOffset(months=) for the calendar-range default. - app.py, migrate.py: request-date parsing uses datetime.date, removing pandas from the endpoint and migrate layers entirely. - The date column is pl.Date end to end, eliminating the pandas normalize() calls and comparing cleanly against stdlib dates. Payloads are unchanged: calendar, day, grade and forecast responses are byte-for-byte identical to the pandas implementation on the same cached record. Tests ported to polars fixtures, with added coverage for the combined feels-like fallback, calendar month-offset (month-end/leap), and the concat/dedup "fresher source wins" rule. * Port notify.py to polars after merging dev's account system Merge origin/dev (accounts + notification subscriptions) and carry the pandas→ polars migration into the newly added notify.py, which the merge brought in still using pandas — with pandas removed from requirements this broke its import. - notify.py: _candidate_rows filters/sorts the recent bundle with polars expressions and returns iter_rows dicts; date scalars are datetime.date; history/recent emptiness via is_empty(). - test_notify.py: synthetic history/rows built with polars + datetime.
278 lines
12 KiB
Python
278 lines
12 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
|
|
|
|
import climate
|
|
import grading
|
|
|
|
# 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)}"
|
|
|
|
|
|
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}"
|
|
|
|
|
|
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 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 = _grade_rows(history, window)
|
|
_attach_dry_streaks(graded, history, recent)
|
|
graded.reverse() # newest first for display
|
|
climo = grading.climatology(history, 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,
|
|
}
|
|
|
|
|
|
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 = _grade_rows(history, future)
|
|
_attach_dry_streaks(graded, history, fc)
|
|
graded.reverse() # furthest-out first, so the chart reverses to L→R chronological
|
|
climo = grading.climatology(history, 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,
|
|
}
|