diff --git a/grading.py b/grading.py index 64578b1..11c2c8f 100644 --- a/grading.py +++ b/grading.py @@ -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 Mod–Heavy tier merged in) - (40, "Typical", "wet-5"), # 40-60 (was Moderate) - (25, "Brisk", "wet-4"), # 25-40 (was Light–Mod) + (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)} diff --git a/tests/data/test_grading.py b/tests/data/test_grading.py index 99fe9c6..c45c31e 100644 --- a/tests/data/test_grading.py +++ b/tests/data/test_grading.py @@ -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. Light–Mod and Moderate were renamed; Mod–Heavy 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", "Light–Mod", "Moderate", "Mod–Heavy"): - assert gone not in labels - # A rain day at the old Mod–Heavy range (60–75 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 (95–99). + 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 ---------------------------------------------------------------