* Migrate backend dataframe layer from pandas to polars Replace pandas with polars across the backend, dropping both pandas and its pyarrow parquet engine from the dependency set. numpy stays (the grading percentile math is unchanged). - climate.py: parquet IO, source→frame mappings, cache read/topup on polars. New _normalize_read casts the cached `date` column to pl.Date (older files were written by pandas as datetime64[ns]); frames now unify missing values as null so the grading boundary drops them consistently across sources. - grading.py: keep the numpy percentile core; swap the frame→numpy bridge to .to_numpy()/.drop_nulls(), day-of-year/year to polars dt expressions, and the per-row loop to iter_rows(named=True). - views.py: filter/anti-join/concat replace boolean-mask, isin and pd.concat; scalar dates are stdlib datetime.date; a local _months_before helper replaces DateOffset(months=) for the calendar-range default. - app.py, migrate.py: request-date parsing uses datetime.date, removing pandas from the endpoint and migrate layers entirely. - The date column is pl.Date end to end, eliminating the pandas normalize() calls and comparing cleanly against stdlib dates. Payloads are unchanged: calendar, day, grade and forecast responses are byte-for-byte identical to the pandas implementation on the same cached record. Tests ported to polars fixtures, with added coverage for the combined feels-like fallback, calendar month-offset (month-end/leap), and the concat/dedup "fresher source wins" rule. * Port notify.py to polars after merging dev's account system Merge origin/dev (accounts + notification subscriptions) and carry the pandas→ polars migration into the newly added notify.py, which the merge brought in still using pandas — with pandas removed from requirements this broke its import. - notify.py: _candidate_rows filters/sorts the recent bundle with polars expressions and returns iter_rows dicts; date scalars are datetime.date; history/recent emptiness via is_empty(). - test_notify.py: synthetic history/rows built with polars + datetime.
83 lines
3.1 KiB
Python
83 lines
3.1 KiB
Python
"""Unit tests for the subscription evaluation engine's pure logic (no DB, no
|
|
network): trigger detection against a synthetic climatology and message wording."""
|
|
import datetime
|
|
import types
|
|
|
|
import numpy as np
|
|
import polars as pl
|
|
|
|
import notify
|
|
|
|
|
|
def _history() -> pl.DataFrame:
|
|
start, end = datetime.date(1990, 1, 1), datetime.date(2020, 12, 31)
|
|
dates = [start + datetime.timedelta(days=i) for i in range((end - start).days + 1)]
|
|
rng = np.random.default_rng(1)
|
|
n = len(dates)
|
|
return pl.DataFrame({
|
|
"date": dates,
|
|
"doy": np.array([d.timetuple().tm_yday for d in dates], dtype="int16"),
|
|
"tmax": 70 + rng.normal(0, 8, n),
|
|
"tmin": 48 + rng.normal(0, 7, n),
|
|
"precip": rng.exponential(0.05, n),
|
|
})
|
|
|
|
|
|
def _sub(**kw):
|
|
d = dict(metrics=["tmax"], threshold=95, two_sided=False, kind="observed",
|
|
label="Testville", lat=1.0, lon=2.0)
|
|
d.update(kw)
|
|
return types.SimpleNamespace(**d)
|
|
|
|
|
|
def _row(date, **vals):
|
|
r = {"date": datetime.date.fromisoformat(date)}
|
|
r.update(vals)
|
|
return r
|
|
|
|
|
|
def test_high_trigger():
|
|
row = _row("2021-07-15", tmax=115.0, tmin=50.0, precip=0.0)
|
|
hit = notify._first_trigger(_sub(metrics=["tmax"], threshold=95), [row], _history(), {})
|
|
assert hit is not None
|
|
_, metric, direction, g = hit
|
|
assert metric == "tmax" and direction == "high" and g["percentile"] >= 95
|
|
|
|
|
|
def test_no_trigger_when_normal():
|
|
row = _row("2021-07-15", tmax=70.0, tmin=48.0, precip=0.0)
|
|
assert notify._first_trigger(_sub(metrics=["tmax"], threshold=95), [row], _history(), {}) is None
|
|
|
|
|
|
def test_low_trigger_only_when_two_sided():
|
|
row = _row("2021-07-15", tmax=70.0, tmin=10.0, precip=0.0) # extreme low overnight
|
|
assert notify._first_trigger(
|
|
_sub(metrics=["tmin"], threshold=95, two_sided=False), [row], _history(), {}) is None
|
|
hit = notify._first_trigger(
|
|
_sub(metrics=["tmin"], threshold=95, two_sided=True), [row], _history(), {})
|
|
assert hit is not None and hit[1] == "tmin" and hit[2] == "low"
|
|
|
|
|
|
def test_precip_never_low_side():
|
|
# A dry day has no rain-percentile, so a precip subscription never fires low
|
|
# even when two-sided (rain is one-directional).
|
|
row = _row("2021-07-15", tmax=70.0, precip=0.0)
|
|
assert notify._first_trigger(
|
|
_sub(metrics=["precip"], threshold=95, two_sided=True), [row], _history(), {}) is None
|
|
|
|
|
|
def test_compose_observed_wording():
|
|
g = {"percentile": 99.0, "value": 115.0, "grade": "Near Record"}
|
|
title, body = notify._compose(_sub(kind="observed", label="Phoenix"),
|
|
"2026-07-14", "tmax", "high", g)
|
|
assert title == "Phoenix: unusually hot day"
|
|
assert body.startswith("On 2026-07-14")
|
|
assert "99th percentile" in body and "Near Record" in body
|
|
|
|
|
|
def test_compose_forecast_wording():
|
|
g = {"percentile": 2.0, "value": 5.0, "grade": "Near Record"}
|
|
title, body = notify._compose(_sub(kind="forecast", label="Nome"),
|
|
"2026-01-02", "tmin", "low", g)
|
|
assert title == "Nome: unusually cold night"
|
|
assert body.startswith("Forecast for 2026-01-02")
|