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
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 = {
"tmax": "High temp", "tmin": "Low temp", "feels": "Feels like",
"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:
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
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:
"""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).
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]
if not present:
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)
if dirs else 0.0)
score = score_of(mad)
tier, css = tier_of(score, bias)
direction = "warmer" if bias >= 0 else "cooler"
tier, css = tier_of(score, 1.0) # magnitude only — net change, not a direction
return {"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}"}
"tier": tier, "class": css, "grade": tier, "descriptor": "net change"}
def build_scores(history: pl.DataFrame) -> dict:
@ -240,5 +259,6 @@ def build_scores(history: pl.DataFrame) -> dict:
"n_recent": int(recent.height),
"n_baseline": int(history.height),
"baseline_overlaps_recent": True,
"wetbulb_stress_f": WETBULB_STRESS_F,
"slices": slices,
}

View file

@ -99,8 +99,11 @@ def test_warming_shift_scores_up_and_warmer():
assert m["direction"] == "warmer"
assert "warmer" in m["grade"]
assert all(q["d"] > 0 for q in m["per_q"]) # every category shifted up
# The overall headline follows the temperature drift.
assert hot["slices"]["annual"]["overall"]["direction"] == "warmer"
# The overall total is a direction-agnostic net change — no warmer/cooler word.
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():
@ -139,6 +142,24 @@ def test_precip_frequency_shift():
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 ----------------------------------
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
# just carries the scoring-math version, so a math change invalidates only score
# rows without disturbing any other kind.
SCORE_VER = "s1"
SCORE_VER = "s2"
def score_key() -> str: