thermograph/backend/tests/data/test_grading.py
emi ca84e0ce95
Some checks failed
Deploy frontend to beta VPS / deploy (push) Failing after 8s
Sync infra to hosts / sync-beta (push) Successful in 7s
Deploy backend to beta VPS / deploy (push) Failing after 13s
secrets-guard / encrypted (push) Successful in 12s
Sync infra to hosts / sync-prod (push) Successful in 14s
shell-lint / shellcheck (push) Successful in 12s
Build + push backend image (Forgejo registry) / build-push (push) Successful in 57s
Build + push frontend image (Forgejo registry) / build-push (push) Successful in 1m2s
Promote dev → main (frontend QA batch → beta) (#71)
2026-07-24 23:20:16 +00:00

190 lines
8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import datetime
import numpy as np
import polars as pl
import pytest
from data 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_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():
# 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% -> Typical, 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"] == "Typical"
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_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])
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 ---------------------------------------------------------------
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)
# Any rain > 0 breaks the streak (matching the dry/rain grading split), so the
# 0.005" trace day resets to 0; only exact 0.0 and NaN count as dry.
assert list(out.values()) == [0, 1, 2, 0, 0]
# ---- 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", "p95", "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
def test_climatology_frequencies(history):
climo = grading.climatology(history, 180)
# ~30% of days carry rain in the fixture; sunshine is absent from this record.
assert 0.0 < climo["rain_freq"] < 1.0
assert climo["sun_freq"] is None
def test_month_climate(history):
mc = grading.month_climate(history)
assert sorted(int(k) for k in mc) == list(range(1, 13))
for k, e in mc.items():
assert 0.0 <= e["rain_freq"] <= 1.0
assert e["sun_freq"] is None # no sunshine column in the fixture
assert e["tmax_med"] is not None
# rainy_days is the rain fraction scaled to the month's length, stored
# rounded to one decimal (so allow for that 0.1 rounding).
assert e["rainy_days"] == pytest.approx(
e["rain_freq"] * grading._DAYS_IN_MONTH[int(k) - 1], abs=0.06)
# Summer (Jul) is warmer than winter (Jan) given the seasonal fixture.
assert mc["7"]["tmax_med"] > mc["1"]["tmax_med"]