Split Very Heavy into Very Heavy + Severe; make Dry mean zero rain (#216)

Rain intensity gains a Severe tier and Dry becomes strictly no-rain:

- The top half of Very Heavy (95th–99th rain-day percentile) becomes a new
  "Severe" tier; Very Heavy keeps the 90–95 band. Eight rain tiers now — Trace /
  Light / Brisk / Typical / Heavy / Very Heavy / Severe / Extreme — which refill
  the wet-2..wet-9 colour ramp contiguously (no gap), so Heavy/Very Heavy shift
  one shade lighter and Severe takes the second-darkest.
- A day is Dry only when it didn't rain at all; any measurable rain, however
  slight, is at least Trace. _grade_precip splits on > 0 rather than the 0.01"
  threshold (which still governs the separate dry-streak metric).
- The distribution strip drops the range under the Dry column — every dry day is
  zero, so a "0–0" span was noise.

_precip_ladder derives its percentile marks from RAIN_BANDS now, so adding or
splitting a tier can't leave a hard-coded list behind (that was the bug the 95th
mark would have hit). The detail-view ladder derives from the band table as before.

Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
This commit is contained in:
Emi Griffith 2026-07-19 22:09:22 -07:00 committed by GitHub
parent 21f7ef4d19
commit af57b89e4b
2 changed files with 52 additions and 35 deletions

View file

@ -45,17 +45,19 @@ TEMP_BANDS = [
(0, "Near Record", "rec-cold"), # <1 extreme low (danger) (0, "Near Record", "rec-cold"), # <1 extreme low (danger)
] ]
# Precipitation is graded ONLY among days that actually rained (>= RAIN_THRESHOLD) # Precipitation is graded among days with ANY rain (> 0) in the seasonal window — a
# in the seasonal window — a "rain percentile". Rain is one-directional (heavier = # "rain percentile". Rain is one-directional (heavier = more extreme), so these 8
# more extreme), so these 7 tiers are sequential light->heavy, using the SAME cut # tiers are sequential light->heavy, using the SAME cut points as temperature. Dry
# points as temperature. Dry days are handled separately (the "dry" class, colored # days (no rain at all) are handled separately (the "dry" class, colored by dry
# by dry streak in the UI). Same [lower, upper) convention as TEMP_BANDS. # streak in the UI). Same [lower, upper) convention as TEMP_BANDS. The eight tiers
# fill the wet-2..wet-9 colour ramp with no gap.
RAIN_BANDS = [ RAIN_BANDS = [
(99, "Extreme", "wet-9"), # heaviest rain for the season (darkest) (99, "Extreme", "wet-9"), # >99 heaviest rain for the season (darkest)
(90, "Very Heavy", "wet-8"), # 90-99 (95, "Severe", "wet-8"), # 95-99 (top half of the old Very Heavy)
(60, "Heavy", "wet-7"), # 60-90 (the old ModHeavy tier merged in) (90, "Very Heavy", "wet-7"), # 90-95 (lower half)
(40, "Typical", "wet-5"), # 40-60 (was Moderate) (60, "Heavy", "wet-6"), # 60-90
(25, "Brisk", "wet-4"), # 25-40 (was LightMod) (40, "Typical", "wet-5"), # 40-60
(25, "Brisk", "wet-4"), # 25-40
(10, "Light", "wet-3"), # 10-25 (10, "Light", "wet-3"), # 10-25
(0, "Trace", "wet-2"), # <10 the lightest measurable rain (0, "Trace", "wet-2"), # <10 the lightest measurable rain
] ]
@ -262,14 +264,15 @@ def _grade_value(samples: np.ndarray, value, bands) -> dict | None:
def _grade_precip(samples: np.ndarray, value) -> dict | None: def _grade_precip(samples: np.ndarray, value) -> dict | None:
"""Grade precipitation by its "rain percentile" — the rank of the day's rainfall """Grade precipitation by its "rain percentile" — the rank of the day's rainfall
among *rain days only* (>= RAIN_THRESHOLD) in the window. Dry days get the "dry" among *rain days only* (any measurable rain, > 0) in the window. A day with no
class with no percentile (the UI colors them by dry streak instead).""" rain at all gets the "dry" class with no percentile (the UI colors it by dry
streak instead); any rain, however slight, is at least a Trace day."""
if value is None or (isinstance(value, float) and np.isnan(value)): if value is None or (isinstance(value, float) and np.isnan(value)):
return None return None
value = float(value) value = float(value)
if value < RAIN_THRESHOLD: if value <= 0:
return {"value": round(value, 2), "percentile": None, "grade": "Dry", "class": "dry"} return {"value": round(value, 2), "percentile": None, "grade": "Dry", "class": "dry"}
rain = samples[samples >= RAIN_THRESHOLD] rain = samples[samples > 0]
pct = empirical_percentile(rain, value) pct = empirical_percentile(rain, value)
if pct is None: # no historical rain days in window (extremely rare) if pct is None: # no historical rain days in window (extremely rare)
pct = 100.0 pct = 100.0
@ -361,15 +364,21 @@ def _temp_ladder(samples: np.ndarray) -> dict | None:
"min": round(float(samples.min()), 1), "max": round(float(samples.max()), 1)} "min": round(float(samples.min()), 1), "max": round(float(samples.max()), 1)}
# The percentile marks the rain ladder needs — every band's lower threshold except
# 0 (the bottom tier bottoms out at the smallest rain day). Derived from RAIN_BANDS
# so adding or splitting a tier can't leave this behind.
_RAIN_MARKS = sorted({thr for thr, _, _ in RAIN_BANDS if thr > 0})
def _precip_ladder(samples: np.ndarray) -> dict | None: def _precip_ladder(samples: np.ndarray) -> dict | None:
"""Value at each rain-day tier boundary, plus how often the window is dry.""" """Value at each rain-day tier boundary, plus how often the window is dry."""
if samples.size == 0: if samples.size == 0:
return None return None
rain = samples[samples >= RAIN_THRESHOLD] rain = samples[samples > 0] # any measurable rain (dry == no rain)
n = int(samples.size) n = int(samples.size)
tiers = [] tiers = []
if rain.size: if rain.size:
marks = {m: round(float(np.percentile(rain, m)), 2) for m in (1, 10, 25, 40, 60, 75, 90, 99)} marks = {m: round(float(np.percentile(rain, m)), 2) for m in _RAIN_MARKS}
rmin = round(float(rain.min()), 2) rmin = round(float(rain.min()), 2)
tiers = [ tiers = [
{"c": c, "label": label, "range": rng, {"c": c, "label": label, "range": rng,
@ -377,7 +386,7 @@ def _precip_ladder(samples: np.ndarray) -> dict | None:
"hi": marks[hi] if hi is not None else None} "hi": marks[hi] if hi is not None else None}
for c, label, rng, lo, hi in _RAIN_LADDER for c, label, rng, lo, hi in _RAIN_LADDER
] ]
tiers.append({"c": "dry", "label": "Dry", "range": f"< {RAIN_THRESHOLD}\"", "lo": 0.0, "hi": None}) tiers.append({"c": "dry", "label": "Dry", "range": "none", "lo": 0.0, "hi": None})
return {"tiers": tiers, "dry_pct": round(100.0 * (n - rain.size) / n, 1), return {"tiers": tiers, "dry_pct": round(100.0 * (n - rain.size) / n, 1),
"rain_days": int(rain.size), "rain_days": int(rain.size),
"min": round(float(samples.min()), 2), "max": round(float(samples.max()), 2)} "min": round(float(samples.min()), 2), "max": round(float(samples.max()), 2)}

View file

@ -62,9 +62,13 @@ def test_window_mask_wraps_across_year_end():
# ---- precip grading ----------------------------------------------------------- # ---- precip grading -----------------------------------------------------------
def test_dry_day_gets_dry_class_without_percentile(): def test_only_zero_precip_is_dry():
g = grading._grade_precip(np.array([0.0, 0.5, 1.0]), 0.005) # Dry means no rain at all; any measurable rain, however slight, is at least a
assert g["class"] == "dry" and g["percentile"] is None and g["grade"] == "Dry" # Trace day (not Dry).
dry = grading._grade_precip(np.array([0.0, 0.5, 1.0]), 0.0)
assert dry["class"] == "dry" and dry["percentile"] is None and dry["grade"] == "Dry"
trace = grading._grade_precip(np.array([0.0, 0.5, 1.0]), 0.005)
assert trace["class"] != "dry" and trace["grade"] == "Trace"
def test_rain_percentile_ranks_among_rain_days_only(): def test_rain_percentile_ranks_among_rain_days_only():
@ -81,22 +85,26 @@ def test_rain_with_no_historical_rain_days_is_extreme():
assert g["percentile"] == 100.0 and g["class"] == "wet-9" assert g["percentile"] == 100.0 and g["class"] == "wet-9"
def test_rain_scale_labels_and_merges(): def test_rain_scale_eight_tiers_with_severe():
# The seven-tier scale: Trace / Light / Brisk / Typical / Heavy / Very Heavy / # Eight tiers filling the wet-2..wet-9 ramp: Trace / Light / Brisk / Typical /
# Extreme. LightMod and Moderate were renamed; ModHeavy was merged up into # Heavy / Very Heavy / Severe / Extreme. Very Heavy was split, its top half
# Heavy (which now floors at the 60th percentile). # becoming Severe (9599).
labels = [b[1] for b in grading.RAIN_BANDS] assert [b[1] for b in grading.RAIN_BANDS] == \
assert labels == ["Extreme", "Very Heavy", "Heavy", "Typical", "Brisk", "Light", "Trace"] ["Extreme", "Severe", "Very Heavy", "Heavy", "Typical", "Brisk", "Light", "Trace"]
for gone in ("Very Light", "LightMod", "Moderate", "ModHeavy"): assert [b[2] for b in grading.RAIN_BANDS] == \
assert gone not in labels ["wet-9", "wet-8", "wet-7", "wet-6", "wet-5", "wet-4", "wet-3", "wet-2"]
# A rain day at the old ModHeavy range (6075 pct) is now Heavy.
rain = np.arange(1, 201, dtype=float) / 100.0 rain = np.arange(1, 201, dtype=float) / 100.0 # 200 rain days: 0.01 .. 2.00
samples = np.concatenate([np.zeros(20), rain]) samples = np.concatenate([np.zeros(20), rain])
g = grading._grade_precip(samples, 1.35) # ~67th percentile of rain days
assert 60 <= g["percentile"] < 75 and g["grade"] == "Heavy" and g["class"] == "wet-7" def grade(v):
# The lightest measurable rain is Trace, bottoming the scale at 0. g = grading._grade_precip(samples, v)
g0 = grading._grade_precip(samples, 0.01) return g["grade"], g["class"]
assert g0["grade"] == "Trace" and g0["class"] == "wet-2" assert grade(0.01) == ("Trace", "wet-2") # <1st pct -> the bottom tier
assert grade(1.35) == ("Heavy", "wet-6") # ~67th pct
assert grade(1.85) == ("Very Heavy", "wet-7") # ~92nd pct (lower half)
assert grade(1.96) == ("Severe", "wet-8") # ~98th pct (top half -> Severe)
assert grade(2.00) == ("Extreme", "wet-9") # >99th pct
# ---- dry streaks --------------------------------------------------------------- # ---- dry streaks ---------------------------------------------------------------