- /api/v2/place still called grid.in_north_america(), which the worldwide- coverage change removed — every request raised AttributeError (500). The failure was invisible because compare.js treats the call as best-effort, so the instant chip-naming feature was silently dead. Drop the dead guard; the lat/lon Query validators already bound the inputs. - /api/v2/day was the only data route not wrapping get_history in _weather_fetch_error, so a rate-limited cold cell returned a raw 500 instead of the clean 503 the other routes emit. - calendar: the #calendar pointerleave handler was re-registered inside attachHover on every render — and the comfort slider re-renders per input tick, stacking dozens of copies. Register it once at module scope next to the matching document-level dismiss handler. - getJSON: check res.ok before parsing the body as JSON and fall back to the status line, so a non-JSON error body (a proxy 502 HTML page) reads as "Request failed (502 Bad Gateway)" instead of a JSON parse error.
822 lines
38 KiB
Python
822 lines
38 KiB
Python
"""Thermograph API — grade recent local weather against ~45 years of climatology."""
|
||
import contextlib
|
||
import datetime
|
||
import functools
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import time
|
||
|
||
import pandas as pd
|
||
from fastapi import APIRouter, FastAPI, HTTPException, Query, Request, Response
|
||
from fastapi.middleware.gzip import GZipMiddleware
|
||
from fastapi.responses import RedirectResponse
|
||
from fastapi.staticfiles import StaticFiles
|
||
|
||
import audit
|
||
import climate
|
||
import grading
|
||
import grid
|
||
import places
|
||
import store
|
||
|
||
FRONTEND_DIR = os.path.join(os.path.dirname(__file__), "..", "frontend")
|
||
|
||
# Warm the local place-name index (/suggest's typo tolerance) in the
|
||
# background; the app boots and serves fine without it — suggestions just fall
|
||
# back to the upstream geocoder until it's ready.
|
||
places.start_loading()
|
||
|
||
# Everything (pages, assets, API) is served under this base path so the app can
|
||
# live at https://<host>/thermograph/ behind a shared domain. Override with the
|
||
# THERMOGRAPH_BASE env var. The frontend uses base-relative URLs, so it follows
|
||
# this automatically without hardcoding the prefix.
|
||
BASE = "/" + os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/")
|
||
|
||
# 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 = "p1"
|
||
|
||
|
||
def _obs_from_row(row) -> dict:
|
||
return {k: row[k] for k in OBS_COLS if k in row}
|
||
|
||
|
||
def _weather_fetch_error(e) -> HTTPException:
|
||
"""A clean, retryable message for a rate limit; the raw error otherwise."""
|
||
low = str(e).lower()
|
||
reason = (climate._rate_limit_reason(e) or "").lower()
|
||
blob = low + " " + reason
|
||
if climate._is_rate_limit(e) or "429" in low or "rate-limited" in low or "limit" in reason:
|
||
if "daily" in blob or "tomorrow" in blob: # daily quota exhausted — resets tomorrow
|
||
return HTTPException(
|
||
status_code=503,
|
||
detail="Open-Meteo's daily request limit is exhausted — new locations will work again "
|
||
"tomorrow. Places you've already viewed still work.",
|
||
)
|
||
return HTTPException(
|
||
status_code=503,
|
||
detail="The weather service is rate-limited right now — please try again in a minute.",
|
||
)
|
||
return HTTPException(status_code=502, detail=f"weather data fetch failed: {e}")
|
||
|
||
|
||
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.iterrows()]
|
||
|
||
|
||
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[["date", "precip"]] for f in precip_frames if f is not None and not f.empty]
|
||
if not frames:
|
||
return
|
||
combined = (pd.concat(frames)
|
||
.drop_duplicates(subset="date", keep="last")
|
||
.sort_values("date"))
|
||
dsr_map = grading.dry_streaks(combined["date"].values, combined["precip"].values)
|
||
for g in graded:
|
||
g["dsr"] = dsr_map.get(g["date"])
|
||
|
||
|
||
app = FastAPI(title="Thermograph", version="0.2.0")
|
||
|
||
# Compress every sizeable response (the 2-year calendar JSON shrinks ~6-8×).
|
||
# Applies to API JSON and static assets alike; tiny responses are left alone.
|
||
app.add_middleware(GZipMiddleware, minimum_size=1024)
|
||
|
||
|
||
@app.middleware("http")
|
||
async def revalidate_static(request, call_next):
|
||
"""Serve the frontend with no-cache (NOT no-store): browsers may keep a copy
|
||
but must revalidate it on every use. Starlette's FileResponse/StaticFiles
|
||
already emit ETag + Last-Modified, so an unchanged asset costs one conditional
|
||
request answered with an empty 304 instead of a full re-download — page-to-page
|
||
navigation stops re-transferring the JS/CSS/HTML while still picking up every
|
||
deploy immediately (no "my change isn't showing" bugs)."""
|
||
response = await call_next(request)
|
||
path = request.url.path
|
||
pages = (BASE, f"{BASE}/", f"{BASE}/calendar", f"{BASE}/day", f"{BASE}/compare", f"{BASE}/legend")
|
||
if path.endswith((".js", ".css", ".html")) or path in pages:
|
||
response.headers["Cache-Control"] = "no-cache"
|
||
return response
|
||
|
||
|
||
# --- derived-store plumbing --------------------------------------------------
|
||
# Every graded payload is cached in SQLite under (kind, cell, key) and validated
|
||
# by a token that encodes what it was computed from (see store.py). The freshness
|
||
# drivers stay where they were — climate.get_history tops up the archive tail
|
||
# hourly and get_recent_forecast refetches hourly — and the tokens are derived
|
||
# from what those return, so a cached payload expires exactly when its inputs
|
||
# change and never before. The same tokens double as ETags: a client sending
|
||
# If-None-Match gets an empty 304 without the payload even being loaded.
|
||
|
||
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 _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 pd.Timestamp(history["date"].max()).date().isoformat()
|
||
|
||
|
||
def _etag_for(kind: str, cell_id: str, key: str, token: str) -> str:
|
||
"""Deterministic weak ETag from a payload's identity + validity token. Weak
|
||
because the same content may be served under different encodings (gzip)."""
|
||
h = hashlib.sha1(f"{kind}:{cell_id}:{key}:{token}".encode()).hexdigest()[:20]
|
||
return f'W/"{h}"'
|
||
|
||
|
||
def _not_modified(request: Request, etag: str) -> bool:
|
||
"""Does the client already hold this exact payload? Answerable from the token
|
||
alone — no payload load needed."""
|
||
inm = request.headers.get("if-none-match")
|
||
if not inm:
|
||
return False
|
||
tags = {t.strip() for t in inm.split(",")}
|
||
return "*" in tags or etag in tags or etag.removeprefix("W/") in tags
|
||
|
||
|
||
def _encode(payload: dict) -> bytes:
|
||
"""Compact JSON bytes, matching store.put_payload's encoding (allow_nan=False
|
||
mirrors Starlette — a NaN from grading should fail loudly, not reach a client)."""
|
||
return json.dumps(payload, default=str, allow_nan=False, separators=(",", ":")).encode()
|
||
|
||
|
||
def _json_response(body: bytes, etag: str | None = None) -> Response:
|
||
headers = {"ETag": etag} if etag else {}
|
||
return Response(content=body, media_type="application/json", headers=headers)
|
||
|
||
|
||
# --- payload builders --------------------------------------------------------
|
||
# Pure "inputs → response dict" functions shared by the per-view endpoints, the
|
||
# /cell bundle, and the offline migrate script. HTTP semantics (audit runs, cache
|
||
# lookups, etags) stay in the endpoints; `run` is the audit run or None.
|
||
|
||
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 - pd.Timedelta(days=days)
|
||
hi = target + pd.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[(recent["date"] >= lo) & (recent["date"] <= hi)]
|
||
hwin = history[(history["date"] >= lo) & (history["date"] <= hi)]
|
||
hwin = hwin[~hwin["date"].isin(set(rwin["date"]))]
|
||
window = pd.concat([rwin, hwin]).sort_values("date")
|
||
graded = _grade_rows(history, window)
|
||
_attach_dry_streaks(graded, history, recent)
|
||
graded.reverse() # newest first for display
|
||
climo = grading.climatology(history, int(target.dayofyear))
|
||
|
||
# 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 = pd.to_datetime(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.date().isoformat(),
|
||
"cache": cache_meta,
|
||
"climatology": climo,
|
||
"recent": graded,
|
||
}
|
||
|
||
|
||
CAL_MAX_SPAN_DAYS = 732 # ~2 years — the most a single calendar request will grade
|
||
# (the frontend splits longer spans into 2-year chunks)
|
||
|
||
|
||
def _cal_span(history, start, end, months) -> tuple[pd.Timestamp, pd.Timestamp]:
|
||
"""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)."""
|
||
first = pd.Timestamp(history["date"].min()).normalize()
|
||
last = pd.Timestamp(history["date"].max()).normalize()
|
||
end_ts = min(pd.Timestamp(end).normalize(), last) if end else last
|
||
if start:
|
||
start_ts = pd.Timestamp(start).normalize()
|
||
else:
|
||
start_ts = (end_ts - pd.DateOffset(months=months)).normalize()
|
||
# Cap the graded span at ~2 years, and keep it inside the available record.
|
||
min_start = end_ts - pd.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.date().isoformat(), "end": end_ts.date().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 = pd.Timestamp(history["date"].max()).normalize()
|
||
|
||
obs = None
|
||
hit = history[history["date"] == target]
|
||
if not hit.empty:
|
||
obs = _obs_from_row(hit.iloc[0])
|
||
elif target > last:
|
||
try:
|
||
if recent is None:
|
||
with run.phase("recent"):
|
||
recent = climate.get_recent_forecast(cell)
|
||
rr = recent[recent["date"] == target]
|
||
if not rr.empty:
|
||
obs = _obs_from_row(rr.iloc[0])
|
||
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.date().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[fc["date"] > today].sort_values("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, int(today.dayofyear))
|
||
run.set(graded_days=len(graded), place_found=place is not None)
|
||
return {
|
||
"api_version": "v2",
|
||
"cell": cell,
|
||
"place": place,
|
||
"target_date": today.date().isoformat(),
|
||
"forecast": True,
|
||
"climatology": climo,
|
||
"recent": graded,
|
||
}
|
||
|
||
|
||
# --- endpoints ----------------------------------------------------------------
|
||
|
||
def api_geocode(q: str = Query(..., min_length=1)):
|
||
try:
|
||
return {"results": climate.geocode(q)}
|
||
except Exception as e: # noqa: BLE001 - surface upstream failures to the client
|
||
raise HTTPException(status_code=502, detail=f"geocoding failed: {e}")
|
||
|
||
|
||
_SUGGEST_LIMIT = 5
|
||
|
||
|
||
@functools.lru_cache(maxsize=1024)
|
||
def _suggest_upstream(q: str) -> tuple:
|
||
"""Open-Meteo lookup for /suggest, memoized per query string — type-ahead
|
||
re-asks the same prefixes constantly (backspacing, retyping). Failures
|
||
raise and are not cached, so a transient upstream error doesn't stick."""
|
||
return tuple(climate.geocode(q, count=_SUGGEST_LIMIT))
|
||
|
||
|
||
def _suggest_score(r: dict, exact: bool) -> int:
|
||
"""Prominence rank with an exactness boost — big enough that a real place
|
||
beats a same-size typo match, small enough that a hamlet spelled exactly
|
||
like the typo can't outrank a metropolis one edit away ("Seatle", a
|
||
village, must not beat Seattle)."""
|
||
pop = r.get("population") or 0
|
||
return pop * 4 + 1 if exact else pop
|
||
|
||
|
||
def _suggest_merge(local: list, upstream: tuple, limit: int) -> list:
|
||
"""Blend local-index and upstream results into one top-`limit` list.
|
||
Everything the user's spelling matches exactly counts as exact; only the
|
||
local index's typo-tolerant matches take the fuzzy (unboosted) score."""
|
||
scored = [(r, _suggest_score(r, r.get("match") != "fuzzy")) for r in local]
|
||
scored += [(r, _suggest_score(r, True)) for r in upstream]
|
||
scored.sort(key=lambda t: t[1], reverse=True)
|
||
out, seen = [], set()
|
||
for r, _ in scored:
|
||
key = ((r.get("name") or "").casefold(), r.get("admin1") or "",
|
||
r.get("country_code") or "")
|
||
if key not in seen:
|
||
seen.add(key)
|
||
out.append(r)
|
||
if len(out) == limit:
|
||
break
|
||
return out
|
||
|
||
|
||
def api_suggest(q: str = Query(..., min_length=1)):
|
||
"""Type-ahead location suggestions: the top 5 places for a (possibly
|
||
typo'd) query prefix. The local GeoNames index answers instantly and
|
||
tolerates a single-letter typo; the upstream geocoder fills remaining slots
|
||
with what the index doesn't know (neighbourhoods, postcodes). When both
|
||
come up empty, one query token is respelled against known place-name tokens
|
||
and retried ("pest seattle" → "west seattle"); `corrected` reports the
|
||
respelling that produced the results."""
|
||
q = q.strip()
|
||
if len(q) < 2:
|
||
return {"results": [], "corrected": None}
|
||
local = places.search(q, _SUGGEST_LIMIT) # None while the index loads
|
||
results = list(local or [])
|
||
upstream_error = None
|
||
# Consult the upstream geocoder unless the local index already answered
|
||
# convincingly: full slots and a substantial place matching the spelling as
|
||
# typed. Fuzzy hits don't count as convincing — they're guesses, and when
|
||
# the query is an alternate name the index doesn't know ("münchen" prefix-
|
||
# matches only small towns; Munich lives upstream), neither those towns nor
|
||
# a coincidental fuzzy big-city hit may suppress the real answer.
|
||
prominent = max((r.get("population") or 0 for r in results
|
||
if r.get("match") == "prefix"), default=0)
|
||
if len(results) < _SUGGEST_LIMIT or prominent < 100_000:
|
||
try:
|
||
results = _suggest_merge(results, _suggest_upstream(q), _SUGGEST_LIMIT)
|
||
except Exception as e: # noqa: BLE001 - local results (if any) still serve
|
||
upstream_error = e
|
||
corrected = None
|
||
if not results and len(q) >= 4:
|
||
for phrase in places.corrections(q):
|
||
hits = places.search(phrase, _SUGGEST_LIMIT) or []
|
||
if not hits:
|
||
try:
|
||
hits = list(_suggest_upstream(phrase))
|
||
except Exception: # noqa: BLE001 - a failed probe just means no hits
|
||
hits = []
|
||
if hits:
|
||
results, corrected = hits[:_SUGGEST_LIMIT], phrase
|
||
break
|
||
if not results and local is None and upstream_error is not None:
|
||
raise HTTPException(status_code=502, detail=f"geocoding failed: {upstream_error}")
|
||
return {"results": results[:_SUGGEST_LIMIT], "corrected": corrected}
|
||
|
||
|
||
def api_place(
|
||
lat: float = Query(..., ge=-90, le=90),
|
||
lon: float = Query(..., ge=-180, le=180),
|
||
):
|
||
"""Best-effort neighbourhood/city label for a point — the same string the view
|
||
endpoints expose as ``place`` (snapped to the grid cell, reverse-geocoded and
|
||
cached). Resolved on its own so the compare page can show a location's name as
|
||
soon as it's added, before its full series loads."""
|
||
cell = grid.snap(lat, lon)
|
||
with audit.RunAudit(endpoint="place", lat=round(lat, 4), lon=round(lon, 4),
|
||
cell_id=cell["id"]) as run:
|
||
with run.phase("reverse_geocode"):
|
||
place = climate.reverse_geocode(cell["center_lat"], cell["center_lon"])
|
||
return {"place": place,
|
||
"cell": {"center_lat": cell["center_lat"], "center_lon": cell["center_lon"]}}
|
||
|
||
|
||
def api_grade(
|
||
request: Request,
|
||
lat: float = Query(..., ge=-90, le=90),
|
||
lon: float = Query(..., ge=-180, le=180),
|
||
date: str | None = Query(None, description="target date YYYY-MM-DD (default today)"),
|
||
days: int = Query(14, ge=1, le=60, description="days of history to grade before the target"),
|
||
after: int = Query(14, ge=0, le=14,
|
||
description="days to grade after the target (observed, or forecast when future; "
|
||
"the forecast reaches ~7 days out so future days cap there)"),
|
||
):
|
||
target = (
|
||
pd.Timestamp(date).normalize()
|
||
if date
|
||
else pd.Timestamp(datetime.date.today())
|
||
)
|
||
cell = grid.snap(lat, lon)
|
||
|
||
with audit.RunAudit(
|
||
endpoint="grade",
|
||
lat=round(lat, 4),
|
||
lon=round(lon, 4),
|
||
target_date=target.date().isoformat(),
|
||
recent_days=days,
|
||
cell_id=cell["id"],
|
||
) as run:
|
||
try:
|
||
with run.phase("history"):
|
||
history, cache_meta = climate.get_history(cell)
|
||
with run.phase("recent"):
|
||
recent = climate.get_recent_forecast(cell)
|
||
except Exception as e: # noqa: BLE001
|
||
raise _weather_fetch_error(e)
|
||
|
||
if history.empty:
|
||
raise HTTPException(status_code=404, detail="No historical data for this cell.")
|
||
|
||
key = f"{target.date().isoformat()}:{days}:{after}"
|
||
token = f"{PAYLOAD_VER}:{_hist_end(history)}:{climate.recent_stamp(cell['id'])}"
|
||
etag = _etag_for("grade", cell["id"], key, token)
|
||
if _not_modified(request, etag):
|
||
run.set(run_type="cache", not_modified=True)
|
||
return Response(status_code=304, headers={"ETag": etag})
|
||
body = store.get_payload("grade", cell["id"], key, token)
|
||
if body is not None:
|
||
run.set(run_type="cache", history_source="store")
|
||
return _json_response(body, etag)
|
||
|
||
with run.phase("reverse_geocode"):
|
||
place = climate.reverse_geocode(cell["center_lat"], cell["center_lon"])
|
||
payload = _build_grade(cell, target, days, history, recent, cache_meta, place, run, after=after)
|
||
return _json_response(store.put_payload("grade", cell["id"], key, token, payload), etag)
|
||
|
||
|
||
def api_calendar(
|
||
request: Request,
|
||
lat: float = Query(..., ge=-90, le=90),
|
||
lon: float = Query(..., ge=-180, le=180),
|
||
start: str | None = Query(None, description="first date YYYY-MM-DD (overrides months)"),
|
||
end: str | None = Query(None, description="last date YYYY-MM-DD (default = latest available)"),
|
||
months: int = Query(24, ge=1, le=24, description="months back to grade when no start given"),
|
||
):
|
||
"""Grade every day over a date range (default: last 2 years) for the calendar view.
|
||
|
||
A custom [start, end] range is supported and capped at ~2 years per request;
|
||
the frontend splits longer spans into successive 2-year chunks. Grades against
|
||
the already-cached history record (which reaches to ~6 days ago), so no extra
|
||
fetch is needed. The graded payload is cached in the derived store keyed by
|
||
the clamped span and validated by the record's end date, so a repeat span is
|
||
a database read — across restarts — until new archive days arrive. Returns a
|
||
compact per-day shape (see grading.grade_range). New in API v2.
|
||
"""
|
||
cell = grid.snap(lat, lon)
|
||
|
||
with audit.RunAudit(
|
||
endpoint="calendar",
|
||
lat=round(lat, 4),
|
||
lon=round(lon, 4),
|
||
recent_days=months * 31, # approximate span graded
|
||
cell_id=cell["id"],
|
||
) as run:
|
||
try:
|
||
with run.phase("history"):
|
||
history, cache_meta = climate.get_history(cell)
|
||
except Exception as e: # noqa: BLE001
|
||
raise _weather_fetch_error(e)
|
||
if history.empty:
|
||
raise HTTPException(status_code=404, detail="No historical data for this cell.")
|
||
|
||
start_ts, end_ts = _cal_span(history, start, end, months)
|
||
key = f"{start_ts.date().isoformat()}:{end_ts.date().isoformat()}:{months}"
|
||
token = f"{PAYLOAD_VER}:{_hist_end(history)}"
|
||
etag = _etag_for("calendar", cell["id"], key, token)
|
||
if _not_modified(request, etag):
|
||
run.set(run_type="cache", not_modified=True)
|
||
return Response(status_code=304, headers={"ETag": etag})
|
||
body = store.get_payload("calendar", cell["id"], key, token)
|
||
if body is not None:
|
||
run.set(run_type="cache", history_source="store")
|
||
return _json_response(body, etag)
|
||
|
||
with run.phase("reverse_geocode"):
|
||
place = climate.reverse_geocode(cell["center_lat"], cell["center_lon"])
|
||
payload = _build_calendar(cell, history, start_ts, end_ts, months, place, run)
|
||
full = not cache_meta.get("cached", False)
|
||
run.set(run_type="full" if full else "partial",
|
||
history_source="fetch" if full else "cache")
|
||
# Don't persist a payload whose place failed to resolve (a transient reverse-
|
||
# geocode miss) — otherwise the bare-coordinates fallback would stick for the
|
||
# life of the token. Compare loads several cells at once, so this is where a
|
||
# miss is most likely; leaving it uncached lets the next request retry.
|
||
return _json_response(
|
||
store.put_payload("calendar", cell["id"], key, token, payload,
|
||
cache=place is not None), etag)
|
||
|
||
|
||
def api_day(
|
||
request: Request,
|
||
lat: float = Query(..., ge=-90, le=90),
|
||
lon: float = Query(..., ge=-180, le=180),
|
||
date: str | None = Query(None, description="target date YYYY-MM-DD (default = latest available)"),
|
||
):
|
||
"""Full percentile breakdown for a single day: the value at every tier boundary
|
||
in that day-of-year's ±7-day window, plus where the observed values land.
|
||
|
||
Powers the single-day detail page. Grades against the cached history record;
|
||
for a date newer than the cache it pulls the recent window for the observation
|
||
(those payloads expire hourly — the recent bundle's own cadence — while fully
|
||
archived days stay valid until the record itself advances). New in API v2.
|
||
"""
|
||
cell = grid.snap(lat, lon)
|
||
|
||
with audit.RunAudit(
|
||
endpoint="day",
|
||
lat=round(lat, 4),
|
||
lon=round(lon, 4),
|
||
cell_id=cell["id"],
|
||
) as run:
|
||
try:
|
||
with run.phase("history"):
|
||
history, cache_meta = climate.get_history(cell)
|
||
except Exception as e: # noqa: BLE001
|
||
raise _weather_fetch_error(e)
|
||
if history.empty:
|
||
raise HTTPException(status_code=404, detail="No historical data for this cell.")
|
||
|
||
last = pd.Timestamp(history["date"].max()).normalize()
|
||
target = pd.Timestamp(date).normalize() if date else last
|
||
run.set(target_date=target.date().isoformat())
|
||
|
||
key = target.date().isoformat()
|
||
token = f"{PAYLOAD_VER}:{_hist_end(history)}"
|
||
if target > last:
|
||
token += f":h{int(time.time() // 3600)}" # obs from the hourly recent bundle
|
||
etag = _etag_for("day", cell["id"], key, token)
|
||
if _not_modified(request, etag):
|
||
run.set(run_type="cache", not_modified=True)
|
||
return Response(status_code=304, headers={"ETag": etag})
|
||
body = store.get_payload("day", cell["id"], key, token)
|
||
if body is not None:
|
||
run.set(run_type="cache", history_source="store")
|
||
return _json_response(body, etag)
|
||
|
||
with run.phase("reverse_geocode"):
|
||
place = climate.reverse_geocode(cell["center_lat"], cell["center_lon"])
|
||
payload = _build_day(cell, history, target, place, run)
|
||
full = not cache_meta.get("cached", False)
|
||
run.set(run_type="full" if full else "partial",
|
||
history_source="fetch" if full else "cache")
|
||
return _json_response(store.put_payload("day", cell["id"], key, token, payload), etag)
|
||
|
||
|
||
def api_forecast(
|
||
request: Request,
|
||
lat: float = Query(..., ge=-90, le=90),
|
||
lon: float = Query(..., ge=-180, le=180),
|
||
days: int = Query(7, ge=1, le=14, description="how many forecast days ahead to grade"),
|
||
):
|
||
"""Grade the next `days` of forecast weather against local climatology.
|
||
|
||
Same shape as /grade (so the frontend renders it identically), but `recent`
|
||
holds the forward forecast — furthest-out day first. The forecast is fetched
|
||
fresh and cached only ~1 hour (see climate.get_recent_forecast) to track
|
||
updates; the graded payload's validity is tied to that fetch stamp, so it
|
||
expires exactly when a new forecast lands. New in API v2.
|
||
"""
|
||
cell = grid.snap(lat, lon)
|
||
|
||
with audit.RunAudit(endpoint="forecast", lat=round(lat, 4), lon=round(lon, 4),
|
||
recent_days=days, cell_id=cell["id"]) as run:
|
||
try:
|
||
with run.phase("history"):
|
||
history, _ = climate.get_history(cell)
|
||
with run.phase("forecast"):
|
||
fc = climate.get_recent_forecast(cell)
|
||
except Exception as e: # noqa: BLE001
|
||
raise _weather_fetch_error(e)
|
||
|
||
if history.empty:
|
||
raise HTTPException(status_code=404, detail="No historical data for this cell.")
|
||
|
||
today = pd.Timestamp(datetime.date.today())
|
||
key = f"{today.date().isoformat()}:{days}"
|
||
token = f"{PAYLOAD_VER}:{_hist_end(history)}:{climate.recent_stamp(cell['id'])}"
|
||
etag = _etag_for("forecast", cell["id"], key, token)
|
||
if _not_modified(request, etag):
|
||
run.set(run_type="cache", not_modified=True)
|
||
return Response(status_code=304, headers={"ETag": etag})
|
||
body = store.get_payload("forecast", cell["id"], key, token)
|
||
if body is not None:
|
||
run.set(run_type="cache", history_source="store")
|
||
return _json_response(body, etag)
|
||
|
||
with run.phase("reverse_geocode"):
|
||
place = climate.reverse_geocode(cell["center_lat"], cell["center_lon"])
|
||
payload = _build_forecast(cell, days, history, fc, today, place, run)
|
||
run.set(run_type="partial")
|
||
return _json_response(store.put_payload("forecast", cell["id"], key, token, payload), etag)
|
||
|
||
|
||
def api_cell(
|
||
request: Request,
|
||
lat: float = Query(..., ge=-90, le=90),
|
||
lon: float = Query(..., ge=-180, le=180),
|
||
prefetch: int = Query(0, ge=0, le=1,
|
||
description="1 = warm-only: never fetch weather upstream; 204 for a cold cell"),
|
||
):
|
||
"""One bundle carrying every view's payload for a cell, so the frontend warms
|
||
all views with a single request instead of four.
|
||
|
||
Each slice is the EXACT payload its per-view endpoint returns — built by the
|
||
same builders and cached under the same derived-store keys/tokens — paired
|
||
with the etag that endpoint would emit. The client seeds its per-view cache
|
||
from the slices and later revalidates each view individually with
|
||
If-None-Match, so the bundle and the per-view endpoints stay one cache.
|
||
|
||
prefetch=1 is the neighbor-warming mode with a hard guarantee: it never
|
||
spends weather-API quota. A cell with no cached archive answers 204 (no
|
||
body), and only the history-derived slices (calendar + latest-day detail)
|
||
are built — grading recent/forecast days needs the hourly upstream bundle.
|
||
Reverse geocoding makes at most one Nominatim call for a never-labeled cell;
|
||
the client staggers neighbor prefetches to respect that service. New in API v2.
|
||
"""
|
||
cell = grid.snap(lat, lon)
|
||
today = pd.Timestamp(datetime.date.today())
|
||
|
||
with audit.RunAudit(endpoint="cell", lat=round(lat, 4), lon=round(lon, 4),
|
||
cell_id=cell["id"], prefetch=bool(prefetch)) as run:
|
||
recent = None
|
||
if prefetch:
|
||
history = climate.load_cached_history(cell)
|
||
if history is None or history.empty:
|
||
run.set(run_type="cold-skip")
|
||
return Response(status_code=204)
|
||
cache_meta = {"cached": True}
|
||
else:
|
||
try:
|
||
with run.phase("history"):
|
||
history, cache_meta = climate.get_history(cell)
|
||
with run.phase("recent"):
|
||
recent = climate.get_recent_forecast(cell)
|
||
except Exception as e: # noqa: BLE001
|
||
raise _weather_fetch_error(e)
|
||
if history.empty:
|
||
raise HTTPException(status_code=404, detail="No historical data for this cell.")
|
||
|
||
cid = cell["id"]
|
||
hist_end = _hist_end(history)
|
||
last = pd.Timestamp(history["date"].max()).normalize()
|
||
|
||
with run.phase("reverse_geocode"):
|
||
place = climate.reverse_geocode(cell["center_lat"], cell["center_lon"])
|
||
|
||
def slice_for(kind: str, key: str, token: str, build) -> dict:
|
||
"""The endpoint's derived row (or build + store it), paired with the
|
||
etag that endpoint would emit for the same request."""
|
||
etag = _etag_for(kind, cid, key, token)
|
||
data = store.get_json(kind, cid, key, token)
|
||
if data is None:
|
||
data = build()
|
||
store.put_payload(kind, cid, key, token, data)
|
||
return {"etag": etag, "data": data}
|
||
|
||
slices = {}
|
||
hist_token = f"{PAYLOAD_VER}:{hist_end}"
|
||
# Calendar: the default last-24-months span — what the calendar view (and
|
||
# the cross-view prefetch) requests first.
|
||
start_ts, end_ts = _cal_span(history, None, None, 24)
|
||
cal_key = f"{start_ts.date().isoformat()}:{end_ts.date().isoformat()}:24"
|
||
slices["calendar"] = slice_for(
|
||
"calendar", cal_key, hist_token,
|
||
lambda: _build_calendar(cell, history, start_ts, end_ts, 24, place, run))
|
||
|
||
if prefetch:
|
||
# Latest archived day: its observation comes from history alone, so
|
||
# it's buildable without the (skipped) recent bundle.
|
||
slices["day"] = slice_for(
|
||
"day", last.date().isoformat(), hist_token,
|
||
lambda: _build_day(cell, history, last, place, run))
|
||
else:
|
||
rf_token = f"{PAYLOAD_VER}:{hist_end}:{climate.recent_stamp(cid)}"
|
||
slices["grade"] = slice_for(
|
||
"grade", f"{today.date().isoformat()}:14:14", rf_token,
|
||
lambda: _build_grade(cell, today, 14, history, recent, cache_meta, place, run, after=14))
|
||
slices["forecast"] = slice_for(
|
||
"forecast", f"{today.date().isoformat()}:7", rf_token,
|
||
lambda: _build_forecast(cell, 7, history, recent, today, place, run))
|
||
day_token = hist_token
|
||
if today > last:
|
||
day_token += f":h{int(time.time() // 3600)}" # obs from the hourly recent bundle
|
||
slices["day"] = slice_for(
|
||
"day", today.date().isoformat(), day_token,
|
||
lambda: _build_day(cell, history, today, place, run, recent=recent))
|
||
|
||
# The bundle's identity is the combination of its slices' identities.
|
||
etag = _etag_for("cell", cid, "bundle" + (":p" if prefetch else ""),
|
||
"|".join(s["etag"] for s in slices.values()))
|
||
if _not_modified(request, etag):
|
||
run.set(run_type="cache", not_modified=True)
|
||
return Response(status_code=304, headers={"ETag": etag})
|
||
run.set(run_type="bundle", slices=sorted(slices))
|
||
payload = {
|
||
"api_version": "v2",
|
||
"cell": cell,
|
||
"place": place,
|
||
"today": today.date().isoformat(),
|
||
"slices": slices,
|
||
}
|
||
# Not stored as its own derived row — the slices already are.
|
||
return _json_response(_encode(payload), etag)
|
||
|
||
|
||
# --- API versioning --------------------------------------------------------
|
||
# Non-backward-compatible additions land in a new version; every version stays
|
||
# mounted and served simultaneously. v1 is the original contract (grade/geocode);
|
||
# v2 adds the calendar. The unversioned /api/* paths are kept as aliases of v1 so
|
||
# existing clients keep working.
|
||
v1 = APIRouter(tags=["v1"])
|
||
v1.add_api_route("/geocode", api_geocode, methods=["GET"])
|
||
v1.add_api_route("/grade", api_grade, methods=["GET"])
|
||
|
||
v2 = APIRouter(tags=["v2"])
|
||
v2.add_api_route("/geocode", api_geocode, methods=["GET"])
|
||
v2.add_api_route("/suggest", api_suggest, methods=["GET"])
|
||
v2.add_api_route("/place", api_place, methods=["GET"])
|
||
v2.add_api_route("/grade", api_grade, methods=["GET"])
|
||
v2.add_api_route("/calendar", api_calendar, methods=["GET"])
|
||
v2.add_api_route("/day", api_day, methods=["GET"])
|
||
v2.add_api_route("/forecast", api_forecast, methods=["GET"])
|
||
v2.add_api_route("/cell", api_cell, methods=["GET"])
|
||
|
||
app.include_router(v1, prefix=f"{BASE}/api") # legacy unversioned == v1 (backward compatible)
|
||
app.include_router(v1, prefix=f"{BASE}/api/v1")
|
||
app.include_router(v2, prefix=f"{BASE}/api/v2")
|
||
|
||
|
||
# --- static frontend (served under BASE) -----------------------------------
|
||
def _page(name):
|
||
"""Route handler for one HTML page. Serves the file with its __ORIGIN__
|
||
placeholders (the link-preview/Open Graph tags) filled in as the request's
|
||
scheme://host + BASE — preview crawlers (Discord, Slack, …) need absolute
|
||
URLs, and the host differs between LAN and prod. The scheme comes from
|
||
X-Forwarded-Proto when a reverse proxy (Caddy) fronts the plain-HTTP
|
||
uvicorn; the proxy passes the original Host header through untouched."""
|
||
path = os.path.join(FRONTEND_DIR, name)
|
||
|
||
def route(request: Request):
|
||
with open(path, encoding="utf-8") as f:
|
||
html = f.read()
|
||
proto = request.headers.get("x-forwarded-proto") or request.url.scheme
|
||
host = request.headers.get("host") or request.url.netloc
|
||
html = html.replace("__ORIGIN__", f"{proto}://{host}{BASE}")
|
||
etag = f'W/"{hashlib.sha1(html.encode()).hexdigest()[:20]}"'
|
||
if _not_modified(request, etag):
|
||
return Response(status_code=304, headers={"ETag": etag})
|
||
return Response(html, media_type="text/html", headers={"ETag": etag})
|
||
return route
|
||
|
||
|
||
# The un-slashed base redirects to BASE/ so the frontend's relative asset URLs
|
||
# resolve correctly. The app stays scoped to BASE and never claims "/", leaving
|
||
# the domain root free for another app (e.g. a portfolio) to own via the proxy.
|
||
# Pages also answer HEAD — link-preview crawlers probe with it before fetching.
|
||
app.add_api_route(BASE, lambda: RedirectResponse(url=f"{BASE}/"), methods=["GET", "HEAD"], include_in_schema=False)
|
||
app.add_api_route(f"{BASE}/", _page("index.html"), methods=["GET", "HEAD"], include_in_schema=False)
|
||
app.add_api_route(f"{BASE}/calendar", _page("calendar.html"), methods=["GET", "HEAD"], include_in_schema=False)
|
||
app.add_api_route(f"{BASE}/day", _page("day.html"), methods=["GET", "HEAD"], include_in_schema=False)
|
||
app.add_api_route(f"{BASE}/compare", _page("compare.html"), methods=["GET", "HEAD"], include_in_schema=False)
|
||
app.add_api_route(f"{BASE}/legend", _page("legend.html"), methods=["GET", "HEAD"], include_in_schema=False)
|
||
|
||
# Everything else under BASE (app.js, style.css, nav.js, …) is a static asset.
|
||
# Registered last so the explicit page routes above win.
|
||
app.mount(BASE, StaticFiles(directory=FRONTEND_DIR), name="static")
|