2026-07-11 00:29:47 +00:00
|
|
|
|
"""Fetch historical + recent daily weather from Open-Meteo and cache to parquet.
|
|
|
|
|
|
|
|
|
|
|
|
The full multi-decade daily record for a grid cell is fetched once and stored as
|
|
|
|
|
|
a zstd-compressed parquet file keyed by cell id. Percentiles are computed on the
|
|
|
|
|
|
fly from this raw record (see grading.py), which keeps the cache small and lets
|
|
|
|
|
|
us handle ties (e.g. many zero-precip days) correctly.
|
|
|
|
|
|
"""
|
2026-07-23 04:41:26 +00:00
|
|
|
|
import concurrent.futures
|
2026-07-11 00:29:47 +00:00
|
|
|
|
import datetime
|
|
|
|
|
|
import os
|
2026-07-23 04:41:26 +00:00
|
|
|
|
import queue
|
|
|
|
|
|
import tempfile
|
2026-07-11 00:29:47 +00:00
|
|
|
|
import threading
|
|
|
|
|
|
import time
|
|
|
|
|
|
|
|
|
|
|
|
import httpx
|
|
|
|
|
|
import numpy as np
|
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
|
2026-07-11 00:29:47 +00:00
|
|
|
|
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
from core import audit
|
|
|
|
|
|
from core import metrics
|
|
|
|
|
|
import paths
|
Move the climate record from parquet to TimescaleDB hypertables (#227)
Replace the per-cell parquet cache with TimescaleDB hypertables as the
production backend for the raw daily climate record, and drop pg_duckdb.
Parquet stays the backend whenever THERMOGRAPH_DATABASE_URL is not a
Postgres URL (dev, tests, offline tooling), the same dialect switch the
accounts DB and derived store already use, so CI stays Postgres-free.
- data/climate_store.py: psycopg + polars bridge over climate_history
(a hypertable), climate_recent, and climate_sync (per-cell freshness).
Reads via pl.read_database, writes via COPY + ON CONFLICT upsert;
fail-soft to a cache miss so a DB hiccup degrades to upstream refetch.
- data/climate.py: route every cache/mtime touchpoint through a backend
dispatch. recent_stamp becomes int(recent_synced_at) on Postgres; the
stale-serve path still avoids bumping it, so derived-payload tokens
invalidate on exactly the same events as before.
- alembic 0002: CREATE EXTENSION timescaledb plus the hypertable schema
(compression policy on year-old chunks), guarded to no-op off Postgres.
- migrate_cache_to_pg.py (make migrate-cache): idempotent backfill of the
parquet cache into the hypertables, preserving file mtimes as sync
timestamps so recent_stamp is unchanged across cutover.
- db image -> stock timescale/timescaledb:latest-pg18; drop the custom
pg_duckdb Dockerfile, the read-only /parquet mount, and the duckdb
tuning GUC. Docs updated for the new backend and cutover.
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-20 20:15:55 +00:00
|
|
|
|
from data import climate_store
|
2026-07-23 21:20:35 +00:00
|
|
|
|
from data import era5lake
|
2026-07-23 14:34:51 +00:00
|
|
|
|
from data import meteostat
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
from data import store
|
2026-07-11 00:29:47 +00:00
|
|
|
|
|
2026-07-23 04:41:26 +00:00
|
|
|
|
# Reused across every fetch (archive/forecast/geocode/revgeo) instead of a bare
|
|
|
|
|
|
# httpx.get per call opening a fresh TCP+TLS connection every time -- mirrors
|
|
|
|
|
|
# web/app.py's reused _frontend_client. Every call still passes its own explicit
|
|
|
|
|
|
# per-call timeout (see _request), so this only changes connection reuse, never
|
|
|
|
|
|
# retry/backoff semantics. Never explicitly closed: this is a long-lived module
|
|
|
|
|
|
# used by both the server process and one-shot scripts (warm_cities.py,
|
|
|
|
|
|
# migrate_cache_to_pg.py), and the OS reclaims the sockets at process exit either
|
|
|
|
|
|
# way.
|
|
|
|
|
|
_client = httpx.Client()
|
|
|
|
|
|
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
|
CACHE_DIR = os.path.join(paths.DATA_DIR, "cache")
|
2026-07-11 00:29:47 +00:00
|
|
|
|
MAX_ATTEMPTS = 3 # per upstream call, before giving up
|
2026-07-23 04:41:26 +00:00
|
|
|
|
|
|
|
|
|
|
# _fetch_history / _fetch_history_nasa run under _load_history's per-cell lock
|
|
|
|
|
|
# (see _cell_lock), so every attempt's timeout adds directly to how long a
|
|
|
|
|
|
# same-cell waiter queues behind this thread. A flat 180s x MAX_ATTEMPTS could
|
|
|
|
|
|
# pin the thread for up to 9 minutes on one slow-but-eventually-ok upstream. The
|
|
|
|
|
|
# full archive response is genuinely large (~16k daily rows from START_DATE, see
|
|
|
|
|
|
# below), so it can legitimately need more than a topup fetch's 60s -- but that
|
|
|
|
|
|
# headroom belongs on the LAST attempt only: the first attempts fail fast so a
|
|
|
|
|
|
# truly wedged upstream frees the lock for a waiter sooner, and only the final,
|
|
|
|
|
|
# worth-waiting-out attempt gets the longer allowance. Worst case under the lock
|
|
|
|
|
|
# drops from 540s (9 min) to 60+60+150=270s (4.5 min).
|
|
|
|
|
|
ARCHIVE_FETCH_TIMEOUTS = (60, 60, 150)
|
2026-07-11 00:29:47 +00:00
|
|
|
|
START_DATE = "1980-01-01" # ERA5 reaches back to 1940; 1980 = 45 yrs, fast + robust
|
|
|
|
|
|
ARCHIVE_LATENCY_DAYS = 6 # ERA5 archive lags real time by a few days
|
2026-07-20 14:02:36 +00:00
|
|
|
|
# A real archive spans decades (~16k daily rows from START_DATE). A self-hosted
|
|
|
|
|
|
# Open-Meteo instance that isn't backfilled yet answers with all-null values or only
|
|
|
|
|
|
# its recent sync window, which _finalize_frame reduces to a near-empty frame. Below
|
|
|
|
|
|
# this many days we treat the archive as "not ready" — fall through to the NASA backup
|
|
|
|
|
|
# and do NOT cache the short result as a complete, indefinitely-held record.
|
|
|
|
|
|
MIN_ARCHIVE_DAYS = 3650 # ~10 yrs: far above any sync window, far below a full archive
|
2026-07-11 00:29:47 +00:00
|
|
|
|
# History rarely changes, so the full multi-decade archive is fetched once and
|
|
|
|
|
|
# cached indefinitely (refetched only to add new metric columns). Only the recent
|
|
|
|
|
|
# tail is refreshed — a small incremental fetch, at most hourly.
|
|
|
|
|
|
HISTORY_TOPUP_INTERVAL = 3600 # seconds between recent-tail refresh attempts
|
2026-07-23 14:34:51 +00:00
|
|
|
|
FORECAST_TTL_HOURS = 4 # refetch the recent+forecast bundle every ~4h (lower IO;
|
|
|
|
|
|
# daily-resolution forecast normals move slowly)
|
2026-07-11 00:29:47 +00:00
|
|
|
|
|
2026-07-20 13:16:56 +00:00
|
|
|
|
# The archive (historical) endpoint. Overridable so it can point at a self-hosted
|
|
|
|
|
|
# Open-Meteo instance (ERA5 in object storage) instead of the rate-limited public
|
|
|
|
|
|
# API; the response shape is identical either way.
|
|
|
|
|
|
ARCHIVE_URL = os.environ.get("THERMOGRAPH_ARCHIVE_URL",
|
|
|
|
|
|
"https://archive-api.open-meteo.com/v1/archive")
|
|
|
|
|
|
# The historical model: the ERA5 "seamless" blend — 0.1° ERA5-Land for
|
|
|
|
|
|
# temperature/precip/humidity/wind, 0.25° ERA5 for wind gusts (which ERA5-Land
|
|
|
|
|
|
# lacks). This is the public API's default; sent explicitly so a self-hosted
|
|
|
|
|
|
# instance serves the same 0.1° resolution. Archive requests only, not forecast.
|
|
|
|
|
|
ARCHIVE_MODEL = "era5_seamless"
|
2026-07-11 00:29:47 +00:00
|
|
|
|
FORECAST_URL = "https://api.open-meteo.com/v1/forecast"
|
2026-07-24 23:56:34 +00:00
|
|
|
|
# NASA POWER is RETIRED from every serving path (2026-07-24): its MERRA-2
|
|
|
|
|
|
# record ran ~1.3°F off the ERA5 lake/archive with location-dependent sign and
|
|
|
|
|
|
# carried no gusts, so serving it beside ERA5 sources skewed percentiles. Kept
|
|
|
|
|
|
# only as drift_check.py's comparison source.
|
2026-07-11 00:29:47 +00:00
|
|
|
|
NASA_POWER_URL = "https://power.larc.nasa.gov/api/temporal/daily/point"
|
|
|
|
|
|
NASA_START = "19810101"
|
|
|
|
|
|
NASA_FILL = -900.0 # POWER's missing-value sentinel is ~-999
|
|
|
|
|
|
|
2026-07-23 14:34:51 +00:00
|
|
|
|
# Forward-forecast source (primary for the forward days). MET Norway (yr.no) is free
|
|
|
|
|
|
# + keyless + global. It returns a sub-daily timeseries in metric units with no gusts
|
|
|
|
|
|
# or apparent temp, so we aggregate to daily, convert units, derive feels-like like
|
|
|
|
|
|
# the NASA path, and fill gusts from Meteostat. It is forecast-only (no recent past),
|
|
|
|
|
|
# so the recent observed days come from a NASA POWER range instead. The /compact
|
|
|
|
|
|
# endpoint carries every field we use at a smaller payload than /complete.
|
|
|
|
|
|
METNO_URL = "https://api.met.no/weatherapi/locationforecast/2.0/compact"
|
Add MET Norway forecast fallback when Open-Meteo is unavailable (#115)
The forecast path had no backup — unlike history, which falls back to NASA POWER.
When Open-Meteo's forecast API was rate-limited or down, the recent/forecast
bundle (and every endpoint that grades future days) failed with a 503.
Add MET Norway (yr.no) Locationforecast as a keyless, global forecast backup,
mirroring the NASA POWER role for history:
- _metno_to_frame: aggregates MET Norway's sub-daily timeseries into the daily
schema, converting units (°C→°F, mm→in, m/s→mph) and deriving feels-like from
the NWS heat index / wind chill (MET has no gusts or apparent temperature).
Precip prefers the 1-hour block and falls back to the 6-hour block so the
hourly→6-hourly resolution switch never double-counts.
- _fetch_forecast_metno: the backup fetch, with the ToS-required identifying
User-Agent and coordinates rounded to 4 decimals.
- _load_recent_forecast: on any Open-Meteo forecast failure, try MET Norway
before surfacing the error; a shared rate limit still raises the typed,
daily-aware WeatherUnavailable.
MET Norway is forecast-only (no recent past days), so it's a degraded-but-working
fallback: the forecast / day-ahead views keep serving during an Open-Meteo outage.
Tests cover the daily aggregation + unit conversion (incl. the no-double-count
precip rule) and the fallback wiring.
2026-07-16 05:30:56 +00:00
|
|
|
|
# MET Norway's ToS requires an identifying User-Agent (a missing/generic one is
|
|
|
|
|
|
# 403'd); include the app and a contact URL so they can reach us if usage misbehaves.
|
|
|
|
|
|
METNO_UA = "Thermograph/0.2 (+https://thermograph.org)"
|
|
|
|
|
|
|
2026-07-11 00:29:47 +00:00
|
|
|
|
DAILY_VARS = (
|
|
|
|
|
|
"temperature_2m_max,temperature_2m_min,precipitation_sum,"
|
|
|
|
|
|
"wind_speed_10m_max,wind_gusts_10m_max,"
|
|
|
|
|
|
"apparent_temperature_max,apparent_temperature_min,"
|
2026-07-22 19:08:24 +00:00
|
|
|
|
"relative_humidity_2m_mean,"
|
|
|
|
|
|
"sunshine_duration,daylight_duration"
|
2026-07-11 00:29:47 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-22 19:07:54 +00:00
|
|
|
|
# Optional metric columns beyond the required date/tmax/tmin/precip schema. A
|
|
|
|
|
|
# source that lacks one gets an all-null column in _finalize_frame, so every
|
|
|
|
|
|
# finalized frame has the same schema without each builder stubbing missing
|
2026-07-22 19:08:24 +00:00
|
|
|
|
# columns by hand. `sun` (the sunshine fraction) only comes from Open-Meteo, so
|
|
|
|
|
|
# the NASA/MET fallbacks pick up its null column here too.
|
|
|
|
|
|
OPTIONAL_COLS = ("wind", "gust", "humid", "fmax", "fmin", "sun")
|
2026-07-22 19:07:54 +00:00
|
|
|
|
|
2026-07-11 00:29:47 +00:00
|
|
|
|
# Columns added after the original tmax/tmin/precip schema. A cached parquet
|
|
|
|
|
|
# missing any of these predates the wind/feels/humidity features (or the split
|
2026-07-22 19:08:24 +00:00
|
|
|
|
# apparent high/low, or the sunshine fraction) and is refetched so the new
|
|
|
|
|
|
# metrics show up immediately instead of after the 30-day cache TTL. `feels` is
|
|
|
|
|
|
# derived in _finalize_frame; the rest are the optional raw inputs, so this stays
|
|
|
|
|
|
# in lockstep with them.
|
2026-07-22 19:07:54 +00:00
|
|
|
|
NEW_COLS = (*OPTIONAL_COLS, "feels")
|
2026-07-11 00:29:47 +00:00
|
|
|
|
|
|
|
|
|
|
# Thermoneutral baseline (°F). The combined "feels like" metric reports whichever
|
|
|
|
|
|
# apparent-temperature extreme — the heat-index-driven daily max or the
|
|
|
|
|
|
# wind-chill-driven daily min — sits further from this comfort point, so a single
|
|
|
|
|
|
# daily value captures both heat index (hot side) and wind chill (cold side).
|
|
|
|
|
|
COMFORT_F = 65.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _cache_path(cell_id: str) -> str:
|
|
|
|
|
|
return os.path.join(CACHE_DIR, f"{cell_id}.parquet")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# One lock per cell so concurrent requests/refreshes don't each pull the full
|
|
|
|
|
|
# multi-decade archive (which quickly trips the upstream rate limit).
|
|
|
|
|
|
_LOCKS_GUARD = threading.Lock()
|
|
|
|
|
|
_CELL_LOCKS: dict[str, threading.Lock] = {}
|
|
|
|
|
|
|
|
|
|
|
|
# When the archive rate-limits us (429), back off globally for a bit rather than
|
|
|
|
|
|
# re-hitting it on every refresh — that only prolongs the limit.
|
|
|
|
|
|
ARCHIVE_COOLDOWN = 120 # seconds
|
|
|
|
|
|
_archive_cooldown_until = 0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_archive_limit_daily = False # is the current cooldown a daily-quota exhaustion?
|
|
|
|
|
|
|
2026-07-23 04:41:26 +00:00
|
|
|
|
# The forecast (recent+forward) endpoint's own cooldown, tracked separately from
|
|
|
|
|
|
# the archive one above: Open-Meteo's archive and forecast APIs have independent
|
|
|
|
|
|
# quotas, so a 429 on one must not gate (or be masked by) a cooldown meant for the
|
|
|
|
|
|
# other. Without this, a forecast brownout let every subscribed cell AND every
|
|
|
|
|
|
# live /cell request independently retry-storm the endpoint -- each paying
|
|
|
|
|
|
# MAX_ATTEMPTS x up to 60s plus the MET Norway fallback's own retry cycle, with no
|
|
|
|
|
|
# circuit breaker (see _load_recent_forecast).
|
|
|
|
|
|
FORECAST_COOLDOWN = 120 # seconds
|
|
|
|
|
|
_forecast_cooldown_until = 0.0
|
|
|
|
|
|
_forecast_limit_daily = False # is the current forecast cooldown a daily-quota exhaustion?
|
|
|
|
|
|
|
2026-07-11 00:29:47 +00:00
|
|
|
|
|
2026-07-11 19:53:46 +00:00
|
|
|
|
class WeatherUnavailable(RuntimeError):
|
|
|
|
|
|
"""Weather data can't be fetched right now (rate limit, upstream outage with
|
|
|
|
|
|
no cached fallback). Carries user-facing text; ``daily`` marks a daily-quota
|
|
|
|
|
|
exhaustion (resets after UTC midnight) rather than a transient burst limit.
|
|
|
|
|
|
The API layer maps this to a retryable 503 — it never needs to parse the
|
|
|
|
|
|
message, and new upstream sources classify their own failures here."""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, message: str, daily: bool = False):
|
|
|
|
|
|
super().__init__(message)
|
|
|
|
|
|
self.daily = daily
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def limit_message(daily: bool) -> str:
|
|
|
|
|
|
"""The user-facing rate-limit copy, in one place."""
|
|
|
|
|
|
if daily:
|
Remove em-dashes from site copy and tighten the prose (#206)
Replace em-dashes in user-facing copy across the server-rendered pages,
static frontend views, and the strings the app injects at runtime, using
colons, commas, parentheses or full stops as the context wants. Data
placeholder glyphs (a lone "—" for a missing reading) are left alone,
since a hyphen there reads as a minus sign in temperature columns.
Also tighten the high-visibility surfaces (home hero and meta, about,
privacy, city and records ledes, glossary blurbs) toward a plainer,
more direct voice while keeping every factual claim intact.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 01:48:33 +00:00
|
|
|
|
return ("Open-Meteo's daily request limit is exhausted, so new locations will work again "
|
2026-07-11 19:53:46 +00:00
|
|
|
|
"tomorrow. Places you've already viewed still work.")
|
Remove em-dashes from site copy and tighten the prose (#206)
Replace em-dashes in user-facing copy across the server-rendered pages,
static frontend views, and the strings the app injects at runtime, using
colons, commas, parentheses or full stops as the context wants. Data
placeholder glyphs (a lone "—" for a missing reading) are left alone,
since a hyphen there reads as a minus sign in temperature columns.
Also tighten the high-visibility surfaces (home hero and meta, about,
privacy, city and records ledes, glossary blurbs) toward a plainer,
more direct voice while keeping every factual claim intact.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 01:48:33 +00:00
|
|
|
|
return "The weather service is rate-limited right now. Please try again in a minute."
|
2026-07-11 19:53:46 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def is_rate_limit(e) -> bool:
|
2026-07-11 00:29:47 +00:00
|
|
|
|
return getattr(getattr(e, "response", None), "status_code", None) == 429
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _rate_limit_reason(e) -> str:
|
|
|
|
|
|
"""Upstream's human-readable 429 reason, if present in the response body."""
|
|
|
|
|
|
resp = getattr(e, "response", None)
|
|
|
|
|
|
if resp is not None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
j = resp.json()
|
|
|
|
|
|
if isinstance(j, dict) and j.get("reason"):
|
|
|
|
|
|
return str(j["reason"])
|
|
|
|
|
|
except Exception: # noqa: BLE001
|
|
|
|
|
|
pass
|
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _seconds_to_utc_reset() -> float:
|
|
|
|
|
|
"""Seconds until just after the next UTC midnight (when daily quotas reset)."""
|
|
|
|
|
|
now = datetime.datetime.now(datetime.timezone.utc)
|
|
|
|
|
|
reset = (now + datetime.timedelta(days=1)).replace(hour=0, minute=10, second=0, microsecond=0)
|
|
|
|
|
|
return max((reset - now).total_seconds(), 600)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _note_rate_limit(e) -> None:
|
2026-07-23 04:41:26 +00:00
|
|
|
|
"""Record an archive rate limit: back off ~2 min, or until tomorrow for a
|
|
|
|
|
|
daily quota."""
|
2026-07-11 00:29:47 +00:00
|
|
|
|
global _archive_cooldown_until, _archive_limit_daily
|
|
|
|
|
|
reason = _rate_limit_reason(e).lower()
|
|
|
|
|
|
_archive_limit_daily = "daily" in reason or "tomorrow" in reason
|
|
|
|
|
|
_archive_cooldown_until = time.time() + (
|
|
|
|
|
|
_seconds_to_utc_reset() if _archive_limit_daily else ARCHIVE_COOLDOWN)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 04:41:26 +00:00
|
|
|
|
def _note_forecast_rate_limit(e) -> None:
|
|
|
|
|
|
"""Record a forecast-endpoint rate limit. Mirrors _note_rate_limit exactly,
|
|
|
|
|
|
but against the separate _forecast_cooldown_until (see its module comment)."""
|
|
|
|
|
|
global _forecast_cooldown_until, _forecast_limit_daily
|
|
|
|
|
|
reason = _rate_limit_reason(e).lower()
|
|
|
|
|
|
_forecast_limit_daily = "daily" in reason or "tomorrow" in reason
|
|
|
|
|
|
_forecast_cooldown_until = time.time() + (
|
|
|
|
|
|
_seconds_to_utc_reset() if _forecast_limit_daily else FORECAST_COOLDOWN)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 00:29:47 +00:00
|
|
|
|
def _cell_lock(cell_id: str) -> threading.Lock:
|
|
|
|
|
|
with _LOCKS_GUARD:
|
|
|
|
|
|
lk = _CELL_LOCKS.get(cell_id)
|
|
|
|
|
|
if lk is None:
|
|
|
|
|
|
lk = _CELL_LOCKS[cell_id] = threading.Lock()
|
|
|
|
|
|
return lk
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _request(url, params, timeout, *, phase, headers=None, attempts=MAX_ATTEMPTS):
|
|
|
|
|
|
"""GET with bounded retries; every retry and the final failure are logged to
|
|
|
|
|
|
the errors folder (tagged ``retry`` / ``error``). A 429 (rate limit) fails fast
|
2026-07-23 04:41:26 +00:00
|
|
|
|
without retrying, so we don't hammer the limit and make it worse.
|
|
|
|
|
|
|
|
|
|
|
|
``timeout`` is either one number applied to every attempt (the common case),
|
|
|
|
|
|
or a sequence of per-attempt values (see ARCHIVE_FETCH_TIMEOUTS) so a caller
|
|
|
|
|
|
that holds a lock across every attempt can fail fast on the early ones and
|
|
|
|
|
|
reserve a longer wait for the last."""
|
2026-07-11 00:29:47 +00:00
|
|
|
|
last = None
|
|
|
|
|
|
for attempt in range(1, attempts + 1):
|
2026-07-23 04:41:26 +00:00
|
|
|
|
attempt_timeout = (timeout[min(attempt - 1, len(timeout) - 1)]
|
|
|
|
|
|
if isinstance(timeout, (tuple, list)) else timeout)
|
2026-07-11 00:29:47 +00:00
|
|
|
|
try:
|
2026-07-23 04:41:26 +00:00
|
|
|
|
r = _client.get(url, params=params, timeout=attempt_timeout, headers=headers)
|
2026-07-11 00:29:47 +00:00
|
|
|
|
r.raise_for_status()
|
Add ops metrics endpoint + SSH/Termux dashboard (#131)
Adds observability for the running app, viewable over SSH (e.g. Termux):
- backend/metrics.py: thread-safe, best-effort in-process counters for inbound
requests (per category) and outbound calls (per external source), plus a
phase->source map and snapshot(). Hooked into climate._request (outbound, all
six weather/geocode sources) and the existing revalidate_static middleware
(inbound). Never raises into the request path.
- GET /api/v2/metrics: token-gated JSON of the counters + warm-queue depth +
thread names/uptime/pid. Returns 404 unless a valid THERMOGRAPH_METRICS_TOKEN
is presented or the request is direct loopback with no proxy X-Forwarded-*
header, so ops data is never exposed through Caddy on the public domain.
- scripts/dashboard.py (+ scripts/dash, make dashboard): stdlib-only terminal
dashboard. Reads cache (parquet + SQLite), accounts (users + active
subscriptions), and system resources (/proc + systemd) directly, and pulls
traffic from the metrics endpoint over loopback. Live refresh, --once, --json;
mobile-terminal width. Paths resolve relative to the script so the same tool
works on the LAN dev and prod VPS checkouts.
- Monitoring sections in DEPLOY.md / DEPLOY-DEV.md; unit tests for the counters.
2026-07-16 20:46:59 +00:00
|
|
|
|
metrics.record_outbound(phase, "ok")
|
2026-07-11 00:29:47 +00:00
|
|
|
|
return r
|
|
|
|
|
|
except Exception as e: # noqa: BLE001 - upstream/network failures are expected
|
|
|
|
|
|
last = e
|
|
|
|
|
|
status = getattr(getattr(e, "response", None), "status_code", None)
|
|
|
|
|
|
rate_limited = status == 429
|
|
|
|
|
|
final = rate_limited or attempt == attempts
|
Add ops metrics endpoint + SSH/Termux dashboard (#131)
Adds observability for the running app, viewable over SSH (e.g. Termux):
- backend/metrics.py: thread-safe, best-effort in-process counters for inbound
requests (per category) and outbound calls (per external source), plus a
phase->source map and snapshot(). Hooked into climate._request (outbound, all
six weather/geocode sources) and the existing revalidate_static middleware
(inbound). Never raises into the request path.
- GET /api/v2/metrics: token-gated JSON of the counters + warm-queue depth +
thread names/uptime/pid. Returns 404 unless a valid THERMOGRAPH_METRICS_TOKEN
is presented or the request is direct loopback with no proxy X-Forwarded-*
header, so ops data is never exposed through Caddy on the public domain.
- scripts/dashboard.py (+ scripts/dash, make dashboard): stdlib-only terminal
dashboard. Reads cache (parquet + SQLite), accounts (users + active
subscriptions), and system resources (/proc + systemd) directly, and pulls
traffic from the metrics endpoint over loopback. Live refresh, --once, --json;
mobile-terminal width. Paths resolve relative to the script so the same tool
works on the LAN dev and prod VPS checkouts.
- Monitoring sections in DEPLOY.md / DEPLOY-DEV.md; unit tests for the counters.
2026-07-16 20:46:59 +00:00
|
|
|
|
metrics.record_outbound(
|
|
|
|
|
|
phase, "rate_limited" if rate_limited else ("error" if final else "retry"))
|
2026-07-11 00:29:47 +00:00
|
|
|
|
audit.log_event(
|
|
|
|
|
|
"error" if final else "retry",
|
|
|
|
|
|
{"phase": phase, "attempt": attempt, "max_attempts": attempts,
|
|
|
|
|
|
"url": url, "status": status, "error": repr(e)},
|
|
|
|
|
|
)
|
|
|
|
|
|
if final:
|
|
|
|
|
|
break
|
|
|
|
|
|
time.sleep(min(0.5 * 2 ** (attempt - 1), 4.0))
|
|
|
|
|
|
raise last
|
|
|
|
|
|
|
|
|
|
|
|
|
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 _normalize_read(df: pl.DataFrame) -> pl.DataFrame:
|
|
|
|
|
|
"""Normalize a frame read from the parquet cache: the daily `date` column is a
|
|
|
|
|
|
calendar date, so coerce it to ``pl.Date`` (older files were written by pandas
|
|
|
|
|
|
as a nanosecond ``Datetime``). Downstream date math and comparisons all key on
|
|
|
|
|
|
``pl.Date`` / stdlib ``datetime.date``."""
|
|
|
|
|
|
if df.schema["date"] != pl.Date:
|
|
|
|
|
|
df = df.with_columns(pl.col("date").cast(pl.Date))
|
|
|
|
|
|
return df
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _combined_feels_expr(hi: str = "fmax", lo: str = "fmin") -> pl.Expr:
|
|
|
|
|
|
"""Expression for one daily "feels like" value: the apparent-temperature extreme
|
|
|
|
|
|
furthest from the comfort baseline — the heat-index high on warm days, the
|
|
|
|
|
|
wind-chill low on cold ones. Falls back to whichever side is present if one is
|
|
|
|
|
|
missing (the coalesce picks the non-null side when a comparison is null)."""
|
|
|
|
|
|
amax, amin = pl.col(hi), pl.col(lo)
|
|
|
|
|
|
hot, cold = amax - COMFORT_F, COMFORT_F - amin
|
|
|
|
|
|
chosen = pl.when(hot >= cold).then(amax).otherwise(amin)
|
|
|
|
|
|
return pl.coalesce([chosen, amax, amin])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _derive_humidity(df: pl.DataFrame) -> pl.DataFrame:
|
2026-07-11 00:29:47 +00:00
|
|
|
|
"""Replace the raw mean *relative* humidity column with *absolute* humidity
|
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
|
|
|
|
(grams of water vapor per m³).
|
2026-07-11 00:29:47 +00:00
|
|
|
|
|
|
|
|
|
|
Absolute humidity is derived from the day's mean RH and its mean temperature
|
|
|
|
|
|
((tmax+tmin)/2) via the Magnus saturation-vapor-pressure formula. It's a far
|
|
|
|
|
|
more informative "how muggy was it" signal than relative humidity, which mostly
|
|
|
|
|
|
tracks the day/night temperature swing (cold nights read ~100% RH regardless of
|
|
|
|
|
|
actual moisture). Applied at the read boundary so the parquet cache keeps the
|
|
|
|
|
|
raw RH the archive returns — no refetch needed for existing cells."""
|
|
|
|
|
|
if "humid" not in df.columns:
|
|
|
|
|
|
return df
|
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
|
|
|
|
tmean_c = ((pl.col("tmax") + pl.col("tmin")) / 2.0 - 32.0) * 5.0 / 9.0
|
|
|
|
|
|
rh = pl.col("humid").cast(pl.Float64, strict=False)
|
|
|
|
|
|
es = 6.112 * (17.67 * tmean_c / (tmean_c + 243.5)).exp() # sat. vapor pressure, hPa
|
|
|
|
|
|
return df.with_columns(
|
|
|
|
|
|
(es * rh * 2.1674 / (273.15 + tmean_c)).round(1).alias("humid")) # abs. humidity, g/m³
|
2026-07-11 00:29:47 +00:00
|
|
|
|
|
|
|
|
|
|
|
Add a climate-score page from recent-vs-baseline percentile divergence (#196)
Score how far a location's last 6 years have drifted from its full 45-year
record. For each metric and percentile category (p10/p25/p50/p75/p90), the
recent-years value is placed on the baseline distribution and the gap from the
expected percentile is the divergence — unit-free, so metrics compare directly.
Scored per meteorological season plus annual, weighted into per-metric and
overall scores (temps, humidity and feels-like weighted heaviest).
- backend/scoring.py: divergence math, seasonal slicing, precip zero-inflation
split (wet-day frequency + amount), tier mapping onto the existing temp scale.
- climate.py: derive a wet-bulb column (Stull 2011) at the read boundary, before
the humidity column is converted to absolute — via a shared _derive_metrics
wrapper at all four read sites.
- api/v2/score endpoint + build_score payload, cached on the history token with
a scoring-version key.
- frontend score page: overall hero, per-metric cards, by-season chips, and a
button-revealed summary (sentences + metrics×season table + per-percentile
detail). Score nav link across all headers.
- Tests for the scoring math, wet-bulb formula, payload shape and route.
2026-07-19 23:02:33 +00:00
|
|
|
|
def _derive_wetbulb(df: pl.DataFrame) -> pl.DataFrame:
|
|
|
|
|
|
"""Add a `wetbulb` column (°F): the daytime-peak wet-bulb temperature via the
|
|
|
|
|
|
Stull (2011) single-value approximation from the day's high (`tmax`) and mean
|
|
|
|
|
|
relative humidity.
|
|
|
|
|
|
|
|
|
|
|
|
Wet bulb is the temperature a parcel reaches by evaporative cooling to
|
|
|
|
|
|
saturation — the ceiling on how much the body can shed heat by sweating, so it
|
|
|
|
|
|
is the sharper heat-stress signal than dry-bulb temperature or humidity alone.
|
|
|
|
|
|
Must run BEFORE ``_derive_humidity`` (which replaces the raw RH column with
|
|
|
|
|
|
absolute humidity, the input this needs). Read-time only, like the humidity
|
|
|
|
|
|
derivation, so the parquet cache is untouched. Stull's fit is valid for
|
|
|
|
|
|
RH 5-99% and −20..50 °C; days outside that range grade as null."""
|
|
|
|
|
|
if "humid" not in df.columns:
|
|
|
|
|
|
return df
|
|
|
|
|
|
t = (pl.col("tmax") - 32.0) * 5.0 / 9.0 # daily high, °C
|
|
|
|
|
|
rh = pl.col("humid").cast(pl.Float64, strict=False)
|
|
|
|
|
|
tw = (t * (0.151977 * (rh + 8.313659).sqrt()).arctan()
|
|
|
|
|
|
+ (t + rh).arctan()
|
|
|
|
|
|
- (rh - 1.676331).arctan()
|
|
|
|
|
|
+ 0.00391838 * rh.pow(1.5) * (0.023101 * rh).arctan()
|
|
|
|
|
|
- 4.686035) # wet bulb, °C
|
|
|
|
|
|
tw_f = (tw * 9.0 / 5.0 + 32.0).round(1)
|
|
|
|
|
|
valid = (rh >= 5) & (rh <= 99) & (t >= -20) & (t <= 50)
|
|
|
|
|
|
return df.with_columns(pl.when(valid).then(tw_f).otherwise(None).alias("wetbulb"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _derive_metrics(df: pl.DataFrame) -> pl.DataFrame:
|
|
|
|
|
|
"""Read-boundary derivations that depend on the raw relative-humidity column.
|
|
|
|
|
|
Wet bulb must be computed before ``_derive_humidity`` replaces raw RH with
|
|
|
|
|
|
absolute humidity, so both live behind this single wrapper to keep the order
|
|
|
|
|
|
right at every read site."""
|
|
|
|
|
|
return _derive_humidity(_derive_wetbulb(df))
|
|
|
|
|
|
|
|
|
|
|
|
|
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 _with_doy(df: pl.DataFrame) -> pl.DataFrame:
|
2026-07-11 20:05:57 +00:00
|
|
|
|
"""(Re)attach the int16 day-of-year column the grading windows key on."""
|
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
|
|
|
|
return df.with_columns(pl.col("date").dt.ordinal_day().cast(pl.Int16).alias("doy"))
|
2026-07-11 20:05:57 +00:00
|
|
|
|
|
|
|
|
|
|
|
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 _write_cache(df: pl.DataFrame, path: str) -> None:
|
2026-07-11 20:05:57 +00:00
|
|
|
|
"""Persist a daily record to the parquet cache — the raw record only, never
|
2026-07-23 04:41:26 +00:00
|
|
|
|
the derived doy column (it's recomputed at read time).
|
|
|
|
|
|
|
|
|
|
|
|
Written to a tempfile in the same directory, then renamed into place
|
|
|
|
|
|
(os.replace is atomic on the same filesystem) — mirrors the pattern in
|
|
|
|
|
|
api/homepage.py's refresh() and data/places.py's _fetch. A reader (or a
|
|
|
|
|
|
second writer — warm_cities.py guards against overlapping runs, but a live
|
|
|
|
|
|
request racing a topup is normal) must never see a partially-written parquet
|
|
|
|
|
|
file; writing straight to ``path`` (the old behavior) could hand a truncated
|
|
|
|
|
|
file to a concurrent read."""
|
|
|
|
|
|
directory = os.path.dirname(path)
|
|
|
|
|
|
os.makedirs(directory, exist_ok=True)
|
|
|
|
|
|
fd, tmp = tempfile.mkstemp(dir=directory, suffix=".tmp")
|
|
|
|
|
|
try:
|
|
|
|
|
|
os.close(fd)
|
|
|
|
|
|
df.drop("doy", strict=False).write_parquet(tmp, compression="zstd")
|
|
|
|
|
|
os.replace(tmp, path)
|
|
|
|
|
|
except BaseException:
|
|
|
|
|
|
try:
|
|
|
|
|
|
os.unlink(tmp)
|
|
|
|
|
|
except OSError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
raise
|
2026-07-11 20:05:57 +00:00
|
|
|
|
|
|
|
|
|
|
|
Move the climate record from parquet to TimescaleDB hypertables (#227)
Replace the per-cell parquet cache with TimescaleDB hypertables as the
production backend for the raw daily climate record, and drop pg_duckdb.
Parquet stays the backend whenever THERMOGRAPH_DATABASE_URL is not a
Postgres URL (dev, tests, offline tooling), the same dialect switch the
accounts DB and derived store already use, so CI stays Postgres-free.
- data/climate_store.py: psycopg + polars bridge over climate_history
(a hypertable), climate_recent, and climate_sync (per-cell freshness).
Reads via pl.read_database, writes via COPY + ON CONFLICT upsert;
fail-soft to a cache miss so a DB hiccup degrades to upstream refetch.
- data/climate.py: route every cache/mtime touchpoint through a backend
dispatch. recent_stamp becomes int(recent_synced_at) on Postgres; the
stale-serve path still avoids bumping it, so derived-payload tokens
invalidate on exactly the same events as before.
- alembic 0002: CREATE EXTENSION timescaledb plus the hypertable schema
(compression policy on year-old chunks), guarded to no-op off Postgres.
- migrate_cache_to_pg.py (make migrate-cache): idempotent backfill of the
parquet cache into the hypertables, preserving file mtimes as sync
timestamps so recent_stamp is unchanged across cutover.
- db image -> stock timescale/timescaledb:latest-pg18; drop the custom
pg_duckdb Dockerfile, the read-only /parquet mount, and the duckdb
tuning GUC. Docs updated for the new backend and cutover.
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-20 20:15:55 +00:00
|
|
|
|
# --- cache backend dispatch --------------------------------------------------
|
|
|
|
|
|
# The raw daily record lives in TimescaleDB hypertables on Postgres (prod) and in
|
|
|
|
|
|
# per-cell parquet files otherwise (dev / tests / offline). These helpers hide the
|
|
|
|
|
|
# choice so the loaders below stay backend-agnostic: they return the same shapes a
|
|
|
|
|
|
# parquet read produced — a frame WITHOUT the derived `doy` column, plus an age in
|
|
|
|
|
|
# seconds since the last write (the mtime age the topup/TTL logic keyed on). The
|
|
|
|
|
|
# active backend is climate_store.is_postgres() (same THERMOGRAPH_DATABASE_URL
|
|
|
|
|
|
# switch as accounts/db.py and data/store.py).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _read_history_backed(cell_id: str):
|
|
|
|
|
|
"""(schema-complete history frame without doy, age_s) or None."""
|
|
|
|
|
|
if climate_store.is_postgres():
|
|
|
|
|
|
hit = climate_store.read_history(cell_id)
|
|
|
|
|
|
if hit is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
df, synced_at = hit
|
|
|
|
|
|
return df, time.time() - synced_at
|
|
|
|
|
|
return _read_history_cache(_cache_path(cell_id))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _stale_history_backed(cell_id: str):
|
|
|
|
|
|
"""Any cached history rows regardless of schema completeness (for the
|
|
|
|
|
|
all-fetches-failed stale serve), or None."""
|
|
|
|
|
|
if climate_store.is_postgres():
|
|
|
|
|
|
return climate_store.read_history_raw(cell_id)
|
|
|
|
|
|
path = _cache_path(cell_id)
|
|
|
|
|
|
return _normalize_read(pl.read_parquet(path)) if os.path.exists(path) else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _write_history_backed(cell_id: str, full_df: pl.DataFrame,
|
|
|
|
|
|
delta_df: "pl.DataFrame | None" = None) -> None:
|
|
|
|
|
|
"""Persist a cell's history. Parquet rewrites the whole file from ``full_df``;
|
|
|
|
|
|
Postgres upserts ``delta_df`` when given (just the topped-up tail) else the full
|
|
|
|
|
|
frame — the upsert makes a delta write equivalent to a full rewrite."""
|
|
|
|
|
|
if climate_store.is_postgres():
|
|
|
|
|
|
climate_store.write_history(
|
|
|
|
|
|
cell_id, delta_df if delta_df is not None else full_df, time.time())
|
|
|
|
|
|
else:
|
|
|
|
|
|
_write_cache(full_df, _cache_path(cell_id))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _touch_history_backed(cell_id: str) -> None:
|
|
|
|
|
|
"""Reset the hourly topup timer when the tail is already current, without a
|
|
|
|
|
|
rewrite — bump history_synced_at on Postgres, or the file mtime on parquet."""
|
|
|
|
|
|
if climate_store.is_postgres():
|
|
|
|
|
|
climate_store.touch_history(cell_id, time.time())
|
|
|
|
|
|
else:
|
|
|
|
|
|
try:
|
|
|
|
|
|
os.utime(_cache_path(cell_id), None)
|
|
|
|
|
|
except OSError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _read_recent_backed(cell_id: str):
|
|
|
|
|
|
"""(recent+forecast frame without doy, age_s) or None."""
|
|
|
|
|
|
if climate_store.is_postgres():
|
|
|
|
|
|
hit = climate_store.read_recent(cell_id)
|
|
|
|
|
|
if hit is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
df, synced_at = hit
|
|
|
|
|
|
return df, time.time() - synced_at
|
|
|
|
|
|
path = _rf_cache_path(cell_id)
|
|
|
|
|
|
if not os.path.exists(path):
|
|
|
|
|
|
return None
|
|
|
|
|
|
try:
|
|
|
|
|
|
return _normalize_read(pl.read_parquet(path)), time.time() - os.path.getmtime(path)
|
|
|
|
|
|
except Exception: # noqa: BLE001 - a truncated/corrupt cache file is a miss
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _write_recent_backed(cell_id: str, df: pl.DataFrame) -> None:
|
|
|
|
|
|
"""Persist a cell's recent+forecast bundle (full replace on both backends)."""
|
|
|
|
|
|
if climate_store.is_postgres():
|
|
|
|
|
|
climate_store.write_recent(cell_id, df, time.time())
|
|
|
|
|
|
else:
|
|
|
|
|
|
_write_cache(df, _rf_cache_path(cell_id))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 20:05:57 +00:00
|
|
|
|
def _om_daily_params(cell: dict, **window) -> dict:
|
|
|
|
|
|
"""The Open-Meteo daily-request params every fetch shares — location, the
|
|
|
|
|
|
variable set, imperial units. The date/window selectors (start_date/end_date
|
|
|
|
|
|
or past_days/forecast_days) come in as kwargs."""
|
|
|
|
|
|
return {
|
|
|
|
|
|
"latitude": cell["center_lat"],
|
|
|
|
|
|
"longitude": cell["center_lon"],
|
|
|
|
|
|
"daily": DAILY_VARS,
|
|
|
|
|
|
"timezone": "auto",
|
|
|
|
|
|
"temperature_unit": "fahrenheit",
|
|
|
|
|
|
"precipitation_unit": "inch",
|
|
|
|
|
|
"wind_speed_unit": "mph",
|
|
|
|
|
|
**window,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
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 _finalize_frame(df: pl.DataFrame) -> pl.DataFrame:
|
|
|
|
|
|
"""Shared tail of every source→frame mapping: unify missing values as null, add
|
|
|
|
|
|
the combined feels-like, apply the valid-day filter, and attach day-of-year. A
|
|
|
|
|
|
usable climate day needs a real high/low; other columns may be missing and
|
|
|
|
|
|
simply grade as None. Float NaN (from numpy-derived columns) is folded into null
|
|
|
|
|
|
so the whole pipeline models "missing" one way — the grading boundary drops it."""
|
2026-07-22 19:07:54 +00:00
|
|
|
|
# Backfill any optional metric a source didn't provide as an all-null column, so
|
|
|
|
|
|
# the finalized schema (and the NEW_COLS cache check) is uniform across sources.
|
|
|
|
|
|
absent = [pl.lit(None, dtype=pl.Float64).alias(c)
|
|
|
|
|
|
for c in OPTIONAL_COLS if c not in df.columns]
|
|
|
|
|
|
if absent:
|
|
|
|
|
|
df = df.with_columns(absent)
|
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
|
|
|
|
df = df.with_columns(pl.col(pl.Float32, pl.Float64).fill_nan(None))
|
|
|
|
|
|
df = df.with_columns(_combined_feels_expr().alias("feels"))
|
|
|
|
|
|
df = df.drop_nulls(subset=["tmax", "tmin"])
|
2026-07-11 20:05:57 +00:00
|
|
|
|
return _with_doy(df)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-22 19:07:54 +00:00
|
|
|
|
def _finalize_approximated(df: pl.DataFrame) -> pl.DataFrame:
|
|
|
|
|
|
"""Finalize a source that lacks apparent temperature (NASA POWER, MET Norway):
|
|
|
|
|
|
approximate the felt high with the NWS heat index and the felt low with wind
|
|
|
|
|
|
chill (each falls back to the air temperature outside its regime), mirroring the
|
|
|
|
|
|
Open-Meteo fmax/fmin columns, then finalize. The numpy NaN these produce lands
|
|
|
|
|
|
in float columns and is folded to null by _finalize_frame."""
|
|
|
|
|
|
df = df.with_columns(
|
|
|
|
|
|
pl.Series("fmax", _heat_index(df["tmax"].to_numpy(), df["humid"].to_numpy())),
|
|
|
|
|
|
pl.Series("fmin", _wind_chill(df["tmin"].to_numpy(), df["wind"].to_numpy())),
|
|
|
|
|
|
)
|
|
|
|
|
|
return _finalize_frame(df)
|
|
|
|
|
|
|
|
|
|
|
|
|
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 _to_frame(daily: dict) -> pl.DataFrame:
|
2026-07-11 00:29:47 +00:00
|
|
|
|
n = len(daily["time"])
|
|
|
|
|
|
# Older upstream responses (or a narrowed variable set) may omit a series; fall
|
|
|
|
|
|
# back to an all-null column of the right length so the frame shape is stable.
|
|
|
|
|
|
def col(key):
|
|
|
|
|
|
vals = daily.get(key)
|
|
|
|
|
|
return vals if vals else [None] * n
|
|
|
|
|
|
|
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
|
|
|
|
df = pl.DataFrame(
|
2026-07-11 00:29:47 +00:00
|
|
|
|
{
|
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": daily["time"],
|
2026-07-11 00:29:47 +00:00
|
|
|
|
"tmax": daily["temperature_2m_max"],
|
|
|
|
|
|
"tmin": daily["temperature_2m_min"],
|
|
|
|
|
|
"precip": daily["precipitation_sum"],
|
|
|
|
|
|
"wind": col("wind_speed_10m_max"),
|
|
|
|
|
|
"gust": col("wind_gusts_10m_max"),
|
|
|
|
|
|
"humid": col("relative_humidity_2m_mean"),
|
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
|
|
|
|
# The apparent (felt) high and low are kept as their own columns — graded
|
|
|
|
|
|
# independently — alongside the combined `feels` (whichever side is further
|
|
|
|
|
|
# from the fixed comfort baseline), which the weekly/day views still use.
|
|
|
|
|
|
"fmax": col("apparent_temperature_max"),
|
|
|
|
|
|
"fmin": col("apparent_temperature_min"),
|
2026-07-22 19:08:24 +00:00
|
|
|
|
# Raw seconds of sunshine and of daylight; combined below into `sun`.
|
|
|
|
|
|
"sunsec": col("sunshine_duration"),
|
|
|
|
|
|
"daysec": col("daylight_duration"),
|
2026-07-11 00:29:47 +00:00
|
|
|
|
}
|
|
|
|
|
|
)
|
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
|
|
|
|
df = df.with_columns(
|
|
|
|
|
|
pl.col("date").str.to_date(),
|
|
|
|
|
|
*[pl.col(c).cast(pl.Float64, strict=False)
|
2026-07-22 19:08:24 +00:00
|
|
|
|
for c in ("tmax", "tmin", "precip", "wind", "gust", "fmax", "fmin",
|
|
|
|
|
|
"sunsec", "daysec")],
|
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
|
|
|
|
)
|
2026-07-22 19:08:24 +00:00
|
|
|
|
# `sun` = fraction of the day's daylight that was sunshine, clamped to [0, 1]
|
|
|
|
|
|
# (sunshine_duration can slightly exceed daylight near the poles). Null when
|
|
|
|
|
|
# either series is missing or daylight is zero (polar night).
|
|
|
|
|
|
df = df.with_columns(
|
|
|
|
|
|
pl.when((pl.col("daysec") > 0) & pl.col("sunsec").is_not_null())
|
|
|
|
|
|
.then((pl.col("sunsec") / pl.col("daysec")).clip(0.0, 1.0))
|
|
|
|
|
|
.otherwise(None)
|
|
|
|
|
|
.alias("sun")
|
|
|
|
|
|
).drop("sunsec", "daysec")
|
2026-07-11 20:05:57 +00:00
|
|
|
|
return _finalize_frame(df)
|
2026-07-11 00:29:47 +00:00
|
|
|
|
|
|
|
|
|
|
|
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 _fetch_history(cell: dict) -> pl.DataFrame:
|
2026-07-11 00:29:47 +00:00
|
|
|
|
end = (datetime.date.today() - datetime.timedelta(days=ARCHIVE_LATENCY_DAYS)).isoformat()
|
2026-07-20 13:16:56 +00:00
|
|
|
|
params = _om_daily_params(cell, start_date=START_DATE, end_date=end, models=ARCHIVE_MODEL)
|
2026-07-23 04:41:26 +00:00
|
|
|
|
r = _request(ARCHIVE_URL, params, ARCHIVE_FETCH_TIMEOUTS, phase="history_fetch")
|
2026-07-11 00:29:47 +00:00
|
|
|
|
return _to_frame(r.json()["daily"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _heat_index(t_f, rh):
|
|
|
|
|
|
"""NWS heat index (°F); below ~80°F it's just the air temperature."""
|
|
|
|
|
|
T, R = t_f, rh
|
|
|
|
|
|
hi = (-42.379 + 2.04901523 * T + 10.14333127 * R - 0.22475541 * T * R
|
|
|
|
|
|
- 0.00683783 * T * T - 0.05481717 * R * R + 0.00122874 * T * T * R
|
|
|
|
|
|
+ 0.00085282 * T * R * R - 0.00000199 * T * T * R * R)
|
|
|
|
|
|
return np.where(np.asarray(T, dtype="float64") >= 80, hi, T)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _wind_chill(t_f, v_mph):
|
|
|
|
|
|
"""NWS wind chill (°F); applies only when cold + breezy, else the air temp."""
|
|
|
|
|
|
T = np.asarray(t_f, dtype="float64")
|
|
|
|
|
|
V = np.clip(np.asarray(v_mph, dtype="float64"), 0, None)
|
|
|
|
|
|
Vp = np.power(V, 0.16)
|
|
|
|
|
|
wc = 35.74 + 0.6215 * T - 35.75 * Vp + 0.4275 * T * Vp
|
|
|
|
|
|
return np.where((T <= 50) & (V >= 3), wc, T)
|
|
|
|
|
|
|
|
|
|
|
|
|
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 _nasa_to_frame(param: dict) -> pl.DataFrame:
|
2026-07-11 00:29:47 +00:00
|
|
|
|
"""Map a NASA POWER daily response to our (tmax/tmin/precip/wind/gust/humid/feels)
|
|
|
|
|
|
schema, converting units (°C→°F, mm→in, m/s→mph) and computing feels-like."""
|
|
|
|
|
|
dates = sorted(param.get("T2M_MAX", {}).keys())
|
|
|
|
|
|
|
|
|
|
|
|
def col(key, transform):
|
|
|
|
|
|
d = param.get(key, {})
|
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
|
|
|
|
return [None if (v is None or v <= NASA_FILL) else transform(v)
|
2026-07-11 00:29:47 +00:00
|
|
|
|
for v in (d.get(k) for k in dates)]
|
|
|
|
|
|
|
|
|
|
|
|
c2f = lambda c: c * 9.0 / 5.0 + 32.0
|
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
|
|
|
|
df = pl.DataFrame({
|
|
|
|
|
|
"date": dates,
|
2026-07-11 00:29:47 +00:00
|
|
|
|
"tmax": col("T2M_MAX", c2f),
|
|
|
|
|
|
"tmin": col("T2M_MIN", c2f),
|
|
|
|
|
|
"precip": col("PRECTOTCORR", lambda mm: mm / 25.4),
|
|
|
|
|
|
"wind": col("WS10M_MAX", lambda ms: ms * 2.2369362920544),
|
|
|
|
|
|
"humid": col("RH2M", lambda v: v),
|
2026-07-22 19:07:54 +00:00
|
|
|
|
}) # POWER has no gusts (backfilled null)
|
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
|
|
|
|
df = df.with_columns(
|
|
|
|
|
|
pl.col("date").str.to_date("%Y%m%d"),
|
|
|
|
|
|
*[pl.col(c).cast(pl.Float64, strict=False)
|
2026-07-22 19:07:54 +00:00
|
|
|
|
for c in ("tmax", "tmin", "precip", "wind")],
|
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
|
|
|
|
)
|
2026-07-22 19:07:54 +00:00
|
|
|
|
return _finalize_approximated(df)
|
2026-07-11 00:29:47 +00:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 14:34:51 +00:00
|
|
|
|
def _fetch_history_nasa(cell: dict, start: str = NASA_START,
|
|
|
|
|
|
end: "str | None" = None) -> pl.DataFrame:
|
|
|
|
|
|
"""Primary history fetch from NASA POWER (keyless, independent of Open-Meteo).
|
|
|
|
|
|
|
|
|
|
|
|
`start`/`end` accept NASA's YYYYMMDD or ISO YYYY-MM-DD; `end` defaults to the
|
|
|
|
|
|
archive-latency cutoff. Serves both the full multi-decade pull and the tail
|
|
|
|
|
|
top-up range. NASA carries no gusts, so they're filled from Meteostat below."""
|
|
|
|
|
|
if end is None:
|
|
|
|
|
|
end = (datetime.date.today() - datetime.timedelta(days=ARCHIVE_LATENCY_DAYS)).strftime("%Y%m%d")
|
2026-07-11 00:29:47 +00:00
|
|
|
|
params = {
|
|
|
|
|
|
"parameters": "T2M_MAX,T2M_MIN,PRECTOTCORR,WS10M_MAX,RH2M",
|
|
|
|
|
|
"community": "RE",
|
|
|
|
|
|
"latitude": cell["center_lat"],
|
|
|
|
|
|
"longitude": cell["center_lon"],
|
2026-07-23 14:34:51 +00:00
|
|
|
|
"start": start.replace("-", ""),
|
|
|
|
|
|
"end": end.replace("-", ""),
|
2026-07-11 00:29:47 +00:00
|
|
|
|
"format": "JSON",
|
|
|
|
|
|
}
|
2026-07-23 04:41:26 +00:00
|
|
|
|
r = _request(NASA_POWER_URL, params, ARCHIVE_FETCH_TIMEOUTS, phase="history_nasa")
|
2026-07-23 14:34:51 +00:00
|
|
|
|
df = _nasa_to_frame(r.json()["properties"]["parameter"])
|
|
|
|
|
|
# NASA POWER carries no gusts; fill from the nearest Meteostat station, falling
|
|
|
|
|
|
# back to an estimate from sustained wind where no station is in range.
|
|
|
|
|
|
return meteostat.fill_gusts(cell["center_lat"], cell["center_lon"], df)
|
2026-07-11 00:29:47 +00:00
|
|
|
|
|
|
|
|
|
|
|
Add MET Norway forecast fallback when Open-Meteo is unavailable (#115)
The forecast path had no backup — unlike history, which falls back to NASA POWER.
When Open-Meteo's forecast API was rate-limited or down, the recent/forecast
bundle (and every endpoint that grades future days) failed with a 503.
Add MET Norway (yr.no) Locationforecast as a keyless, global forecast backup,
mirroring the NASA POWER role for history:
- _metno_to_frame: aggregates MET Norway's sub-daily timeseries into the daily
schema, converting units (°C→°F, mm→in, m/s→mph) and deriving feels-like from
the NWS heat index / wind chill (MET has no gusts or apparent temperature).
Precip prefers the 1-hour block and falls back to the 6-hour block so the
hourly→6-hourly resolution switch never double-counts.
- _fetch_forecast_metno: the backup fetch, with the ToS-required identifying
User-Agent and coordinates rounded to 4 decimals.
- _load_recent_forecast: on any Open-Meteo forecast failure, try MET Norway
before surfacing the error; a shared rate limit still raises the typed,
daily-aware WeatherUnavailable.
MET Norway is forecast-only (no recent past days), so it's a degraded-but-working
fallback: the forecast / day-ahead views keep serving during an Open-Meteo outage.
Tests cover the daily aggregation + unit conversion (incl. the no-double-count
precip rule) and the fallback wiring.
2026-07-16 05:30:56 +00:00
|
|
|
|
def _metno_to_frame(props: dict) -> pl.DataFrame:
|
|
|
|
|
|
"""Map a MET Norway Locationforecast (yr.no) response to our daily schema.
|
|
|
|
|
|
|
|
|
|
|
|
MET Norway returns a per-timestep timeseries (hourly near-term, then 6-hourly)
|
|
|
|
|
|
in metric units, with no gusts or apparent temperature, so we aggregate to daily
|
|
|
|
|
|
extremes/means, convert units (°C→°F, mm→in, m/s→mph), and approximate feels-like
|
|
|
|
|
|
from the NWS heat index / wind chill — the same treatment as the NASA POWER path."""
|
|
|
|
|
|
# Aggregate the sub-daily steps into per-day values, keyed by (UTC) calendar day.
|
|
|
|
|
|
agg: dict[str, dict] = {}
|
|
|
|
|
|
for step in props.get("timeseries", []):
|
|
|
|
|
|
day = step["time"][:10]
|
|
|
|
|
|
data = step.get("data", {})
|
|
|
|
|
|
inst = data.get("instant", {}).get("details", {})
|
2026-07-24 22:23:51 +00:00
|
|
|
|
a = agg.setdefault(day, {"t": [], "rh": [], "wind": [], "precip": None,
|
|
|
|
|
|
"hours": []})
|
|
|
|
|
|
a["hours"].append(int(step["time"][11:13]))
|
Add MET Norway forecast fallback when Open-Meteo is unavailable (#115)
The forecast path had no backup — unlike history, which falls back to NASA POWER.
When Open-Meteo's forecast API was rate-limited or down, the recent/forecast
bundle (and every endpoint that grades future days) failed with a 503.
Add MET Norway (yr.no) Locationforecast as a keyless, global forecast backup,
mirroring the NASA POWER role for history:
- _metno_to_frame: aggregates MET Norway's sub-daily timeseries into the daily
schema, converting units (°C→°F, mm→in, m/s→mph) and deriving feels-like from
the NWS heat index / wind chill (MET has no gusts or apparent temperature).
Precip prefers the 1-hour block and falls back to the 6-hour block so the
hourly→6-hourly resolution switch never double-counts.
- _fetch_forecast_metno: the backup fetch, with the ToS-required identifying
User-Agent and coordinates rounded to 4 decimals.
- _load_recent_forecast: on any Open-Meteo forecast failure, try MET Norway
before surfacing the error; a shared rate limit still raises the typed,
daily-aware WeatherUnavailable.
MET Norway is forecast-only (no recent past days), so it's a degraded-but-working
fallback: the forecast / day-ahead views keep serving during an Open-Meteo outage.
Tests cover the daily aggregation + unit conversion (incl. the no-double-count
precip rule) and the fallback wiring.
2026-07-16 05:30:56 +00:00
|
|
|
|
if (t := inst.get("air_temperature")) is not None:
|
|
|
|
|
|
a["t"].append(t)
|
|
|
|
|
|
if (rh := inst.get("relative_humidity")) is not None:
|
|
|
|
|
|
a["rh"].append(rh)
|
|
|
|
|
|
if (w := inst.get("wind_speed")) is not None:
|
|
|
|
|
|
a["wind"].append(w)
|
|
|
|
|
|
# Precip: prefer the 1-hour block (hourly near-term), else the 6-hour block
|
|
|
|
|
|
# (6-hourly far-term). Never add both — they overlap — so summing each step's
|
|
|
|
|
|
# chosen block avoids double counting across the resolution switch.
|
|
|
|
|
|
p = (data.get("next_1_hours") or {}).get("details", {}).get("precipitation_amount")
|
|
|
|
|
|
if p is None:
|
|
|
|
|
|
p = (data.get("next_6_hours") or {}).get("details", {}).get("precipitation_amount")
|
|
|
|
|
|
if p is not None:
|
|
|
|
|
|
a["precip"] = (a["precip"] or 0.0) + p
|
|
|
|
|
|
|
2026-07-24 22:23:51 +00:00
|
|
|
|
# Coverage gate: a day's min/max are only real if its samples span most of
|
|
|
|
|
|
# the day. MET's series starts mid-today and thins to 6-hourly then sparser
|
|
|
|
|
|
# in the tail, so without this the current day is built from the remaining
|
|
|
|
|
|
# afternoon hours (seen live: a served "overnight low" of 114.8°F graded at
|
|
|
|
|
|
# the ~100th percentile) and the final day can collapse to one sample with
|
|
|
|
|
|
# tmin == tmax. 18h keeps full hourly days and the 0/6/12/18 6-hourly shape;
|
|
|
|
|
|
# it drops partial today and the degenerate tail rather than grading them.
|
|
|
|
|
|
MIN_SPAN_HOURS = 18
|
|
|
|
|
|
dates = [d for d in sorted(agg)
|
|
|
|
|
|
if agg[d]["hours"]
|
|
|
|
|
|
and max(agg[d]["hours"]) - min(agg[d]["hours"]) >= MIN_SPAN_HOURS]
|
Add MET Norway forecast fallback when Open-Meteo is unavailable (#115)
The forecast path had no backup — unlike history, which falls back to NASA POWER.
When Open-Meteo's forecast API was rate-limited or down, the recent/forecast
bundle (and every endpoint that grades future days) failed with a 503.
Add MET Norway (yr.no) Locationforecast as a keyless, global forecast backup,
mirroring the NASA POWER role for history:
- _metno_to_frame: aggregates MET Norway's sub-daily timeseries into the daily
schema, converting units (°C→°F, mm→in, m/s→mph) and deriving feels-like from
the NWS heat index / wind chill (MET has no gusts or apparent temperature).
Precip prefers the 1-hour block and falls back to the 6-hour block so the
hourly→6-hourly resolution switch never double-counts.
- _fetch_forecast_metno: the backup fetch, with the ToS-required identifying
User-Agent and coordinates rounded to 4 decimals.
- _load_recent_forecast: on any Open-Meteo forecast failure, try MET Norway
before surfacing the error; a shared rate limit still raises the typed,
daily-aware WeatherUnavailable.
MET Norway is forecast-only (no recent past days), so it's a degraded-but-working
fallback: the forecast / day-ahead views keep serving during an Open-Meteo outage.
Tests cover the daily aggregation + unit conversion (incl. the no-double-count
precip rule) and the fallback wiring.
2026-07-16 05:30:56 +00:00
|
|
|
|
c2f = lambda c: c * 9.0 / 5.0 + 32.0
|
|
|
|
|
|
df = pl.DataFrame({
|
|
|
|
|
|
"date": dates,
|
|
|
|
|
|
"tmax": [c2f(max(agg[d]["t"])) if agg[d]["t"] else None for d in dates],
|
|
|
|
|
|
"tmin": [c2f(min(agg[d]["t"])) if agg[d]["t"] else None for d in dates],
|
|
|
|
|
|
"precip": [agg[d]["precip"] / 25.4 if agg[d]["precip"] is not None else None
|
|
|
|
|
|
for d in dates],
|
|
|
|
|
|
"wind": [max(agg[d]["wind"]) * 2.2369362920544 if agg[d]["wind"] else None
|
|
|
|
|
|
for d in dates],
|
|
|
|
|
|
"humid": [sum(agg[d]["rh"]) / len(agg[d]["rh"]) if agg[d]["rh"] else None
|
|
|
|
|
|
for d in dates],
|
2026-07-22 19:07:54 +00:00
|
|
|
|
}) # MET Norway has no gusts (backfilled null)
|
Add MET Norway forecast fallback when Open-Meteo is unavailable (#115)
The forecast path had no backup — unlike history, which falls back to NASA POWER.
When Open-Meteo's forecast API was rate-limited or down, the recent/forecast
bundle (and every endpoint that grades future days) failed with a 503.
Add MET Norway (yr.no) Locationforecast as a keyless, global forecast backup,
mirroring the NASA POWER role for history:
- _metno_to_frame: aggregates MET Norway's sub-daily timeseries into the daily
schema, converting units (°C→°F, mm→in, m/s→mph) and deriving feels-like from
the NWS heat index / wind chill (MET has no gusts or apparent temperature).
Precip prefers the 1-hour block and falls back to the 6-hour block so the
hourly→6-hourly resolution switch never double-counts.
- _fetch_forecast_metno: the backup fetch, with the ToS-required identifying
User-Agent and coordinates rounded to 4 decimals.
- _load_recent_forecast: on any Open-Meteo forecast failure, try MET Norway
before surfacing the error; a shared rate limit still raises the typed,
daily-aware WeatherUnavailable.
MET Norway is forecast-only (no recent past days), so it's a degraded-but-working
fallback: the forecast / day-ahead views keep serving during an Open-Meteo outage.
Tests cover the daily aggregation + unit conversion (incl. the no-double-count
precip rule) and the fallback wiring.
2026-07-16 05:30:56 +00:00
|
|
|
|
df = df.with_columns(
|
|
|
|
|
|
pl.col("date").str.to_date(),
|
|
|
|
|
|
*[pl.col(c).cast(pl.Float64, strict=False)
|
2026-07-22 19:07:54 +00:00
|
|
|
|
for c in ("tmax", "tmin", "precip", "wind")],
|
Add MET Norway forecast fallback when Open-Meteo is unavailable (#115)
The forecast path had no backup — unlike history, which falls back to NASA POWER.
When Open-Meteo's forecast API was rate-limited or down, the recent/forecast
bundle (and every endpoint that grades future days) failed with a 503.
Add MET Norway (yr.no) Locationforecast as a keyless, global forecast backup,
mirroring the NASA POWER role for history:
- _metno_to_frame: aggregates MET Norway's sub-daily timeseries into the daily
schema, converting units (°C→°F, mm→in, m/s→mph) and deriving feels-like from
the NWS heat index / wind chill (MET has no gusts or apparent temperature).
Precip prefers the 1-hour block and falls back to the 6-hour block so the
hourly→6-hourly resolution switch never double-counts.
- _fetch_forecast_metno: the backup fetch, with the ToS-required identifying
User-Agent and coordinates rounded to 4 decimals.
- _load_recent_forecast: on any Open-Meteo forecast failure, try MET Norway
before surfacing the error; a shared rate limit still raises the typed,
daily-aware WeatherUnavailable.
MET Norway is forecast-only (no recent past days), so it's a degraded-but-working
fallback: the forecast / day-ahead views keep serving during an Open-Meteo outage.
Tests cover the daily aggregation + unit conversion (incl. the no-double-count
precip rule) and the fallback wiring.
2026-07-16 05:30:56 +00:00
|
|
|
|
)
|
2026-07-22 19:07:54 +00:00
|
|
|
|
return _finalize_approximated(df)
|
Add MET Norway forecast fallback when Open-Meteo is unavailable (#115)
The forecast path had no backup — unlike history, which falls back to NASA POWER.
When Open-Meteo's forecast API was rate-limited or down, the recent/forecast
bundle (and every endpoint that grades future days) failed with a 503.
Add MET Norway (yr.no) Locationforecast as a keyless, global forecast backup,
mirroring the NASA POWER role for history:
- _metno_to_frame: aggregates MET Norway's sub-daily timeseries into the daily
schema, converting units (°C→°F, mm→in, m/s→mph) and deriving feels-like from
the NWS heat index / wind chill (MET has no gusts or apparent temperature).
Precip prefers the 1-hour block and falls back to the 6-hour block so the
hourly→6-hourly resolution switch never double-counts.
- _fetch_forecast_metno: the backup fetch, with the ToS-required identifying
User-Agent and coordinates rounded to 4 decimals.
- _load_recent_forecast: on any Open-Meteo forecast failure, try MET Norway
before surfacing the error; a shared rate limit still raises the typed,
daily-aware WeatherUnavailable.
MET Norway is forecast-only (no recent past days), so it's a degraded-but-working
fallback: the forecast / day-ahead views keep serving during an Open-Meteo outage.
Tests cover the daily aggregation + unit conversion (incl. the no-double-count
precip rule) and the fallback wiring.
2026-07-16 05:30:56 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _fetch_forecast_metno(cell: dict) -> pl.DataFrame:
|
|
|
|
|
|
"""Backup forward-forecast fetch from MET Norway / yr.no (used when Open-Meteo's
|
|
|
|
|
|
forecast API is unavailable). Coordinates are rounded to 4 decimals per MET's
|
|
|
|
|
|
ToS (improves their cache hit rate)."""
|
|
|
|
|
|
r = _request(
|
|
|
|
|
|
METNO_URL,
|
|
|
|
|
|
{"lat": round(cell["center_lat"], 4), "lon": round(cell["center_lon"], 4)},
|
|
|
|
|
|
60,
|
|
|
|
|
|
phase="forecast_metno",
|
|
|
|
|
|
headers={"User-Agent": METNO_UA},
|
|
|
|
|
|
)
|
|
|
|
|
|
return _metno_to_frame(r.json().get("properties", {}))
|
|
|
|
|
|
|
|
|
|
|
|
|
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 _fetch_history_range(cell: dict, start_date: str, end_date: str) -> pl.DataFrame:
|
2026-07-11 00:29:47 +00:00
|
|
|
|
"""Fetch just a date range of archive history (used to top up the recent tail)."""
|
2026-07-20 13:16:56 +00:00
|
|
|
|
params = _om_daily_params(cell, start_date=start_date, end_date=end_date, models=ARCHIVE_MODEL)
|
2026-07-11 00:29:47 +00:00
|
|
|
|
r = _request(ARCHIVE_URL, params, 60, phase="history_topup")
|
|
|
|
|
|
return _to_frame(r.json()["daily"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _read_history_cache(path):
|
|
|
|
|
|
"""Schema-complete cached frame (no doy) + its file age in seconds, or None.
|
|
|
|
|
|
|
|
|
|
|
|
No age expiry — history is cached indefinitely; a pre-wind/humidity schema is
|
|
|
|
|
|
the only reason to refetch (to add the new metric columns)."""
|
|
|
|
|
|
if not os.path.exists(path):
|
|
|
|
|
|
return None
|
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
|
|
|
|
df = _normalize_read(pl.read_parquet(path))
|
2026-07-11 00:29:47 +00:00
|
|
|
|
if not all(c in df.columns for c in NEW_COLS):
|
|
|
|
|
|
return None
|
|
|
|
|
|
return df, time.time() - os.path.getmtime(path)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 14:34:51 +00:00
|
|
|
|
def _fetch_history_tail(cell: dict, start_date: str, end_date: str) -> pl.DataFrame:
|
2026-07-24 23:56:34 +00:00
|
|
|
|
"""Fetch a recent history range to top up the cached tail — the Open-Meteo
|
|
|
|
|
|
archive, which is the same ERA5 family as the lake record it extends, so
|
|
|
|
|
|
topped-up days grade against the climatology without a source seam (NASA's
|
|
|
|
|
|
MERRA-2 days ran ~1.3°F off ERA5, sign varying by location). Raises while
|
|
|
|
|
|
the archive is in a rate-limit cooldown; the top-up is best-effort and
|
|
|
|
|
|
retries next interval."""
|
|
|
|
|
|
if time.time() < _archive_cooldown_until:
|
|
|
|
|
|
raise WeatherUnavailable(limit_message(_archive_limit_daily),
|
|
|
|
|
|
daily=_archive_limit_daily)
|
|
|
|
|
|
return _fetch_history_range(cell, start_date, end_date)
|
2026-07-23 14:34:51 +00:00
|
|
|
|
|
|
|
|
|
|
|
Move the climate record from parquet to TimescaleDB hypertables (#227)
Replace the per-cell parquet cache with TimescaleDB hypertables as the
production backend for the raw daily climate record, and drop pg_duckdb.
Parquet stays the backend whenever THERMOGRAPH_DATABASE_URL is not a
Postgres URL (dev, tests, offline tooling), the same dialect switch the
accounts DB and derived store already use, so CI stays Postgres-free.
- data/climate_store.py: psycopg + polars bridge over climate_history
(a hypertable), climate_recent, and climate_sync (per-cell freshness).
Reads via pl.read_database, writes via COPY + ON CONFLICT upsert;
fail-soft to a cache miss so a DB hiccup degrades to upstream refetch.
- data/climate.py: route every cache/mtime touchpoint through a backend
dispatch. recent_stamp becomes int(recent_synced_at) on Postgres; the
stale-serve path still avoids bumping it, so derived-payload tokens
invalidate on exactly the same events as before.
- alembic 0002: CREATE EXTENSION timescaledb plus the hypertable schema
(compression policy on year-old chunks), guarded to no-op off Postgres.
- migrate_cache_to_pg.py (make migrate-cache): idempotent backfill of the
parquet cache into the hypertables, preserving file mtimes as sync
timestamps so recent_stamp is unchanged across cutover.
- db image -> stock timescale/timescaledb:latest-pg18; drop the custom
pg_duckdb Dockerfile, the read-only /parquet mount, and the duckdb
tuning GUC. Docs updated for the new backend and cutover.
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-20 20:15:55 +00:00
|
|
|
|
def _topup_tail(cell: dict, df: pl.DataFrame) -> pl.DataFrame:
|
2026-07-11 00:29:47 +00:00
|
|
|
|
"""Append newly-available archive days to a cached record. Best-effort and
|
|
|
|
|
|
serialized per cell; a small incremental fetch, not the full multi-decade pull."""
|
Move the climate record from parquet to TimescaleDB hypertables (#227)
Replace the per-cell parquet cache with TimescaleDB hypertables as the
production backend for the raw daily climate record, and drop pg_duckdb.
Parquet stays the backend whenever THERMOGRAPH_DATABASE_URL is not a
Postgres URL (dev, tests, offline tooling), the same dialect switch the
accounts DB and derived store already use, so CI stays Postgres-free.
- data/climate_store.py: psycopg + polars bridge over climate_history
(a hypertable), climate_recent, and climate_sync (per-cell freshness).
Reads via pl.read_database, writes via COPY + ON CONFLICT upsert;
fail-soft to a cache miss so a DB hiccup degrades to upstream refetch.
- data/climate.py: route every cache/mtime touchpoint through a backend
dispatch. recent_stamp becomes int(recent_synced_at) on Postgres; the
stale-serve path still avoids bumping it, so derived-payload tokens
invalidate on exactly the same events as before.
- alembic 0002: CREATE EXTENSION timescaledb plus the hypertable schema
(compression policy on year-old chunks), guarded to no-op off Postgres.
- migrate_cache_to_pg.py (make migrate-cache): idempotent backfill of the
parquet cache into the hypertables, preserving file mtimes as sync
timestamps so recent_stamp is unchanged across cutover.
- db image -> stock timescale/timescaledb:latest-pg18; drop the custom
pg_duckdb Dockerfile, the read-only /parquet mount, and the duckdb
tuning GUC. Docs updated for the new backend and cutover.
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-20 20:15:55 +00:00
|
|
|
|
cell_id = cell["id"]
|
|
|
|
|
|
with _cell_lock(cell_id):
|
|
|
|
|
|
fresh = _read_history_backed(cell_id) # another thread may have just refreshed it
|
2026-07-11 00:29:47 +00:00
|
|
|
|
if fresh is not None:
|
|
|
|
|
|
df, age_s = fresh
|
|
|
|
|
|
if age_s < HISTORY_TOPUP_INTERVAL:
|
|
|
|
|
|
return df
|
|
|
|
|
|
expected = datetime.date.today() - datetime.timedelta(days=ARCHIVE_LATENCY_DAYS)
|
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
|
|
|
|
cached_max = df["date"].max()
|
2026-07-11 00:29:47 +00:00
|
|
|
|
if cached_max >= expected:
|
Move the climate record from parquet to TimescaleDB hypertables (#227)
Replace the per-cell parquet cache with TimescaleDB hypertables as the
production backend for the raw daily climate record, and drop pg_duckdb.
Parquet stays the backend whenever THERMOGRAPH_DATABASE_URL is not a
Postgres URL (dev, tests, offline tooling), the same dialect switch the
accounts DB and derived store already use, so CI stays Postgres-free.
- data/climate_store.py: psycopg + polars bridge over climate_history
(a hypertable), climate_recent, and climate_sync (per-cell freshness).
Reads via pl.read_database, writes via COPY + ON CONFLICT upsert;
fail-soft to a cache miss so a DB hiccup degrades to upstream refetch.
- data/climate.py: route every cache/mtime touchpoint through a backend
dispatch. recent_stamp becomes int(recent_synced_at) on Postgres; the
stale-serve path still avoids bumping it, so derived-payload tokens
invalidate on exactly the same events as before.
- alembic 0002: CREATE EXTENSION timescaledb plus the hypertable schema
(compression policy on year-old chunks), guarded to no-op off Postgres.
- migrate_cache_to_pg.py (make migrate-cache): idempotent backfill of the
parquet cache into the hypertables, preserving file mtimes as sync
timestamps so recent_stamp is unchanged across cutover.
- db image -> stock timescale/timescaledb:latest-pg18; drop the custom
pg_duckdb Dockerfile, the read-only /parquet mount, and the duckdb
tuning GUC. Docs updated for the new backend and cutover.
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-20 20:15:55 +00:00
|
|
|
|
_touch_history_backed(cell_id) # tail already current — reset the hourly timer
|
2026-07-11 00:29:47 +00:00
|
|
|
|
return df
|
|
|
|
|
|
try:
|
2026-07-23 14:34:51 +00:00
|
|
|
|
recent = _fetch_history_tail(
|
2026-07-11 00:29:47 +00:00
|
|
|
|
cell, (cached_max + datetime.timedelta(days=1)).isoformat(), expected.isoformat())
|
|
|
|
|
|
except Exception as e: # noqa: BLE001 - keep the cached record on failure
|
2026-07-11 19:53:46 +00:00
|
|
|
|
if is_rate_limit(e):
|
2026-07-11 00:29:47 +00:00
|
|
|
|
_note_rate_limit(e)
|
|
|
|
|
|
return df
|
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
|
|
|
|
# Concatenate archive-first, recent-last so `keep="last"` prefers a freshly
|
|
|
|
|
|
# fetched day over its cached duplicate; maintain_order keeps that precedence
|
|
|
|
|
|
# before the final chronological sort.
|
Move the climate record from parquet to TimescaleDB hypertables (#227)
Replace the per-cell parquet cache with TimescaleDB hypertables as the
production backend for the raw daily climate record, and drop pg_duckdb.
Parquet stays the backend whenever THERMOGRAPH_DATABASE_URL is not a
Postgres URL (dev, tests, offline tooling), the same dialect switch the
accounts DB and derived store already use, so CI stays Postgres-free.
- data/climate_store.py: psycopg + polars bridge over climate_history
(a hypertable), climate_recent, and climate_sync (per-cell freshness).
Reads via pl.read_database, writes via COPY + ON CONFLICT upsert;
fail-soft to a cache miss so a DB hiccup degrades to upstream refetch.
- data/climate.py: route every cache/mtime touchpoint through a backend
dispatch. recent_stamp becomes int(recent_synced_at) on Postgres; the
stale-serve path still avoids bumping it, so derived-payload tokens
invalidate on exactly the same events as before.
- alembic 0002: CREATE EXTENSION timescaledb plus the hypertable schema
(compression policy on year-old chunks), guarded to no-op off Postgres.
- migrate_cache_to_pg.py (make migrate-cache): idempotent backfill of the
parquet cache into the hypertables, preserving file mtimes as sync
timestamps so recent_stamp is unchanged across cutover.
- db image -> stock timescale/timescaledb:latest-pg18; drop the custom
pg_duckdb Dockerfile, the read-only /parquet mount, and the duckdb
tuning GUC. Docs updated for the new backend and cutover.
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-20 20:15:55 +00:00
|
|
|
|
tail = recent.drop("doy", strict=False)
|
|
|
|
|
|
merged = (pl.concat([df, tail], how="diagonal_relaxed")
|
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
|
|
|
|
.unique(subset="date", keep="last", maintain_order=True)
|
|
|
|
|
|
.sort("date"))
|
Move the climate record from parquet to TimescaleDB hypertables (#227)
Replace the per-cell parquet cache with TimescaleDB hypertables as the
production backend for the raw daily climate record, and drop pg_duckdb.
Parquet stays the backend whenever THERMOGRAPH_DATABASE_URL is not a
Postgres URL (dev, tests, offline tooling), the same dialect switch the
accounts DB and derived store already use, so CI stays Postgres-free.
- data/climate_store.py: psycopg + polars bridge over climate_history
(a hypertable), climate_recent, and climate_sync (per-cell freshness).
Reads via pl.read_database, writes via COPY + ON CONFLICT upsert;
fail-soft to a cache miss so a DB hiccup degrades to upstream refetch.
- data/climate.py: route every cache/mtime touchpoint through a backend
dispatch. recent_stamp becomes int(recent_synced_at) on Postgres; the
stale-serve path still avoids bumping it, so derived-payload tokens
invalidate on exactly the same events as before.
- alembic 0002: CREATE EXTENSION timescaledb plus the hypertable schema
(compression policy on year-old chunks), guarded to no-op off Postgres.
- migrate_cache_to_pg.py (make migrate-cache): idempotent backfill of the
parquet cache into the hypertables, preserving file mtimes as sync
timestamps so recent_stamp is unchanged across cutover.
- db image -> stock timescale/timescaledb:latest-pg18; drop the custom
pg_duckdb Dockerfile, the read-only /parquet mount, and the duckdb
tuning GUC. Docs updated for the new backend and cutover.
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-20 20:15:55 +00:00
|
|
|
|
# Postgres only needs the new/refetched tail rows upserted (ON CONFLICT gives
|
|
|
|
|
|
# the same keep="last" precedence); parquet rewrites the whole file.
|
|
|
|
|
|
_write_history_backed(cell_id, merged, delta_df=tail)
|
2026-07-11 00:29:47 +00:00
|
|
|
|
return merged
|
|
|
|
|
|
|
|
|
|
|
|
|
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 get_history(cell: dict) -> tuple[pl.DataFrame, dict]:
|
2026-07-11 00:29:47 +00:00
|
|
|
|
"""Return (daily history frame, cache metadata) for a cell, with humidity as
|
|
|
|
|
|
absolute humidity (g/m³). Thin wrapper over the raw loader (see below)."""
|
|
|
|
|
|
df, meta = _load_history(cell)
|
Add a climate-score page from recent-vs-baseline percentile divergence (#196)
Score how far a location's last 6 years have drifted from its full 45-year
record. For each metric and percentile category (p10/p25/p50/p75/p90), the
recent-years value is placed on the baseline distribution and the gap from the
expected percentile is the divergence — unit-free, so metrics compare directly.
Scored per meteorological season plus annual, weighted into per-metric and
overall scores (temps, humidity and feels-like weighted heaviest).
- backend/scoring.py: divergence math, seasonal slicing, precip zero-inflation
split (wet-day frequency + amount), tier mapping onto the existing temp scale.
- climate.py: derive a wet-bulb column (Stull 2011) at the read boundary, before
the humidity column is converted to absolute — via a shared _derive_metrics
wrapper at all four read sites.
- api/v2/score endpoint + build_score payload, cached on the history token with
a scoring-version key.
- frontend score page: overall hero, per-metric cards, by-season chips, and a
button-revealed summary (sentences + metrics×season table + per-percentile
detail). Score nav link across all headers.
- Tests for the scoring math, wet-bulb formula, payload shape and route.
2026-07-19 23:02:33 +00:00
|
|
|
|
return _derive_metrics(df), meta
|
2026-07-11 00:29:47 +00:00
|
|
|
|
|
|
|
|
|
|
|
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 load_cached_history(cell: dict) -> pl.DataFrame | None:
|
Move the climate record from parquet to TimescaleDB hypertables (#227)
Replace the per-cell parquet cache with TimescaleDB hypertables as the
production backend for the raw daily climate record, and drop pg_duckdb.
Parquet stays the backend whenever THERMOGRAPH_DATABASE_URL is not a
Postgres URL (dev, tests, offline tooling), the same dialect switch the
accounts DB and derived store already use, so CI stays Postgres-free.
- data/climate_store.py: psycopg + polars bridge over climate_history
(a hypertable), climate_recent, and climate_sync (per-cell freshness).
Reads via pl.read_database, writes via COPY + ON CONFLICT upsert;
fail-soft to a cache miss so a DB hiccup degrades to upstream refetch.
- data/climate.py: route every cache/mtime touchpoint through a backend
dispatch. recent_stamp becomes int(recent_synced_at) on Postgres; the
stale-serve path still avoids bumping it, so derived-payload tokens
invalidate on exactly the same events as before.
- alembic 0002: CREATE EXTENSION timescaledb plus the hypertable schema
(compression policy on year-old chunks), guarded to no-op off Postgres.
- migrate_cache_to_pg.py (make migrate-cache): idempotent backfill of the
parquet cache into the hypertables, preserving file mtimes as sync
timestamps so recent_stamp is unchanged across cutover.
- db image -> stock timescale/timescaledb:latest-pg18; drop the custom
pg_duckdb Dockerfile, the read-only /parquet mount, and the duckdb
tuning GUC. Docs updated for the new backend and cutover.
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-20 20:15:55 +00:00
|
|
|
|
"""History from the cache ONLY — never fetches upstream and never tops up the
|
|
|
|
|
|
tail. Powers the warm-only prefetch path (which must not spend upstream quota)
|
|
|
|
|
|
and the offline migrate script. None when the cell has no (schema-complete)
|
|
|
|
|
|
cached record."""
|
|
|
|
|
|
hit = _read_history_backed(cell["id"])
|
Persistent derived-data cache: SQLite store, ETag revalidation, view bundle, IndexedDB frontend (#21)
* Compress API responses and revalidate static assets instead of re-downloading
- Add GZipMiddleware (min 1 KB): the 2-year calendar JSON shrinks ~6-8x.
- Serve pages/assets with Cache-Control: no-cache instead of no-store, so
browsers revalidate via the ETag/Last-Modified that FileResponse and
StaticFiles already emit. Unchanged assets now cost an empty 304 rather
than a full transfer on every page navigation, while deploys still show
up immediately.
* Persist derived responses in SQLite so grading is computed once per cell, not per request
New data/thermograph.sqlite (WAL) holds what's derived from the raw parquet
records — finished grade/calendar/day/forecast payloads and reverse-geocode
labels — so the expensive work (notably the 2-year calendar grade_range, ~270ms)
becomes a ~5ms database read that survives restarts and is shared across views.
Raw parquet stays the source of truth; the store is a pure accelerator (every
reader falls back to recomputing on a miss, and deleting the db is a safe reset).
Freshness is token-driven, not clock-driven: each cached payload is validated by
a token encoding what it was computed from (payload schema version, the archive
record's end date, the recent-fetch stamp). The existing freshness drivers are
untouched — get_history still tops up the tail hourly and get_recent_forecast
still refetches hourly — and tokens are derived from what they return, so cached
payloads expire exactly when their inputs change. The same tokens double as weak
ETags: If-None-Match answers with an empty 304 without touching the payload.
- backend/store.py: derived-payload + revgeo tables, thread-local WAL conns,
every helper fail-soft.
- app.py: endpoints split into pure payload builders + HTTP/caching shells; the
in-memory 10-minute _CAL_CACHE is retired (superseded by the persistent store).
- climate.py: revgeo persisted through the store; recent_stamp() and
load_cached_history() (no-network read) helpers.
- backend/migrate.py + make migrate: idempotent, resumable backfill of the store
from existing parquet caches (default calendar span + latest-day detail +
revgeo, ≤1 throttled Nominatim call per unlabeled cell). Never fetches weather.
- grid.from_id(): rebuild a cell from its cache filename (migrate tooling).
* Add /api/v2/cell: one bundle carrying every view's payload
GET /api/v2/cell?lat&lon returns the grade, forecast, calendar (last 24 months)
and day (today) payloads in one response, each the exact payload its per-view
endpoint returns — built by the same builders and cached under the same
derived-store keys/tokens — paired with the etag that endpoint would emit. The
frontend can warm all views with a single request, seed its per-view cache from
the slices, and later revalidate each view individually with If-None-Match. The
bundle's own etag combines the slices', so an unchanged bundle is an empty 304.
prefetch=1 is a warm-only mode for neighbor-cell prefetching with a hard
guarantee: it never spends weather-API quota. A cell with no cached archive
answers 204, and only the history-derived slices (calendar + latest-day detail)
are built. At most one Nominatim lookup for a never-labeled cell.
* Frontend: IndexedDB response cache with stale-while-revalidate + bundle prefetch
The per-URL response cache moves from localStorage/sessionStorage (~5 MB quota,
which multi-year calendar payloads regularly blew through) to IndexedDB, with an
in-memory map in front. Entries carry the server's ETag, so anything stale
revalidates conditionally — unchanged data costs an empty 304 and a re-stamp,
never a re-transfer. On network failure the stale copy is served over an error.
getJSON gains an optional onUpdate callback opting into stale-while-revalidate:
the three views (weekly, day, calendar) now render a cached copy immediately —
spinners are delayed 150ms so warm loads never flash-blank — and repaint only if
background revalidation finds changed data. New data shows up the moment it
exists instead of waiting out a TTL.
Cross-view prefetch collapses from one request per view to a single /api/v2/cell
bundle, whose slices (exact per-view payloads + their etags) are seeded under the
URLs each view actually requests; the bundle call itself is conditional via a
remembered etag. Afterwards the 8 surrounding grid cells are warmed server-side
with prefetch=1 (never spends weather-API quota; staggered ≥1.1s for the one
possible Nominatim lookup each), so tapping nearby lands on already-graded data.
Legacy tg:* storage entries are cleared once; cache entries untouched for two
weeks are pruned on page load.
2026-07-11 07:31:28 +00:00
|
|
|
|
if hit is None:
|
|
|
|
|
|
return None
|
Add a climate-score page from recent-vs-baseline percentile divergence (#196)
Score how far a location's last 6 years have drifted from its full 45-year
record. For each metric and percentile category (p10/p25/p50/p75/p90), the
recent-years value is placed on the baseline distribution and the gap from the
expected percentile is the divergence — unit-free, so metrics compare directly.
Scored per meteorological season plus annual, weighted into per-metric and
overall scores (temps, humidity and feels-like weighted heaviest).
- backend/scoring.py: divergence math, seasonal slicing, precip zero-inflation
split (wet-day frequency + amount), tier mapping onto the existing temp scale.
- climate.py: derive a wet-bulb column (Stull 2011) at the read boundary, before
the humidity column is converted to absolute — via a shared _derive_metrics
wrapper at all four read sites.
- api/v2/score endpoint + build_score payload, cached on the history token with
a scoring-version key.
- frontend score page: overall hero, per-metric cards, by-season chips, and a
button-revealed summary (sentences + metrics×season table + per-percentile
detail). Score nav link across all headers.
- Tests for the scoring math, wet-bulb formula, payload shape and route.
2026-07-19 23:02:33 +00:00
|
|
|
|
return _derive_metrics(_with_doy(hit[0]))
|
Persistent derived-data cache: SQLite store, ETag revalidation, view bundle, IndexedDB frontend (#21)
* Compress API responses and revalidate static assets instead of re-downloading
- Add GZipMiddleware (min 1 KB): the 2-year calendar JSON shrinks ~6-8x.
- Serve pages/assets with Cache-Control: no-cache instead of no-store, so
browsers revalidate via the ETag/Last-Modified that FileResponse and
StaticFiles already emit. Unchanged assets now cost an empty 304 rather
than a full transfer on every page navigation, while deploys still show
up immediately.
* Persist derived responses in SQLite so grading is computed once per cell, not per request
New data/thermograph.sqlite (WAL) holds what's derived from the raw parquet
records — finished grade/calendar/day/forecast payloads and reverse-geocode
labels — so the expensive work (notably the 2-year calendar grade_range, ~270ms)
becomes a ~5ms database read that survives restarts and is shared across views.
Raw parquet stays the source of truth; the store is a pure accelerator (every
reader falls back to recomputing on a miss, and deleting the db is a safe reset).
Freshness is token-driven, not clock-driven: each cached payload is validated by
a token encoding what it was computed from (payload schema version, the archive
record's end date, the recent-fetch stamp). The existing freshness drivers are
untouched — get_history still tops up the tail hourly and get_recent_forecast
still refetches hourly — and tokens are derived from what they return, so cached
payloads expire exactly when their inputs change. The same tokens double as weak
ETags: If-None-Match answers with an empty 304 without touching the payload.
- backend/store.py: derived-payload + revgeo tables, thread-local WAL conns,
every helper fail-soft.
- app.py: endpoints split into pure payload builders + HTTP/caching shells; the
in-memory 10-minute _CAL_CACHE is retired (superseded by the persistent store).
- climate.py: revgeo persisted through the store; recent_stamp() and
load_cached_history() (no-network read) helpers.
- backend/migrate.py + make migrate: idempotent, resumable backfill of the store
from existing parquet caches (default calendar span + latest-day detail +
revgeo, ≤1 throttled Nominatim call per unlabeled cell). Never fetches weather.
- grid.from_id(): rebuild a cell from its cache filename (migrate tooling).
* Add /api/v2/cell: one bundle carrying every view's payload
GET /api/v2/cell?lat&lon returns the grade, forecast, calendar (last 24 months)
and day (today) payloads in one response, each the exact payload its per-view
endpoint returns — built by the same builders and cached under the same
derived-store keys/tokens — paired with the etag that endpoint would emit. The
frontend can warm all views with a single request, seed its per-view cache from
the slices, and later revalidate each view individually with If-None-Match. The
bundle's own etag combines the slices', so an unchanged bundle is an empty 304.
prefetch=1 is a warm-only mode for neighbor-cell prefetching with a hard
guarantee: it never spends weather-API quota. A cell with no cached archive
answers 204, and only the history-derived slices (calendar + latest-day detail)
are built. At most one Nominatim lookup for a never-labeled cell.
* Frontend: IndexedDB response cache with stale-while-revalidate + bundle prefetch
The per-URL response cache moves from localStorage/sessionStorage (~5 MB quota,
which multi-year calendar payloads regularly blew through) to IndexedDB, with an
in-memory map in front. Entries carry the server's ETag, so anything stale
revalidates conditionally — unchanged data costs an empty 304 and a re-stamp,
never a re-transfer. On network failure the stale copy is served over an error.
getJSON gains an optional onUpdate callback opting into stale-while-revalidate:
the three views (weekly, day, calendar) now render a cached copy immediately —
spinners are delayed 150ms so warm loads never flash-blank — and repaint only if
background revalidation finds changed data. New data shows up the moment it
exists instead of waiting out a TTL.
Cross-view prefetch collapses from one request per view to a single /api/v2/cell
bundle, whose slices (exact per-view payloads + their etags) are seeded under the
URLs each view actually requests; the bundle call itself is conditional via a
remembered etag. Afterwards the 8 surrounding grid cells are warmed server-side
with prefetch=1 (never spends weather-API quota; staggered ≥1.1s for the one
possible Nominatim lookup each), so tapping nearby lands on already-graded data.
Legacy tg:* storage entries are cleared once; cache entries untouched for two
weeks are pruned on page load.
2026-07-11 07:31:28 +00:00
|
|
|
|
|
|
|
|
|
|
|
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 _load_history(cell: dict) -> tuple[pl.DataFrame, dict]:
|
2026-07-11 00:29:47 +00:00
|
|
|
|
"""Return (daily history frame, cache metadata) for a cell.
|
|
|
|
|
|
|
|
|
|
|
|
The full archive is cached indefinitely (fetched once); only the recent tail is
|
|
|
|
|
|
topped up, at most hourly. Concurrent callers are serialized so only one archive
|
|
|
|
|
|
fetch happens; on an upstream failure (e.g. 429) any existing cache is served."""
|
Move the climate record from parquet to TimescaleDB hypertables (#227)
Replace the per-cell parquet cache with TimescaleDB hypertables as the
production backend for the raw daily climate record, and drop pg_duckdb.
Parquet stays the backend whenever THERMOGRAPH_DATABASE_URL is not a
Postgres URL (dev, tests, offline tooling), the same dialect switch the
accounts DB and derived store already use, so CI stays Postgres-free.
- data/climate_store.py: psycopg + polars bridge over climate_history
(a hypertable), climate_recent, and climate_sync (per-cell freshness).
Reads via pl.read_database, writes via COPY + ON CONFLICT upsert;
fail-soft to a cache miss so a DB hiccup degrades to upstream refetch.
- data/climate.py: route every cache/mtime touchpoint through a backend
dispatch. recent_stamp becomes int(recent_synced_at) on Postgres; the
stale-serve path still avoids bumping it, so derived-payload tokens
invalidate on exactly the same events as before.
- alembic 0002: CREATE EXTENSION timescaledb plus the hypertable schema
(compression policy on year-old chunks), guarded to no-op off Postgres.
- migrate_cache_to_pg.py (make migrate-cache): idempotent backfill of the
parquet cache into the hypertables, preserving file mtimes as sync
timestamps so recent_stamp is unchanged across cutover.
- db image -> stock timescale/timescaledb:latest-pg18; drop the custom
pg_duckdb Dockerfile, the read-only /parquet mount, and the duckdb
tuning GUC. Docs updated for the new backend and cutover.
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-20 20:15:55 +00:00
|
|
|
|
cell_id = cell["id"]
|
2026-07-11 00:29:47 +00:00
|
|
|
|
|
Move the climate record from parquet to TimescaleDB hypertables (#227)
Replace the per-cell parquet cache with TimescaleDB hypertables as the
production backend for the raw daily climate record, and drop pg_duckdb.
Parquet stays the backend whenever THERMOGRAPH_DATABASE_URL is not a
Postgres URL (dev, tests, offline tooling), the same dialect switch the
accounts DB and derived store already use, so CI stays Postgres-free.
- data/climate_store.py: psycopg + polars bridge over climate_history
(a hypertable), climate_recent, and climate_sync (per-cell freshness).
Reads via pl.read_database, writes via COPY + ON CONFLICT upsert;
fail-soft to a cache miss so a DB hiccup degrades to upstream refetch.
- data/climate.py: route every cache/mtime touchpoint through a backend
dispatch. recent_stamp becomes int(recent_synced_at) on Postgres; the
stale-serve path still avoids bumping it, so derived-payload tokens
invalidate on exactly the same events as before.
- alembic 0002: CREATE EXTENSION timescaledb plus the hypertable schema
(compression policy on year-old chunks), guarded to no-op off Postgres.
- migrate_cache_to_pg.py (make migrate-cache): idempotent backfill of the
parquet cache into the hypertables, preserving file mtimes as sync
timestamps so recent_stamp is unchanged across cutover.
- db image -> stock timescale/timescaledb:latest-pg18; drop the custom
pg_duckdb Dockerfile, the read-only /parquet mount, and the duckdb
tuning GUC. Docs updated for the new backend and cutover.
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-20 20:15:55 +00:00
|
|
|
|
hit = _read_history_backed(cell_id)
|
2026-07-11 00:29:47 +00:00
|
|
|
|
if hit is not None:
|
|
|
|
|
|
df, age_s = hit
|
|
|
|
|
|
if age_s > HISTORY_TOPUP_INTERVAL:
|
Move the climate record from parquet to TimescaleDB hypertables (#227)
Replace the per-cell parquet cache with TimescaleDB hypertables as the
production backend for the raw daily climate record, and drop pg_duckdb.
Parquet stays the backend whenever THERMOGRAPH_DATABASE_URL is not a
Postgres URL (dev, tests, offline tooling), the same dialect switch the
accounts DB and derived store already use, so CI stays Postgres-free.
- data/climate_store.py: psycopg + polars bridge over climate_history
(a hypertable), climate_recent, and climate_sync (per-cell freshness).
Reads via pl.read_database, writes via COPY + ON CONFLICT upsert;
fail-soft to a cache miss so a DB hiccup degrades to upstream refetch.
- data/climate.py: route every cache/mtime touchpoint through a backend
dispatch. recent_stamp becomes int(recent_synced_at) on Postgres; the
stale-serve path still avoids bumping it, so derived-payload tokens
invalidate on exactly the same events as before.
- alembic 0002: CREATE EXTENSION timescaledb plus the hypertable schema
(compression policy on year-old chunks), guarded to no-op off Postgres.
- migrate_cache_to_pg.py (make migrate-cache): idempotent backfill of the
parquet cache into the hypertables, preserving file mtimes as sync
timestamps so recent_stamp is unchanged across cutover.
- db image -> stock timescale/timescaledb:latest-pg18; drop the custom
pg_duckdb Dockerfile, the read-only /parquet mount, and the duckdb
tuning GUC. Docs updated for the new backend and cutover.
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-20 20:15:55 +00:00
|
|
|
|
df = _topup_tail(cell, df) # refresh just the recent days
|
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
|
|
|
|
return _with_doy(df), {"cached": True, "cache_age_days": round(age_s / 86400.0, 1)}
|
2026-07-11 00:29:47 +00:00
|
|
|
|
|
|
|
|
|
|
global _archive_cooldown_until
|
|
|
|
|
|
|
Move the climate record from parquet to TimescaleDB hypertables (#227)
Replace the per-cell parquet cache with TimescaleDB hypertables as the
production backend for the raw daily climate record, and drop pg_duckdb.
Parquet stays the backend whenever THERMOGRAPH_DATABASE_URL is not a
Postgres URL (dev, tests, offline tooling), the same dialect switch the
accounts DB and derived store already use, so CI stays Postgres-free.
- data/climate_store.py: psycopg + polars bridge over climate_history
(a hypertable), climate_recent, and climate_sync (per-cell freshness).
Reads via pl.read_database, writes via COPY + ON CONFLICT upsert;
fail-soft to a cache miss so a DB hiccup degrades to upstream refetch.
- data/climate.py: route every cache/mtime touchpoint through a backend
dispatch. recent_stamp becomes int(recent_synced_at) on Postgres; the
stale-serve path still avoids bumping it, so derived-payload tokens
invalidate on exactly the same events as before.
- alembic 0002: CREATE EXTENSION timescaledb plus the hypertable schema
(compression policy on year-old chunks), guarded to no-op off Postgres.
- migrate_cache_to_pg.py (make migrate-cache): idempotent backfill of the
parquet cache into the hypertables, preserving file mtimes as sync
timestamps so recent_stamp is unchanged across cutover.
- db image -> stock timescale/timescaledb:latest-pg18; drop the custom
pg_duckdb Dockerfile, the read-only /parquet mount, and the duckdb
tuning GUC. Docs updated for the new backend and cutover.
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-20 20:15:55 +00:00
|
|
|
|
with _cell_lock(cell_id):
|
|
|
|
|
|
hit = _read_history_backed(cell_id) # another thread may have populated it while we waited
|
2026-07-11 00:29:47 +00:00
|
|
|
|
if hit is not None:
|
|
|
|
|
|
df, age_s = hit
|
2026-07-11 20:05:57 +00:00
|
|
|
|
return _with_doy(df), {"cached": True, "cache_age_days": round(age_s / 86400.0, 1)}
|
2026-07-11 00:29:47 +00:00
|
|
|
|
|
Move the climate record from parquet to TimescaleDB hypertables (#227)
Replace the per-cell parquet cache with TimescaleDB hypertables as the
production backend for the raw daily climate record, and drop pg_duckdb.
Parquet stays the backend whenever THERMOGRAPH_DATABASE_URL is not a
Postgres URL (dev, tests, offline tooling), the same dialect switch the
accounts DB and derived store already use, so CI stays Postgres-free.
- data/climate_store.py: psycopg + polars bridge over climate_history
(a hypertable), climate_recent, and climate_sync (per-cell freshness).
Reads via pl.read_database, writes via COPY + ON CONFLICT upsert;
fail-soft to a cache miss so a DB hiccup degrades to upstream refetch.
- data/climate.py: route every cache/mtime touchpoint through a backend
dispatch. recent_stamp becomes int(recent_synced_at) on Postgres; the
stale-serve path still avoids bumping it, so derived-payload tokens
invalidate on exactly the same events as before.
- alembic 0002: CREATE EXTENSION timescaledb plus the hypertable schema
(compression policy on year-old chunks), guarded to no-op off Postgres.
- migrate_cache_to_pg.py (make migrate-cache): idempotent backfill of the
parquet cache into the hypertables, preserving file mtimes as sync
timestamps so recent_stamp is unchanged across cutover.
- db image -> stock timescale/timescaledb:latest-pg18; drop the custom
pg_duckdb Dockerfile, the read-only /parquet mount, and the duckdb
tuning GUC. Docs updated for the new backend and cutover.
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-20 20:15:55 +00:00
|
|
|
|
stale = _stale_history_backed(cell_id)
|
2026-07-11 00:29:47 +00:00
|
|
|
|
|
|
|
|
|
|
def _serve_stale():
|
2026-07-11 20:05:57 +00:00
|
|
|
|
return _with_doy(stale), {"cached": True, "stale": True, "cache_age_days": None}
|
2026-07-11 00:29:47 +00:00
|
|
|
|
|
2026-07-24 23:56:34 +00:00
|
|
|
|
# Fetch. The ERA5 lake is first (true ERA5 with measured gusts, served
|
|
|
|
|
|
# from our own bucket — no third-party API in the path); unconfigured
|
|
|
|
|
|
# or missing-point cells fall through at zero cost. The backup is the
|
|
|
|
|
|
# Open-Meteo archive — the same ERA5 family as the lake, so a
|
|
|
|
|
|
# backup-sourced record grades consistently with lake-sourced
|
|
|
|
|
|
# neighbors — still guarded by its rate-limit cooldown. NASA POWER is
|
|
|
|
|
|
# retired from serving (its MERRA-2 record ran ~1.3°F off ERA5 with no
|
|
|
|
|
|
# gusts; kept only for drift_check). Every source must return a
|
2026-07-23 21:20:35 +00:00
|
|
|
|
# plausibly-full span before being accepted and cached as a complete
|
|
|
|
|
|
# record (a short/partial response is rejected rather than held
|
|
|
|
|
|
# indefinitely).
|
2026-07-11 00:29:47 +00:00
|
|
|
|
df = None
|
|
|
|
|
|
source = None
|
2026-07-23 14:34:51 +00:00
|
|
|
|
try:
|
2026-07-23 21:20:35 +00:00
|
|
|
|
fetched = era5lake.fetch_history_lake(cell, start=START_DATE)
|
2026-07-23 14:34:51 +00:00
|
|
|
|
if fetched.height >= MIN_ARCHIVE_DAYS:
|
|
|
|
|
|
df = fetched
|
2026-07-23 21:20:35 +00:00
|
|
|
|
source = "era5-lake"
|
2026-07-24 23:56:34 +00:00
|
|
|
|
except Exception: # noqa: BLE001 - lake unconfigured/miss; fall through to Open-Meteo
|
2026-07-23 14:34:51 +00:00
|
|
|
|
pass
|
|
|
|
|
|
if df is None and time.time() >= _archive_cooldown_until:
|
2026-07-11 00:29:47 +00:00
|
|
|
|
try:
|
2026-07-20 14:02:36 +00:00
|
|
|
|
fetched = _fetch_history(cell)
|
|
|
|
|
|
if fetched.height >= MIN_ARCHIVE_DAYS:
|
|
|
|
|
|
df = fetched
|
|
|
|
|
|
source = "open-meteo"
|
2026-07-11 00:29:47 +00:00
|
|
|
|
except Exception as e: # noqa: BLE001
|
2026-07-11 19:53:46 +00:00
|
|
|
|
if is_rate_limit(e):
|
2026-07-11 00:29:47 +00:00
|
|
|
|
_note_rate_limit(e)
|
|
|
|
|
|
if df is None:
|
|
|
|
|
|
if stale is not None:
|
|
|
|
|
|
return _serve_stale()
|
2026-07-11 19:53:46 +00:00
|
|
|
|
raise WeatherUnavailable(limit_message(_archive_limit_daily),
|
|
|
|
|
|
daily=_archive_limit_daily)
|
2026-07-11 00:29:47 +00:00
|
|
|
|
|
|
|
|
|
|
# Store the raw record; percentiles are derived at request time.
|
Move the climate record from parquet to TimescaleDB hypertables (#227)
Replace the per-cell parquet cache with TimescaleDB hypertables as the
production backend for the raw daily climate record, and drop pg_duckdb.
Parquet stays the backend whenever THERMOGRAPH_DATABASE_URL is not a
Postgres URL (dev, tests, offline tooling), the same dialect switch the
accounts DB and derived store already use, so CI stays Postgres-free.
- data/climate_store.py: psycopg + polars bridge over climate_history
(a hypertable), climate_recent, and climate_sync (per-cell freshness).
Reads via pl.read_database, writes via COPY + ON CONFLICT upsert;
fail-soft to a cache miss so a DB hiccup degrades to upstream refetch.
- data/climate.py: route every cache/mtime touchpoint through a backend
dispatch. recent_stamp becomes int(recent_synced_at) on Postgres; the
stale-serve path still avoids bumping it, so derived-payload tokens
invalidate on exactly the same events as before.
- alembic 0002: CREATE EXTENSION timescaledb plus the hypertable schema
(compression policy on year-old chunks), guarded to no-op off Postgres.
- migrate_cache_to_pg.py (make migrate-cache): idempotent backfill of the
parquet cache into the hypertables, preserving file mtimes as sync
timestamps so recent_stamp is unchanged across cutover.
- db image -> stock timescale/timescaledb:latest-pg18; drop the custom
pg_duckdb Dockerfile, the read-only /parquet mount, and the duckdb
tuning GUC. Docs updated for the new backend and cutover.
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-20 20:15:55 +00:00
|
|
|
|
_write_history_backed(cell_id, df)
|
2026-07-11 00:29:47 +00:00
|
|
|
|
return df, {"cached": False, "cache_age_days": 0, "source": source}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
RECENT_PAST_DAYS = 25 # recent observations window (covers the ~2-week graded view)
|
|
|
|
|
|
FORECAST_DAYS = 8 # today + 7 days ahead
|
2026-07-23 14:34:51 +00:00
|
|
|
|
# NASA POWER near-real-time lags a couple of days, so the recent observed window ends
|
|
|
|
|
|
# here; the small today-1/today-2 gap before the MET Norway forecast begins grades as
|
|
|
|
|
|
# missing (drop_nulls), bracketed by history behind and forecast ahead.
|
|
|
|
|
|
RECENT_END_LAG_DAYS = 2
|
2026-07-11 00:29:47 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _rf_cache_path(cell_id: str) -> str:
|
|
|
|
|
|
return os.path.join(CACHE_DIR, f"{cell_id}_rf.parquet")
|
|
|
|
|
|
|
|
|
|
|
|
|
Persistent derived-data cache: SQLite store, ETag revalidation, view bundle, IndexedDB frontend (#21)
* Compress API responses and revalidate static assets instead of re-downloading
- Add GZipMiddleware (min 1 KB): the 2-year calendar JSON shrinks ~6-8x.
- Serve pages/assets with Cache-Control: no-cache instead of no-store, so
browsers revalidate via the ETag/Last-Modified that FileResponse and
StaticFiles already emit. Unchanged assets now cost an empty 304 rather
than a full transfer on every page navigation, while deploys still show
up immediately.
* Persist derived responses in SQLite so grading is computed once per cell, not per request
New data/thermograph.sqlite (WAL) holds what's derived from the raw parquet
records — finished grade/calendar/day/forecast payloads and reverse-geocode
labels — so the expensive work (notably the 2-year calendar grade_range, ~270ms)
becomes a ~5ms database read that survives restarts and is shared across views.
Raw parquet stays the source of truth; the store is a pure accelerator (every
reader falls back to recomputing on a miss, and deleting the db is a safe reset).
Freshness is token-driven, not clock-driven: each cached payload is validated by
a token encoding what it was computed from (payload schema version, the archive
record's end date, the recent-fetch stamp). The existing freshness drivers are
untouched — get_history still tops up the tail hourly and get_recent_forecast
still refetches hourly — and tokens are derived from what they return, so cached
payloads expire exactly when their inputs change. The same tokens double as weak
ETags: If-None-Match answers with an empty 304 without touching the payload.
- backend/store.py: derived-payload + revgeo tables, thread-local WAL conns,
every helper fail-soft.
- app.py: endpoints split into pure payload builders + HTTP/caching shells; the
in-memory 10-minute _CAL_CACHE is retired (superseded by the persistent store).
- climate.py: revgeo persisted through the store; recent_stamp() and
load_cached_history() (no-network read) helpers.
- backend/migrate.py + make migrate: idempotent, resumable backfill of the store
from existing parquet caches (default calendar span + latest-day detail +
revgeo, ≤1 throttled Nominatim call per unlabeled cell). Never fetches weather.
- grid.from_id(): rebuild a cell from its cache filename (migrate tooling).
* Add /api/v2/cell: one bundle carrying every view's payload
GET /api/v2/cell?lat&lon returns the grade, forecast, calendar (last 24 months)
and day (today) payloads in one response, each the exact payload its per-view
endpoint returns — built by the same builders and cached under the same
derived-store keys/tokens — paired with the etag that endpoint would emit. The
frontend can warm all views with a single request, seed its per-view cache from
the slices, and later revalidate each view individually with If-None-Match. The
bundle's own etag combines the slices', so an unchanged bundle is an empty 304.
prefetch=1 is a warm-only mode for neighbor-cell prefetching with a hard
guarantee: it never spends weather-API quota. A cell with no cached archive
answers 204, and only the history-derived slices (calendar + latest-day detail)
are built. At most one Nominatim lookup for a never-labeled cell.
* Frontend: IndexedDB response cache with stale-while-revalidate + bundle prefetch
The per-URL response cache moves from localStorage/sessionStorage (~5 MB quota,
which multi-year calendar payloads regularly blew through) to IndexedDB, with an
in-memory map in front. Entries carry the server's ETag, so anything stale
revalidates conditionally — unchanged data costs an empty 304 and a re-stamp,
never a re-transfer. On network failure the stale copy is served over an error.
getJSON gains an optional onUpdate callback opting into stale-while-revalidate:
the three views (weekly, day, calendar) now render a cached copy immediately —
spinners are delayed 150ms so warm loads never flash-blank — and repaint only if
background revalidation finds changed data. New data shows up the moment it
exists instead of waiting out a TTL.
Cross-view prefetch collapses from one request per view to a single /api/v2/cell
bundle, whose slices (exact per-view payloads + their etags) are seeded under the
URLs each view actually requests; the bundle call itself is conditional via a
remembered etag. Afterwards the 8 surrounding grid cells are warmed server-side
with prefetch=1 (never spends weather-API quota; staggered ≥1.1s for the one
possible Nominatim lookup each), so tapping nearby lands on already-graded data.
Legacy tg:* storage entries are cleared once; cache entries untouched for two
weeks are pruned on page load.
2026-07-11 07:31:28 +00:00
|
|
|
|
def recent_stamp(cell_id: str) -> int:
|
Move the climate record from parquet to TimescaleDB hypertables (#227)
Replace the per-cell parquet cache with TimescaleDB hypertables as the
production backend for the raw daily climate record, and drop pg_duckdb.
Parquet stays the backend whenever THERMOGRAPH_DATABASE_URL is not a
Postgres URL (dev, tests, offline tooling), the same dialect switch the
accounts DB and derived store already use, so CI stays Postgres-free.
- data/climate_store.py: psycopg + polars bridge over climate_history
(a hypertable), climate_recent, and climate_sync (per-cell freshness).
Reads via pl.read_database, writes via COPY + ON CONFLICT upsert;
fail-soft to a cache miss so a DB hiccup degrades to upstream refetch.
- data/climate.py: route every cache/mtime touchpoint through a backend
dispatch. recent_stamp becomes int(recent_synced_at) on Postgres; the
stale-serve path still avoids bumping it, so derived-payload tokens
invalidate on exactly the same events as before.
- alembic 0002: CREATE EXTENSION timescaledb plus the hypertable schema
(compression policy on year-old chunks), guarded to no-op off Postgres.
- migrate_cache_to_pg.py (make migrate-cache): idempotent backfill of the
parquet cache into the hypertables, preserving file mtimes as sync
timestamps so recent_stamp is unchanged across cutover.
- db image -> stock timescale/timescaledb:latest-pg18; drop the custom
pg_duckdb Dockerfile, the read-only /parquet mount, and the duckdb
tuning GUC. Docs updated for the new backend and cutover.
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-20 20:15:55 +00:00
|
|
|
|
"""Identity stamp (whole seconds) of the cell's cached recent+forecast bundle;
|
|
|
|
|
|
0 when absent. On Postgres it's int(recent_synced_at) from climate_sync; on
|
|
|
|
|
|
parquet it's the file mtime. Either way it changes exactly when the
|
|
|
|
|
|
recent/forecast data is rewritten (and NOT on a stale serve), so it's the
|
|
|
|
|
|
freshness token for every payload that grades recent or future days."""
|
|
|
|
|
|
if climate_store.is_postgres():
|
|
|
|
|
|
return int(climate_store.recent_synced_at(cell_id))
|
Persistent derived-data cache: SQLite store, ETag revalidation, view bundle, IndexedDB frontend (#21)
* Compress API responses and revalidate static assets instead of re-downloading
- Add GZipMiddleware (min 1 KB): the 2-year calendar JSON shrinks ~6-8x.
- Serve pages/assets with Cache-Control: no-cache instead of no-store, so
browsers revalidate via the ETag/Last-Modified that FileResponse and
StaticFiles already emit. Unchanged assets now cost an empty 304 rather
than a full transfer on every page navigation, while deploys still show
up immediately.
* Persist derived responses in SQLite so grading is computed once per cell, not per request
New data/thermograph.sqlite (WAL) holds what's derived from the raw parquet
records — finished grade/calendar/day/forecast payloads and reverse-geocode
labels — so the expensive work (notably the 2-year calendar grade_range, ~270ms)
becomes a ~5ms database read that survives restarts and is shared across views.
Raw parquet stays the source of truth; the store is a pure accelerator (every
reader falls back to recomputing on a miss, and deleting the db is a safe reset).
Freshness is token-driven, not clock-driven: each cached payload is validated by
a token encoding what it was computed from (payload schema version, the archive
record's end date, the recent-fetch stamp). The existing freshness drivers are
untouched — get_history still tops up the tail hourly and get_recent_forecast
still refetches hourly — and tokens are derived from what they return, so cached
payloads expire exactly when their inputs change. The same tokens double as weak
ETags: If-None-Match answers with an empty 304 without touching the payload.
- backend/store.py: derived-payload + revgeo tables, thread-local WAL conns,
every helper fail-soft.
- app.py: endpoints split into pure payload builders + HTTP/caching shells; the
in-memory 10-minute _CAL_CACHE is retired (superseded by the persistent store).
- climate.py: revgeo persisted through the store; recent_stamp() and
load_cached_history() (no-network read) helpers.
- backend/migrate.py + make migrate: idempotent, resumable backfill of the store
from existing parquet caches (default calendar span + latest-day detail +
revgeo, ≤1 throttled Nominatim call per unlabeled cell). Never fetches weather.
- grid.from_id(): rebuild a cell from its cache filename (migrate tooling).
* Add /api/v2/cell: one bundle carrying every view's payload
GET /api/v2/cell?lat&lon returns the grade, forecast, calendar (last 24 months)
and day (today) payloads in one response, each the exact payload its per-view
endpoint returns — built by the same builders and cached under the same
derived-store keys/tokens — paired with the etag that endpoint would emit. The
frontend can warm all views with a single request, seed its per-view cache from
the slices, and later revalidate each view individually with If-None-Match. The
bundle's own etag combines the slices', so an unchanged bundle is an empty 304.
prefetch=1 is a warm-only mode for neighbor-cell prefetching with a hard
guarantee: it never spends weather-API quota. A cell with no cached archive
answers 204, and only the history-derived slices (calendar + latest-day detail)
are built. At most one Nominatim lookup for a never-labeled cell.
* Frontend: IndexedDB response cache with stale-while-revalidate + bundle prefetch
The per-URL response cache moves from localStorage/sessionStorage (~5 MB quota,
which multi-year calendar payloads regularly blew through) to IndexedDB, with an
in-memory map in front. Entries carry the server's ETag, so anything stale
revalidates conditionally — unchanged data costs an empty 304 and a re-stamp,
never a re-transfer. On network failure the stale copy is served over an error.
getJSON gains an optional onUpdate callback opting into stale-while-revalidate:
the three views (weekly, day, calendar) now render a cached copy immediately —
spinners are delayed 150ms so warm loads never flash-blank — and repaint only if
background revalidation finds changed data. New data shows up the moment it
exists instead of waiting out a TTL.
Cross-view prefetch collapses from one request per view to a single /api/v2/cell
bundle, whose slices (exact per-view payloads + their etags) are seeded under the
URLs each view actually requests; the bundle call itself is conditional via a
remembered etag. Afterwards the 8 surrounding grid cells are warmed server-side
with prefetch=1 (never spends weather-API quota; staggered ≥1.1s for the one
possible Nominatim lookup each), so tapping nearby lands on already-graded data.
Legacy tg:* storage entries are cleared once; cache entries untouched for two
weeks are pruned on page load.
2026-07-11 07:31:28 +00:00
|
|
|
|
try:
|
|
|
|
|
|
return int(os.path.getmtime(_rf_cache_path(cell_id)))
|
|
|
|
|
|
except OSError:
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-24 19:30:38 +00:00
|
|
|
|
def history_max_date(cell_id: str) -> "str | None":
|
|
|
|
|
|
"""ISO date (YYYY-MM-DD) of the cell's newest cached archived day, or None when
|
|
|
|
|
|
nothing is cached — read WITHOUT loading the full multi-decade history.
|
|
|
|
|
|
|
|
|
|
|
|
Backs the content-page cache token (api/payloads.content_token). On Postgres
|
|
|
|
|
|
it's an indexed ``MAX(date)`` over climate_history; on the parquet backend it's
|
|
|
|
|
|
the max of the cached file's date column (a single columnar read via
|
|
|
|
|
|
``scan_parquet``, not a full frame load). Fail-soft: any error reads as None."""
|
|
|
|
|
|
if climate_store.is_postgres():
|
|
|
|
|
|
return climate_store.history_max_date(cell_id)
|
|
|
|
|
|
path = _cache_path(cell_id)
|
|
|
|
|
|
if not os.path.exists(path):
|
|
|
|
|
|
return None
|
|
|
|
|
|
try:
|
|
|
|
|
|
val = pl.scan_parquet(path).select(pl.col("date").max()).collect().item()
|
|
|
|
|
|
if val is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
if isinstance(val, datetime.datetime): # older files stored date as Datetime
|
|
|
|
|
|
val = val.date()
|
|
|
|
|
|
return val.isoformat()
|
|
|
|
|
|
except Exception: # noqa: BLE001 - a corrupt/absent cache reads as no max date
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
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 get_recent_forecast(cell: dict) -> pl.DataFrame:
|
2026-07-11 00:29:47 +00:00
|
|
|
|
"""Recent observations + forward forecast, with humidity as absolute humidity
|
|
|
|
|
|
(g/m³). Thin wrapper over the raw loader (see below)."""
|
Add a climate-score page from recent-vs-baseline percentile divergence (#196)
Score how far a location's last 6 years have drifted from its full 45-year
record. For each metric and percentile category (p10/p25/p50/p75/p90), the
recent-years value is placed on the baseline distribution and the gap from the
expected percentile is the divergence — unit-free, so metrics compare directly.
Scored per meteorological season plus annual, weighted into per-metric and
overall scores (temps, humidity and feels-like weighted heaviest).
- backend/scoring.py: divergence math, seasonal slicing, precip zero-inflation
split (wet-day frequency + amount), tier mapping onto the existing temp scale.
- climate.py: derive a wet-bulb column (Stull 2011) at the read boundary, before
the humidity column is converted to absolute — via a shared _derive_metrics
wrapper at all four read sites.
- api/v2/score endpoint + build_score payload, cached on the history token with
a scoring-version key.
- frontend score page: overall hero, per-metric cards, by-season chips, and a
button-revealed summary (sentences + metrics×season table + per-percentile
detail). Score nav link across all headers.
- Tests for the scoring math, wet-bulb formula, payload shape and route.
2026-07-19 23:02:33 +00:00
|
|
|
|
return _derive_metrics(_load_recent_forecast(cell))
|
2026-07-11 00:29:47 +00:00
|
|
|
|
|
|
|
|
|
|
|
Rebuild the homepage as a distribution landing; add an SMTP seam (#178)
The homepage was a bare tool: a find-bar and an empty panel reading "Find a
location to begin." A visitor arriving from a search result or a shared link
learned nothing about what the product does before deciding to leave.
Rebuild it around the Weekly view, which is untouched:
- Hero with the question as the h1, and a grade card showing a real graded
example — the most unusual city we're currently tracking, either tail. The
card's frame and text slots are server-rendered with reserved heights, so
app.js re-pointing it at the visitor's own place shifts nothing.
- "Unusual right now" strip, CSS scroll-snap, no JS carousel. A cold-tail city
is force-included whenever one qualifies.
- Stance line, how-it-works, explore cards, and 12 city chips linking into the
~1000-page /climate surface.
- Monthly digest form, in the footer of every page.
Serve / from Jinja instead of a static file with placeholder substitution, so
crawlers and no-JS readers get the whole page as real HTML. home.html.j2
extends base.html.j2 and carries app.js's DOM contract over verbatim; the brand
degrades to a <p> so the hero owns the sole h1. frontend/index.html is deleted
rather than left behind the static mount, where it would keep serving indexable
duplicate content.
"Where is it most unusual right now" has no cheap answer at request time —
percentiles live inside zlib-compressed payload blobs with no column to sort on.
So homepage.py sweeps the warm cache and writes data/homepage.json, read by the
template. The sweep is strictly cache-only (climate.load_cached_recent_forecast
is new, the sibling of load_cached_history), so grading ~1000 cities costs zero
upstream requests. It rides the notifier's timer behind an hourly guard rather
than starting a second daemon, and also runs at the tail of warm_cities so a
fresh deploy has a populated feed.
Instrumentation: metrics gains a product-event counter keyed by (event,
referrer domain, UTC day), behind an allowlist and a per-IP rate limit, fed by
POST /api/v2/event. The referrer is taken from the request's own header, never
from the client. The beacon gets its own inbound category that record_inbound
ignores, so reporting an interaction doesn't also count as traffic. The
dashboard grows an events block with per-referrer attribution.
Email is scaffolded but sends nothing yet. mailer.py talks stdlib smtplib to a
local Postfix null client on 127.0.0.1:25 (deploy/provision-mail.sh), so the
choice between direct-to-MX and relaying through a provider stays a Postfix
config change with no code change. The backend defaults to "console", which
logs and sends nothing, so dev and tests exercise the whole signup path safely.
Signups land in pending_digest unconfirmed; collecting the list shouldn't wait
on delivery.
Also adds /privacy, linked from the footer and kept out of the sitemap.
The strip's classes are named unusual-* rather than record-*: .record-card is
already the SEO records page's, and reusing it leaked layout rules onto
/climate/<slug>/records.
2026-07-18 07:39:47 +00:00
|
|
|
|
def load_cached_recent_forecast(cell: dict) -> "pl.DataFrame | None":
|
Move the climate record from parquet to TimescaleDB hypertables (#227)
Replace the per-cell parquet cache with TimescaleDB hypertables as the
production backend for the raw daily climate record, and drop pg_duckdb.
Parquet stays the backend whenever THERMOGRAPH_DATABASE_URL is not a
Postgres URL (dev, tests, offline tooling), the same dialect switch the
accounts DB and derived store already use, so CI stays Postgres-free.
- data/climate_store.py: psycopg + polars bridge over climate_history
(a hypertable), climate_recent, and climate_sync (per-cell freshness).
Reads via pl.read_database, writes via COPY + ON CONFLICT upsert;
fail-soft to a cache miss so a DB hiccup degrades to upstream refetch.
- data/climate.py: route every cache/mtime touchpoint through a backend
dispatch. recent_stamp becomes int(recent_synced_at) on Postgres; the
stale-serve path still avoids bumping it, so derived-payload tokens
invalidate on exactly the same events as before.
- alembic 0002: CREATE EXTENSION timescaledb plus the hypertable schema
(compression policy on year-old chunks), guarded to no-op off Postgres.
- migrate_cache_to_pg.py (make migrate-cache): idempotent backfill of the
parquet cache into the hypertables, preserving file mtimes as sync
timestamps so recent_stamp is unchanged across cutover.
- db image -> stock timescale/timescaledb:latest-pg18; drop the custom
pg_duckdb Dockerfile, the read-only /parquet mount, and the duckdb
tuning GUC. Docs updated for the new backend and cutover.
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-20 20:15:55 +00:00
|
|
|
|
"""Recent+forecast from the cache ONLY — never fetches upstream, and unlike the
|
|
|
|
|
|
loader below it does not care how stale the record is. The sibling of
|
Rebuild the homepage as a distribution landing; add an SMTP seam (#178)
The homepage was a bare tool: a find-bar and an empty panel reading "Find a
location to begin." A visitor arriving from a search result or a shared link
learned nothing about what the product does before deciding to leave.
Rebuild it around the Weekly view, which is untouched:
- Hero with the question as the h1, and a grade card showing a real graded
example — the most unusual city we're currently tracking, either tail. The
card's frame and text slots are server-rendered with reserved heights, so
app.js re-pointing it at the visitor's own place shifts nothing.
- "Unusual right now" strip, CSS scroll-snap, no JS carousel. A cold-tail city
is force-included whenever one qualifies.
- Stance line, how-it-works, explore cards, and 12 city chips linking into the
~1000-page /climate surface.
- Monthly digest form, in the footer of every page.
Serve / from Jinja instead of a static file with placeholder substitution, so
crawlers and no-JS readers get the whole page as real HTML. home.html.j2
extends base.html.j2 and carries app.js's DOM contract over verbatim; the brand
degrades to a <p> so the hero owns the sole h1. frontend/index.html is deleted
rather than left behind the static mount, where it would keep serving indexable
duplicate content.
"Where is it most unusual right now" has no cheap answer at request time —
percentiles live inside zlib-compressed payload blobs with no column to sort on.
So homepage.py sweeps the warm cache and writes data/homepage.json, read by the
template. The sweep is strictly cache-only (climate.load_cached_recent_forecast
is new, the sibling of load_cached_history), so grading ~1000 cities costs zero
upstream requests. It rides the notifier's timer behind an hourly guard rather
than starting a second daemon, and also runs at the tail of warm_cities so a
fresh deploy has a populated feed.
Instrumentation: metrics gains a product-event counter keyed by (event,
referrer domain, UTC day), behind an allowlist and a per-IP rate limit, fed by
POST /api/v2/event. The referrer is taken from the request's own header, never
from the client. The beacon gets its own inbound category that record_inbound
ignores, so reporting an interaction doesn't also count as traffic. The
dashboard grows an events block with per-referrer attribution.
Email is scaffolded but sends nothing yet. mailer.py talks stdlib smtplib to a
local Postfix null client on 127.0.0.1:25 (deploy/provision-mail.sh), so the
choice between direct-to-MX and relaying through a provider stays a Postfix
config change with no code change. The backend defaults to "console", which
logs and sends nothing, so dev and tests exercise the whole signup path safely.
Signups land in pending_digest unconfirmed; collecting the list shouldn't wait
on delivery.
Also adds /privacy, linked from the footer and kept out of the sitemap.
The strip's classes are named unusual-* rather than record-*: .record-card is
already the SEO records page's, and reusing it leaked layout rules onto
/climate/<slug>/records.
2026-07-18 07:39:47 +00:00
|
|
|
|
load_cached_history, for the same reason: the homepage precompute sweeps every
|
|
|
|
|
|
cached city and must not spend a single upstream request doing it. None when
|
|
|
|
|
|
the cell has no cached recent/forecast record."""
|
Move the climate record from parquet to TimescaleDB hypertables (#227)
Replace the per-cell parquet cache with TimescaleDB hypertables as the
production backend for the raw daily climate record, and drop pg_duckdb.
Parquet stays the backend whenever THERMOGRAPH_DATABASE_URL is not a
Postgres URL (dev, tests, offline tooling), the same dialect switch the
accounts DB and derived store already use, so CI stays Postgres-free.
- data/climate_store.py: psycopg + polars bridge over climate_history
(a hypertable), climate_recent, and climate_sync (per-cell freshness).
Reads via pl.read_database, writes via COPY + ON CONFLICT upsert;
fail-soft to a cache miss so a DB hiccup degrades to upstream refetch.
- data/climate.py: route every cache/mtime touchpoint through a backend
dispatch. recent_stamp becomes int(recent_synced_at) on Postgres; the
stale-serve path still avoids bumping it, so derived-payload tokens
invalidate on exactly the same events as before.
- alembic 0002: CREATE EXTENSION timescaledb plus the hypertable schema
(compression policy on year-old chunks), guarded to no-op off Postgres.
- migrate_cache_to_pg.py (make migrate-cache): idempotent backfill of the
parquet cache into the hypertables, preserving file mtimes as sync
timestamps so recent_stamp is unchanged across cutover.
- db image -> stock timescale/timescaledb:latest-pg18; drop the custom
pg_duckdb Dockerfile, the read-only /parquet mount, and the duckdb
tuning GUC. Docs updated for the new backend and cutover.
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-20 20:15:55 +00:00
|
|
|
|
hit = _read_recent_backed(cell["id"])
|
|
|
|
|
|
if hit is None:
|
Rebuild the homepage as a distribution landing; add an SMTP seam (#178)
The homepage was a bare tool: a find-bar and an empty panel reading "Find a
location to begin." A visitor arriving from a search result or a shared link
learned nothing about what the product does before deciding to leave.
Rebuild it around the Weekly view, which is untouched:
- Hero with the question as the h1, and a grade card showing a real graded
example — the most unusual city we're currently tracking, either tail. The
card's frame and text slots are server-rendered with reserved heights, so
app.js re-pointing it at the visitor's own place shifts nothing.
- "Unusual right now" strip, CSS scroll-snap, no JS carousel. A cold-tail city
is force-included whenever one qualifies.
- Stance line, how-it-works, explore cards, and 12 city chips linking into the
~1000-page /climate surface.
- Monthly digest form, in the footer of every page.
Serve / from Jinja instead of a static file with placeholder substitution, so
crawlers and no-JS readers get the whole page as real HTML. home.html.j2
extends base.html.j2 and carries app.js's DOM contract over verbatim; the brand
degrades to a <p> so the hero owns the sole h1. frontend/index.html is deleted
rather than left behind the static mount, where it would keep serving indexable
duplicate content.
"Where is it most unusual right now" has no cheap answer at request time —
percentiles live inside zlib-compressed payload blobs with no column to sort on.
So homepage.py sweeps the warm cache and writes data/homepage.json, read by the
template. The sweep is strictly cache-only (climate.load_cached_recent_forecast
is new, the sibling of load_cached_history), so grading ~1000 cities costs zero
upstream requests. It rides the notifier's timer behind an hourly guard rather
than starting a second daemon, and also runs at the tail of warm_cities so a
fresh deploy has a populated feed.
Instrumentation: metrics gains a product-event counter keyed by (event,
referrer domain, UTC day), behind an allowlist and a per-IP rate limit, fed by
POST /api/v2/event. The referrer is taken from the request's own header, never
from the client. The beacon gets its own inbound category that record_inbound
ignores, so reporting an interaction doesn't also count as traffic. The
dashboard grows an events block with per-referrer attribution.
Email is scaffolded but sends nothing yet. mailer.py talks stdlib smtplib to a
local Postfix null client on 127.0.0.1:25 (deploy/provision-mail.sh), so the
choice between direct-to-MX and relaying through a provider stays a Postfix
config change with no code change. The backend defaults to "console", which
logs and sends nothing, so dev and tests exercise the whole signup path safely.
Signups land in pending_digest unconfirmed; collecting the list shouldn't wait
on delivery.
Also adds /privacy, linked from the footer and kept out of the sitemap.
The strip's classes are named unusual-* rather than record-*: .record-card is
already the SEO records page's, and reusing it leaked layout rules onto
/climate/<slug>/records.
2026-07-18 07:39:47 +00:00
|
|
|
|
return None
|
|
|
|
|
|
try:
|
Move the climate record from parquet to TimescaleDB hypertables (#227)
Replace the per-cell parquet cache with TimescaleDB hypertables as the
production backend for the raw daily climate record, and drop pg_duckdb.
Parquet stays the backend whenever THERMOGRAPH_DATABASE_URL is not a
Postgres URL (dev, tests, offline tooling), the same dialect switch the
accounts DB and derived store already use, so CI stays Postgres-free.
- data/climate_store.py: psycopg + polars bridge over climate_history
(a hypertable), climate_recent, and climate_sync (per-cell freshness).
Reads via pl.read_database, writes via COPY + ON CONFLICT upsert;
fail-soft to a cache miss so a DB hiccup degrades to upstream refetch.
- data/climate.py: route every cache/mtime touchpoint through a backend
dispatch. recent_stamp becomes int(recent_synced_at) on Postgres; the
stale-serve path still avoids bumping it, so derived-payload tokens
invalidate on exactly the same events as before.
- alembic 0002: CREATE EXTENSION timescaledb plus the hypertable schema
(compression policy on year-old chunks), guarded to no-op off Postgres.
- migrate_cache_to_pg.py (make migrate-cache): idempotent backfill of the
parquet cache into the hypertables, preserving file mtimes as sync
timestamps so recent_stamp is unchanged across cutover.
- db image -> stock timescale/timescaledb:latest-pg18; drop the custom
pg_duckdb Dockerfile, the read-only /parquet mount, and the duckdb
tuning GUC. Docs updated for the new backend and cutover.
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-20 20:15:55 +00:00
|
|
|
|
return _derive_metrics(_with_doy(hit[0]))
|
|
|
|
|
|
except Exception: # noqa: BLE001 - a truncated/corrupt cache record is a miss, not a crash
|
Rebuild the homepage as a distribution landing; add an SMTP seam (#178)
The homepage was a bare tool: a find-bar and an empty panel reading "Find a
location to begin." A visitor arriving from a search result or a shared link
learned nothing about what the product does before deciding to leave.
Rebuild it around the Weekly view, which is untouched:
- Hero with the question as the h1, and a grade card showing a real graded
example — the most unusual city we're currently tracking, either tail. The
card's frame and text slots are server-rendered with reserved heights, so
app.js re-pointing it at the visitor's own place shifts nothing.
- "Unusual right now" strip, CSS scroll-snap, no JS carousel. A cold-tail city
is force-included whenever one qualifies.
- Stance line, how-it-works, explore cards, and 12 city chips linking into the
~1000-page /climate surface.
- Monthly digest form, in the footer of every page.
Serve / from Jinja instead of a static file with placeholder substitution, so
crawlers and no-JS readers get the whole page as real HTML. home.html.j2
extends base.html.j2 and carries app.js's DOM contract over verbatim; the brand
degrades to a <p> so the hero owns the sole h1. frontend/index.html is deleted
rather than left behind the static mount, where it would keep serving indexable
duplicate content.
"Where is it most unusual right now" has no cheap answer at request time —
percentiles live inside zlib-compressed payload blobs with no column to sort on.
So homepage.py sweeps the warm cache and writes data/homepage.json, read by the
template. The sweep is strictly cache-only (climate.load_cached_recent_forecast
is new, the sibling of load_cached_history), so grading ~1000 cities costs zero
upstream requests. It rides the notifier's timer behind an hourly guard rather
than starting a second daemon, and also runs at the tail of warm_cities so a
fresh deploy has a populated feed.
Instrumentation: metrics gains a product-event counter keyed by (event,
referrer domain, UTC day), behind an allowlist and a per-IP rate limit, fed by
POST /api/v2/event. The referrer is taken from the request's own header, never
from the client. The beacon gets its own inbound category that record_inbound
ignores, so reporting an interaction doesn't also count as traffic. The
dashboard grows an events block with per-referrer attribution.
Email is scaffolded but sends nothing yet. mailer.py talks stdlib smtplib to a
local Postfix null client on 127.0.0.1:25 (deploy/provision-mail.sh), so the
choice between direct-to-MX and relaying through a provider stays a Postfix
config change with no code change. The backend defaults to "console", which
logs and sends nothing, so dev and tests exercise the whole signup path safely.
Signups land in pending_digest unconfirmed; collecting the list shouldn't wait
on delivery.
Also adds /privacy, linked from the footer and kept out of the sitemap.
The strip's classes are named unusual-* rather than record-*: .record-card is
already the SEO records page's, and reusing it leaked layout rules onto
/climate/<slug>/records.
2026-07-18 07:39:47 +00:00
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 14:34:51 +00:00
|
|
|
|
def _fetch_recent_forecast(cell: dict) -> pl.DataFrame:
|
2026-07-24 23:56:34 +00:00
|
|
|
|
"""Fallback recent+forecast bundle without the Open-Meteo *forecast* API:
|
|
|
|
|
|
the recent observed window from the Open-Meteo ARCHIVE (an independent
|
|
|
|
|
|
quota, and the same ERA5 family as the history record so the graded days
|
|
|
|
|
|
sit on a consistent baseline), the forward days from MET Norway with
|
|
|
|
|
|
Meteostat-estimated gusts. The archive lags a few days more than a live
|
|
|
|
|
|
feed, so this degraded path serves a slightly shorter observed window —
|
|
|
|
|
|
honest and consistent beats fresh and skewed."""
|
2026-07-23 14:34:51 +00:00
|
|
|
|
today = datetime.date.today()
|
|
|
|
|
|
start = (today - datetime.timedelta(days=RECENT_PAST_DAYS)).isoformat()
|
2026-07-24 23:56:34 +00:00
|
|
|
|
end = (today - datetime.timedelta(days=ARCHIVE_LATENCY_DAYS)).isoformat()
|
|
|
|
|
|
past = _fetch_history_range(cell, start, end) # ERA5 days, gusts included
|
2026-07-23 14:34:51 +00:00
|
|
|
|
fwd = meteostat.fill_gusts(
|
|
|
|
|
|
cell["center_lat"], cell["center_lon"], _fetch_forecast_metno(cell))
|
2026-07-24 23:56:34 +00:00
|
|
|
|
# Forecast wins on any overlapping day (freshest model value); the archive
|
|
|
|
|
|
# days fill the recent past MET Norway lacks.
|
2026-07-23 14:34:51 +00:00
|
|
|
|
return (pl.concat([past, fwd], how="diagonal_relaxed")
|
|
|
|
|
|
.unique(subset="date", keep="last", maintain_order=True)
|
|
|
|
|
|
.sort("date"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _fetch_recent_forecast_om(cell: dict) -> pl.DataFrame:
|
2026-07-25 16:56:21 +00:00
|
|
|
|
"""Primary recent+forecast bundle from the Open-Meteo forecast API: recent past
|
|
|
|
|
|
and forward days in one call, covering today + 7 (see FORECAST_DAYS).
|
|
|
|
|
|
|
|
|
|
|
|
The cell's in-progress *local* day is KEPT. An earlier revision dropped it on
|
|
|
|
|
|
the assumption that Open-Meteo's daily high/low for today aggregates only the
|
|
|
|
|
|
hours elapsed so far — true of the MET Norway series, which genuinely starts
|
|
|
|
|
|
mid-day and is gated on diurnal coverage (see _metno_to_frame), but NOT of this
|
|
|
|
|
|
endpoint: Open-Meteo backfills the rest of today from the model run, so today's
|
|
|
|
|
|
daily value spans the whole local day exactly as tomorrow's does.
|
|
|
|
|
|
|
|
|
|
|
|
Verified against the live API at four cells spanning 01:00-19:00 local: the
|
|
|
|
|
|
reported daily max equals the max over all 24 hourly values, and diverges from
|
|
|
|
|
|
the elapsed-hours-only max wherever the day still has hours left (Los Angeles
|
|
|
|
|
|
at 09:16 local reported 93.9F, the whole-day figure, where the hours elapsed
|
|
|
|
|
|
topped out at 76.1F). Re-check it that way before reintroducing any exclusion.
|
|
|
|
|
|
|
|
|
|
|
|
Dropping today cost every freshly-synced cell its current day — a hole between
|
|
|
|
|
|
a complete yesterday and a forecast tomorrow, which the UI renders as a missing
|
|
|
|
|
|
column. A day that genuinely cannot be graded, because it came back with no
|
|
|
|
|
|
high/low, is already dropped upstream by _to_frame."""
|
2026-07-11 00:29:47 +00:00
|
|
|
|
params = {
|
|
|
|
|
|
"latitude": cell["center_lat"],
|
|
|
|
|
|
"longitude": cell["center_lon"],
|
|
|
|
|
|
"daily": DAILY_VARS,
|
|
|
|
|
|
"timezone": "auto",
|
|
|
|
|
|
"temperature_unit": "fahrenheit",
|
|
|
|
|
|
"precipitation_unit": "inch",
|
|
|
|
|
|
"wind_speed_unit": "mph",
|
|
|
|
|
|
"past_days": RECENT_PAST_DAYS,
|
|
|
|
|
|
"forecast_days": FORECAST_DAYS,
|
|
|
|
|
|
}
|
2026-07-23 14:34:51 +00:00
|
|
|
|
r = _request(FORECAST_URL, params, 60, phase="recent_forecast_fetch")
|
2026-07-25 16:56:21 +00:00
|
|
|
|
return _to_frame(r.json()["daily"])
|
2026-07-23 14:34:51 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _load_recent_forecast(cell: dict) -> pl.DataFrame:
|
|
|
|
|
|
"""Recent observations AND the forward forecast for a cell.
|
|
|
|
|
|
|
2026-07-24 23:56:34 +00:00
|
|
|
|
The primary source is the Open-Meteo forecast API (recent past + forward in
|
|
|
|
|
|
one call, gusts included, ERA5-consistent with the history record — see
|
|
|
|
|
|
_fetch_recent_forecast_om), skipped while it's in its own rate-limit
|
|
|
|
|
|
cooldown (_note_forecast_rate_limit). The fallback is Open-Meteo-forecast-
|
|
|
|
|
|
free: an archive recent-past range plus MET Norway forward days
|
|
|
|
|
|
(_fetch_recent_forecast). Then a stale cache. Cached per cell for
|
2026-07-23 14:34:51 +00:00
|
|
|
|
FORECAST_TTL_HOURS so it follows model updates without per-hour upstream IO.
|
|
|
|
|
|
"""
|
|
|
|
|
|
cell_id = cell["id"]
|
|
|
|
|
|
hit = _read_recent_backed(cell_id)
|
|
|
|
|
|
if hit is not None:
|
|
|
|
|
|
cached, age_s = hit
|
|
|
|
|
|
if age_s / 3600.0 < FORECAST_TTL_HOURS:
|
|
|
|
|
|
return _with_doy(cached)
|
|
|
|
|
|
|
2026-07-23 04:41:26 +00:00
|
|
|
|
df = None
|
2026-07-23 14:34:51 +00:00
|
|
|
|
forecast_error = None
|
2026-07-24 23:56:34 +00:00
|
|
|
|
if time.time() >= _forecast_cooldown_until:
|
2026-07-23 04:41:26 +00:00
|
|
|
|
try:
|
2026-07-24 23:56:34 +00:00
|
|
|
|
df = _fetch_recent_forecast_om(cell) # primary: one call, gusts, ERA5-consistent
|
2026-07-23 04:41:26 +00:00
|
|
|
|
except Exception as e: # noqa: BLE001
|
|
|
|
|
|
if is_rate_limit(e):
|
|
|
|
|
|
_note_forecast_rate_limit(e)
|
2026-07-23 14:34:51 +00:00
|
|
|
|
forecast_error = e
|
2026-07-23 04:41:26 +00:00
|
|
|
|
|
2026-07-24 23:56:34 +00:00
|
|
|
|
if df is None:
|
|
|
|
|
|
# Fallback: archive past + MET forward — no Open-Meteo forecast quota.
|
|
|
|
|
|
try:
|
|
|
|
|
|
df = _fetch_recent_forecast(cell)
|
|
|
|
|
|
forecast_error = None
|
|
|
|
|
|
except Exception: # noqa: BLE001 - fallback also down; stale cache next
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
2026-07-23 04:41:26 +00:00
|
|
|
|
if df is None:
|
2026-07-23 14:34:51 +00:00
|
|
|
|
# Last resort: serve the stale cache if we have one, WITHOUT rewriting it, so
|
|
|
|
|
|
# recent_stamp stays old and the next request retries upstream first (mirrors
|
|
|
|
|
|
# _load_history's stale-serve).
|
|
|
|
|
|
if hit is not None:
|
|
|
|
|
|
return _with_doy(hit[0])
|
|
|
|
|
|
# No cache either: surface the error, classifying an Open-Meteo rate limit as
|
|
|
|
|
|
# the typed, daily-aware WeatherUnavailable. forecast_error is None when the
|
|
|
|
|
|
# fallback was skipped outright (cooldown active) — report the cooldown then.
|
|
|
|
|
|
if forecast_error is not None and is_rate_limit(forecast_error):
|
|
|
|
|
|
daily = "daily" in _rate_limit_reason(forecast_error).lower()
|
|
|
|
|
|
raise WeatherUnavailable(limit_message(daily), daily=daily) from forecast_error
|
|
|
|
|
|
if forecast_error is not None:
|
|
|
|
|
|
raise forecast_error
|
|
|
|
|
|
raise WeatherUnavailable(limit_message(_forecast_limit_daily),
|
|
|
|
|
|
daily=_forecast_limit_daily)
|
Move the climate record from parquet to TimescaleDB hypertables (#227)
Replace the per-cell parquet cache with TimescaleDB hypertables as the
production backend for the raw daily climate record, and drop pg_duckdb.
Parquet stays the backend whenever THERMOGRAPH_DATABASE_URL is not a
Postgres URL (dev, tests, offline tooling), the same dialect switch the
accounts DB and derived store already use, so CI stays Postgres-free.
- data/climate_store.py: psycopg + polars bridge over climate_history
(a hypertable), climate_recent, and climate_sync (per-cell freshness).
Reads via pl.read_database, writes via COPY + ON CONFLICT upsert;
fail-soft to a cache miss so a DB hiccup degrades to upstream refetch.
- data/climate.py: route every cache/mtime touchpoint through a backend
dispatch. recent_stamp becomes int(recent_synced_at) on Postgres; the
stale-serve path still avoids bumping it, so derived-payload tokens
invalidate on exactly the same events as before.
- alembic 0002: CREATE EXTENSION timescaledb plus the hypertable schema
(compression policy on year-old chunks), guarded to no-op off Postgres.
- migrate_cache_to_pg.py (make migrate-cache): idempotent backfill of the
parquet cache into the hypertables, preserving file mtimes as sync
timestamps so recent_stamp is unchanged across cutover.
- db image -> stock timescale/timescaledb:latest-pg18; drop the custom
pg_duckdb Dockerfile, the read-only /parquet mount, and the duckdb
tuning GUC. Docs updated for the new backend and cutover.
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-20 20:15:55 +00:00
|
|
|
|
_write_recent_backed(cell_id, df)
|
2026-07-11 00:29:47 +00:00
|
|
|
|
return df
|
|
|
|
|
|
|
|
|
|
|
|
|
Persistent derived-data cache: SQLite store, ETag revalidation, view bundle, IndexedDB frontend (#21)
* Compress API responses and revalidate static assets instead of re-downloading
- Add GZipMiddleware (min 1 KB): the 2-year calendar JSON shrinks ~6-8x.
- Serve pages/assets with Cache-Control: no-cache instead of no-store, so
browsers revalidate via the ETag/Last-Modified that FileResponse and
StaticFiles already emit. Unchanged assets now cost an empty 304 rather
than a full transfer on every page navigation, while deploys still show
up immediately.
* Persist derived responses in SQLite so grading is computed once per cell, not per request
New data/thermograph.sqlite (WAL) holds what's derived from the raw parquet
records — finished grade/calendar/day/forecast payloads and reverse-geocode
labels — so the expensive work (notably the 2-year calendar grade_range, ~270ms)
becomes a ~5ms database read that survives restarts and is shared across views.
Raw parquet stays the source of truth; the store is a pure accelerator (every
reader falls back to recomputing on a miss, and deleting the db is a safe reset).
Freshness is token-driven, not clock-driven: each cached payload is validated by
a token encoding what it was computed from (payload schema version, the archive
record's end date, the recent-fetch stamp). The existing freshness drivers are
untouched — get_history still tops up the tail hourly and get_recent_forecast
still refetches hourly — and tokens are derived from what they return, so cached
payloads expire exactly when their inputs change. The same tokens double as weak
ETags: If-None-Match answers with an empty 304 without touching the payload.
- backend/store.py: derived-payload + revgeo tables, thread-local WAL conns,
every helper fail-soft.
- app.py: endpoints split into pure payload builders + HTTP/caching shells; the
in-memory 10-minute _CAL_CACHE is retired (superseded by the persistent store).
- climate.py: revgeo persisted through the store; recent_stamp() and
load_cached_history() (no-network read) helpers.
- backend/migrate.py + make migrate: idempotent, resumable backfill of the store
from existing parquet caches (default calendar span + latest-day detail +
revgeo, ≤1 throttled Nominatim call per unlabeled cell). Never fetches weather.
- grid.from_id(): rebuild a cell from its cache filename (migrate tooling).
* Add /api/v2/cell: one bundle carrying every view's payload
GET /api/v2/cell?lat&lon returns the grade, forecast, calendar (last 24 months)
and day (today) payloads in one response, each the exact payload its per-view
endpoint returns — built by the same builders and cached under the same
derived-store keys/tokens — paired with the etag that endpoint would emit. The
frontend can warm all views with a single request, seed its per-view cache from
the slices, and later revalidate each view individually with If-None-Match. The
bundle's own etag combines the slices', so an unchanged bundle is an empty 304.
prefetch=1 is a warm-only mode for neighbor-cell prefetching with a hard
guarantee: it never spends weather-API quota. A cell with no cached archive
answers 204, and only the history-derived slices (calendar + latest-day detail)
are built. At most one Nominatim lookup for a never-labeled cell.
* Frontend: IndexedDB response cache with stale-while-revalidate + bundle prefetch
The per-URL response cache moves from localStorage/sessionStorage (~5 MB quota,
which multi-year calendar payloads regularly blew through) to IndexedDB, with an
in-memory map in front. Entries carry the server's ETag, so anything stale
revalidates conditionally — unchanged data costs an empty 304 and a re-stamp,
never a re-transfer. On network failure the stale copy is served over an error.
getJSON gains an optional onUpdate callback opting into stale-while-revalidate:
the three views (weekly, day, calendar) now render a cached copy immediately —
spinners are delayed 150ms so warm loads never flash-blank — and repaint only if
background revalidation finds changed data. New data shows up the moment it
exists instead of waiting out a TTL.
Cross-view prefetch collapses from one request per view to a single /api/v2/cell
bundle, whose slices (exact per-view payloads + their etags) are seeded under the
URLs each view actually requests; the bundle call itself is conditional via a
remembered etag. Afterwards the 8 surrounding grid cells are warmed server-side
with prefetch=1 (never spends weather-API quota; staggered ≥1.1s for the one
possible Nominatim lookup each), so tapping nearby lands on already-graded data.
Legacy tg:* storage entries are cleared once; cache entries untouched for two
weeks are pruned on page load.
2026-07-11 07:31:28 +00:00
|
|
|
|
_REVGEO_CACHE: dict[str, str | None] = {}
|
|
|
|
|
|
|
2026-07-11 09:03:57 +00:00
|
|
|
|
# Nominatim's usage policy allows ~1 request/second and rejects bursts. The compare
|
2026-07-23 04:41:26 +00:00
|
|
|
|
# page loads several locations at once, so the actual fetch + pacing run on ONE
|
|
|
|
|
|
# dedicated worker thread (see _revgeo_worker), never on a caller's own thread —
|
|
|
|
|
|
# in the server, a caller's thread is one of Starlette's shared sync threadpool
|
|
|
|
|
|
# threads, and the old design serialized AND slept (up to _REVGEO_MIN_INTERVAL)
|
|
|
|
|
|
# right there, pinning a threadpool thread for the whole wait. A single worker
|
|
|
|
|
|
# draining a queue gets the same ~1/sec pacing for free (nothing else ever calls
|
|
|
|
|
|
# Nominatim) without blocking anything but itself.
|
2026-07-11 09:03:57 +00:00
|
|
|
|
_REVGEO_MIN_INTERVAL = 1.1 # seconds between successive Nominatim reverse calls
|
|
|
|
|
|
_revgeo_last = 0.0
|
|
|
|
|
|
|
2026-07-24 19:45:49 +00:00
|
|
|
|
_REVGEO_QUEUE: "queue.Queue[tuple]" = queue.Queue()
|
2026-07-23 04:41:26 +00:00
|
|
|
|
_REVGEO_WORKER_LOCK = threading.Lock()
|
|
|
|
|
|
_revgeo_worker_started = False
|
|
|
|
|
|
_REVGEO_WAIT_TIMEOUT = 10.0 # seconds a caller waits for ITS OWN request before giving up
|
2026-07-24 19:45:49 +00:00
|
|
|
|
_GEOCODE_WAIT_TIMEOUT = 35.0 # forward /geocode is a deliberate user search, not a map-pan
|
|
|
|
|
|
# enrichment -- worth a longer wait than reverse geocoding's,
|
|
|
|
|
|
# covering the 30s per-request timeout plus queue wait.
|
2026-07-23 04:41:26 +00:00
|
|
|
|
|
Persistent derived-data cache: SQLite store, ETag revalidation, view bundle, IndexedDB frontend (#21)
* Compress API responses and revalidate static assets instead of re-downloading
- Add GZipMiddleware (min 1 KB): the 2-year calendar JSON shrinks ~6-8x.
- Serve pages/assets with Cache-Control: no-cache instead of no-store, so
browsers revalidate via the ETag/Last-Modified that FileResponse and
StaticFiles already emit. Unchanged assets now cost an empty 304 rather
than a full transfer on every page navigation, while deploys still show
up immediately.
* Persist derived responses in SQLite so grading is computed once per cell, not per request
New data/thermograph.sqlite (WAL) holds what's derived from the raw parquet
records — finished grade/calendar/day/forecast payloads and reverse-geocode
labels — so the expensive work (notably the 2-year calendar grade_range, ~270ms)
becomes a ~5ms database read that survives restarts and is shared across views.
Raw parquet stays the source of truth; the store is a pure accelerator (every
reader falls back to recomputing on a miss, and deleting the db is a safe reset).
Freshness is token-driven, not clock-driven: each cached payload is validated by
a token encoding what it was computed from (payload schema version, the archive
record's end date, the recent-fetch stamp). The existing freshness drivers are
untouched — get_history still tops up the tail hourly and get_recent_forecast
still refetches hourly — and tokens are derived from what they return, so cached
payloads expire exactly when their inputs change. The same tokens double as weak
ETags: If-None-Match answers with an empty 304 without touching the payload.
- backend/store.py: derived-payload + revgeo tables, thread-local WAL conns,
every helper fail-soft.
- app.py: endpoints split into pure payload builders + HTTP/caching shells; the
in-memory 10-minute _CAL_CACHE is retired (superseded by the persistent store).
- climate.py: revgeo persisted through the store; recent_stamp() and
load_cached_history() (no-network read) helpers.
- backend/migrate.py + make migrate: idempotent, resumable backfill of the store
from existing parquet caches (default calendar span + latest-day detail +
revgeo, ≤1 throttled Nominatim call per unlabeled cell). Never fetches weather.
- grid.from_id(): rebuild a cell from its cache filename (migrate tooling).
* Add /api/v2/cell: one bundle carrying every view's payload
GET /api/v2/cell?lat&lon returns the grade, forecast, calendar (last 24 months)
and day (today) payloads in one response, each the exact payload its per-view
endpoint returns — built by the same builders and cached under the same
derived-store keys/tokens — paired with the etag that endpoint would emit. The
frontend can warm all views with a single request, seed its per-view cache from
the slices, and later revalidate each view individually with If-None-Match. The
bundle's own etag combines the slices', so an unchanged bundle is an empty 304.
prefetch=1 is a warm-only mode for neighbor-cell prefetching with a hard
guarantee: it never spends weather-API quota. A cell with no cached archive
answers 204, and only the history-derived slices (calendar + latest-day detail)
are built. At most one Nominatim lookup for a never-labeled cell.
* Frontend: IndexedDB response cache with stale-while-revalidate + bundle prefetch
The per-URL response cache moves from localStorage/sessionStorage (~5 MB quota,
which multi-year calendar payloads regularly blew through) to IndexedDB, with an
in-memory map in front. Entries carry the server's ETag, so anything stale
revalidates conditionally — unchanged data costs an empty 304 and a re-stamp,
never a re-transfer. On network failure the stale copy is served over an error.
getJSON gains an optional onUpdate callback opting into stale-while-revalidate:
the three views (weekly, day, calendar) now render a cached copy immediately —
spinners are delayed 150ms so warm loads never flash-blank — and repaint only if
background revalidation finds changed data. New data shows up the moment it
exists instead of waiting out a TTL.
Cross-view prefetch collapses from one request per view to a single /api/v2/cell
bundle, whose slices (exact per-view payloads + their etags) are seeded under the
URLs each view actually requests; the bundle call itself is conditional via a
remembered etag. Afterwards the 8 surrounding grid cells are warmed server-side
with prefetch=1 (never spends weather-API quota; staggered ≥1.1s for the one
possible Nominatim lookup each), so tapping nearby lands on already-graded data.
Legacy tg:* storage entries are cleared once; cache entries untouched for two
weeks are pruned on page load.
2026-07-11 07:31:28 +00:00
|
|
|
|
|
|
|
|
|
|
def reverse_geocode_cached(lat: float, lon: float) -> tuple[bool, str | None]:
|
|
|
|
|
|
"""(found, label) from the in-memory + SQLite revgeo caches only — never calls
|
|
|
|
|
|
Nominatim. Used by paths that must not add upstream traffic (prefetch)."""
|
|
|
|
|
|
key = store.revgeo_key(lat, lon)
|
|
|
|
|
|
if key in _REVGEO_CACHE:
|
|
|
|
|
|
return (True, _REVGEO_CACHE[key])
|
|
|
|
|
|
found, label = store.get_revgeo(key)
|
|
|
|
|
|
if found:
|
|
|
|
|
|
_REVGEO_CACHE[key] = label
|
|
|
|
|
|
return (found, label)
|
2026-07-11 00:29:47 +00:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 04:41:26 +00:00
|
|
|
|
def _fetch_revgeo_label(lat: float, lon: float) -> str | None:
|
|
|
|
|
|
"""The actual Nominatim reverse-geocode HTTP call + label assembly. Called
|
|
|
|
|
|
ONLY from _revgeo_worker (never on a caller's thread) — returns None (never
|
|
|
|
|
|
raises) so a bad response degrades to bare coordinates instead of killing the
|
|
|
|
|
|
worker thread."""
|
|
|
|
|
|
try:
|
|
|
|
|
|
r = _request(
|
|
|
|
|
|
"https://nominatim.openstreetmap.org/reverse",
|
|
|
|
|
|
# zoom 14 resolves to the suburb/neighbourhood level so we can lead
|
|
|
|
|
|
# with it when OSM has one (zoom 10 only ever returns the city).
|
|
|
|
|
|
# accept-language=en keeps labels in one script worldwide (matches
|
|
|
|
|
|
# the forward geocoder's language=en).
|
|
|
|
|
|
{"lat": lat, "lon": lon, "format": "jsonv2", "zoom": 14,
|
|
|
|
|
|
"addressdetails": 1, "accept-language": "en"},
|
|
|
|
|
|
15,
|
|
|
|
|
|
phase="reverse_geocode",
|
|
|
|
|
|
headers={"User-Agent": "Thermograph/0.1 (local weather grading app)"},
|
|
|
|
|
|
)
|
|
|
|
|
|
a = r.json().get("address", {}) or {}
|
|
|
|
|
|
# Lead with the neighbourhood when available, then the city, then region.
|
|
|
|
|
|
neighborhood = (a.get("neighbourhood") or a.get("suburb")
|
|
|
|
|
|
or a.get("quarter") or a.get("city_district")
|
|
|
|
|
|
or a.get("borough"))
|
|
|
|
|
|
city = (a.get("city") or a.get("town") or a.get("village")
|
|
|
|
|
|
or a.get("hamlet") or a.get("county"))
|
|
|
|
|
|
region = a.get("state") or a.get("province") or a.get("region")
|
|
|
|
|
|
country = a.get("country")
|
|
|
|
|
|
# dict.fromkeys drops duplicates (e.g. neighbourhood == city) in order.
|
|
|
|
|
|
# Country trails the neighbourhood/city/region so the label reads as a full
|
|
|
|
|
|
# hierarchy ("West Seattle, Seattle, Washington, United States"); the
|
|
|
|
|
|
# frontend leads with the first part and mutes the rest.
|
|
|
|
|
|
parts = dict.fromkeys(p for p in (neighborhood, city, region, country) if p)
|
|
|
|
|
|
return ", ".join(parts) or None
|
|
|
|
|
|
except Exception: # noqa: BLE001 - reverse geocoding is a nicety, never fatal
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _revgeo_worker() -> None:
|
2026-07-24 19:45:49 +00:00
|
|
|
|
"""Drains _REVGEO_QUEUE one job at a time, forever — both reverse ("revgeo")
|
|
|
|
|
|
and forward ("geocode") jobs, so the ~1/sec pacing below is enforced just by
|
|
|
|
|
|
doing the work serially on this one thread, no lock needed, since nothing
|
|
|
|
|
|
else ever touches Nominatim. Reverse jobs re-check the cache before fetching
|
|
|
|
|
|
(a request queued behind an identical one is answered from what the earlier
|
|
|
|
|
|
request just cached, no duplicate call) and always finish + persist even if
|
|
|
|
|
|
the original caller already gave up waiting (see reverse_geocode's timeout).
|
|
|
|
|
|
Forward jobs have no cache (see geocode_nominatim) but share the same pacer."""
|
2026-07-23 04:41:26 +00:00
|
|
|
|
global _revgeo_last
|
|
|
|
|
|
while True:
|
2026-07-24 19:45:49 +00:00
|
|
|
|
job = _REVGEO_QUEUE.get()
|
|
|
|
|
|
kind = job[0]
|
2026-07-23 04:41:26 +00:00
|
|
|
|
try:
|
2026-07-24 19:45:49 +00:00
|
|
|
|
if kind == "revgeo":
|
|
|
|
|
|
_, lat, lon, key, fut = job
|
|
|
|
|
|
found, label = reverse_geocode_cached(lat, lon)
|
|
|
|
|
|
if not found:
|
|
|
|
|
|
wait = _REVGEO_MIN_INTERVAL - (time.monotonic() - _revgeo_last)
|
|
|
|
|
|
if wait > 0:
|
|
|
|
|
|
time.sleep(wait)
|
|
|
|
|
|
label = _fetch_revgeo_label(lat, lon)
|
|
|
|
|
|
_revgeo_last = time.monotonic()
|
|
|
|
|
|
_REVGEO_CACHE[key] = label
|
|
|
|
|
|
store.put_revgeo(key, label) # survive restarts (a None label retries after its TTL)
|
|
|
|
|
|
if not fut.done():
|
|
|
|
|
|
fut.set_result(label)
|
|
|
|
|
|
else: # "geocode"
|
|
|
|
|
|
_, name, count, fut = job
|
2026-07-23 04:41:26 +00:00
|
|
|
|
wait = _REVGEO_MIN_INTERVAL - (time.monotonic() - _revgeo_last)
|
|
|
|
|
|
if wait > 0:
|
|
|
|
|
|
time.sleep(wait)
|
2026-07-24 19:45:49 +00:00
|
|
|
|
try:
|
|
|
|
|
|
results = _fetch_geocode_forward(name, count)
|
|
|
|
|
|
finally:
|
|
|
|
|
|
_revgeo_last = time.monotonic()
|
|
|
|
|
|
if not fut.done():
|
|
|
|
|
|
fut.set_result(results)
|
2026-07-23 04:41:26 +00:00
|
|
|
|
except Exception: # noqa: BLE001 - never let a bad request kill the worker
|
|
|
|
|
|
if not fut.done():
|
2026-07-24 19:45:49 +00:00
|
|
|
|
fut.set_result(None if kind == "revgeo" else [])
|
2026-07-23 04:41:26 +00:00
|
|
|
|
finally:
|
|
|
|
|
|
_REVGEO_QUEUE.task_done()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _start_revgeo_worker() -> None:
|
|
|
|
|
|
"""Start the dedicated reverse-geocode worker thread, once per process
|
|
|
|
|
|
(idempotent, thread-safe). Lazy (on first use) rather than at import, since
|
|
|
|
|
|
climate.py has no app-lifespan hook of its own — mirrors data/places.py's
|
|
|
|
|
|
start_loading()."""
|
|
|
|
|
|
global _revgeo_worker_started
|
|
|
|
|
|
with _REVGEO_WORKER_LOCK:
|
|
|
|
|
|
if _revgeo_worker_started:
|
|
|
|
|
|
return
|
|
|
|
|
|
_revgeo_worker_started = True
|
|
|
|
|
|
threading.Thread(target=_revgeo_worker, name="revgeo-worker", daemon=True).start()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 00:29:47 +00:00
|
|
|
|
def reverse_geocode(lat: float, lon: float) -> str | None:
|
Persistent derived-data cache: SQLite store, ETag revalidation, view bundle, IndexedDB frontend (#21)
* Compress API responses and revalidate static assets instead of re-downloading
- Add GZipMiddleware (min 1 KB): the 2-year calendar JSON shrinks ~6-8x.
- Serve pages/assets with Cache-Control: no-cache instead of no-store, so
browsers revalidate via the ETag/Last-Modified that FileResponse and
StaticFiles already emit. Unchanged assets now cost an empty 304 rather
than a full transfer on every page navigation, while deploys still show
up immediately.
* Persist derived responses in SQLite so grading is computed once per cell, not per request
New data/thermograph.sqlite (WAL) holds what's derived from the raw parquet
records — finished grade/calendar/day/forecast payloads and reverse-geocode
labels — so the expensive work (notably the 2-year calendar grade_range, ~270ms)
becomes a ~5ms database read that survives restarts and is shared across views.
Raw parquet stays the source of truth; the store is a pure accelerator (every
reader falls back to recomputing on a miss, and deleting the db is a safe reset).
Freshness is token-driven, not clock-driven: each cached payload is validated by
a token encoding what it was computed from (payload schema version, the archive
record's end date, the recent-fetch stamp). The existing freshness drivers are
untouched — get_history still tops up the tail hourly and get_recent_forecast
still refetches hourly — and tokens are derived from what they return, so cached
payloads expire exactly when their inputs change. The same tokens double as weak
ETags: If-None-Match answers with an empty 304 without touching the payload.
- backend/store.py: derived-payload + revgeo tables, thread-local WAL conns,
every helper fail-soft.
- app.py: endpoints split into pure payload builders + HTTP/caching shells; the
in-memory 10-minute _CAL_CACHE is retired (superseded by the persistent store).
- climate.py: revgeo persisted through the store; recent_stamp() and
load_cached_history() (no-network read) helpers.
- backend/migrate.py + make migrate: idempotent, resumable backfill of the store
from existing parquet caches (default calendar span + latest-day detail +
revgeo, ≤1 throttled Nominatim call per unlabeled cell). Never fetches weather.
- grid.from_id(): rebuild a cell from its cache filename (migrate tooling).
* Add /api/v2/cell: one bundle carrying every view's payload
GET /api/v2/cell?lat&lon returns the grade, forecast, calendar (last 24 months)
and day (today) payloads in one response, each the exact payload its per-view
endpoint returns — built by the same builders and cached under the same
derived-store keys/tokens — paired with the etag that endpoint would emit. The
frontend can warm all views with a single request, seed its per-view cache from
the slices, and later revalidate each view individually with If-None-Match. The
bundle's own etag combines the slices', so an unchanged bundle is an empty 304.
prefetch=1 is a warm-only mode for neighbor-cell prefetching with a hard
guarantee: it never spends weather-API quota. A cell with no cached archive
answers 204, and only the history-derived slices (calendar + latest-day detail)
are built. At most one Nominatim lookup for a never-labeled cell.
* Frontend: IndexedDB response cache with stale-while-revalidate + bundle prefetch
The per-URL response cache moves from localStorage/sessionStorage (~5 MB quota,
which multi-year calendar payloads regularly blew through) to IndexedDB, with an
in-memory map in front. Entries carry the server's ETag, so anything stale
revalidates conditionally — unchanged data costs an empty 304 and a re-stamp,
never a re-transfer. On network failure the stale copy is served over an error.
getJSON gains an optional onUpdate callback opting into stale-while-revalidate:
the three views (weekly, day, calendar) now render a cached copy immediately —
spinners are delayed 150ms so warm loads never flash-blank — and repaint only if
background revalidation finds changed data. New data shows up the moment it
exists instead of waiting out a TTL.
Cross-view prefetch collapses from one request per view to a single /api/v2/cell
bundle, whose slices (exact per-view payloads + their etags) are seeded under the
URLs each view actually requests; the bundle call itself is conditional via a
remembered etag. Afterwards the 8 surrounding grid cells are warmed server-side
with prefetch=1 (never spends weather-API quota; staggered ≥1.1s for the one
possible Nominatim lookup each), so tapping nearby lands on already-graded data.
Legacy tg:* storage entries are cleared once; cache entries untouched for two
weeks are pruned on page load.
2026-07-11 07:31:28 +00:00
|
|
|
|
"""Best-effort neighbourhood/city label for a point (OpenStreetMap Nominatim).
|
2026-07-11 00:29:47 +00:00
|
|
|
|
|
Persistent derived-data cache: SQLite store, ETag revalidation, view bundle, IndexedDB frontend (#21)
* Compress API responses and revalidate static assets instead of re-downloading
- Add GZipMiddleware (min 1 KB): the 2-year calendar JSON shrinks ~6-8x.
- Serve pages/assets with Cache-Control: no-cache instead of no-store, so
browsers revalidate via the ETag/Last-Modified that FileResponse and
StaticFiles already emit. Unchanged assets now cost an empty 304 rather
than a full transfer on every page navigation, while deploys still show
up immediately.
* Persist derived responses in SQLite so grading is computed once per cell, not per request
New data/thermograph.sqlite (WAL) holds what's derived from the raw parquet
records — finished grade/calendar/day/forecast payloads and reverse-geocode
labels — so the expensive work (notably the 2-year calendar grade_range, ~270ms)
becomes a ~5ms database read that survives restarts and is shared across views.
Raw parquet stays the source of truth; the store is a pure accelerator (every
reader falls back to recomputing on a miss, and deleting the db is a safe reset).
Freshness is token-driven, not clock-driven: each cached payload is validated by
a token encoding what it was computed from (payload schema version, the archive
record's end date, the recent-fetch stamp). The existing freshness drivers are
untouched — get_history still tops up the tail hourly and get_recent_forecast
still refetches hourly — and tokens are derived from what they return, so cached
payloads expire exactly when their inputs change. The same tokens double as weak
ETags: If-None-Match answers with an empty 304 without touching the payload.
- backend/store.py: derived-payload + revgeo tables, thread-local WAL conns,
every helper fail-soft.
- app.py: endpoints split into pure payload builders + HTTP/caching shells; the
in-memory 10-minute _CAL_CACHE is retired (superseded by the persistent store).
- climate.py: revgeo persisted through the store; recent_stamp() and
load_cached_history() (no-network read) helpers.
- backend/migrate.py + make migrate: idempotent, resumable backfill of the store
from existing parquet caches (default calendar span + latest-day detail +
revgeo, ≤1 throttled Nominatim call per unlabeled cell). Never fetches weather.
- grid.from_id(): rebuild a cell from its cache filename (migrate tooling).
* Add /api/v2/cell: one bundle carrying every view's payload
GET /api/v2/cell?lat&lon returns the grade, forecast, calendar (last 24 months)
and day (today) payloads in one response, each the exact payload its per-view
endpoint returns — built by the same builders and cached under the same
derived-store keys/tokens — paired with the etag that endpoint would emit. The
frontend can warm all views with a single request, seed its per-view cache from
the slices, and later revalidate each view individually with If-None-Match. The
bundle's own etag combines the slices', so an unchanged bundle is an empty 304.
prefetch=1 is a warm-only mode for neighbor-cell prefetching with a hard
guarantee: it never spends weather-API quota. A cell with no cached archive
answers 204, and only the history-derived slices (calendar + latest-day detail)
are built. At most one Nominatim lookup for a never-labeled cell.
* Frontend: IndexedDB response cache with stale-while-revalidate + bundle prefetch
The per-URL response cache moves from localStorage/sessionStorage (~5 MB quota,
which multi-year calendar payloads regularly blew through) to IndexedDB, with an
in-memory map in front. Entries carry the server's ETag, so anything stale
revalidates conditionally — unchanged data costs an empty 304 and a re-stamp,
never a re-transfer. On network failure the stale copy is served over an error.
getJSON gains an optional onUpdate callback opting into stale-while-revalidate:
the three views (weekly, day, calendar) now render a cached copy immediately —
spinners are delayed 150ms so warm loads never flash-blank — and repaint only if
background revalidation finds changed data. New data shows up the moment it
exists instead of waiting out a TTL.
Cross-view prefetch collapses from one request per view to a single /api/v2/cell
bundle, whose slices (exact per-view payloads + their etags) are seeded under the
URLs each view actually requests; the bundle call itself is conditional via a
remembered etag. Afterwards the 8 surrounding grid cells are warmed server-side
with prefetch=1 (never spends weather-API quota; staggered ≥1.1s for the one
possible Nominatim lookup each), so tapping nearby lands on already-graded data.
Legacy tg:* storage entries are cleared once; cache entries untouched for two
weeks are pruned on page load.
2026-07-11 07:31:28 +00:00
|
|
|
|
Cached per ~cell — in memory for the process and in SQLite across restarts —
|
|
|
|
|
|
so panning around (or redeploying) doesn't re-hit the service, and failures
|
2026-07-11 00:29:47 +00:00
|
|
|
|
return None so the caller can fall back to bare coordinates.
|
2026-07-23 04:41:26 +00:00
|
|
|
|
|
|
|
|
|
|
The actual fetch (and Nominatim's pacing) happens on the dedicated
|
|
|
|
|
|
_revgeo_worker thread, not here — this just enqueues the request and waits up
|
|
|
|
|
|
to _REVGEO_WAIT_TIMEOUT for its result, so a burst of concurrent uncached
|
|
|
|
|
|
lookups (the compare page fires several at once) queues behind the ~1/sec
|
|
|
|
|
|
limit without pinning a caller's (in the server, a shared threadpool) thread
|
|
|
|
|
|
for the wait. A timed-out wait returns None like any other failed lookup —
|
|
|
|
|
|
the caller falls back to bare coordinates — but the worker keeps going and
|
|
|
|
|
|
still caches the answer for the next call.
|
2026-07-11 00:29:47 +00:00
|
|
|
|
"""
|
Persistent derived-data cache: SQLite store, ETag revalidation, view bundle, IndexedDB frontend (#21)
* Compress API responses and revalidate static assets instead of re-downloading
- Add GZipMiddleware (min 1 KB): the 2-year calendar JSON shrinks ~6-8x.
- Serve pages/assets with Cache-Control: no-cache instead of no-store, so
browsers revalidate via the ETag/Last-Modified that FileResponse and
StaticFiles already emit. Unchanged assets now cost an empty 304 rather
than a full transfer on every page navigation, while deploys still show
up immediately.
* Persist derived responses in SQLite so grading is computed once per cell, not per request
New data/thermograph.sqlite (WAL) holds what's derived from the raw parquet
records — finished grade/calendar/day/forecast payloads and reverse-geocode
labels — so the expensive work (notably the 2-year calendar grade_range, ~270ms)
becomes a ~5ms database read that survives restarts and is shared across views.
Raw parquet stays the source of truth; the store is a pure accelerator (every
reader falls back to recomputing on a miss, and deleting the db is a safe reset).
Freshness is token-driven, not clock-driven: each cached payload is validated by
a token encoding what it was computed from (payload schema version, the archive
record's end date, the recent-fetch stamp). The existing freshness drivers are
untouched — get_history still tops up the tail hourly and get_recent_forecast
still refetches hourly — and tokens are derived from what they return, so cached
payloads expire exactly when their inputs change. The same tokens double as weak
ETags: If-None-Match answers with an empty 304 without touching the payload.
- backend/store.py: derived-payload + revgeo tables, thread-local WAL conns,
every helper fail-soft.
- app.py: endpoints split into pure payload builders + HTTP/caching shells; the
in-memory 10-minute _CAL_CACHE is retired (superseded by the persistent store).
- climate.py: revgeo persisted through the store; recent_stamp() and
load_cached_history() (no-network read) helpers.
- backend/migrate.py + make migrate: idempotent, resumable backfill of the store
from existing parquet caches (default calendar span + latest-day detail +
revgeo, ≤1 throttled Nominatim call per unlabeled cell). Never fetches weather.
- grid.from_id(): rebuild a cell from its cache filename (migrate tooling).
* Add /api/v2/cell: one bundle carrying every view's payload
GET /api/v2/cell?lat&lon returns the grade, forecast, calendar (last 24 months)
and day (today) payloads in one response, each the exact payload its per-view
endpoint returns — built by the same builders and cached under the same
derived-store keys/tokens — paired with the etag that endpoint would emit. The
frontend can warm all views with a single request, seed its per-view cache from
the slices, and later revalidate each view individually with If-None-Match. The
bundle's own etag combines the slices', so an unchanged bundle is an empty 304.
prefetch=1 is a warm-only mode for neighbor-cell prefetching with a hard
guarantee: it never spends weather-API quota. A cell with no cached archive
answers 204, and only the history-derived slices (calendar + latest-day detail)
are built. At most one Nominatim lookup for a never-labeled cell.
* Frontend: IndexedDB response cache with stale-while-revalidate + bundle prefetch
The per-URL response cache moves from localStorage/sessionStorage (~5 MB quota,
which multi-year calendar payloads regularly blew through) to IndexedDB, with an
in-memory map in front. Entries carry the server's ETag, so anything stale
revalidates conditionally — unchanged data costs an empty 304 and a re-stamp,
never a re-transfer. On network failure the stale copy is served over an error.
getJSON gains an optional onUpdate callback opting into stale-while-revalidate:
the three views (weekly, day, calendar) now render a cached copy immediately —
spinners are delayed 150ms so warm loads never flash-blank — and repaint only if
background revalidation finds changed data. New data shows up the moment it
exists instead of waiting out a TTL.
Cross-view prefetch collapses from one request per view to a single /api/v2/cell
bundle, whose slices (exact per-view payloads + their etags) are seeded under the
URLs each view actually requests; the bundle call itself is conditional via a
remembered etag. Afterwards the 8 surrounding grid cells are warmed server-side
with prefetch=1 (never spends weather-API quota; staggered ≥1.1s for the one
possible Nominatim lookup each), so tapping nearby lands on already-graded data.
Legacy tg:* storage entries are cleared once; cache entries untouched for two
weeks are pruned on page load.
2026-07-11 07:31:28 +00:00
|
|
|
|
key = store.revgeo_key(lat, lon)
|
|
|
|
|
|
found, label = reverse_geocode_cached(lat, lon)
|
|
|
|
|
|
if found:
|
|
|
|
|
|
return label
|
2026-07-23 04:41:26 +00:00
|
|
|
|
_start_revgeo_worker()
|
|
|
|
|
|
fut: "concurrent.futures.Future[str | None]" = concurrent.futures.Future()
|
2026-07-24 19:45:49 +00:00
|
|
|
|
_REVGEO_QUEUE.put(("revgeo", lat, lon, key, fut))
|
2026-07-23 04:41:26 +00:00
|
|
|
|
try:
|
|
|
|
|
|
return fut.result(timeout=_REVGEO_WAIT_TIMEOUT)
|
|
|
|
|
|
except concurrent.futures.TimeoutError:
|
|
|
|
|
|
return None
|
2026-07-11 00:29:47 +00:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-24 19:45:49 +00:00
|
|
|
|
def _fetch_geocode_forward(name: str, count: int) -> list[dict]:
|
|
|
|
|
|
"""The actual Nominatim forward-search HTTP call + result shaping. Called
|
|
|
|
|
|
ONLY from _revgeo_worker (never on a caller's thread), mirroring
|
|
|
|
|
|
_fetch_revgeo_label — but unlike that function, a bad response is allowed to
|
|
|
|
|
|
raise here; _revgeo_worker's own except clause turns it into an empty list,
|
|
|
|
|
|
same net effect without a second layer of exception-swallowing."""
|
|
|
|
|
|
r = _request(
|
|
|
|
|
|
"https://nominatim.openstreetmap.org/search",
|
|
|
|
|
|
{"q": name, "format": "jsonv2", "addressdetails": 1,
|
|
|
|
|
|
"limit": count, "accept-language": "en"},
|
|
|
|
|
|
30,
|
|
|
|
|
|
phase="geocode",
|
|
|
|
|
|
headers={"User-Agent": "Thermograph/0.1 (local weather grading app)"},
|
|
|
|
|
|
)
|
|
|
|
|
|
rows = r.json() or []
|
2026-07-23 14:34:51 +00:00
|
|
|
|
out = []
|
|
|
|
|
|
for g in rows:
|
|
|
|
|
|
a = g.get("address", {}) or {}
|
|
|
|
|
|
# jsonv2 gives a `name` for named features; for addresses/areas fall back to
|
|
|
|
|
|
# the most specific place component, then the head of display_name.
|
|
|
|
|
|
label = (g.get("name")
|
|
|
|
|
|
or a.get("city") or a.get("town") or a.get("village")
|
|
|
|
|
|
or a.get("hamlet") or a.get("suburb") or a.get("neighbourhood")
|
|
|
|
|
|
or (g.get("display_name") or "").split(",")[0].strip() or None)
|
|
|
|
|
|
cc = (a.get("country_code") or "").upper() or None
|
|
|
|
|
|
out.append({
|
|
|
|
|
|
"name": label,
|
|
|
|
|
|
"admin1": a.get("state") or a.get("province") or a.get("region"),
|
|
|
|
|
|
"country": a.get("country"),
|
|
|
|
|
|
"country_code": cc,
|
|
|
|
|
|
"lat": float(g["lat"]) if g.get("lat") is not None else None,
|
|
|
|
|
|
"lon": float(g["lon"]) if g.get("lon") is not None else None,
|
|
|
|
|
|
"population": None, # Nominatim has no population; order is relevance-based
|
|
|
|
|
|
})
|
|
|
|
|
|
return out
|
2026-07-24 19:45:49 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def geocode_nominatim(name: str, count: int = 5) -> list[dict]:
|
|
|
|
|
|
"""Forward place-name lookup via OpenStreetMap Nominatim (keyless).
|
|
|
|
|
|
|
|
|
|
|
|
Replaces the former Open-Meteo geocoder. Nominatim covers the long tail the
|
|
|
|
|
|
local GeoNames index can't — neighbourhoods, postcodes, sub-1000-population
|
|
|
|
|
|
villages, and alternate/native-language spellings — so /geocode falls back to
|
|
|
|
|
|
it on a local miss. It carries no population, so results keep Nominatim's own
|
|
|
|
|
|
relevance order; name/admin/country map straight across.
|
|
|
|
|
|
|
|
|
|
|
|
Shares the reverse geocoder's worker thread and ~1/sec pacing: both hit the
|
|
|
|
|
|
same host, and both jobs are drained serially by the one thread in
|
|
|
|
|
|
_revgeo_worker, so there is exactly one writer of _revgeo_last — no lock
|
|
|
|
|
|
needed, same reasoning as reverse_geocode. Only the low-volume /geocode miss
|
|
|
|
|
|
path reaches here — autocomplete (/suggest) is served purely from the local
|
|
|
|
|
|
index and never calls out.
|
|
|
|
|
|
"""
|
|
|
|
|
|
_start_revgeo_worker()
|
|
|
|
|
|
fut: "concurrent.futures.Future[list[dict]]" = concurrent.futures.Future()
|
|
|
|
|
|
_REVGEO_QUEUE.put(("geocode", name, count, fut))
|
|
|
|
|
|
try:
|
|
|
|
|
|
return fut.result(timeout=_GEOCODE_WAIT_TIMEOUT) or []
|
|
|
|
|
|
except concurrent.futures.TimeoutError:
|
|
|
|
|
|
return []
|