thermograph/backend/api/content_payloads.py
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

409 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Payload builders for the SSR content API (backend/api/content_routes.py).
These mirror backend/web/content.py's context builders — same underlying
grading.climatology()/all_time_records()/etc. calls — but return plain JSON-safe
dicts (raw floats, no Markup-wrapped HTML spans, no ContextVar-based unit
rendering) instead of pre-rendered strings, so a caller doesn't need to be a
Jinja/HTML consumer. Transitional duplication with content.py's own logic is
expected here: Stage 3 of the repo-split rewrites content.py to call these
endpoints via HTTP instead of computing in-process, at which point content.py's
copies of this logic go away and this becomes the only implementation.
"""
import datetime
import polars as pl
from api.payloads import OBS_COLS
from data import cities
from data import city_events
from data import grading
MONTHS = ["january", "february", "march", "april", "may", "june",
"july", "august", "september", "october", "november", "december"]
MONTHS_TITLE = [m.capitalize() for m in MONTHS]
MONTH_INDEX = {m: i + 1 for i, m in enumerate(MONTHS)}
SEASONS = [
("Winter", "Summer", [12, 1, 2], "DecFeb"),
("Spring", "Autumn", [3, 4, 5], "MarMay"),
("Summer", "Winter", [6, 7, 8], "JunAug"),
("Autumn", "Spring", [9, 10, 11], "SepNov"),
]
METRIC_LABELS = [
("tmax", "High"), ("tmin", "Low"), ("feels", "Feels-like"),
("humid", "Humidity"), ("wind", "Wind"), ("gust", "Gust"), ("precip", "Precip"),
]
_TEMP_METRICS = {"tmax", "tmin", "feels"}
# Countries that actually use Fahrenheit. Mirrors content.py's F_COUNTRIES /
# frontend/units.js's F_REGIONS — a test asserts all three stay identical.
F_COUNTRIES = frozenset({"US", "PR", "GU", "VI", "AS", "MP", "UM",
"BS", "BZ", "KY", "PW", "FM", "MH", "LR"})
# 12 city chips for the homepage, geographically spread — mirrors content.py's
# HOME_CITY_SLUGS exactly (a test asserts the two lists stay identical).
HOME_CITY_SLUGS = (
"new-york-city-new-york-us", "london-england-gb", "tokyo-jp",
"sydney-new-south-wales-au", "sao-paulo-br", "lagos-ng",
"mumbai-maharashtra-in", "mexico-city-mx", "cairo-eg",
"toronto-ontario-ca", "berlin-state-of-berlin-de", "seattle-washington-us",
)
def unit_for_country(code: str | None) -> str:
return "F" if (code or "").upper() in F_COUNTRIES else "C"
def _month_doy(month_idx: int) -> int:
return datetime.date(2001, month_idx, 15).timetuple().tm_yday
def _titled(text: str, suffix: str = " · Thermograph", limit: int = 60) -> str:
return text + suffix if len(text) + len(suffix) <= limit else text
def _clamp_desc(text: str, limit: int = 155) -> str:
text = " ".join(text.split())
if len(text) <= limit:
return text
return text[:limit].rsplit(" ", 1)[0].rstrip(",;—-") + ""
def _month_year(date_s) -> str:
"""'1995-07-13' -> 'Jul 1995'. Meta descriptions have ~155 characters to
spend, so a record's date gives up its day."""
try:
return datetime.date.fromisoformat(str(date_s)).strftime("%b %Y")
except (ValueError, TypeError):
return str(date_s)
def _temp_text(f, unit: str) -> str:
if f is None:
return ""
v = round((f - 32) * 5 / 9) if unit == "C" else round(f)
return f"{v}°{unit}"
def _breadcrumb_jsonld(origin: str, breadcrumb: list[dict]) -> dict:
"""BreadcrumbList structured data. Google requires `item` on every ListItem
except the last, so unlinked intermediate crumbs (e.g. the country, which
has no page of its own) are omitted here even though the visible breadcrumb
shows them."""
crumbs = [c for c in breadcrumb[:-1] if c["href"]] + breadcrumb[-1:]
return {"@type": "BreadcrumbList",
"itemListElement": [
{"@type": "ListItem", "position": i + 1, "name": c["name"],
**({"item": f"{origin}{c['href']}"} if c["href"] else {})}
for i, c in enumerate(crumbs)]}
def hub_payload() -> dict:
groups = cities.by_country()
return {
"n_cities": sum(len(v) for v in groups.values()),
"n_countries": len(groups),
"countries": [
{"country": country,
"cities": [{"slug": c["slug"], "name": c["name"],
"display": cities.display_name(c)} for c in group]}
for country, group in groups.items()
],
}
def _monthly_normals_raw(history) -> list[dict]:
"""One row per month: raw high/low/precip means + the range-strip bounds."""
rows = []
for i, name in enumerate(MONTHS_TITLE, start=1):
clim = grading.climatology(history, _month_doy(i))
tmax, tmin, precip = clim.get("tmax"), clim.get("tmin"), clim.get("precip")
rows.append({
"name": name, "slug": MONTHS[i - 1],
"high_f": tmax["mean"] if tmax else None,
"low_f": tmin["mean"] if tmin else None,
"range_lo_f": tmin["p10"] if tmin else None,
"range_hi_f": tmax["p90"] if tmax else None,
"precip_f": precip["mean"] if precip else None,
})
return rows
def _extreme_raw(metric_rec, key: str) -> dict | None:
if not metric_rec:
return None
return {"value_f": metric_rec[key], "date": metric_rec[f"{key}_date"]}
def _period_records_raw(history, months: list[int]) -> dict:
if len(months) == 1:
sub = history.filter(pl.col("date").dt.month() == months[0])
else:
sub = history.filter(pl.col("date").dt.month().is_in(months))
rec = grading.all_time_records(sub) if not sub.is_empty() else {}
tmax, tmin = rec.get("tmax"), rec.get("tmin")
return {
"high": {"warm": _extreme_raw(tmax, "max"), "cold": _extreme_raw(tmax, "min")},
"low": {"warm": _extreme_raw(tmin, "max"), "cold": _extreme_raw(tmin, "min")},
}
def _monthly_records_raw(history) -> list[dict]:
return [
{"name": name, "slug": MONTHS[i - 1], **_period_records_raw(history, [i])}
for i, name in enumerate(MONTHS_TITLE, start=1)
]
def _seasonal_records_raw(history, lat: float) -> list[dict]:
south = lat < 0
return [
{"name": (south_lbl if south else north_lbl), "span": span,
**_period_records_raw(history, months)}
for north_lbl, south_lbl, months, span in SEASONS
]
def _today_vs_normal_raw(history, recent) -> dict | None:
if recent is None or recent.is_empty() or "date" not in recent.columns:
return None
today = datetime.date.today()
observed = recent.filter(pl.col("date") <= today).sort("date")
if observed.is_empty():
return None
row = observed.row(observed.height - 1, named=True)
obs = {k: row[k] for k in OBS_COLS if k in row}
graded = grading.grade_day(history, row["date"], obs)
cards = []
for key, label in METRIC_LABELS:
g = graded.get(key)
if not g:
continue
cards.append({
"label": label, "metric": key, "value_f": g.get("value"),
"percentile": g.get("percentile"), "grade": g.get("grade"), "cls": g.get("class"),
})
date = row["date"]
return {"date": date.isoformat() if hasattr(date, "isoformat") else str(date), "cards": cards}
def city_payload(request_origin: str, base: str, city: dict, history, recent=None) -> dict:
"""The full /api/v2/content/city/{slug} payload. `recent` is the caller's
already-fetched recent/forecast bundle (or None to skip today_vs_normal)."""
display = cities.display_name(city)
title = cities.title_name(city)
years = history["date"].dt.year()
year_range = [int(years.min()), int(years.max())]
months = _monthly_normals_raw(history)
warmest = max((m for m in months if m["high_f"] is not None), key=lambda m: m["high_f"], default=None)
coldest = min((m for m in months if m["low_f"] is not None), key=lambda m: m["low_f"], default=None)
wettest = max((m for m in months if m["precip_f"] is not None), key=lambda m: m["precip_f"], default=None)
records = grading.all_time_records(history)
unit = unit_for_country(city.get("country_code"))
flavor = cities.flavor(city["slug"])
event = city_events.get(city["slug"])
today = _today_vs_normal_raw(history, recent) if recent is not None else None
breadcrumb = [{"name": "Home", "href": f"{base}/"}, {"name": "Climate", "href": f"{base}/climate"}]
if city.get("country"):
breadcrumb.append({"name": city["country"], "href": None})
breadcrumb.append({"name": city["name"], "href": None})
page_url = f"{request_origin}{base}/climate/{city['slug']}"
n_years = year_range[1] - year_range[0]
jsonld = {
"@context": "https://schema.org",
"@graph": [
{"@type": "Dataset",
"name": f"{display} climate normals and records",
"description": f"Average temperatures, precipitation and record highs and lows for "
f"{display}, from ~{n_years} years of daily climate history.",
"url": page_url,
"temporalCoverage": f"{year_range[0]}/{year_range[1]}",
"spatialCoverage": {"@type": "Place", "name": display,
"geo": {"@type": "GeoCoordinates",
"latitude": city["lat"], "longitude": city["lon"]}},
"creator": {"@type": "Organization", "name": "Thermograph"},
"isBasedOn": "https://open-meteo.com/ (ERA5 reanalysis)"},
_breadcrumb_jsonld(request_origin, breadcrumb),
],
}
return {
"city": city, "display": display, "title": title,
"year_range": year_range, "n_years": n_years,
"months": months,
"warmest_month_slug": warmest["slug"] if warmest else None,
"coldest_month_slug": coldest["slug"] if coldest else None,
"wettest_month_slug": wettest["slug"] if wettest else None,
"all_time_records": records,
"today_vs_normal": today,
"flavor": flavor, "event": event, "default_unit": unit,
"breadcrumb": breadcrumb,
"canonical_path": f"/climate/{city['slug']}",
"page_title": _titled(f"{title} climate: daily normals, records & how unusual it is now"),
"page_description": _clamp_desc(
f"{title} averages highs of {_temp_text(warmest['high_f'], unit) if warmest else ''} in "
f"{warmest['name'] if warmest else 'summer'} and lows of "
f"{_temp_text(coldest['low_f'], unit) if coldest else ''} in "
f"{coldest['name'] if coldest else 'winter'}. "
f"Every day graded against {n_years} years of local history."),
"jsonld": jsonld,
}
def month_payload(base: str, city: dict, history, month_idx: int) -> dict:
name = city["name"]
title = cities.title_name(city)
month_name = MONTHS_TITLE[month_idx - 1]
month_slug = MONTHS[month_idx - 1]
years = history["date"].dt.year()
year_range = [int(years.min()), int(years.max())]
clim = grading.climatology(history, _month_doy(month_idx))
tmax, tmin, precip = clim.get("tmax"), clim.get("tmin"), clim.get("precip")
mdf = history.filter(pl.col("date").dt.month() == month_idx)
mrec = grading.all_time_records(mdf) if not mdf.is_empty() else {}
records = []
for key, label in METRIC_LABELS:
r = mrec.get(key)
if not r:
continue
if key == "precip":
totals = (mdf.filter(pl.col("precip").is_not_null())
.group_by(pl.col("date").dt.year().alias("yr"))
.agg(pl.col("precip").sum().alias("total"),
pl.len().alias("days"))
.filter(pl.col("days") >= 26)
.sort("total"))
if totals.is_empty():
continue
lo, hi = totals.row(0, named=True), totals.row(totals.height - 1, named=True)
records.append({"label": label,
"high_f": hi["total"], "high_date": str(hi["yr"]), "high_tag": "Wettest",
"low_f": lo["total"], "low_date": str(lo["yr"]), "low_tag": "Driest"})
else:
records.append({"label": label,
"high_f": r["max"], "high_date": r["max_date"], "high_tag": "Highest",
"low_f": r["min"], "low_date": r["min_date"], "low_tag": "Lowest"})
prev_i = 12 if month_idx == 1 else month_idx - 1
next_i = 1 if month_idx == 12 else month_idx + 1
n_years = year_range[1] - year_range[0]
unit = unit_for_country(city.get("country_code"))
breadcrumb = [{"name": "Home", "href": f"{base}/"}, {"name": "Climate", "href": f"{base}/climate"},
{"name": name, "href": f"{base}/climate/{city['slug']}"},
{"name": month_name, "href": None}]
return {
"month_name": month_name, "month_slug": month_slug,
"year_range": year_range, "n_years": n_years,
"avg_high_f": tmax["mean"] if tmax else None,
"avg_low_f": tmin["mean"] if tmin else None,
"typical_high_range_f": [tmax["p10"], tmax["p90"]] if tmax else None,
"typical_low_range_f": [tmin["p10"], tmin["p90"]] if tmin else None,
"avg_precip_f": precip["mean"] if precip else None,
"records": records,
"prev": {"name": MONTHS_TITLE[prev_i - 1], "slug": MONTHS[prev_i - 1]},
"next": {"name": MONTHS_TITLE[next_i - 1], "slug": MONTHS[next_i - 1]},
"breadcrumb": breadcrumb,
"canonical_path": f"/climate/{city['slug']}/{month_slug}",
"page_title": _titled(f"{title} in {month_name}: normal weather & records"),
"page_description": _clamp_desc(
f"{title} averages {_temp_text(tmax['mean'] if tmax else None, unit)} highs and "
f"{_temp_text(tmin['mean'] if tmin else None, unit)} lows in {month_name}. "
f"Every day graded against {n_years} years of local history."),
}
def records_payload(request_origin: str, base: str, city: dict, history) -> dict:
display = cities.display_name(city)
title = cities.title_name(city)
years = history["date"].dt.year()
year_range = [int(years.min()), int(years.max())]
n_years = year_range[1] - year_range[0]
rec = grading.all_time_records(history)
hi_rec = (rec.get("tmax") or {}).get("max")
hi_date = (rec.get("tmax") or {}).get("max_date")
lo_rec = (rec.get("tmin") or {}).get("min")
lo_date = (rec.get("tmin") or {}).get("min_date")
unit = unit_for_country(city.get("country_code"))
rows = []
for key, label in METRIC_LABELS:
r = rec.get(key)
if not r:
continue
if key == "precip":
# "Record low" rain is meaningless (it's just 0) -- the low side is
# the longest dry streak instead, which is a day count + start date,
# not a precip value, so low_f/low_date stay None here.
days, dry_start = grading.longest_dry_streak(history)
low_f, low_date = None, dry_start or None
dry_streak_days = days or None
else:
low_f, low_date, dry_streak_days = r["min"], r["min_date"], None
rows.append({
"label": label,
"high_f": r["max"], "high_date": r["max_date"],
"low_f": low_f, "low_date": low_date,
"dry_streak_days": dry_streak_days,
})
hemisphere = "Southern" if city["lat"] < 0 else "Northern"
breadcrumb = [{"name": "Home", "href": f"{base}/"}, {"name": "Climate", "href": f"{base}/climate"},
{"name": city["name"], "href": f"{base}/climate/{city['slug']}"},
{"name": "Records", "href": None}]
page_url = f"{request_origin}{base}/climate/{city['slug']}/records"
jsonld = {
"@context": "https://schema.org",
"@graph": [
{"@type": "Dataset",
"name": f"{display} monthly and seasonal weather records",
"description": f"Record high and low temperatures for {display} by month and by "
f"meteorological season, with the dates they occurred, from ~{n_years} "
f"years of daily climate history.",
"url": page_url,
"temporalCoverage": f"{year_range[0]}/{year_range[1]}",
"spatialCoverage": {"@type": "Place", "name": display,
"geo": {"@type": "GeoCoordinates",
"latitude": city["lat"], "longitude": city["lon"]}},
"creator": {"@type": "Organization", "name": "Thermograph"},
"isBasedOn": "https://open-meteo.com/ (ERA5 reanalysis)"},
_breadcrumb_jsonld(request_origin, breadcrumb),
],
}
return {
"year_range": year_range, "n_years": n_years,
"rows": rows,
"all_time_records": rec,
"monthly": _monthly_records_raw(history),
"seasonal": _seasonal_records_raw(history, city["lat"]),
"hemisphere": hemisphere,
"breadcrumb": breadcrumb,
"canonical_path": f"/climate/{city['slug']}/records",
"page_title": _titled(f"{title} weather records: hottest & coldest days since {year_range[0]}"),
"page_description": _clamp_desc(
f"{title}'s hottest day hit {_temp_text(hi_rec, unit)}"
f"{f' ({_month_year(hi_date)})' if hi_date else ''}; its coldest fell to "
f"{_temp_text(lo_rec, unit)}{f' ({_month_year(lo_date)})' if lo_date else ''}. "
f"Every day graded against {n_years} years of local history."),
"jsonld": jsonld,
}
def home_payload() -> dict:
from api import homepage
feed = homepage.load()
stale = bool(feed) and homepage.is_stale(feed)
ranked, unusual = [], None
if feed:
ranked = feed.get("ranked") or []
pick = (feed.get("picks") or {}).get("extreme")
if pick:
unusual = dict(pick, is_default=True)
home_cities = []
for slug in HOME_CITY_SLUGS:
city = cities.get(slug)
if city:
home_cities.append({"slug": slug, "name": city["name"]})
return {"unusual": unusual, "stale": stale, "ranked": ranked, "cities": home_cities}