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:
parent
befffedd5c
commit
d2ba9eac66
2 changed files with 61 additions and 8 deletions
67
scoring.py
67
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()],
|
||||
|
|
|
|||
2
views.py
2
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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue