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)
]
# Precipitation is graded ONLY among days that actually rained (>= RAIN_THRESHOLD)
# in the seasonal window — a "rain percentile". Rain is one-directional (heavier =
# more extreme), so these 7 tiers are sequential light->heavy, using the SAME cut
# points as temperature. Dry days are handled separately (the "dry" class, colored
# by dry streak in the UI). Same [lower, upper) convention as TEMP_BANDS.
# Precipitation is graded among days with ANY rain (> 0) in the seasonal window — a
# "rain percentile". Rain is one-directional (heavier = more extreme), so these 8
# tiers are sequential light->heavy, using the SAME cut points as temperature. Dry
# days (no rain at all) are handled separately (the "dry" class, colored by dry
# 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 = [
(99, "Extreme", "wet-9"), # heaviest rain for the season (darkest)
(90, "Very Heavy", "wet-8"), # 90-99
(60, "Heavy", "wet-7"), # 60-90 (the old ModHeavy tier merged in)
(40, "Typical", "wet-5"), # 40-60 (was Moderate)
(25, "Brisk", "wet-4"), # 25-40 (was LightMod)
(99, "Extreme", "wet-9"), # >99 heaviest rain for the season (darkest)
(95, "Severe", "wet-8"), # 95-99 (top half of the old Very Heavy)
(90, "Very Heavy", "wet-7"), # 90-95 (lower half)
(60, "Heavy", "wet-6"), # 60-90
(40, "Typical", "wet-5"), # 40-60
(25, "Brisk", "wet-4"), # 25-40
(10, "Light", "wet-3"), # 10-25
(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:
"""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"
class with no percentile (the UI colors them by dry streak instead)."""
among *rain days only* (any measurable rain, > 0) in the window. A day with no
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)):
return None
value = float(value)
if value < RAIN_THRESHOLD:
if value <= 0:
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)
if pct is None: # no historical rain days in window (extremely rare)
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)}
# 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:
"""Value at each rain-day tier boundary, plus how often the window is dry."""
if samples.size == 0:
return None
rain = samples[samples >= RAIN_THRESHOLD]
rain = samples[samples > 0] # any measurable rain (dry == no rain)
n = int(samples.size)
tiers = []
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)
tiers = [
{"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}
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),
"rain_days": int(rain.size),
"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 -----------------------------------------------------------
def test_dry_day_gets_dry_class_without_percentile():
g = grading._grade_precip(np.array([0.0, 0.5, 1.0]), 0.005)
assert g["class"] == "dry" and g["percentile"] is None and g["grade"] == "Dry"
def test_only_zero_precip_is_dry():
# Dry means no rain at all; any measurable rain, however slight, is at least a
# 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():
@ -81,22 +85,26 @@ def test_rain_with_no_historical_rain_days_is_extreme():
assert g["percentile"] == 100.0 and g["class"] == "wet-9"
def test_rain_scale_labels_and_merges():
# The seven-tier scale: Trace / Light / Brisk / Typical / Heavy / Very Heavy /
# Extreme. LightMod and Moderate were renamed; ModHeavy was merged up into
# Heavy (which now floors at the 60th percentile).
labels = [b[1] for b in grading.RAIN_BANDS]
assert labels == ["Extreme", "Very Heavy", "Heavy", "Typical", "Brisk", "Light", "Trace"]
for gone in ("Very Light", "LightMod", "Moderate", "ModHeavy"):
assert gone not in labels
# A rain day at the old ModHeavy range (6075 pct) is now Heavy.
rain = np.arange(1, 201, dtype=float) / 100.0
def test_rain_scale_eight_tiers_with_severe():
# Eight tiers filling the wet-2..wet-9 ramp: Trace / Light / Brisk / Typical /
# Heavy / Very Heavy / Severe / Extreme. Very Heavy was split, its top half
# becoming Severe (9599).
assert [b[1] for b in grading.RAIN_BANDS] == \
["Extreme", "Severe", "Very Heavy", "Heavy", "Typical", "Brisk", "Light", "Trace"]
assert [b[2] for b in grading.RAIN_BANDS] == \
["wet-9", "wet-8", "wet-7", "wet-6", "wet-5", "wet-4", "wet-3", "wet-2"]
rain = np.arange(1, 201, dtype=float) / 100.0 # 200 rain days: 0.01 .. 2.00
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"
# The lightest measurable rain is Trace, bottoming the scale at 0.
g0 = grading._grade_precip(samples, 0.01)
assert g0["grade"] == "Trace" and g0["class"] == "wet-2"
def grade(v):
g = grading._grade_precip(samples, v)
return g["grade"], g["class"]
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 ---------------------------------------------------------------