diff --git a/scoring.py b/scoring.py index 141e1ce..64745f4 100644 --- a/scoring.py +++ b/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, } diff --git a/tests/test_scoring.py b/tests/test_scoring.py index 1cd5453..e4a6fb8 100644 --- a/tests/test_scoring.py +++ b/tests/test_scoring.py @@ -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(): diff --git a/views.py b/views.py index bba612d..39a64a9 100644 --- a/views.py +++ b/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: