A subscription to a city that had never been viewed had no cached ~45-year archive, so the evaluator skipped it and it never fired. Now when a subscribed cell has no cached archive, the pass fetches it once via get_history (which caches it); every later pass reads it from cache and never re-fetches. A per-pass budget (THERMOGRAPH_NOTIFY_ARCHIVE_FETCHES, default 4) caps how many missing archives one pass will fetch so a burst of new subscriptions can't exhaust the archive quota, and a rate-limited fetch just retries on the next pass. The recent/forecast bundle keeps refreshing on its own hourly cadence. Add integration tests covering fetch-when-missing and never-refetch-when-cached.
148 lines
5.7 KiB
Python
148 lines
5.7 KiB
Python
"""Tests for the subscription evaluation engine: trigger detection and message
|
|
wording (pure logic), plus an integration check that a full pass fetches a MISSING
|
|
archive once but never re-fetches a cached one (against conftest's throwaway DB)."""
|
|
import datetime
|
|
import types
|
|
import uuid
|
|
|
|
import numpy as np
|
|
import polars as pl
|
|
|
|
import climate
|
|
import db
|
|
import notify
|
|
from models import Notification, Subscription, User
|
|
from sqlalchemy import delete, select
|
|
|
|
|
|
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")
|
|
|
|
|
|
# --- integration: on-demand archive fetch during a full pass -----------------
|
|
def _recent_extreme(today):
|
|
dates = [today - datetime.timedelta(days=1), today]
|
|
return pl.DataFrame({
|
|
"date": dates,
|
|
"doy": np.array([d.timetuple().tm_yday for d in dates], dtype="int16"),
|
|
"tmax": [72.0, 115.0], # today's high is an extreme -> should trigger
|
|
"tmin": [48.0, 48.0],
|
|
"precip": [0.0, 0.0],
|
|
})
|
|
|
|
|
|
def _seed_single_subscription(cell_id="100_200"):
|
|
db.Base.metadata.create_all(db.sync_engine)
|
|
uid = uuid.uuid4()
|
|
with db.sync_session_maker() as s:
|
|
s.execute(delete(User)) # clean slate in the throwaway DB
|
|
s.commit()
|
|
s.add(User(id=uid, email="pass@example.com", hashed_password="x", is_active=True))
|
|
s.commit()
|
|
s.add(Subscription(user_id=uid, cell_id=cell_id, label="X", lat=1.0, lon=2.0,
|
|
threshold=95, metrics=["tmax"], kind="observed", two_sided=False))
|
|
s.commit()
|
|
return uid
|
|
|
|
|
|
def _user_notifications(uid):
|
|
with db.sync_session_maker() as s:
|
|
return s.execute(
|
|
select(Notification).where(Notification.user_id == uid)
|
|
).scalars().all()
|
|
|
|
|
|
def test_missing_archive_is_fetched_once(monkeypatch):
|
|
uid = _seed_single_subscription()
|
|
fetched = []
|
|
monkeypatch.setattr(climate, "load_cached_history", lambda cell: None)
|
|
monkeypatch.setattr(climate, "get_history",
|
|
lambda cell: (fetched.append(cell["id"]) or (_history(), {})))
|
|
monkeypatch.setattr(climate, "get_recent_forecast",
|
|
lambda cell: _recent_extreme(datetime.date.today()))
|
|
notify.run_pass()
|
|
assert fetched, "a subscribed cell with no cached archive should be fetched once"
|
|
assert len(_user_notifications(uid)) == 1
|
|
|
|
|
|
def test_cached_archive_is_never_refetched(monkeypatch):
|
|
uid = _seed_single_subscription()
|
|
fetched = []
|
|
monkeypatch.setattr(climate, "load_cached_history", lambda cell: _history())
|
|
monkeypatch.setattr(climate, "get_history",
|
|
lambda cell: (fetched.append(cell["id"]) or (_history(), {})))
|
|
monkeypatch.setattr(climate, "get_recent_forecast",
|
|
lambda cell: _recent_extreme(datetime.date.today()))
|
|
notify.run_pass()
|
|
assert not fetched, "a cached archive must never be re-fetched by the evaluator"
|
|
assert len(_user_notifications(uid)) == 1
|