Score each metric on the average of its seasonal differentials (#200)

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.
This commit is contained in:
Emi Griffith 2026-07-19 16:57:55 -07:00 committed by GitHub
parent befffedd5c
commit d2ba9eac66
2 changed files with 61 additions and 8 deletions

View file

@ -230,6 +230,56 @@ def _overall(entries: dict) -> dict | None:
"tier": tier, "class": css, "grade": tier, "descriptor": "net change"} "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: def build_scores(history: pl.DataFrame) -> dict:
"""Full score payload for a cell's history frame. Returns an ``{"unavailable": """Full score payload for a cell's history frame. Returns an ``{"unavailable":
reason}`` shape when the record is too short to score.""" 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) recent, baseline = recent_baseline_split(history)
latest = history["date"].max() latest = history["date"].max()
# Each season scored on its own distribution first…
slices = {} slices = {}
for key in SLICES: for key in SEASONS:
base_s, rec_s = _slice(baseline, key), _slice(recent, key) base_s, rec_s = _slice(baseline, key), _slice(recent, key)
entries = {} entries = {m: _slice_entry(m, history, base_s, rec_s) for m in SCORE_METRICS}
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]))
slices[key] = {"overall": _overall(entries), "metrics": entries} 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 { return {
"recent_years": RECENT_YEARS, "recent_years": RECENT_YEARS,
"recent_range": [recent["date"].min().isoformat(), latest.isoformat()], "recent_range": [recent["date"].min().isoformat(), latest.isoformat()],

View file

@ -108,7 +108,7 @@ def forecast_key(today, days: int) -> str:
# is the plain `history_token` (expires when the archive tail advances). The key # 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 # just carries the scoring-math version, so a math change invalidates only score
# rows without disturbing any other kind. # rows without disturbing any other kind.
SCORE_VER = "s2" SCORE_VER = "s3"
def score_key() -> str: def score_key() -> str: