- backend/tests: 74 hermetic tests (no network, no repo data//logs/ writes) covering grid snapping/round-trips, grading percentiles/bands/windows/ dry streaks, the places index (norm, one-edit matchers, search, corrections), the derived store (token validity, cache=False, degraded mode), and route-level API tests over a faked climate layer — routing, validation, ETag/304 revalidation, store replay, the /cell bundle, and the v1/v2 aliases. The API tests would have caught the /place AttributeError regression. - requirements-dev.txt + make test (venv prefers uv-pinned 3.12, matching deploy-dev.sh — pyarrow wheels stop at 3.12 and some pyenv builds lack sqlite). - CI: extract the build job into a reusable build.yml, add the test run and an API health probe (page-only curl can't catch route wiring faults); deploy-dev.yml now runs the same build gate before deploying direct pushes, which previously deployed with no CI at all. - Deploys serialize under one dev-lan-deploy concurrency group across both workflows (previously per-PR groups could interleave two deploys to the same checkout), and are never cancelled mid-restart. - deploy-dev.sh health check also probes /api/v2/place — best-effort externals mean a failure there is a genuine server bug.
138 lines
5.5 KiB
Python
138 lines
5.5 KiB
Python
import numpy as np
|
|
import pandas as pd
|
|
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"
|
|
|
|
|
|
# ---- dry streaks ---------------------------------------------------------------
|
|
|
|
def test_dry_streaks_walk():
|
|
dates = pd.date_range("2024-01-01", periods=5, freq="D")
|
|
precips = [0.5, 0.0, float("nan"), 0.02, 0.005]
|
|
out = grading.dry_streaks(dates.values, 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 = pd.Timestamp(history["date"].max())
|
|
start = end - pd.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 = pd.Timestamp(history["date"].max())
|
|
row = history[history["date"] == target].iloc[0]
|
|
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 = pd.Timestamp(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
|