thermograph/tests/data/test_scoring.py

215 lines
8.6 KiB
Python
Raw Normal View History

"""Divergence-scoring tests: synthetic records with known shifts drive known
scores. The recent window is always the last 6 years OF THE SAME frame, so a
"shift" here means perturbing only those trailing years."""
import datetime
import numpy as np
import polars as pl
import pytest
Split the backend into domain packages (#217) * Centralize filesystem paths in a single module Add paths.py, which resolves the repo root once and derives the cache, accounts DB, logs, templates, frontend and bundled-city-data locations from it. Replace the 13 per-module `dirname(__file__)/..` anchors with references to it, so a module's location no longer determines where the app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are unchanged; every resolved path is byte-identical to before. Groundwork for moving modules into packages without re-pointing paths. Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62 * Split the backend into domain packages Group the flat backend modules into packages that mirror their concerns: data/ climate, grading, scoring, grid, places, cities, city_events, store web/ app, views, homepage, content, schemas notifications/ notify, digest, push, mailer, discord, discord_interactions, discord_link accounts/ models, users, api_accounts, db core/ metrics, singleton, audit Intra-project imports are rewritten to the package-qualified form. The entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor) and paths.py stay at the backend/ root, and backend/app.py becomes a shim re-exporting web.app:app so the launch target stays `app:app` — run.sh, the systemd units, and CI need no change. Verified: full suite (318) passes, `uvicorn app:app` boots and serves the home/SEO/static/API surfaces, and every root script imports clean. Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
from data import scoring
ALL_METRICS = ("tmax", "tmin", "feels", "humid", "wetbulb", "wind", "gust", "precip")
def _years_before(d: datetime.date, years: int) -> datetime.date:
try:
return d.replace(year=d.year - years)
except ValueError:
return d.replace(year=d.year - years, day=28)
def make_frame(years: int = 45, seed: int = 3, drop: tuple = (), end: str | None = None):
"""A full multi-metric daily record, every metric i.i.d. across years around a
seasonal cycle so the last 6 years are statistically identical to the rest
until a test perturbs them. Columns in ``drop`` are omitted (to exercise the
missing-metric path)."""
end_ts = datetime.date.fromisoformat(end) if end else datetime.date(2026, 7, 11)
start = _years_before(end_ts, years)
dates = [start + datetime.timedelta(days=i) for i in range((end_ts - start).days + 1)]
n = len(dates)
rng = np.random.default_rng(seed)
doy = np.array([d.timetuple().tm_yday for d in dates])
seasonal = 55 + 30 * np.sin((doy - 100) / 366.0 * 2 * np.pi)
tmax = seasonal + rng.normal(0, 8, n)
tmin = tmax - 15 + rng.normal(0, 3, n)
cols = {
"date": dates,
"tmax": np.round(tmax, 1),
"tmin": np.round(tmin, 1),
"feels": np.round(tmax + rng.normal(0, 2, n), 1),
"humid": np.round(np.clip(12 + rng.normal(0, 3, n), 1, None), 1),
"wetbulb": np.round(tmax - 12 + rng.normal(0, 2, n), 1),
"wind": np.round(np.clip(8 + rng.normal(0, 3, n), 0, None), 1),
"gust": np.round(np.clip(16 + rng.normal(0, 5, n), 0, None), 1),
"precip": np.where(rng.random(n) < 0.3, np.round(rng.gamma(1.5, 0.2, n), 2), 0.0),
}
for d in drop:
cols.pop(d)
df = pl.DataFrame(cols)
return df.with_columns(pl.col("date").dt.ordinal_day().cast(pl.Int16).alias("doy"))
def _recent_mask(df: pl.DataFrame) -> np.ndarray:
cutoff = scoring._minus_years(df["date"].max(), scoring.RECENT_YEARS)
return (df["date"] > cutoff).to_numpy()
def shift_metric(df, metric, amount, months=None):
"""Add ``amount`` to ``metric`` for the recent-6-years rows (optionally only
within ``months``) the perturbation the scorer should detect."""
mask = _recent_mask(df)
if months is not None:
mask = mask & df["date"].dt.month().is_in(list(months)).to_numpy()
vals = df[metric].to_numpy().copy()
vals[mask] = vals[mask] + amount
return df.with_columns(pl.Series(metric, np.round(vals, 2)))
# --- baseline / no-shift ------------------------------------------------------
def test_no_shift_is_steady():
out = scoring.build_scores(make_frame())
ann = out["slices"]["annual"]
assert ann["overall"]["score"] < 15 # Steady
for k in ("tmax", "tmin", "feels", "humid"):
m = ann["metrics"][k]
assert m["score"] is not None
assert abs(m["bias"]) <= 5 # within sampling noise
assert m["mad"] <= 5
def test_payload_metadata():
out = scoring.build_scores(make_frame(years=45))
assert out["recent_years"] == 6
assert out["baseline_overlaps_recent"] is True
assert out["n_recent"] < out["n_baseline"]
assert set(out["slices"]) == set(scoring.SLICES)
assert out["recent_range"][1] == out["baseline_range"][1]
# --- known directional shift --------------------------------------------------
def test_warming_shift_scores_up_and_warmer():
base = scoring.build_scores(make_frame())["slices"]["annual"]["metrics"]["tmax"]
hot = scoring.build_scores(shift_metric(make_frame(), "tmax", 9.0))
m = hot["slices"]["annual"]["metrics"]["tmax"]
assert m["score"] >= 30 and m["score"] > base["score"]
assert m["bias"] > 0
assert m["direction"] == "warmer"
assert "warmer" in m["grade"]
assert all(q["d"] > 0 for q in m["per_q"]) # every category shifted up
# The overall total is a direction-agnostic net change — no warmer/cooler word.
ov = hot["slices"]["annual"]["overall"]
assert ov["descriptor"] == "net change"
assert "" not in ov["grade"] and "warmer" not in ov["grade"]
assert ov["score"] > 0 and "direction" not in ov
def test_cooling_shift_reads_cooler():
out = scoring.build_scores(shift_metric(make_frame(), "tmin", -9.0))
m = out["slices"]["annual"]["metrics"]["tmin"]
assert m["bias"] < 0
assert m["direction"] == "cooler"
assert m["class"] in ("cool", "cold", "very-cold", "rec-cold")
# --- seasonal isolation -------------------------------------------------------
def test_summer_only_shift_isolated_to_jja():
out = scoring.build_scores(shift_metric(make_frame(), "tmax", 12.0, months=(6, 7, 8)))
jja = out["slices"]["jja"]["metrics"]["tmax"]
djf = out["slices"]["djf"]["metrics"]["tmax"]
assert jja["mad"] > djf["mad"]
assert jja["score"] > djf["score"]
assert jja["bias"] > 0
# --- precip zero-inflation ----------------------------------------------------
def test_precip_frequency_shift():
df = make_frame()
mask = _recent_mask(df)
rng = np.random.default_rng(99)
p = df["precip"].to_numpy().copy()
# Double the wet-day frequency in the recent window at similar amounts.
extra = mask & (rng.random(len(p)) < 0.35) & (p < scoring.grading.RAIN_THRESHOLD)
p[extra] = 0.12
df = df.with_columns(pl.Series("precip", np.round(p, 2)))
m = scoring.build_scores(df)["slices"]["annual"]["metrics"]["precip"]
assert m["freq"]["d"] > 8 # wetter days more often
assert m["direction"] == "wetter"
assert m["bias"] > 0
# --- wet-bulb heat-stress-day frequency ---------------------------------------
def test_wetbulb_stress_frequency_present():
out = scoring.build_scores(make_frame())
assert out["wetbulb_stress_f"] == scoring.WETBULB_STRESS_F
freq = out["slices"]["annual"]["metrics"]["wetbulb"]["freq"]
assert freq["threshold_f"] == scoring.WETBULB_STRESS_F
assert 0 <= freq["f6"] <= 100 and 0 <= freq["f45"] <= 100
assert freq["d"] == pytest.approx(freq["f6"] - freq["f45"], abs=0.05)
def test_wetbulb_stress_frequency_rises_with_heat():
# A large recent wet-bulb shift pushes summer days over the stress threshold.
out = scoring.build_scores(shift_metric(make_frame(), "wetbulb", 30.0))
freq = out["slices"]["annual"]["metrics"]["wetbulb"]["freq"]
assert freq["f6"] > freq["f45"] and freq["d"] > 0
# --- overall aggregation ------------------------------------------------------
def test_overall_is_equal_average_of_metric_mads():
# Every present metric counts the same — no per-metric weighting.
entries = {
"a": {"score": 20, "mad": 3.0},
"b": {"score": 80, "mad": 12.0},
"c": {"score": None}, # missing -> dropped from the average
}
ov = scoring._overall(entries)
assert ov["mad"] == 7.5 # (3.0 + 12.0) / 2
assert ov["score"] == scoring.score_of(7.5) == 50
def test_missing_gust_column_nulls_out_but_overall_survives():
out = scoring.build_scores(make_frame(drop=("gust",)))
ann = out["slices"]["annual"]
g = ann["metrics"]["gust"]
assert g["score"] is None and "not available" in g["reason"]
assert ann["overall"]["score"] is not None # averaged over present metrics
def test_all_null_metric_reports_not_enough_data():
df = make_frame().with_columns(pl.lit(None, dtype=pl.Float64).alias("humid"))
m = scoring.build_scores(df)["slices"]["annual"]["metrics"]["humid"]
assert m["score"] is None
assert "not enough" in m["reason"]
# --- guards -------------------------------------------------------------------
def test_short_record_is_unavailable():
out = scoring.build_scores(make_frame(years=10))
assert "unavailable" in out
assert "slices" not in out
# --- unit helpers -------------------------------------------------------------
def test_score_and_tier_mapping():
assert scoring.score_of(0) == 0
assert scoring.score_of(scoring.SATURATION) == 100
assert scoring.score_of(scoring.SATURATION * 2) == 100 # clamped
assert scoring.tier_of(90, 5)[1] == "rec-hot"
assert scoring.tier_of(90, -5)[1] == "rec-cold"
assert scoring.tier_of(5, 1)[0] == "Steady"
def test_divergence_none_below_min_samples():
base = np.linspace(0, 100, 5000)
rec = np.linspace(0, 100, scoring.MIN_SLICE_SAMPLES - 1)
assert scoring.divergence(base, rec) is None