"""Homepage "unusual right now" feed. The homepage wants to answer "where is the weather most unusual today?" across every city we track. That question has no cheap answer at request time: the derived store keeps graded payloads as zlib-compressed JSON blobs keyed by (kind, cell, key), with no percentile column to sort on, so finding today's extremes means grading every cached city from scratch. So we precompute it. A sweep grades every city whose parquet cache is already warm and writes the ranked result to data/homepage.json; the homepage template reads that file. The sweep is deliberately **cache-only** — it calls climate.load_cached_history and climate.load_cached_recent_forecast, never their fetching counterparts, so a sweep over ~1000 cities costs zero upstream requests and cannot burst the archive quota. Cities without a warm cache are skipped; the deploy-time warm_cities.py run is what makes them eligible. Runs from the notifier daemon (hourly guard) and at the tail of warm_cities, so it refreshes both on a live server and right after a deploy. """ from __future__ import annotations import datetime import json import os import tempfile import time import polars as pl import cities import climate import grading import grid from views import OBS_COLS # data/ is the only writable path under the hardened systemd unit # (ReadWritePaths=/opt/thermograph/data …), so the feed lives there. FEED_PATH = os.path.join(os.path.dirname(__file__), os.pardir, "data", "homepage.json") # How many cards the "Unusual right now" strip can show. RANK_LIMIT = 12 # A city must be at least this far from the median to be worth calling unusual. MIN_DEPARTURE = 15.0 # A cold-tail card is force-included when one is at or below this percentile, # even if every warmer city outranks it. Both tails or it isn't honest. COLD_TAIL_MAX_PCT = 10.0 # The feed is stale (label it "as of " rather than claiming today) beyond this. STALE_AFTER_S = 6 * 3600 # Ranking looks at the two metrics whose percentile people read as "how unusual # was today" — the same pair grade_day's own `departure` score uses. RANK_METRICS = ("tmax", "tmin") _METRIC_LABEL = {"tmax": "High temp", "tmin": "Low temp"} def _seasonal_window_label(d: datetime.date) -> str: """'mid-July' — the ±7-day seasonal window a percentile is measured against, said the way a person would say it.""" part = "early" if d.day <= 10 else ("mid" if d.day <= 20 else "late") return f"{part}-{d.strftime('%B')}" def _grade_city(city: dict, today: datetime.date) -> dict | None: """Grade one city's latest observed day from cache alone. None when the city has no warm cache, no observed row, or nothing gradeable.""" cell = grid.snap(city["lat"], city["lon"]) history = climate.load_cached_history(cell) if history is None: return None recent = climate.load_cached_recent_forecast(cell) if recent is None or recent.is_empty() or "date" not in recent.columns: return None 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} try: graded = grading.grade_day(history, row["date"], obs) except Exception: # noqa: BLE001 - one bad cell must not abort the sweep return None # Pick the metric that strayed furthest from the median; that is the one the # card should lead with. best = None for metric in RANK_METRICS: g = graded.get(metric) if not g or g.get("percentile") is None: continue departure = abs(g["percentile"] - 50) if best is None or departure > best[0]: best = (departure, metric, g) if best is None: return None departure, metric, g = best date = row["date"] date_s = date.isoformat() if hasattr(date, "isoformat") else str(date) return { "slug": city["slug"], "name": city["name"], "display": cities.display_name(city), "lat": round(city["lat"], 4), "lon": round(city["lon"], 4), "cell_id": cell["id"], "date": date_s, "metric": metric, "metric_label": _METRIC_LABEL.get(metric, metric), "window_label": _seasonal_window_label( date if hasattr(date, "timetuple") else today ), "value": g.get("value"), "percentile": g.get("percentile"), "grade": g.get("grade"), # grading's css class is already the style.css token name minus the # leading '--', so the template emits it with no mapping table. "cls": g.get("class"), "departure": round(departure, 1), "tail": "cold" if g["percentile"] < 50 else "warm", } def build(limit: int | None = None) -> dict: """Sweep the warm cache and rank today's most unusual cities. Pure — does no I/O beyond reading the parquet cache, and never touches the network.""" today = datetime.date.today() graded: list[dict] = [] considered = 0 for city in cities.all_cities()[: limit or None]: row = _grade_city(city, today) if row is None: continue considered += 1 if row["departure"] >= MIN_DEPARTURE: graded.append(row) graded.sort(key=lambda r: r["departure"], reverse=True) ranked = graded[:RANK_LIMIT] # Two-sided honesty: if any tracked city sits in the cold tail, one must # appear in the strip even when warm anomalies dominate the ranking. if not any(r["tail"] == "cold" for r in ranked): cold = next( (r for r in graded if r["tail"] == "cold" and r["percentile"] <= COLD_TAIL_MAX_PCT), None, ) if cold is not None: ranked = ranked[: RANK_LIMIT - 1] + [cold] picks = {} if graded: picks["extreme"] = graded[0] cold = next((r for r in graded if r["tail"] == "cold"), None) if cold is not None: picks["cold"] = cold return { "generated_at": time.time(), "date": today.isoformat(), "considered": considered, "picks": picks, "ranked": ranked, } def refresh(limit: int | None = None) -> dict: """Rebuild the feed and write it atomically, so a concurrent reader never sees a half-written file.""" feed = build(limit=limit) path = os.path.abspath(FEED_PATH) os.makedirs(os.path.dirname(path), exist_ok=True) fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path), suffix=".tmp") try: with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(feed, f, separators=(",", ":")) os.replace(tmp, path) except BaseException: try: os.unlink(tmp) except OSError: pass raise return feed def load() -> dict | None: """Read the feed. Returns None when it is missing or unreadable — the homepage renders its frame without live numbers rather than failing, so a cold checkout with no feed still serves a complete page.""" try: with open(os.path.abspath(FEED_PATH), encoding="utf-8") as f: feed = json.load(f) except (OSError, ValueError): return None if not isinstance(feed, dict) or "ranked" not in feed: return None return feed def is_stale(feed: dict) -> bool: """Older than STALE_AFTER_S, or built for a day that is no longer today.""" if feed.get("date") != datetime.date.today().isoformat(): return True return (time.time() - float(feed.get("generated_at") or 0)) > STALE_AFTER_S if __name__ == "__main__": import sys n = int(sys.argv[1]) if len(sys.argv) > 1 else None out = refresh(limit=n) print(f"graded {out['considered']} cached cities, " f"{len(out['ranked'])} ranked, top: " f"{out['ranked'][0]['display'] if out['ranked'] else '—'}")