* 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.
253 lines
9.6 KiB
Python
253 lines
9.6 KiB
Python
"""Subscription evaluation engine — the background worker that turns unusual
|
|
weather into notifications.
|
|
|
|
A daemon thread (modeled on app.py's neighbor warmer) wakes on an interval and, for
|
|
every active subscription, checks whether the subscribed cell's recent/forecast
|
|
weather crossed the user's percentile threshold on any watched metric. Crossings
|
|
become rows in the notifications table.
|
|
|
|
Two guards keep it quiet and cheap:
|
|
|
|
* **Per-event dedup** — the notifications table has a UNIQUE(subscription_id,
|
|
event_date, metric, direction, kind); an INSERT OR IGNORE means re-running the
|
|
pass never re-notifies the same event.
|
|
* **Weekly cap** — a subscription that has notified within the last 7 days is
|
|
skipped entirely (Subscription.last_notified_at), so one alert = at most one
|
|
notification per week, as specified.
|
|
|
|
Quota safety: the loop reads history from the parquet cache only
|
|
(climate.load_cached_history) and treats a forecast-fetch failure as "skip this
|
|
cell", so it never spends archive quota or dies on an upstream rate limit.
|
|
"""
|
|
import datetime
|
|
import os
|
|
import threading
|
|
import time
|
|
|
|
import polars as pl
|
|
from sqlalchemy import delete, select
|
|
|
|
import climate
|
|
import grading
|
|
import grid
|
|
from db import sync_session_maker
|
|
from models import AccessToken, Notification, Subscription
|
|
from views import OBS_COLS
|
|
|
|
WEEK_SECONDS = 7 * 86400
|
|
INTERVAL = int(os.environ.get("THERMOGRAPH_NOTIFY_INTERVAL", "900")) # 15 min default
|
|
SESSION_TTL_SECONDS = int(os.environ.get("THERMOGRAPH_SESSION_TTL_DAYS", "30")) * 86400
|
|
LOOKBACK_DAYS = 3 # re-check the last few observed days (dedup makes it safe)
|
|
FORECAST_HORIZON_DAYS = 7
|
|
|
|
# Metrics whose low tail also counts as unusual when a subscription is two-sided
|
|
# (cold snaps, unusually calm/dry). Precipitation is one-directional (high only).
|
|
TWO_SIDED_METRICS = set(grading.TEMP_METRICS)
|
|
|
|
METRIC_NOUN = {
|
|
"tmax": "daytime high",
|
|
"tmin": "overnight low",
|
|
"feels": "feels-like temperature",
|
|
"humid": "humidity",
|
|
"wind": "wind speed",
|
|
"gust": "wind gusts",
|
|
"precip": "rainfall",
|
|
}
|
|
METRIC_UNIT = {
|
|
"tmax": "°F", "tmin": "°F", "feels": "°F",
|
|
"humid": " g/m³", "wind": " mph", "gust": " mph", "precip": " in",
|
|
}
|
|
# A readable descriptor per (metric, direction) so titles don't read like
|
|
# "unusually high high temperature". Precipitation is high-only.
|
|
METRIC_PHRASE = {
|
|
("tmax", "high"): "unusually hot day", ("tmax", "low"): "unusually cool day",
|
|
("tmin", "high"): "unusually warm night", ("tmin", "low"): "unusually cold night",
|
|
("feels", "high"): "unusually hot conditions", ("feels", "low"): "unusually cold conditions",
|
|
("humid", "high"): "unusually humid air", ("humid", "low"): "unusually dry air",
|
|
("wind", "high"): "unusually strong wind", ("wind", "low"): "unusually calm wind",
|
|
("gust", "high"): "unusually strong gusts", ("gust", "low"): "unusually light gusts",
|
|
("precip", "high"): "unusually heavy rain",
|
|
}
|
|
|
|
_STOP = threading.Event()
|
|
_thread = None
|
|
|
|
|
|
# --- message building --------------------------------------------------------
|
|
def _place(sub: Subscription) -> str:
|
|
return sub.label or f"{sub.lat:.2f}, {sub.lon:.2f}"
|
|
|
|
|
|
def _compose(sub, event_date, metric, direction, graded):
|
|
noun = METRIC_NOUN.get(metric, metric)
|
|
pct = graded["percentile"]
|
|
grade = graded.get("grade")
|
|
value = graded.get("value")
|
|
unit = METRIC_UNIT.get(metric, "")
|
|
phrase = METRIC_PHRASE.get((metric, direction), f"unusual {noun}")
|
|
title = f"{_place(sub)}: {phrase}"
|
|
val_txt = f" ({value}{unit})" if value is not None else ""
|
|
if sub.kind == "forecast":
|
|
body = (f"Forecast for {event_date}: the {noun} is projected at the "
|
|
f"{pct:g}th percentile{val_txt} — {grade}.")
|
|
else:
|
|
body = (f"On {event_date}, the {noun} hit the {pct:g}th "
|
|
f"percentile{val_txt} — {grade}.")
|
|
return title, body
|
|
|
|
|
|
# --- per-cell evaluation -----------------------------------------------------
|
|
def _obs_from_row(row) -> dict:
|
|
return {k: row[k] for k in OBS_COLS if k in row}
|
|
|
|
|
|
def _candidate_rows(recent, kind: str, today: datetime.date):
|
|
"""Rows to grade for a subscription of the given kind, most-relevant first.
|
|
`today` is a datetime.date; `recent["date"]` is a pl.Date column."""
|
|
if recent is None or recent.is_empty() or "date" not in recent.columns:
|
|
return []
|
|
if kind == "forecast":
|
|
window = (pl.col("date") > today) & (
|
|
pl.col("date") <= today + datetime.timedelta(days=FORECAST_HORIZON_DAYS))
|
|
ordered = recent.filter(window).sort("date") # soonest first
|
|
else:
|
|
window = (pl.col("date") <= today) & (
|
|
pl.col("date") >= today - datetime.timedelta(days=LOOKBACK_DAYS))
|
|
ordered = recent.filter(window).sort("date", descending=True) # newest first
|
|
return list(ordered.iter_rows(named=True))
|
|
|
|
|
|
def _first_trigger(sub: Subscription, rows, history, grade_cache):
|
|
"""The first (event_date, metric, direction, graded) that crosses, or None."""
|
|
for row in rows:
|
|
date_key = row["date"].isoformat()
|
|
graded = grade_cache.get(date_key)
|
|
if graded is None:
|
|
graded = grading.grade_day(history, row["date"], _obs_from_row(row))
|
|
grade_cache[date_key] = graded
|
|
for metric in sub.metrics:
|
|
g = graded.get(metric)
|
|
if not g or g.get("percentile") is None:
|
|
continue
|
|
pct = g["percentile"]
|
|
if pct >= sub.threshold:
|
|
return (date_key, metric, "high", g)
|
|
if sub.two_sided and metric in TWO_SIDED_METRICS and pct <= 100 - sub.threshold:
|
|
return (date_key, metric, "low", g)
|
|
return None
|
|
|
|
|
|
def _process_cell(session, cell_id, subs, today, now):
|
|
"""Evaluate every subscription on one cell, inserting any new notifications."""
|
|
try:
|
|
cell = grid.from_id(cell_id)
|
|
except Exception: # noqa: BLE001 - a malformed cell_id shouldn't kill the pass
|
|
return 0
|
|
history = climate.load_cached_history(cell)
|
|
if history is None or history.is_empty():
|
|
return 0 # nothing cached yet — never spend archive quota from here
|
|
try:
|
|
recent = climate.get_recent_forecast(cell)
|
|
except climate.WeatherUnavailable:
|
|
return 0 # upstream rate-limited — skip this cell this pass
|
|
except Exception: # noqa: BLE001
|
|
return 0
|
|
|
|
grade_cache = {}
|
|
created = 0
|
|
for sub in subs:
|
|
# Weekly cap: one notification per subscription per 7 days.
|
|
if sub.last_notified_at and now - sub.last_notified_at < WEEK_SECONDS:
|
|
continue
|
|
rows = _candidate_rows(recent, sub.kind, today)
|
|
hit = _first_trigger(sub, rows, history, grade_cache)
|
|
if hit is None:
|
|
continue
|
|
event_date, metric, direction, graded = hit
|
|
title, body = _compose(sub, event_date, metric, direction, graded)
|
|
notif = Notification(
|
|
user_id=sub.user_id,
|
|
subscription_id=sub.id,
|
|
event_date=event_date,
|
|
metric=metric,
|
|
direction=direction,
|
|
kind=sub.kind,
|
|
percentile=graded["percentile"],
|
|
value=graded.get("value"),
|
|
grade=graded.get("grade"),
|
|
title=title,
|
|
body=body,
|
|
channel="inapp",
|
|
created_at=now,
|
|
)
|
|
session.add(notif)
|
|
try:
|
|
session.flush() # trips the UNIQUE dedup constraint if already seen
|
|
except Exception: # noqa: BLE001 - already notified for this exact event
|
|
session.rollback()
|
|
continue
|
|
sub.last_notified_at = now
|
|
created += 1
|
|
session.commit()
|
|
return created
|
|
|
|
|
|
# --- housekeeping ------------------------------------------------------------
|
|
def _cleanup_sessions(session) -> None:
|
|
"""Drop expired login sessions (access tokens past their lifetime)."""
|
|
cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(
|
|
seconds=SESSION_TTL_SECONDS
|
|
)
|
|
session.execute(delete(AccessToken).where(AccessToken.created_at < cutoff))
|
|
session.commit()
|
|
|
|
|
|
# --- pass + loop -------------------------------------------------------------
|
|
def run_pass() -> int:
|
|
"""One full evaluation sweep over all active subscriptions. Returns the number
|
|
of notifications created."""
|
|
now = time.time()
|
|
today = datetime.date.today()
|
|
created = 0
|
|
with sync_session_maker() as session:
|
|
subs = session.execute(
|
|
select(Subscription).where(Subscription.active.is_(True))
|
|
).scalars().all()
|
|
by_cell: dict[str, list] = {}
|
|
for sub in subs:
|
|
by_cell.setdefault(sub.cell_id, []).append(sub)
|
|
for cell_id, cell_subs in by_cell.items():
|
|
try:
|
|
created += _process_cell(session, cell_id, cell_subs, today, now)
|
|
except Exception: # noqa: BLE001 - one bad cell must not abort the pass
|
|
session.rollback()
|
|
try:
|
|
_cleanup_sessions(session) # opportunistic expired-token sweep
|
|
except Exception: # noqa: BLE001
|
|
session.rollback()
|
|
return created
|
|
|
|
|
|
def run_loop():
|
|
# Wait first, then run — gives the app a moment to finish booting.
|
|
while not _STOP.wait(INTERVAL):
|
|
try:
|
|
run_pass()
|
|
except Exception: # noqa: BLE001 - a bad pass must never kill the loop
|
|
pass
|
|
|
|
|
|
def start():
|
|
"""Start the notifier daemon thread (unless disabled via env)."""
|
|
global _thread
|
|
if os.environ.get("THERMOGRAPH_ENABLE_NOTIFIER", "1") == "0":
|
|
return
|
|
if _thread is not None:
|
|
return
|
|
_STOP.clear()
|
|
_thread = threading.Thread(target=run_loop, name="subscription-notifier", daemon=True)
|
|
_thread.start()
|
|
|
|
|
|
def stop():
|
|
_STOP.set()
|