Move the flat backend/tests/*.py into domain subfolders so the suite mirrors the code's concerns: data/ climate, grading, scoring, grid, places, store web/ api, content, homepage, views notifications/ notify, digest, discord (+ dm/interactions/link) accounts/ api_accounts core/ metrics, singleton, dashboard conftest.py stays at the tests/ root, so its sys.path setup and shared fixtures still apply to every subfolder. The four tests that derive repo paths from __file__ get their depth bumped one level to match their new location. Pytest discovers the subfolders recursively; the CI command (python -m pytest backend/tests) is unchanged. Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
158 lines
6.6 KiB
Python
158 lines
6.6 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% -> 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_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
|
||
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"
|
||
|
||
|
||
# ---- 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
|