From d2ba9eac6690c8f316cea81dc74dfc29eb20d409 Mon Sep 17 00:00:00 2001 From: Emi Griffith Date: Sun, 19 Jul 2026 16:57:55 -0700 Subject: [PATCH] Score each metric on the average of its seasonal differentials (#200) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Annual scores were computed from an all-year pooled distribution, which widens the reference spread and hides a shift confined to one season — Seattle's daily high read 16 despite a summer high of 65. Build each metric's annual score (and the overall) as the mean of its four seasonal divergences instead, so a real seasonal shift shows through (that high now reads 28). Frequency read-outs (precip wet days, wet-bulb heat-stress days) stay pooled over the year. Also lock the by-season table to fixed, uniform columns (min-width to scroll on a phone) so each metric lines up vertically across the seasons, and show the per-percentile detail as the season-averaged shift. Bumps the score cache version. --- scoring.py | 67 ++++++++++++++++++++++++++++++++++++++++++++++++------ views.py | 2 +- 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/scoring.py b/scoring.py index 64745f4..c66decd 100644 --- a/scoring.py +++ b/scoring.py @@ -230,6 +230,56 @@ def _overall(entries: dict) -> dict | None: "tier": tier, "class": css, "grade": tier, "descriptor": "net change"} +def _slice_entry(metric: str, history: pl.DataFrame, base_s: pl.DataFrame, + rec_s: pl.DataFrame) -> dict: + if metric not in history.columns: + return _null_entry(metric, "not available for this location") + return _metric_entry(metric, grading._finite(base_s[metric]), grading._finite(rec_s[metric])) + + +def _annual_entry(metric: str, seasonal: list, history: pl.DataFrame, + baseline: pl.DataFrame, recent: pl.DataFrame) -> dict: + """The metric's headline score, built as the AVERAGE of its seasonal + differentials rather than from an annually-pooled distribution. Pooling all + days together widens the reference spread and hides a shift confined to one + season (a hot-summer trend barely moves the all-year percentiles); averaging + the four seasons' divergences keeps it visible. Frequency read-outs + (precip wet days, wet-bulb heat-stress days) are day counts, so those stay + pooled over the whole year.""" + if metric not in history.columns: + return _null_entry(metric, "not available for this location") + present = [e for e in seasonal if e.get("score") is not None] + if not present: + return _null_entry(metric, "not enough recent data") + mad = sum(e["mad"] for e in present) / len(present) + bias = sum(e["bias"] for e in present) / len(present) + score = score_of(mad) + tier, css = tier_of(score, bias) + direction = _direction(metric, bias) + per_q = [] + for q in QS: + ds = [pq["d"] for e in present for pq in e.get("per_q", []) if pq["q"] == q] + if ds: + per_q.append({"q": q, "d": round(sum(ds) / len(ds), 1)}) + entry = { + "key": metric, "label": METRIC_LABELS[metric], + "score": score, "mad": round(mad, 1), "bias": round(bias, 1), + "tier": tier, "class": css, "direction": direction, + "grade": tier if score < 15 else f"{tier} — {direction}", + "weight": WEIGHTS[metric], "per_q": per_q, + "n_recent": sum(e["n_recent"] for e in present), + "n_base": sum(e["n_base"] for e in present), + } + if metric == "precip": + div = precip_divergence(grading._finite(baseline["precip"]), grading._finite(recent["precip"])) + if div: + entry["freq"] = div["freq"] + elif metric == "wetbulb": + entry["freq"] = _stress_freq(grading._finite(baseline["wetbulb"]), + grading._finite(recent["wetbulb"]), WETBULB_STRESS_F) + return entry + + def build_scores(history: pl.DataFrame) -> dict: """Full score payload for a cell's history frame. Returns an ``{"unavailable": reason}`` shape when the record is too short to score.""" @@ -241,17 +291,20 @@ def build_scores(history: pl.DataFrame) -> dict: recent, baseline = recent_baseline_split(history) latest = history["date"].max() + # Each season scored on its own distribution first… slices = {} - for key in SLICES: + for key in SEASONS: base_s, rec_s = _slice(baseline, key), _slice(recent, key) - entries = {} - for m in SCORE_METRICS: - if m not in history.columns: - entries[m] = _null_entry(m, "not available for this location") - continue - entries[m] = _metric_entry(m, grading._finite(base_s[m]), grading._finite(rec_s[m])) + entries = {m: _slice_entry(m, history, base_s, rec_s) for m in SCORE_METRICS} slices[key] = {"overall": _overall(entries), "metrics": entries} + # …then the annual headline is the average of those seasonal differentials. + annual = {} + for m in SCORE_METRICS: + seasonal = [slices[k]["metrics"][m] for k in SEASONS] + annual[m] = _annual_entry(m, seasonal, history, baseline, recent) + slices = {"annual": {"overall": _overall(annual), "metrics": annual}, **slices} + return { "recent_years": RECENT_YEARS, "recent_range": [recent["date"].min().isoformat(), latest.isoformat()], diff --git a/views.py b/views.py index 39a64a9..7712c4b 100644 --- a/views.py +++ b/views.py @@ -108,7 +108,7 @@ def forecast_key(today, days: int) -> str: # 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 = "s2" +SCORE_VER = "s3" def score_key() -> str: