thermograph/tests/test_grading.py
Emi Griffith 4520b9e4ba Merge the Trace rain tier into Very Light (#212)
The rain-intensity scale had a Trace tier (below the 1st percentile of a place's
rain days) sitting under Very Light — a sliver category that mostly showed 1% and
crowded the distribution strip. Fold it into Very Light, which now bottoms out the
scale at 0, so the lightest measurable rain reads as Very Light.

- grading.py: RAIN_BANDS drops the Trace band; Very Light's floor goes 1 -> 0. The
  detail-view ladder derives from the table, so it follows automatically.
- shared.js: SCALE_RAIN drops the Trace row; Very Light's range becomes "<10".

Eight rain tiers now instead of nine; the strip shows one fewer column. The wet-1
colour token is kept (unreferenced by new gradings) so any day still cached with
the old class renders until the derived store recomputes.

Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-20 04:25:13 +00:00

152 lines
6.2 KiB
Python

import datetime
import numpy as np
import polars as pl
import pytest
import grading
# ---- empirical percentile ----------------------------------------------------
def test_percentile_mid_rank_handles_ties():
samples = np.array([1.0, 2.0, 2.0, 3.0])
# less=1, equal=2 -> (1 + 0.5*2) / 4 = 50%
assert grading.empirical_percentile(samples, 2.0) == 50.0
def test_percentile_extremes_and_empties():
samples = np.array([1.0, 2.0, 3.0])
assert grading.empirical_percentile(samples, 0.0) == 0.0
assert grading.empirical_percentile(samples, 4.0) == 100.0
assert grading.empirical_percentile(np.array([]), 1.0) is None
assert grading.empirical_percentile(samples, None) is None
assert grading.empirical_percentile(samples, float("nan")) is None
# ---- tier bands ---------------------------------------------------------------
@pytest.mark.parametrize("pct,label,css", [
(99.5, "Near Record", "rec-hot"), # top tier is strict: > 99 only
(99.0, "Very High", "very-hot"), # p99 exactly is NOT near-record
(90.0, "Very High", "very-hot"),
(75.0, "High", "hot"),
(60.0, "Above Normal", "warm"),
(59.9, "Normal", "normal"),
(40.0, "Normal", "normal"),
(39.9, "Below Normal", "cool"),
(10.0, "Low", "cold"),
(1.0, "Very Low", "very-cold"),
(0.5, "Near Record", "rec-cold"), # strictly below the 1st percentile
])
def test_temp_band_boundaries(pct, label, css):
assert grading._band(pct, grading.TEMP_BANDS) == (label, css)
def test_ladders_stay_aligned_with_bands():
"""The detail-view ladders re-encode the band tables by hand; catch drift."""
for bands, ladder in [(grading.TEMP_BANDS, grading._TEMP_LADDER),
(grading.RAIN_BANDS, grading._RAIN_LADDER)]:
assert len(bands) == len(ladder)
for (_, label, css), (lcss, llabel, *_rest) in zip(bands, ladder):
assert (label, css) == (llabel, lcss)
# ---- seasonal window ----------------------------------------------------------
def test_window_mask_wraps_across_year_end():
doys = np.array([1, 180, 360, 366])
mask = grading.window_mask(doys, target_doy=1, half=7)
assert mask.tolist() == [True, False, True, True]
# ---- 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_rain_percentile_ranks_among_rain_days_only():
# 7 dry days + rain days [0.1, 0.2, 0.4]; 0.2 ranks among the 3 rain days:
# less=1, equal=1 -> (1 + 0.5) / 3 = 50% -> Moderate, unaffected by the dry mass.
samples = np.array([0.0] * 7 + [0.1, 0.2, 0.4])
g = grading._grade_precip(samples, 0.2)
assert g["percentile"] == 50.0
assert g["grade"] == "Moderate"
def test_rain_with_no_historical_rain_days_is_extreme():
g = grading._grade_precip(np.zeros(10), 0.3)
assert g["percentile"] == 100.0 and g["class"] == "wet-9"
def test_lightest_rain_day_grades_very_light_not_trace():
# The former "Trace" tier (<1st percentile of rain days) was merged into
# "Very Light", which now bottoms out the scale at 0.
assert "Trace" not in [b[1] for b in grading.RAIN_BANDS]
assert grading.RAIN_BANDS[-1] == (0, "Very Light", "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, 0.01) # the very lightest -> <1 pct
assert g["percentile"] < 1
assert g["grade"] == "Very Light" and g["class"] == "wet-2"
# ---- dry streaks ---------------------------------------------------------------
def test_dry_streaks_walk():
dates = [datetime.date(2024, 1, 1) + datetime.timedelta(days=i) for i in range(5)]
precips = [0.5, 0.0, float("nan"), 0.02, 0.005]
out = grading.dry_streaks(dates, precips)
assert list(out.values()) == [0, 1, 2, 0, 1] # NaN counts as dry
# ---- range + day grading over a synthetic record --------------------------------
def test_grade_range_compact_shape(history):
end = history["date"].max()
start = end - datetime.timedelta(days=30)
days = grading.grade_range(history, start, end)
assert len(days) == 31
first = days[0]
assert set(first) == {"date", "dsr", *grading.TEMP_METRICS, "precip"}
assert first["dsr"] >= 0
g = first["tmax"]
assert set(g) == {"v", "pct", "c", "g"}
for rec in days: # dry days carry no rain percentile, wet days always do
if rec["precip"]["c"] == "dry":
assert rec["precip"]["pct"] is None
else:
assert rec["precip"]["pct"] is not None
def test_grade_day_normals_and_departure(history):
target = history["date"].max()
row = history.filter(pl.col("date") == target).row(0, named=True)
result = grading.grade_day(history, target, {"tmax": row["tmax"], "tmin": row["tmin"],
"precip": row["precip"]})
assert set(result["normals"]["tmax"]) == {"p1", "p10", "p25", "p40", "p50", "p60",
"p75", "p90", "p99"}
expected = max(abs(result["tmax"]["percentile"] - 50), abs(result["tmin"]["percentile"] - 50))
assert result["departure"] == round(expected, 1)
def test_day_detail_with_and_without_observation(history):
target = history["date"].max()
detail = grading.day_detail(history, target, {"tmax": 60.0, "precip": 0.0})
assert detail["metrics"]["tmax"]["obs"]["value"] == 60.0
assert detail["metrics"]["tmax"]["ladder"]["tiers"][0]["c"] == "rec-hot"
assert detail["metrics"]["precip"]["ladder"]["tiers"][-1]["c"] == "dry"
bare = grading.day_detail(history, target, None)
assert bare["metrics"]["tmax"]["obs"] is None
assert bare["metrics"]["tmax"]["ladder"] is not None
def test_climatology_summary(history):
climo = grading.climatology(history, 180)
# ±7-day window over ~20 years -> ~15 samples per year.
assert climo["n_samples"] >= 15 * 19
assert climo["tmax"]["p40"] <= climo["tmax"]["p50"] <= climo["tmax"]["p60"]
assert climo["feels"] is None # column absent from this record