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:
parent
aec64b4058
commit
a6dfa97bb0
3 changed files with 50 additions and 9 deletions
32
scoring.py
32
scoring.py
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
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 = "s1"
|
||||
SCORE_VER = "s2"
|
||||
|
||||
|
||||
def score_key() -> str:
|
||||
|
|
|
|||
Loading…
Reference in a new issue