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:
parent
21f7ef4d19
commit
af57b89e4b
2 changed files with 52 additions and 35 deletions
43
grading.py
43
grading.py
|
|
@ -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 Mod–Heavy 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 Light–Mod)
|
(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)}
|
||||||
|
|
|
||||||
|
|
@ -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. Light–Mod and Moderate were renamed; Mod–Heavy 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 (95–99).
|
||||||
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", "Light–Mod", "Moderate", "Mod–Heavy"):
|
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 Mod–Heavy range (60–75 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 ---------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue