Clarify wet bulb, add heat-stress-day share, frame total as net change (#197)

- Explain on the page what wet-bulb temperature measures (the evaporative-cooling
  ceiling on shedding heat), so the metric isn't opaque.
- Report the share of heat-stress "wet-bulb" days (peak wet bulb >= 26 C) vs
  normal days, recent window vs the full record — mirroring the precip wet-day
  frequency.
- Present the overall total as a direction-agnostic net change (magnitude only),
  not "warmer/cooler"; per-metric cards still carry direction.

Bumps the score cache version.
This commit is contained in:
Emi Griffith 2026-07-19 16:37:42 -07:00 committed by GitHub
parent aec64b4058
commit a6dfa97bb0
3 changed files with 50 additions and 9 deletions

View file

@ -44,6 +44,11 @@ MIN_SLICE_SAMPLES = 300 # recent-slice floor (~540 expected per 6-yr season)
MIN_WET_DAYS = 30 # recent wet-day floor for the precip amount component MIN_WET_DAYS = 30 # recent wet-day floor for the precip amount component
SATURATION = 15.0 # mean |divergence| (pct points) that maps to score 100 SATURATION = 15.0 # mean |divergence| (pct points) that maps to score 100
# A day whose peak wet bulb reaches this counts as a heat-stress ("wet-bulb") day
# rather than a normal one — 26 °C is the onset of serious heat stress for
# sustained exertion. Reported as a recent-vs-baseline share alongside the score.
WETBULB_STRESS_F = 78.8 # 26 °C
METRIC_LABELS = { METRIC_LABELS = {
"tmax": "High temp", "tmin": "Low temp", "feels": "Feels like", "tmax": "High temp", "tmin": "Low temp", "feels": "Feels like",
"humid": "Humidity", "wetbulb": "Wet bulb", "wind": "Wind", "humid": "Humidity", "wetbulb": "Wet bulb", "wind": "Wind",
@ -187,14 +192,30 @@ def _metric_entry(metric: str, base: np.ndarray, rec: np.ndarray) -> dict:
} }
if "freq" in div: if "freq" in div:
entry["freq"] = div["freq"] entry["freq"] = div["freq"]
if metric == "wetbulb":
# How often the peak wet bulb reaches heat-stress levels — a "wet-bulb day"
# vs a normal one — recent share vs the full record.
entry["freq"] = _stress_freq(base, rec, WETBULB_STRESS_F)
return entry return entry
def _stress_freq(base: np.ndarray, rec: np.ndarray, thr: float) -> dict:
"""Share of days at or above ``thr`` — recent vs baseline, in percentage
points. The rest of the days are 'normal'."""
f6 = float(np.mean(rec >= thr)) * 100.0
f45 = float(np.mean(base >= thr)) * 100.0
return {"f6": round(f6, 1), "f45": round(f45, 1),
"d": round(f6 - f45, 1), "threshold_f": thr}
def _overall(entries: dict) -> dict | None: def _overall(entries: dict) -> dict | None:
"""Weighted roll-up of the present metrics in one slice. Weights renormalize """Weighted roll-up of the present metrics in one slice. Weights renormalize
over whatever is present (a metric missing for the source drops out cleanly). over whatever is present (a metric missing for the source drops out cleanly).
The headline bias comes only from the temperature-direction metrics so the
overall reads 'warmer / cooler' rather than being muddied by wind/precip.""" The total reads as a direction-agnostic NET CHANGE: it is colored by magnitude
on the intensity ramp and labeled by tier alone, not 'warmer/cooler' the
per-metric cards still carry direction. A signed ``bias`` (from the
temperature-direction metrics) stays in the payload for reference."""
present = [e for e in entries.values() if e.get("score") is not None] present = [e for e in entries.values() if e.get("score") is not None]
if not present: if not present:
return None return None
@ -204,11 +225,9 @@ def _overall(entries: dict) -> dict | None:
bias = (sum(e["weight"] * e["bias"] for e in dirs) / sum(e["weight"] for e in dirs) bias = (sum(e["weight"] * e["bias"] for e in dirs) / sum(e["weight"] for e in dirs)
if dirs else 0.0) if dirs else 0.0)
score = score_of(mad) score = score_of(mad)
tier, css = tier_of(score, bias) tier, css = tier_of(score, 1.0) # magnitude only — net change, not a direction
direction = "warmer" if bias >= 0 else "cooler"
return {"score": score, "mad": round(mad, 1), "bias": round(bias, 1), return {"score": score, "mad": round(mad, 1), "bias": round(bias, 1),
"tier": tier, "class": css, "direction": direction, "tier": tier, "class": css, "grade": tier, "descriptor": "net change"}
"grade": tier if score < 15 else f"{tier}{direction}"}
def build_scores(history: pl.DataFrame) -> dict: def build_scores(history: pl.DataFrame) -> dict:
@ -240,5 +259,6 @@ def build_scores(history: pl.DataFrame) -> dict:
"n_recent": int(recent.height), "n_recent": int(recent.height),
"n_baseline": int(history.height), "n_baseline": int(history.height),
"baseline_overlaps_recent": True, "baseline_overlaps_recent": True,
"wetbulb_stress_f": WETBULB_STRESS_F,
"slices": slices, "slices": slices,
} }

View file

@ -99,8 +99,11 @@ def test_warming_shift_scores_up_and_warmer():
assert m["direction"] == "warmer" assert m["direction"] == "warmer"
assert "warmer" in m["grade"] assert "warmer" in m["grade"]
assert all(q["d"] > 0 for q in m["per_q"]) # every category shifted up assert all(q["d"] > 0 for q in m["per_q"]) # every category shifted up
# The overall headline follows the temperature drift. # The overall total is a direction-agnostic net change — no warmer/cooler word.
assert hot["slices"]["annual"]["overall"]["direction"] == "warmer" ov = hot["slices"]["annual"]["overall"]
assert ov["descriptor"] == "net change"
assert "" not in ov["grade"] and "warmer" not in ov["grade"]
assert ov["score"] > 0 and "direction" not in ov
def test_cooling_shift_reads_cooler(): def test_cooling_shift_reads_cooler():
@ -139,6 +142,24 @@ def test_precip_frequency_shift():
assert m["bias"] > 0 assert m["bias"] > 0
# --- wet-bulb heat-stress-day frequency ---------------------------------------
def test_wetbulb_stress_frequency_present():
out = scoring.build_scores(make_frame())
assert out["wetbulb_stress_f"] == scoring.WETBULB_STRESS_F
freq = out["slices"]["annual"]["metrics"]["wetbulb"]["freq"]
assert freq["threshold_f"] == scoring.WETBULB_STRESS_F
assert 0 <= freq["f6"] <= 100 and 0 <= freq["f45"] <= 100
assert freq["d"] == pytest.approx(freq["f6"] - freq["f45"], abs=0.05)
def test_wetbulb_stress_frequency_rises_with_heat():
# A large recent wet-bulb shift pushes summer days over the stress threshold.
out = scoring.build_scores(shift_metric(make_frame(), "wetbulb", 30.0))
freq = out["slices"]["annual"]["metrics"]["wetbulb"]["freq"]
assert freq["f6"] > freq["f45"] and freq["d"] > 0
# --- missing metric / weight renormalization ---------------------------------- # --- missing metric / weight renormalization ----------------------------------
def test_missing_gust_column_nulls_out_but_overall_survives(): def test_missing_gust_column_nulls_out_but_overall_survives():

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 = "s1" SCORE_VER = "s2"
def score_key() -> str: def score_key() -> str: