thermograph/notify.py

281 lines
11 KiB
Python
Raw Normal View History

Account system with weather-notification subscriptions (#89) * Add account system foundation: email/password auth with cookie sessions Introduce the app's first authoritative, user-owned data in a separate data/accounts.sqlite (SQLAlchemy), kept apart from the disposable derived-cache DB. Wire fastapi-users for email/password signup, cookie-based login/logout, and a session-check endpoint, backed by a database session strategy so logins survive restarts and are revocable. - db.py: async (aiosqlite) + sync SQLAlchemy engines over accounts.sqlite, WAL + foreign keys, create_db_and_tables(). - models.py: User, AccessToken, Subscription, Notification tables. - users.py: pwdlib hashing, HttpOnly cookie transport (path-scoped, SameSite=Lax, Secure via env), DatabaseStrategy sessions, current-user dependencies. - schemas.py: user + subscription + notification Pydantic models. - app.py: mount auth/register/users routers on v2, create tables at startup. - Pin fastapi-users[sqlalchemy]/aiosqlite; ignore data/accounts.sqlite*. * Add account header entry and auth modal (frontend) account.js self-injects a header entry (following the units.js pattern) that shows a Sign in button when logged out and an account menu when logged in, plus an auth modal reusing the existing .mp-overlay/.mp-modal chrome for email/password sign-in and account creation. A shared apiFetch helper sends the same-origin cookie for authed calls; exported getUser/openAuth/onAuthChange back later phases. Imported by every page entry module. On narrow screens the entry collapses to an icon-only button so it doesn't crowd the title. Enforce an 8-character minimum password in the user manager. * Add subscription CRUD API and the alerts management page Backend api_accounts.py adds user-scoped, cookie-authenticated endpoints to create/list/update/delete subscriptions (and the notification reads used next): POST snaps lat/lon to a grid cell, resolves a label, and rejects a duplicate location+kind with 409; PATCH/DELETE are ownership-checked (404 on mismatch). Mounted on the v2 prefix. Frontend subscriptions.js + subscriptions.html serve the /alerts page: a sign-in gate when logged out, an add flow that reuses the shared map picker and an editor modal (kind, watched metrics, 95-99 percentile, two-sided), and a card list with inline threshold/active edits and remove. Reachable from the account menu. * Add background subscription evaluation engine notify.py runs a daemon thread that periodically evaluates every active subscription: it groups them by grid cell, reads history from the parquet cache only (never spends archive quota) plus the hourly recent/forecast bundle, and grades candidate days with the existing grading.grade_day. A watched metric that lands at or beyond the threshold percentile fires a 'high' alert; a two-sided subscription also fires 'low' for the symmetric cold/calm/dry tail (precip stays one-directional). Observed subscriptions look at the last few recorded days, forecast subscriptions at the coming week. Two guards keep it quiet: a UNIQUE(subscription, event_date, metric, direction, kind) constraint dedups repeat events, and a per-subscription weekly cap (last_notified_at) limits each alert to one notification per 7 days. The loop tolerates a bad cell or an upstream rate limit without aborting the pass. Started and stopped from the app lifespan; gated by THERMOGRAPH_ENABLE_NOTIFIER. * Add in-app notification center (header bell) Extend account.js with a notification bell beside the account menu: an unread badge, a dropdown listing recent notifications (title, body, relative time), a per-item mark-read on click, and a Mark all read action, all through the cookie-authed notifications API. Unread state refreshes on open and polls every two minutes while signed in; polling stops on sign-out. Styled to match the app, responsive down to mobile. * Harden accounts: expired-session cleanup, engine tests, ops docs - notify.py sweeps expired login sessions (access tokens past their lifetime) once per evaluation pass. - Add hermetic unit tests for the evaluation engine's trigger detection (high/low tails, precip one-directional, normal = no trigger) and notification wording. - Document accounts.sqlite (authoritative, back it up), the single-worker requirement for the in-process evaluator, and the new env vars in DEPLOY.md.
2026-07-15 18:46:46 +00:00
"""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: a subscribed cell's ~45-year archive is read from the parquet cache
(climate.load_cached_history) and never re-fetched once present. A cell that has no
cached archive yet is fetched ONCE (budget-capped per pass) so a freshly-subscribed
city starts working; the recent/forecast bundle refreshes on its own hourly cadence.
A forecast-fetch failure just skips the cell, so a pass never dies on a rate limit.
Account system with weather-notification subscriptions (#89) * Add account system foundation: email/password auth with cookie sessions Introduce the app's first authoritative, user-owned data in a separate data/accounts.sqlite (SQLAlchemy), kept apart from the disposable derived-cache DB. Wire fastapi-users for email/password signup, cookie-based login/logout, and a session-check endpoint, backed by a database session strategy so logins survive restarts and are revocable. - db.py: async (aiosqlite) + sync SQLAlchemy engines over accounts.sqlite, WAL + foreign keys, create_db_and_tables(). - models.py: User, AccessToken, Subscription, Notification tables. - users.py: pwdlib hashing, HttpOnly cookie transport (path-scoped, SameSite=Lax, Secure via env), DatabaseStrategy sessions, current-user dependencies. - schemas.py: user + subscription + notification Pydantic models. - app.py: mount auth/register/users routers on v2, create tables at startup. - Pin fastapi-users[sqlalchemy]/aiosqlite; ignore data/accounts.sqlite*. * Add account header entry and auth modal (frontend) account.js self-injects a header entry (following the units.js pattern) that shows a Sign in button when logged out and an account menu when logged in, plus an auth modal reusing the existing .mp-overlay/.mp-modal chrome for email/password sign-in and account creation. A shared apiFetch helper sends the same-origin cookie for authed calls; exported getUser/openAuth/onAuthChange back later phases. Imported by every page entry module. On narrow screens the entry collapses to an icon-only button so it doesn't crowd the title. Enforce an 8-character minimum password in the user manager. * Add subscription CRUD API and the alerts management page Backend api_accounts.py adds user-scoped, cookie-authenticated endpoints to create/list/update/delete subscriptions (and the notification reads used next): POST snaps lat/lon to a grid cell, resolves a label, and rejects a duplicate location+kind with 409; PATCH/DELETE are ownership-checked (404 on mismatch). Mounted on the v2 prefix. Frontend subscriptions.js + subscriptions.html serve the /alerts page: a sign-in gate when logged out, an add flow that reuses the shared map picker and an editor modal (kind, watched metrics, 95-99 percentile, two-sided), and a card list with inline threshold/active edits and remove. Reachable from the account menu. * Add background subscription evaluation engine notify.py runs a daemon thread that periodically evaluates every active subscription: it groups them by grid cell, reads history from the parquet cache only (never spends archive quota) plus the hourly recent/forecast bundle, and grades candidate days with the existing grading.grade_day. A watched metric that lands at or beyond the threshold percentile fires a 'high' alert; a two-sided subscription also fires 'low' for the symmetric cold/calm/dry tail (precip stays one-directional). Observed subscriptions look at the last few recorded days, forecast subscriptions at the coming week. Two guards keep it quiet: a UNIQUE(subscription, event_date, metric, direction, kind) constraint dedups repeat events, and a per-subscription weekly cap (last_notified_at) limits each alert to one notification per 7 days. The loop tolerates a bad cell or an upstream rate limit without aborting the pass. Started and stopped from the app lifespan; gated by THERMOGRAPH_ENABLE_NOTIFIER. * Add in-app notification center (header bell) Extend account.js with a notification bell beside the account menu: an unread badge, a dropdown listing recent notifications (title, body, relative time), a per-item mark-read on click, and a Mark all read action, all through the cookie-authed notifications API. Unread state refreshes on open and polls every two minutes while signed in; polling stops on sign-out. Styled to match the app, responsive down to mobile. * Harden accounts: expired-session cleanup, engine tests, ops docs - notify.py sweeps expired login sessions (access tokens past their lifetime) once per evaluation pass. - Add hermetic unit tests for the evaluation engine's trigger detection (high/low tails, precip one-directional, normal = no trigger) and notification wording. - Document accounts.sqlite (authoritative, back it up), the single-worker requirement for the in-process evaluator, and the new env vars in DEPLOY.md.
2026-07-15 18:46:46 +00:00
"""
import datetime
import os
import threading
import time
Migrate backend dataframe layer from pandas to polars (#90) * 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.
2026-07-15 19:07:38 +00:00
import polars as pl
Account system with weather-notification subscriptions (#89) * Add account system foundation: email/password auth with cookie sessions Introduce the app's first authoritative, user-owned data in a separate data/accounts.sqlite (SQLAlchemy), kept apart from the disposable derived-cache DB. Wire fastapi-users for email/password signup, cookie-based login/logout, and a session-check endpoint, backed by a database session strategy so logins survive restarts and are revocable. - db.py: async (aiosqlite) + sync SQLAlchemy engines over accounts.sqlite, WAL + foreign keys, create_db_and_tables(). - models.py: User, AccessToken, Subscription, Notification tables. - users.py: pwdlib hashing, HttpOnly cookie transport (path-scoped, SameSite=Lax, Secure via env), DatabaseStrategy sessions, current-user dependencies. - schemas.py: user + subscription + notification Pydantic models. - app.py: mount auth/register/users routers on v2, create tables at startup. - Pin fastapi-users[sqlalchemy]/aiosqlite; ignore data/accounts.sqlite*. * Add account header entry and auth modal (frontend) account.js self-injects a header entry (following the units.js pattern) that shows a Sign in button when logged out and an account menu when logged in, plus an auth modal reusing the existing .mp-overlay/.mp-modal chrome for email/password sign-in and account creation. A shared apiFetch helper sends the same-origin cookie for authed calls; exported getUser/openAuth/onAuthChange back later phases. Imported by every page entry module. On narrow screens the entry collapses to an icon-only button so it doesn't crowd the title. Enforce an 8-character minimum password in the user manager. * Add subscription CRUD API and the alerts management page Backend api_accounts.py adds user-scoped, cookie-authenticated endpoints to create/list/update/delete subscriptions (and the notification reads used next): POST snaps lat/lon to a grid cell, resolves a label, and rejects a duplicate location+kind with 409; PATCH/DELETE are ownership-checked (404 on mismatch). Mounted on the v2 prefix. Frontend subscriptions.js + subscriptions.html serve the /alerts page: a sign-in gate when logged out, an add flow that reuses the shared map picker and an editor modal (kind, watched metrics, 95-99 percentile, two-sided), and a card list with inline threshold/active edits and remove. Reachable from the account menu. * Add background subscription evaluation engine notify.py runs a daemon thread that periodically evaluates every active subscription: it groups them by grid cell, reads history from the parquet cache only (never spends archive quota) plus the hourly recent/forecast bundle, and grades candidate days with the existing grading.grade_day. A watched metric that lands at or beyond the threshold percentile fires a 'high' alert; a two-sided subscription also fires 'low' for the symmetric cold/calm/dry tail (precip stays one-directional). Observed subscriptions look at the last few recorded days, forecast subscriptions at the coming week. Two guards keep it quiet: a UNIQUE(subscription, event_date, metric, direction, kind) constraint dedups repeat events, and a per-subscription weekly cap (last_notified_at) limits each alert to one notification per 7 days. The loop tolerates a bad cell or an upstream rate limit without aborting the pass. Started and stopped from the app lifespan; gated by THERMOGRAPH_ENABLE_NOTIFIER. * Add in-app notification center (header bell) Extend account.js with a notification bell beside the account menu: an unread badge, a dropdown listing recent notifications (title, body, relative time), a per-item mark-read on click, and a Mark all read action, all through the cookie-authed notifications API. Unread state refreshes on open and polls every two minutes while signed in; polling stops on sign-out. Styled to match the app, responsive down to mobile. * Harden accounts: expired-session cleanup, engine tests, ops docs - notify.py sweeps expired login sessions (access tokens past their lifetime) once per evaluation pass. - Add hermetic unit tests for the evaluation engine's trigger detection (high/low tails, precip one-directional, normal = no trigger) and notification wording. - Document accounts.sqlite (authoritative, back it up), the single-worker requirement for the in-process evaluator, and the new env vars in DEPLOY.md.
2026-07-15 18:46:46 +00:00
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
# The ~45-year archive is otherwise never fetched from the background loop. When a
# subscribed cell has no cached archive yet, fetch it ONCE (it then stays cached and
# is only ever read afterwards). This caps how many missing archives a single pass
# will fetch, so a burst of new subscriptions can't hammer the strict archive quota
# — the rest are picked up on later passes.
MAX_ARCHIVE_FETCHES_PER_PASS = int(os.environ.get("THERMOGRAPH_NOTIFY_ARCHIVE_FETCHES", "4"))
Account system with weather-notification subscriptions (#89) * Add account system foundation: email/password auth with cookie sessions Introduce the app's first authoritative, user-owned data in a separate data/accounts.sqlite (SQLAlchemy), kept apart from the disposable derived-cache DB. Wire fastapi-users for email/password signup, cookie-based login/logout, and a session-check endpoint, backed by a database session strategy so logins survive restarts and are revocable. - db.py: async (aiosqlite) + sync SQLAlchemy engines over accounts.sqlite, WAL + foreign keys, create_db_and_tables(). - models.py: User, AccessToken, Subscription, Notification tables. - users.py: pwdlib hashing, HttpOnly cookie transport (path-scoped, SameSite=Lax, Secure via env), DatabaseStrategy sessions, current-user dependencies. - schemas.py: user + subscription + notification Pydantic models. - app.py: mount auth/register/users routers on v2, create tables at startup. - Pin fastapi-users[sqlalchemy]/aiosqlite; ignore data/accounts.sqlite*. * Add account header entry and auth modal (frontend) account.js self-injects a header entry (following the units.js pattern) that shows a Sign in button when logged out and an account menu when logged in, plus an auth modal reusing the existing .mp-overlay/.mp-modal chrome for email/password sign-in and account creation. A shared apiFetch helper sends the same-origin cookie for authed calls; exported getUser/openAuth/onAuthChange back later phases. Imported by every page entry module. On narrow screens the entry collapses to an icon-only button so it doesn't crowd the title. Enforce an 8-character minimum password in the user manager. * Add subscription CRUD API and the alerts management page Backend api_accounts.py adds user-scoped, cookie-authenticated endpoints to create/list/update/delete subscriptions (and the notification reads used next): POST snaps lat/lon to a grid cell, resolves a label, and rejects a duplicate location+kind with 409; PATCH/DELETE are ownership-checked (404 on mismatch). Mounted on the v2 prefix. Frontend subscriptions.js + subscriptions.html serve the /alerts page: a sign-in gate when logged out, an add flow that reuses the shared map picker and an editor modal (kind, watched metrics, 95-99 percentile, two-sided), and a card list with inline threshold/active edits and remove. Reachable from the account menu. * Add background subscription evaluation engine notify.py runs a daemon thread that periodically evaluates every active subscription: it groups them by grid cell, reads history from the parquet cache only (never spends archive quota) plus the hourly recent/forecast bundle, and grades candidate days with the existing grading.grade_day. A watched metric that lands at or beyond the threshold percentile fires a 'high' alert; a two-sided subscription also fires 'low' for the symmetric cold/calm/dry tail (precip stays one-directional). Observed subscriptions look at the last few recorded days, forecast subscriptions at the coming week. Two guards keep it quiet: a UNIQUE(subscription, event_date, metric, direction, kind) constraint dedups repeat events, and a per-subscription weekly cap (last_notified_at) limits each alert to one notification per 7 days. The loop tolerates a bad cell or an upstream rate limit without aborting the pass. Started and stopped from the app lifespan; gated by THERMOGRAPH_ENABLE_NOTIFIER. * Add in-app notification center (header bell) Extend account.js with a notification bell beside the account menu: an unread badge, a dropdown listing recent notifications (title, body, relative time), a per-item mark-read on click, and a Mark all read action, all through the cookie-authed notifications API. Unread state refreshes on open and polls every two minutes while signed in; polling stops on sign-out. Styled to match the app, responsive down to mobile. * Harden accounts: expired-session cleanup, engine tests, ops docs - notify.py sweeps expired login sessions (access tokens past their lifetime) once per evaluation pass. - Add hermetic unit tests for the evaluation engine's trigger detection (high/low tails, precip one-directional, normal = no trigger) and notification wording. - Document accounts.sqlite (authoritative, back it up), the single-worker requirement for the in-process evaluator, and the new env vars in DEPLOY.md.
2026-07-15 18:46:46 +00:00
# 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}
Migrate backend dataframe layer from pandas to polars (#90) * 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.
2026-07-15 19:07:38 +00:00
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:
Account system with weather-notification subscriptions (#89) * Add account system foundation: email/password auth with cookie sessions Introduce the app's first authoritative, user-owned data in a separate data/accounts.sqlite (SQLAlchemy), kept apart from the disposable derived-cache DB. Wire fastapi-users for email/password signup, cookie-based login/logout, and a session-check endpoint, backed by a database session strategy so logins survive restarts and are revocable. - db.py: async (aiosqlite) + sync SQLAlchemy engines over accounts.sqlite, WAL + foreign keys, create_db_and_tables(). - models.py: User, AccessToken, Subscription, Notification tables. - users.py: pwdlib hashing, HttpOnly cookie transport (path-scoped, SameSite=Lax, Secure via env), DatabaseStrategy sessions, current-user dependencies. - schemas.py: user + subscription + notification Pydantic models. - app.py: mount auth/register/users routers on v2, create tables at startup. - Pin fastapi-users[sqlalchemy]/aiosqlite; ignore data/accounts.sqlite*. * Add account header entry and auth modal (frontend) account.js self-injects a header entry (following the units.js pattern) that shows a Sign in button when logged out and an account menu when logged in, plus an auth modal reusing the existing .mp-overlay/.mp-modal chrome for email/password sign-in and account creation. A shared apiFetch helper sends the same-origin cookie for authed calls; exported getUser/openAuth/onAuthChange back later phases. Imported by every page entry module. On narrow screens the entry collapses to an icon-only button so it doesn't crowd the title. Enforce an 8-character minimum password in the user manager. * Add subscription CRUD API and the alerts management page Backend api_accounts.py adds user-scoped, cookie-authenticated endpoints to create/list/update/delete subscriptions (and the notification reads used next): POST snaps lat/lon to a grid cell, resolves a label, and rejects a duplicate location+kind with 409; PATCH/DELETE are ownership-checked (404 on mismatch). Mounted on the v2 prefix. Frontend subscriptions.js + subscriptions.html serve the /alerts page: a sign-in gate when logged out, an add flow that reuses the shared map picker and an editor modal (kind, watched metrics, 95-99 percentile, two-sided), and a card list with inline threshold/active edits and remove. Reachable from the account menu. * Add background subscription evaluation engine notify.py runs a daemon thread that periodically evaluates every active subscription: it groups them by grid cell, reads history from the parquet cache only (never spends archive quota) plus the hourly recent/forecast bundle, and grades candidate days with the existing grading.grade_day. A watched metric that lands at or beyond the threshold percentile fires a 'high' alert; a two-sided subscription also fires 'low' for the symmetric cold/calm/dry tail (precip stays one-directional). Observed subscriptions look at the last few recorded days, forecast subscriptions at the coming week. Two guards keep it quiet: a UNIQUE(subscription, event_date, metric, direction, kind) constraint dedups repeat events, and a per-subscription weekly cap (last_notified_at) limits each alert to one notification per 7 days. The loop tolerates a bad cell or an upstream rate limit without aborting the pass. Started and stopped from the app lifespan; gated by THERMOGRAPH_ENABLE_NOTIFIER. * Add in-app notification center (header bell) Extend account.js with a notification bell beside the account menu: an unread badge, a dropdown listing recent notifications (title, body, relative time), a per-item mark-read on click, and a Mark all read action, all through the cookie-authed notifications API. Unread state refreshes on open and polls every two minutes while signed in; polling stops on sign-out. Styled to match the app, responsive down to mobile. * Harden accounts: expired-session cleanup, engine tests, ops docs - notify.py sweeps expired login sessions (access tokens past their lifetime) once per evaluation pass. - Add hermetic unit tests for the evaluation engine's trigger detection (high/low tails, precip one-directional, normal = no trigger) and notification wording. - Document accounts.sqlite (authoritative, back it up), the single-worker requirement for the in-process evaluator, and the new env vars in DEPLOY.md.
2026-07-15 18:46:46 +00:00
return []
if kind == "forecast":
Migrate backend dataframe layer from pandas to polars (#90) * 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.
2026-07-15 19:07:38 +00:00
window = (pl.col("date") > today) & (
pl.col("date") <= today + datetime.timedelta(days=FORECAST_HORIZON_DAYS))
ordered = recent.filter(window).sort("date") # soonest first
Account system with weather-notification subscriptions (#89) * Add account system foundation: email/password auth with cookie sessions Introduce the app's first authoritative, user-owned data in a separate data/accounts.sqlite (SQLAlchemy), kept apart from the disposable derived-cache DB. Wire fastapi-users for email/password signup, cookie-based login/logout, and a session-check endpoint, backed by a database session strategy so logins survive restarts and are revocable. - db.py: async (aiosqlite) + sync SQLAlchemy engines over accounts.sqlite, WAL + foreign keys, create_db_and_tables(). - models.py: User, AccessToken, Subscription, Notification tables. - users.py: pwdlib hashing, HttpOnly cookie transport (path-scoped, SameSite=Lax, Secure via env), DatabaseStrategy sessions, current-user dependencies. - schemas.py: user + subscription + notification Pydantic models. - app.py: mount auth/register/users routers on v2, create tables at startup. - Pin fastapi-users[sqlalchemy]/aiosqlite; ignore data/accounts.sqlite*. * Add account header entry and auth modal (frontend) account.js self-injects a header entry (following the units.js pattern) that shows a Sign in button when logged out and an account menu when logged in, plus an auth modal reusing the existing .mp-overlay/.mp-modal chrome for email/password sign-in and account creation. A shared apiFetch helper sends the same-origin cookie for authed calls; exported getUser/openAuth/onAuthChange back later phases. Imported by every page entry module. On narrow screens the entry collapses to an icon-only button so it doesn't crowd the title. Enforce an 8-character minimum password in the user manager. * Add subscription CRUD API and the alerts management page Backend api_accounts.py adds user-scoped, cookie-authenticated endpoints to create/list/update/delete subscriptions (and the notification reads used next): POST snaps lat/lon to a grid cell, resolves a label, and rejects a duplicate location+kind with 409; PATCH/DELETE are ownership-checked (404 on mismatch). Mounted on the v2 prefix. Frontend subscriptions.js + subscriptions.html serve the /alerts page: a sign-in gate when logged out, an add flow that reuses the shared map picker and an editor modal (kind, watched metrics, 95-99 percentile, two-sided), and a card list with inline threshold/active edits and remove. Reachable from the account menu. * Add background subscription evaluation engine notify.py runs a daemon thread that periodically evaluates every active subscription: it groups them by grid cell, reads history from the parquet cache only (never spends archive quota) plus the hourly recent/forecast bundle, and grades candidate days with the existing grading.grade_day. A watched metric that lands at or beyond the threshold percentile fires a 'high' alert; a two-sided subscription also fires 'low' for the symmetric cold/calm/dry tail (precip stays one-directional). Observed subscriptions look at the last few recorded days, forecast subscriptions at the coming week. Two guards keep it quiet: a UNIQUE(subscription, event_date, metric, direction, kind) constraint dedups repeat events, and a per-subscription weekly cap (last_notified_at) limits each alert to one notification per 7 days. The loop tolerates a bad cell or an upstream rate limit without aborting the pass. Started and stopped from the app lifespan; gated by THERMOGRAPH_ENABLE_NOTIFIER. * Add in-app notification center (header bell) Extend account.js with a notification bell beside the account menu: an unread badge, a dropdown listing recent notifications (title, body, relative time), a per-item mark-read on click, and a Mark all read action, all through the cookie-authed notifications API. Unread state refreshes on open and polls every two minutes while signed in; polling stops on sign-out. Styled to match the app, responsive down to mobile. * Harden accounts: expired-session cleanup, engine tests, ops docs - notify.py sweeps expired login sessions (access tokens past their lifetime) once per evaluation pass. - Add hermetic unit tests for the evaluation engine's trigger detection (high/low tails, precip one-directional, normal = no trigger) and notification wording. - Document accounts.sqlite (authoritative, back it up), the single-worker requirement for the in-process evaluator, and the new env vars in DEPLOY.md.
2026-07-15 18:46:46 +00:00
else:
Migrate backend dataframe layer from pandas to polars (#90) * 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.
2026-07-15 19:07:38 +00:00
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))
Account system with weather-notification subscriptions (#89) * Add account system foundation: email/password auth with cookie sessions Introduce the app's first authoritative, user-owned data in a separate data/accounts.sqlite (SQLAlchemy), kept apart from the disposable derived-cache DB. Wire fastapi-users for email/password signup, cookie-based login/logout, and a session-check endpoint, backed by a database session strategy so logins survive restarts and are revocable. - db.py: async (aiosqlite) + sync SQLAlchemy engines over accounts.sqlite, WAL + foreign keys, create_db_and_tables(). - models.py: User, AccessToken, Subscription, Notification tables. - users.py: pwdlib hashing, HttpOnly cookie transport (path-scoped, SameSite=Lax, Secure via env), DatabaseStrategy sessions, current-user dependencies. - schemas.py: user + subscription + notification Pydantic models. - app.py: mount auth/register/users routers on v2, create tables at startup. - Pin fastapi-users[sqlalchemy]/aiosqlite; ignore data/accounts.sqlite*. * Add account header entry and auth modal (frontend) account.js self-injects a header entry (following the units.js pattern) that shows a Sign in button when logged out and an account menu when logged in, plus an auth modal reusing the existing .mp-overlay/.mp-modal chrome for email/password sign-in and account creation. A shared apiFetch helper sends the same-origin cookie for authed calls; exported getUser/openAuth/onAuthChange back later phases. Imported by every page entry module. On narrow screens the entry collapses to an icon-only button so it doesn't crowd the title. Enforce an 8-character minimum password in the user manager. * Add subscription CRUD API and the alerts management page Backend api_accounts.py adds user-scoped, cookie-authenticated endpoints to create/list/update/delete subscriptions (and the notification reads used next): POST snaps lat/lon to a grid cell, resolves a label, and rejects a duplicate location+kind with 409; PATCH/DELETE are ownership-checked (404 on mismatch). Mounted on the v2 prefix. Frontend subscriptions.js + subscriptions.html serve the /alerts page: a sign-in gate when logged out, an add flow that reuses the shared map picker and an editor modal (kind, watched metrics, 95-99 percentile, two-sided), and a card list with inline threshold/active edits and remove. Reachable from the account menu. * Add background subscription evaluation engine notify.py runs a daemon thread that periodically evaluates every active subscription: it groups them by grid cell, reads history from the parquet cache only (never spends archive quota) plus the hourly recent/forecast bundle, and grades candidate days with the existing grading.grade_day. A watched metric that lands at or beyond the threshold percentile fires a 'high' alert; a two-sided subscription also fires 'low' for the symmetric cold/calm/dry tail (precip stays one-directional). Observed subscriptions look at the last few recorded days, forecast subscriptions at the coming week. Two guards keep it quiet: a UNIQUE(subscription, event_date, metric, direction, kind) constraint dedups repeat events, and a per-subscription weekly cap (last_notified_at) limits each alert to one notification per 7 days. The loop tolerates a bad cell or an upstream rate limit without aborting the pass. Started and stopped from the app lifespan; gated by THERMOGRAPH_ENABLE_NOTIFIER. * Add in-app notification center (header bell) Extend account.js with a notification bell beside the account menu: an unread badge, a dropdown listing recent notifications (title, body, relative time), a per-item mark-read on click, and a Mark all read action, all through the cookie-authed notifications API. Unread state refreshes on open and polls every two minutes while signed in; polling stops on sign-out. Styled to match the app, responsive down to mobile. * Harden accounts: expired-session cleanup, engine tests, ops docs - notify.py sweeps expired login sessions (access tokens past their lifetime) once per evaluation pass. - Add hermetic unit tests for the evaluation engine's trigger detection (high/low tails, precip one-directional, normal = no trigger) and notification wording. - Document accounts.sqlite (authoritative, back it up), the single-worker requirement for the in-process evaluator, and the new env vars in DEPLOY.md.
2026-07-15 18:46:46 +00:00
def _first_trigger(sub: Subscription, rows, history, grade_cache):
"""The first (event_date, metric, direction, graded) that crosses, or None."""
for row in rows:
Migrate backend dataframe layer from pandas to polars (#90) * 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.
2026-07-15 19:07:38 +00:00
date_key = row["date"].isoformat()
Account system with weather-notification subscriptions (#89) * Add account system foundation: email/password auth with cookie sessions Introduce the app's first authoritative, user-owned data in a separate data/accounts.sqlite (SQLAlchemy), kept apart from the disposable derived-cache DB. Wire fastapi-users for email/password signup, cookie-based login/logout, and a session-check endpoint, backed by a database session strategy so logins survive restarts and are revocable. - db.py: async (aiosqlite) + sync SQLAlchemy engines over accounts.sqlite, WAL + foreign keys, create_db_and_tables(). - models.py: User, AccessToken, Subscription, Notification tables. - users.py: pwdlib hashing, HttpOnly cookie transport (path-scoped, SameSite=Lax, Secure via env), DatabaseStrategy sessions, current-user dependencies. - schemas.py: user + subscription + notification Pydantic models. - app.py: mount auth/register/users routers on v2, create tables at startup. - Pin fastapi-users[sqlalchemy]/aiosqlite; ignore data/accounts.sqlite*. * Add account header entry and auth modal (frontend) account.js self-injects a header entry (following the units.js pattern) that shows a Sign in button when logged out and an account menu when logged in, plus an auth modal reusing the existing .mp-overlay/.mp-modal chrome for email/password sign-in and account creation. A shared apiFetch helper sends the same-origin cookie for authed calls; exported getUser/openAuth/onAuthChange back later phases. Imported by every page entry module. On narrow screens the entry collapses to an icon-only button so it doesn't crowd the title. Enforce an 8-character minimum password in the user manager. * Add subscription CRUD API and the alerts management page Backend api_accounts.py adds user-scoped, cookie-authenticated endpoints to create/list/update/delete subscriptions (and the notification reads used next): POST snaps lat/lon to a grid cell, resolves a label, and rejects a duplicate location+kind with 409; PATCH/DELETE are ownership-checked (404 on mismatch). Mounted on the v2 prefix. Frontend subscriptions.js + subscriptions.html serve the /alerts page: a sign-in gate when logged out, an add flow that reuses the shared map picker and an editor modal (kind, watched metrics, 95-99 percentile, two-sided), and a card list with inline threshold/active edits and remove. Reachable from the account menu. * Add background subscription evaluation engine notify.py runs a daemon thread that periodically evaluates every active subscription: it groups them by grid cell, reads history from the parquet cache only (never spends archive quota) plus the hourly recent/forecast bundle, and grades candidate days with the existing grading.grade_day. A watched metric that lands at or beyond the threshold percentile fires a 'high' alert; a two-sided subscription also fires 'low' for the symmetric cold/calm/dry tail (precip stays one-directional). Observed subscriptions look at the last few recorded days, forecast subscriptions at the coming week. Two guards keep it quiet: a UNIQUE(subscription, event_date, metric, direction, kind) constraint dedups repeat events, and a per-subscription weekly cap (last_notified_at) limits each alert to one notification per 7 days. The loop tolerates a bad cell or an upstream rate limit without aborting the pass. Started and stopped from the app lifespan; gated by THERMOGRAPH_ENABLE_NOTIFIER. * Add in-app notification center (header bell) Extend account.js with a notification bell beside the account menu: an unread badge, a dropdown listing recent notifications (title, body, relative time), a per-item mark-read on click, and a Mark all read action, all through the cookie-authed notifications API. Unread state refreshes on open and polls every two minutes while signed in; polling stops on sign-out. Styled to match the app, responsive down to mobile. * Harden accounts: expired-session cleanup, engine tests, ops docs - notify.py sweeps expired login sessions (access tokens past their lifetime) once per evaluation pass. - Add hermetic unit tests for the evaluation engine's trigger detection (high/low tails, precip one-directional, normal = no trigger) and notification wording. - Document accounts.sqlite (authoritative, back it up), the single-worker requirement for the in-process evaluator, and the new env vars in DEPLOY.md.
2026-07-15 18:46:46 +00:00
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, archive_budget):
"""Evaluate every subscription on one cell, inserting any new notifications.
`archive_budget` is a one-element list holding the remaining number of missing
archives this pass may fetch; it's decremented when this cell fetches one.
"""
Account system with weather-notification subscriptions (#89) * Add account system foundation: email/password auth with cookie sessions Introduce the app's first authoritative, user-owned data in a separate data/accounts.sqlite (SQLAlchemy), kept apart from the disposable derived-cache DB. Wire fastapi-users for email/password signup, cookie-based login/logout, and a session-check endpoint, backed by a database session strategy so logins survive restarts and are revocable. - db.py: async (aiosqlite) + sync SQLAlchemy engines over accounts.sqlite, WAL + foreign keys, create_db_and_tables(). - models.py: User, AccessToken, Subscription, Notification tables. - users.py: pwdlib hashing, HttpOnly cookie transport (path-scoped, SameSite=Lax, Secure via env), DatabaseStrategy sessions, current-user dependencies. - schemas.py: user + subscription + notification Pydantic models. - app.py: mount auth/register/users routers on v2, create tables at startup. - Pin fastapi-users[sqlalchemy]/aiosqlite; ignore data/accounts.sqlite*. * Add account header entry and auth modal (frontend) account.js self-injects a header entry (following the units.js pattern) that shows a Sign in button when logged out and an account menu when logged in, plus an auth modal reusing the existing .mp-overlay/.mp-modal chrome for email/password sign-in and account creation. A shared apiFetch helper sends the same-origin cookie for authed calls; exported getUser/openAuth/onAuthChange back later phases. Imported by every page entry module. On narrow screens the entry collapses to an icon-only button so it doesn't crowd the title. Enforce an 8-character minimum password in the user manager. * Add subscription CRUD API and the alerts management page Backend api_accounts.py adds user-scoped, cookie-authenticated endpoints to create/list/update/delete subscriptions (and the notification reads used next): POST snaps lat/lon to a grid cell, resolves a label, and rejects a duplicate location+kind with 409; PATCH/DELETE are ownership-checked (404 on mismatch). Mounted on the v2 prefix. Frontend subscriptions.js + subscriptions.html serve the /alerts page: a sign-in gate when logged out, an add flow that reuses the shared map picker and an editor modal (kind, watched metrics, 95-99 percentile, two-sided), and a card list with inline threshold/active edits and remove. Reachable from the account menu. * Add background subscription evaluation engine notify.py runs a daemon thread that periodically evaluates every active subscription: it groups them by grid cell, reads history from the parquet cache only (never spends archive quota) plus the hourly recent/forecast bundle, and grades candidate days with the existing grading.grade_day. A watched metric that lands at or beyond the threshold percentile fires a 'high' alert; a two-sided subscription also fires 'low' for the symmetric cold/calm/dry tail (precip stays one-directional). Observed subscriptions look at the last few recorded days, forecast subscriptions at the coming week. Two guards keep it quiet: a UNIQUE(subscription, event_date, metric, direction, kind) constraint dedups repeat events, and a per-subscription weekly cap (last_notified_at) limits each alert to one notification per 7 days. The loop tolerates a bad cell or an upstream rate limit without aborting the pass. Started and stopped from the app lifespan; gated by THERMOGRAPH_ENABLE_NOTIFIER. * Add in-app notification center (header bell) Extend account.js with a notification bell beside the account menu: an unread badge, a dropdown listing recent notifications (title, body, relative time), a per-item mark-read on click, and a Mark all read action, all through the cookie-authed notifications API. Unread state refreshes on open and polls every two minutes while signed in; polling stops on sign-out. Styled to match the app, responsive down to mobile. * Harden accounts: expired-session cleanup, engine tests, ops docs - notify.py sweeps expired login sessions (access tokens past their lifetime) once per evaluation pass. - Add hermetic unit tests for the evaluation engine's trigger detection (high/low tails, precip one-directional, normal = no trigger) and notification wording. - Document accounts.sqlite (authoritative, back it up), the single-worker requirement for the in-process evaluator, and the new env vars in DEPLOY.md.
2026-07-15 18:46:46 +00:00
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)
Migrate backend dataframe layer from pandas to polars (#90) * 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.
2026-07-15 19:07:38 +00:00
if history is None or history.is_empty():
# No cached archive for this subscribed cell. Grab it ONCE (get_history
# fetches + caches the full archive); every later pass reads it from cache
# above and never re-fetches. Budget-gated so a burst of new subscriptions
# can't exhaust the archive quota in a single pass.
if archive_budget[0] <= 0:
return 0
archive_budget[0] -= 1
try:
history, _ = climate.get_history(cell)
except climate.WeatherUnavailable:
return 0 # archive rate-limited — retry on a later pass
except Exception: # noqa: BLE001
return 0
if history is None or history.is_empty():
return 0
Account system with weather-notification subscriptions (#89) * Add account system foundation: email/password auth with cookie sessions Introduce the app's first authoritative, user-owned data in a separate data/accounts.sqlite (SQLAlchemy), kept apart from the disposable derived-cache DB. Wire fastapi-users for email/password signup, cookie-based login/logout, and a session-check endpoint, backed by a database session strategy so logins survive restarts and are revocable. - db.py: async (aiosqlite) + sync SQLAlchemy engines over accounts.sqlite, WAL + foreign keys, create_db_and_tables(). - models.py: User, AccessToken, Subscription, Notification tables. - users.py: pwdlib hashing, HttpOnly cookie transport (path-scoped, SameSite=Lax, Secure via env), DatabaseStrategy sessions, current-user dependencies. - schemas.py: user + subscription + notification Pydantic models. - app.py: mount auth/register/users routers on v2, create tables at startup. - Pin fastapi-users[sqlalchemy]/aiosqlite; ignore data/accounts.sqlite*. * Add account header entry and auth modal (frontend) account.js self-injects a header entry (following the units.js pattern) that shows a Sign in button when logged out and an account menu when logged in, plus an auth modal reusing the existing .mp-overlay/.mp-modal chrome for email/password sign-in and account creation. A shared apiFetch helper sends the same-origin cookie for authed calls; exported getUser/openAuth/onAuthChange back later phases. Imported by every page entry module. On narrow screens the entry collapses to an icon-only button so it doesn't crowd the title. Enforce an 8-character minimum password in the user manager. * Add subscription CRUD API and the alerts management page Backend api_accounts.py adds user-scoped, cookie-authenticated endpoints to create/list/update/delete subscriptions (and the notification reads used next): POST snaps lat/lon to a grid cell, resolves a label, and rejects a duplicate location+kind with 409; PATCH/DELETE are ownership-checked (404 on mismatch). Mounted on the v2 prefix. Frontend subscriptions.js + subscriptions.html serve the /alerts page: a sign-in gate when logged out, an add flow that reuses the shared map picker and an editor modal (kind, watched metrics, 95-99 percentile, two-sided), and a card list with inline threshold/active edits and remove. Reachable from the account menu. * Add background subscription evaluation engine notify.py runs a daemon thread that periodically evaluates every active subscription: it groups them by grid cell, reads history from the parquet cache only (never spends archive quota) plus the hourly recent/forecast bundle, and grades candidate days with the existing grading.grade_day. A watched metric that lands at or beyond the threshold percentile fires a 'high' alert; a two-sided subscription also fires 'low' for the symmetric cold/calm/dry tail (precip stays one-directional). Observed subscriptions look at the last few recorded days, forecast subscriptions at the coming week. Two guards keep it quiet: a UNIQUE(subscription, event_date, metric, direction, kind) constraint dedups repeat events, and a per-subscription weekly cap (last_notified_at) limits each alert to one notification per 7 days. The loop tolerates a bad cell or an upstream rate limit without aborting the pass. Started and stopped from the app lifespan; gated by THERMOGRAPH_ENABLE_NOTIFIER. * Add in-app notification center (header bell) Extend account.js with a notification bell beside the account menu: an unread badge, a dropdown listing recent notifications (title, body, relative time), a per-item mark-read on click, and a Mark all read action, all through the cookie-authed notifications API. Unread state refreshes on open and polls every two minutes while signed in; polling stops on sign-out. Styled to match the app, responsive down to mobile. * Harden accounts: expired-session cleanup, engine tests, ops docs - notify.py sweeps expired login sessions (access tokens past their lifetime) once per evaluation pass. - Add hermetic unit tests for the evaluation engine's trigger detection (high/low tails, precip one-directional, normal = no trigger) and notification wording. - Document accounts.sqlite (authoritative, back it up), the single-worker requirement for the in-process evaluator, and the new env vars in DEPLOY.md.
2026-07-15 18:46:46 +00:00
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()
Migrate backend dataframe layer from pandas to polars (#90) * 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.
2026-07-15 19:07:38 +00:00
today = datetime.date.today()
Account system with weather-notification subscriptions (#89) * Add account system foundation: email/password auth with cookie sessions Introduce the app's first authoritative, user-owned data in a separate data/accounts.sqlite (SQLAlchemy), kept apart from the disposable derived-cache DB. Wire fastapi-users for email/password signup, cookie-based login/logout, and a session-check endpoint, backed by a database session strategy so logins survive restarts and are revocable. - db.py: async (aiosqlite) + sync SQLAlchemy engines over accounts.sqlite, WAL + foreign keys, create_db_and_tables(). - models.py: User, AccessToken, Subscription, Notification tables. - users.py: pwdlib hashing, HttpOnly cookie transport (path-scoped, SameSite=Lax, Secure via env), DatabaseStrategy sessions, current-user dependencies. - schemas.py: user + subscription + notification Pydantic models. - app.py: mount auth/register/users routers on v2, create tables at startup. - Pin fastapi-users[sqlalchemy]/aiosqlite; ignore data/accounts.sqlite*. * Add account header entry and auth modal (frontend) account.js self-injects a header entry (following the units.js pattern) that shows a Sign in button when logged out and an account menu when logged in, plus an auth modal reusing the existing .mp-overlay/.mp-modal chrome for email/password sign-in and account creation. A shared apiFetch helper sends the same-origin cookie for authed calls; exported getUser/openAuth/onAuthChange back later phases. Imported by every page entry module. On narrow screens the entry collapses to an icon-only button so it doesn't crowd the title. Enforce an 8-character minimum password in the user manager. * Add subscription CRUD API and the alerts management page Backend api_accounts.py adds user-scoped, cookie-authenticated endpoints to create/list/update/delete subscriptions (and the notification reads used next): POST snaps lat/lon to a grid cell, resolves a label, and rejects a duplicate location+kind with 409; PATCH/DELETE are ownership-checked (404 on mismatch). Mounted on the v2 prefix. Frontend subscriptions.js + subscriptions.html serve the /alerts page: a sign-in gate when logged out, an add flow that reuses the shared map picker and an editor modal (kind, watched metrics, 95-99 percentile, two-sided), and a card list with inline threshold/active edits and remove. Reachable from the account menu. * Add background subscription evaluation engine notify.py runs a daemon thread that periodically evaluates every active subscription: it groups them by grid cell, reads history from the parquet cache only (never spends archive quota) plus the hourly recent/forecast bundle, and grades candidate days with the existing grading.grade_day. A watched metric that lands at or beyond the threshold percentile fires a 'high' alert; a two-sided subscription also fires 'low' for the symmetric cold/calm/dry tail (precip stays one-directional). Observed subscriptions look at the last few recorded days, forecast subscriptions at the coming week. Two guards keep it quiet: a UNIQUE(subscription, event_date, metric, direction, kind) constraint dedups repeat events, and a per-subscription weekly cap (last_notified_at) limits each alert to one notification per 7 days. The loop tolerates a bad cell or an upstream rate limit without aborting the pass. Started and stopped from the app lifespan; gated by THERMOGRAPH_ENABLE_NOTIFIER. * Add in-app notification center (header bell) Extend account.js with a notification bell beside the account menu: an unread badge, a dropdown listing recent notifications (title, body, relative time), a per-item mark-read on click, and a Mark all read action, all through the cookie-authed notifications API. Unread state refreshes on open and polls every two minutes while signed in; polling stops on sign-out. Styled to match the app, responsive down to mobile. * Harden accounts: expired-session cleanup, engine tests, ops docs - notify.py sweeps expired login sessions (access tokens past their lifetime) once per evaluation pass. - Add hermetic unit tests for the evaluation engine's trigger detection (high/low tails, precip one-directional, normal = no trigger) and notification wording. - Document accounts.sqlite (authoritative, back it up), the single-worker requirement for the in-process evaluator, and the new env vars in DEPLOY.md.
2026-07-15 18:46:46 +00:00
created = 0
archive_budget = [MAX_ARCHIVE_FETCHES_PER_PASS] # missing archives to fetch this pass
Account system with weather-notification subscriptions (#89) * Add account system foundation: email/password auth with cookie sessions Introduce the app's first authoritative, user-owned data in a separate data/accounts.sqlite (SQLAlchemy), kept apart from the disposable derived-cache DB. Wire fastapi-users for email/password signup, cookie-based login/logout, and a session-check endpoint, backed by a database session strategy so logins survive restarts and are revocable. - db.py: async (aiosqlite) + sync SQLAlchemy engines over accounts.sqlite, WAL + foreign keys, create_db_and_tables(). - models.py: User, AccessToken, Subscription, Notification tables. - users.py: pwdlib hashing, HttpOnly cookie transport (path-scoped, SameSite=Lax, Secure via env), DatabaseStrategy sessions, current-user dependencies. - schemas.py: user + subscription + notification Pydantic models. - app.py: mount auth/register/users routers on v2, create tables at startup. - Pin fastapi-users[sqlalchemy]/aiosqlite; ignore data/accounts.sqlite*. * Add account header entry and auth modal (frontend) account.js self-injects a header entry (following the units.js pattern) that shows a Sign in button when logged out and an account menu when logged in, plus an auth modal reusing the existing .mp-overlay/.mp-modal chrome for email/password sign-in and account creation. A shared apiFetch helper sends the same-origin cookie for authed calls; exported getUser/openAuth/onAuthChange back later phases. Imported by every page entry module. On narrow screens the entry collapses to an icon-only button so it doesn't crowd the title. Enforce an 8-character minimum password in the user manager. * Add subscription CRUD API and the alerts management page Backend api_accounts.py adds user-scoped, cookie-authenticated endpoints to create/list/update/delete subscriptions (and the notification reads used next): POST snaps lat/lon to a grid cell, resolves a label, and rejects a duplicate location+kind with 409; PATCH/DELETE are ownership-checked (404 on mismatch). Mounted on the v2 prefix. Frontend subscriptions.js + subscriptions.html serve the /alerts page: a sign-in gate when logged out, an add flow that reuses the shared map picker and an editor modal (kind, watched metrics, 95-99 percentile, two-sided), and a card list with inline threshold/active edits and remove. Reachable from the account menu. * Add background subscription evaluation engine notify.py runs a daemon thread that periodically evaluates every active subscription: it groups them by grid cell, reads history from the parquet cache only (never spends archive quota) plus the hourly recent/forecast bundle, and grades candidate days with the existing grading.grade_day. A watched metric that lands at or beyond the threshold percentile fires a 'high' alert; a two-sided subscription also fires 'low' for the symmetric cold/calm/dry tail (precip stays one-directional). Observed subscriptions look at the last few recorded days, forecast subscriptions at the coming week. Two guards keep it quiet: a UNIQUE(subscription, event_date, metric, direction, kind) constraint dedups repeat events, and a per-subscription weekly cap (last_notified_at) limits each alert to one notification per 7 days. The loop tolerates a bad cell or an upstream rate limit without aborting the pass. Started and stopped from the app lifespan; gated by THERMOGRAPH_ENABLE_NOTIFIER. * Add in-app notification center (header bell) Extend account.js with a notification bell beside the account menu: an unread badge, a dropdown listing recent notifications (title, body, relative time), a per-item mark-read on click, and a Mark all read action, all through the cookie-authed notifications API. Unread state refreshes on open and polls every two minutes while signed in; polling stops on sign-out. Styled to match the app, responsive down to mobile. * Harden accounts: expired-session cleanup, engine tests, ops docs - notify.py sweeps expired login sessions (access tokens past their lifetime) once per evaluation pass. - Add hermetic unit tests for the evaluation engine's trigger detection (high/low tails, precip one-directional, normal = no trigger) and notification wording. - Document accounts.sqlite (authoritative, back it up), the single-worker requirement for the in-process evaluator, and the new env vars in DEPLOY.md.
2026-07-15 18:46:46 +00:00
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, archive_budget)
Account system with weather-notification subscriptions (#89) * Add account system foundation: email/password auth with cookie sessions Introduce the app's first authoritative, user-owned data in a separate data/accounts.sqlite (SQLAlchemy), kept apart from the disposable derived-cache DB. Wire fastapi-users for email/password signup, cookie-based login/logout, and a session-check endpoint, backed by a database session strategy so logins survive restarts and are revocable. - db.py: async (aiosqlite) + sync SQLAlchemy engines over accounts.sqlite, WAL + foreign keys, create_db_and_tables(). - models.py: User, AccessToken, Subscription, Notification tables. - users.py: pwdlib hashing, HttpOnly cookie transport (path-scoped, SameSite=Lax, Secure via env), DatabaseStrategy sessions, current-user dependencies. - schemas.py: user + subscription + notification Pydantic models. - app.py: mount auth/register/users routers on v2, create tables at startup. - Pin fastapi-users[sqlalchemy]/aiosqlite; ignore data/accounts.sqlite*. * Add account header entry and auth modal (frontend) account.js self-injects a header entry (following the units.js pattern) that shows a Sign in button when logged out and an account menu when logged in, plus an auth modal reusing the existing .mp-overlay/.mp-modal chrome for email/password sign-in and account creation. A shared apiFetch helper sends the same-origin cookie for authed calls; exported getUser/openAuth/onAuthChange back later phases. Imported by every page entry module. On narrow screens the entry collapses to an icon-only button so it doesn't crowd the title. Enforce an 8-character minimum password in the user manager. * Add subscription CRUD API and the alerts management page Backend api_accounts.py adds user-scoped, cookie-authenticated endpoints to create/list/update/delete subscriptions (and the notification reads used next): POST snaps lat/lon to a grid cell, resolves a label, and rejects a duplicate location+kind with 409; PATCH/DELETE are ownership-checked (404 on mismatch). Mounted on the v2 prefix. Frontend subscriptions.js + subscriptions.html serve the /alerts page: a sign-in gate when logged out, an add flow that reuses the shared map picker and an editor modal (kind, watched metrics, 95-99 percentile, two-sided), and a card list with inline threshold/active edits and remove. Reachable from the account menu. * Add background subscription evaluation engine notify.py runs a daemon thread that periodically evaluates every active subscription: it groups them by grid cell, reads history from the parquet cache only (never spends archive quota) plus the hourly recent/forecast bundle, and grades candidate days with the existing grading.grade_day. A watched metric that lands at or beyond the threshold percentile fires a 'high' alert; a two-sided subscription also fires 'low' for the symmetric cold/calm/dry tail (precip stays one-directional). Observed subscriptions look at the last few recorded days, forecast subscriptions at the coming week. Two guards keep it quiet: a UNIQUE(subscription, event_date, metric, direction, kind) constraint dedups repeat events, and a per-subscription weekly cap (last_notified_at) limits each alert to one notification per 7 days. The loop tolerates a bad cell or an upstream rate limit without aborting the pass. Started and stopped from the app lifespan; gated by THERMOGRAPH_ENABLE_NOTIFIER. * Add in-app notification center (header bell) Extend account.js with a notification bell beside the account menu: an unread badge, a dropdown listing recent notifications (title, body, relative time), a per-item mark-read on click, and a Mark all read action, all through the cookie-authed notifications API. Unread state refreshes on open and polls every two minutes while signed in; polling stops on sign-out. Styled to match the app, responsive down to mobile. * Harden accounts: expired-session cleanup, engine tests, ops docs - notify.py sweeps expired login sessions (access tokens past their lifetime) once per evaluation pass. - Add hermetic unit tests for the evaluation engine's trigger detection (high/low tails, precip one-directional, normal = no trigger) and notification wording. - Document accounts.sqlite (authoritative, back it up), the single-worker requirement for the in-process evaluator, and the new env vars in DEPLOY.md.
2026-07-15 18:46:46 +00:00
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()