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
|
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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
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
|
# 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:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue