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>
This commit is contained in:
parent
1fbf3a29b0
commit
bf889681f6
5 changed files with 775 additions and 38 deletions
112
alembic/versions/0002_climate_hypertables.py
Normal file
112
alembic/versions/0002_climate_hypertables.py
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
"""climate record: TimescaleDB hypertables
|
||||||
|
|
||||||
|
Moves the raw daily climate record out of per-cell parquet files and into
|
||||||
|
Postgres, so the app queries the database instead of the filesystem (see
|
||||||
|
data/climate_store.py). Three objects:
|
||||||
|
|
||||||
|
* ``climate_history`` — a TimescaleDB hypertable of the full daily archive,
|
||||||
|
durable/LOGGED (a 45-year, rate-limited refetch is expensive). Range-partitioned
|
||||||
|
on ``date`` with a large 5-year chunk interval (the access pattern is
|
||||||
|
all-rows-for-one-cell, so time chunking is incidental — a big interval keeps the
|
||||||
|
chunk count tiny). Old chunks compress; the recent chunk stays writable for the
|
||||||
|
hourly tail top-up.
|
||||||
|
* ``climate_recent`` — a plain table (NOT a hypertable) for the recent+forecast
|
||||||
|
bundle. It holds *future* dates and is fully rewritten hourly, both of which
|
||||||
|
fight time-partitioning.
|
||||||
|
* ``climate_sync`` — per-cell freshness (epoch seconds), replacing the parquet
|
||||||
|
file mtimes that drove the topup cadence, the forecast TTL, and the
|
||||||
|
``recent_stamp`` token.
|
||||||
|
|
||||||
|
Postgres-only: guarded to no-op on the SQLite bind (tests / CI / offline Alembic),
|
||||||
|
where climate.py uses the parquet backend and these tables are never needed.
|
||||||
|
|
||||||
|
Revision ID: 0002_climate_hypertables
|
||||||
|
Revises: 0001_initial_schema
|
||||||
|
Create Date: 2026-07-20
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0002_climate_hypertables"
|
||||||
|
down_revision = "0001_initial_schema"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
# The daily measurement columns shared by both tables (see data/climate_store.COLS).
|
||||||
|
_MEASURES = """
|
||||||
|
tmax DOUBLE PRECISION,
|
||||||
|
tmin DOUBLE PRECISION,
|
||||||
|
precip DOUBLE PRECISION,
|
||||||
|
wind DOUBLE PRECISION,
|
||||||
|
gust DOUBLE PRECISION,
|
||||||
|
humid DOUBLE PRECISION,
|
||||||
|
fmax DOUBLE PRECISION,
|
||||||
|
fmin DOUBLE PRECISION,
|
||||||
|
feels DOUBLE PRECISION
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if op.get_bind().dialect.name != "postgresql":
|
||||||
|
return # SQLite fallback (tests/CI): climate uses the parquet backend.
|
||||||
|
|
||||||
|
op.execute("CREATE EXTENSION IF NOT EXISTS timescaledb")
|
||||||
|
|
||||||
|
# History: durable hypertable. PK (cell_id, date) includes the range-partition
|
||||||
|
# column (date), which TimescaleDB requires of any unique index, and gives the
|
||||||
|
# single-cell chronological scan the read path needs for free.
|
||||||
|
op.execute(f"""
|
||||||
|
CREATE TABLE climate_history (
|
||||||
|
cell_id TEXT NOT NULL,
|
||||||
|
date DATE NOT NULL,
|
||||||
|
{_MEASURES},
|
||||||
|
PRIMARY KEY (cell_id, date)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
op.execute(
|
||||||
|
"SELECT create_hypertable('climate_history', "
|
||||||
|
"by_range('date', INTERVAL '5 years'))")
|
||||||
|
|
||||||
|
# Columnar compression, grouped per cell. Only chunks older than a year are
|
||||||
|
# compressed — the hourly tail top-up only ever writes dates within the last
|
||||||
|
# week, i.e. the current uncompressed chunk, so compression never collides with
|
||||||
|
# a write.
|
||||||
|
op.execute("""
|
||||||
|
ALTER TABLE climate_history SET (
|
||||||
|
timescaledb.compress,
|
||||||
|
timescaledb.compress_segmentby = 'cell_id',
|
||||||
|
timescaledb.compress_orderby = 'date'
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
op.execute("SELECT add_compression_policy('climate_history', INTERVAL '365 days')")
|
||||||
|
|
||||||
|
# Recent + forecast bundle: plain table (holds future dates, rewritten hourly).
|
||||||
|
op.execute(f"""
|
||||||
|
CREATE TABLE climate_recent (
|
||||||
|
cell_id TEXT NOT NULL,
|
||||||
|
date DATE NOT NULL,
|
||||||
|
{_MEASURES},
|
||||||
|
PRIMARY KEY (cell_id, date)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Per-cell freshness — epoch seconds (DOUBLE mirrors store.py's updated_at and
|
||||||
|
# makes recent_stamp = int(recent_synced_at) trivial).
|
||||||
|
op.execute("""
|
||||||
|
CREATE TABLE climate_sync (
|
||||||
|
cell_id TEXT PRIMARY KEY,
|
||||||
|
history_synced_at DOUBLE PRECISION,
|
||||||
|
recent_synced_at DOUBLE PRECISION,
|
||||||
|
cols_version SMALLINT NOT NULL DEFAULT 1
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if op.get_bind().dialect.name != "postgresql":
|
||||||
|
return
|
||||||
|
op.execute("DROP TABLE IF EXISTS climate_sync")
|
||||||
|
op.execute("DROP TABLE IF EXISTS climate_recent")
|
||||||
|
op.execute("DROP TABLE IF EXISTS climate_history")
|
||||||
|
# Leave the timescaledb extension installed — other objects may rely on it and
|
||||||
|
# dropping an extension is a heavier, rarely-wanted operation.
|
||||||
164
data/climate.py
164
data/climate.py
|
|
@ -17,6 +17,7 @@ import polars as pl
|
||||||
from core import audit
|
from core import audit
|
||||||
from core import metrics
|
from core import metrics
|
||||||
import paths
|
import paths
|
||||||
|
from data import climate_store
|
||||||
from data import store
|
from data import store
|
||||||
|
|
||||||
CACHE_DIR = os.path.join(paths.DATA_DIR, "cache")
|
CACHE_DIR = os.path.join(paths.DATA_DIR, "cache")
|
||||||
|
|
@ -277,6 +278,85 @@ def _write_cache(df: pl.DataFrame, path: str) -> None:
|
||||||
df.drop("doy", strict=False).write_parquet(path, compression="zstd")
|
df.drop("doy", strict=False).write_parquet(path, compression="zstd")
|
||||||
|
|
||||||
|
|
||||||
|
# --- 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))
|
||||||
|
|
||||||
|
|
||||||
def _om_daily_params(cell: dict, **window) -> dict:
|
def _om_daily_params(cell: dict, **window) -> dict:
|
||||||
"""The Open-Meteo daily-request params every fetch shares — location, the
|
"""The Open-Meteo daily-request params every fetch shares — location, the
|
||||||
variable set, imperial units. The date/window selectors (start_date/end_date
|
variable set, imperial units. The date/window selectors (start_date/end_date
|
||||||
|
|
@ -503,12 +583,13 @@ def _read_history_cache(path):
|
||||||
return df, time.time() - os.path.getmtime(path)
|
return df, time.time() - os.path.getmtime(path)
|
||||||
|
|
||||||
|
|
||||||
def _topup_tail(cell: dict, df: pl.DataFrame, path: str) -> pl.DataFrame:
|
def _topup_tail(cell: dict, df: pl.DataFrame) -> pl.DataFrame:
|
||||||
"""Append newly-available archive days to a cached record. Best-effort and
|
"""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."""
|
serialized per cell; a small incremental fetch, not the full multi-decade pull."""
|
||||||
global _archive_cooldown_until
|
global _archive_cooldown_until
|
||||||
with _cell_lock(cell["id"]):
|
cell_id = cell["id"]
|
||||||
fresh = _read_history_cache(path) # another thread may have just refreshed it
|
with _cell_lock(cell_id):
|
||||||
|
fresh = _read_history_backed(cell_id) # another thread may have just refreshed it
|
||||||
if fresh is not None:
|
if fresh is not None:
|
||||||
df, age_s = fresh
|
df, age_s = fresh
|
||||||
if age_s < HISTORY_TOPUP_INTERVAL:
|
if age_s < HISTORY_TOPUP_INTERVAL:
|
||||||
|
|
@ -518,8 +599,7 @@ def _topup_tail(cell: dict, df: pl.DataFrame, path: str) -> pl.DataFrame:
|
||||||
expected = datetime.date.today() - datetime.timedelta(days=ARCHIVE_LATENCY_DAYS)
|
expected = datetime.date.today() - datetime.timedelta(days=ARCHIVE_LATENCY_DAYS)
|
||||||
cached_max = df["date"].max()
|
cached_max = df["date"].max()
|
||||||
if cached_max >= expected:
|
if cached_max >= expected:
|
||||||
try: os.utime(path, None) # tail already current — reset the hourly timer
|
_touch_history_backed(cell_id) # tail already current — reset the hourly timer
|
||||||
except OSError: pass
|
|
||||||
return df
|
return df
|
||||||
try:
|
try:
|
||||||
recent = _fetch_history_range(
|
recent = _fetch_history_range(
|
||||||
|
|
@ -531,10 +611,13 @@ def _topup_tail(cell: dict, df: pl.DataFrame, path: str) -> pl.DataFrame:
|
||||||
# Concatenate archive-first, recent-last so `keep="last"` prefers a freshly
|
# Concatenate archive-first, recent-last so `keep="last"` prefers a freshly
|
||||||
# fetched day over its cached duplicate; maintain_order keeps that precedence
|
# fetched day over its cached duplicate; maintain_order keeps that precedence
|
||||||
# before the final chronological sort.
|
# before the final chronological sort.
|
||||||
merged = (pl.concat([df, recent.drop("doy", strict=False)], how="diagonal_relaxed")
|
tail = recent.drop("doy", strict=False)
|
||||||
|
merged = (pl.concat([df, tail], how="diagonal_relaxed")
|
||||||
.unique(subset="date", keep="last", maintain_order=True)
|
.unique(subset="date", keep="last", maintain_order=True)
|
||||||
.sort("date"))
|
.sort("date"))
|
||||||
_write_cache(merged, path)
|
# 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)
|
||||||
return merged
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -546,11 +629,11 @@ def get_history(cell: dict) -> tuple[pl.DataFrame, dict]:
|
||||||
|
|
||||||
|
|
||||||
def load_cached_history(cell: dict) -> pl.DataFrame | None:
|
def load_cached_history(cell: dict) -> pl.DataFrame | None:
|
||||||
"""History from the parquet cache ONLY — never fetches upstream and never tops
|
"""History from the cache ONLY — never fetches upstream and never tops up the
|
||||||
up the tail. Powers the warm-only prefetch path (which must not spend upstream
|
tail. Powers the warm-only prefetch path (which must not spend upstream quota)
|
||||||
quota) and the offline migrate script. None when the cell has no
|
and the offline migrate script. None when the cell has no (schema-complete)
|
||||||
(schema-complete) cached record."""
|
cached record."""
|
||||||
hit = _read_history_cache(_cache_path(cell["id"]))
|
hit = _read_history_backed(cell["id"])
|
||||||
if hit is None:
|
if hit is None:
|
||||||
return None
|
return None
|
||||||
return _derive_metrics(_with_doy(hit[0]))
|
return _derive_metrics(_with_doy(hit[0]))
|
||||||
|
|
@ -562,24 +645,24 @@ def _load_history(cell: dict) -> tuple[pl.DataFrame, dict]:
|
||||||
The full archive is cached indefinitely (fetched once); only the recent tail is
|
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
|
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."""
|
fetch happens; on an upstream failure (e.g. 429) any existing cache is served."""
|
||||||
path = _cache_path(cell["id"])
|
cell_id = cell["id"]
|
||||||
|
|
||||||
hit = _read_history_cache(path)
|
hit = _read_history_backed(cell_id)
|
||||||
if hit is not None:
|
if hit is not None:
|
||||||
df, age_s = hit
|
df, age_s = hit
|
||||||
if age_s > HISTORY_TOPUP_INTERVAL:
|
if age_s > HISTORY_TOPUP_INTERVAL:
|
||||||
df = _topup_tail(cell, df, path) # refresh just the recent days
|
df = _topup_tail(cell, df) # refresh just the recent days
|
||||||
return _with_doy(df), {"cached": True, "cache_age_days": round(age_s / 86400.0, 1)}
|
return _with_doy(df), {"cached": True, "cache_age_days": round(age_s / 86400.0, 1)}
|
||||||
|
|
||||||
global _archive_cooldown_until
|
global _archive_cooldown_until
|
||||||
|
|
||||||
with _cell_lock(cell["id"]):
|
with _cell_lock(cell_id):
|
||||||
hit = _read_history_cache(path) # another thread may have populated it while we waited
|
hit = _read_history_backed(cell_id) # another thread may have populated it while we waited
|
||||||
if hit is not None:
|
if hit is not None:
|
||||||
df, age_s = hit
|
df, age_s = hit
|
||||||
return _with_doy(df), {"cached": True, "cache_age_days": round(age_s / 86400.0, 1)}
|
return _with_doy(df), {"cached": True, "cache_age_days": round(age_s / 86400.0, 1)}
|
||||||
|
|
||||||
stale = _normalize_read(pl.read_parquet(path)) if os.path.exists(path) else None
|
stale = _stale_history_backed(cell_id)
|
||||||
|
|
||||||
def _serve_stale():
|
def _serve_stale():
|
||||||
return _with_doy(stale), {"cached": True, "stale": True, "cache_age_days": None}
|
return _with_doy(stale), {"cached": True, "stale": True, "cache_age_days": None}
|
||||||
|
|
@ -615,7 +698,7 @@ def _load_history(cell: dict) -> tuple[pl.DataFrame, dict]:
|
||||||
daily=_archive_limit_daily)
|
daily=_archive_limit_daily)
|
||||||
|
|
||||||
# Store the raw record; percentiles are derived at request time.
|
# Store the raw record; percentiles are derived at request time.
|
||||||
_write_cache(df, path)
|
_write_history_backed(cell_id, df)
|
||||||
return df, {"cached": False, "cache_age_days": 0, "source": source}
|
return df, {"cached": False, "cache_age_days": 0, "source": source}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -628,9 +711,13 @@ def _rf_cache_path(cell_id: str) -> str:
|
||||||
|
|
||||||
|
|
||||||
def recent_stamp(cell_id: str) -> int:
|
def recent_stamp(cell_id: str) -> int:
|
||||||
"""Identity stamp (mtime, whole seconds) of the cell's cached recent+forecast
|
"""Identity stamp (whole seconds) of the cell's cached recent+forecast bundle;
|
||||||
parquet; 0 when absent. Changes exactly when the recent/forecast data does, so
|
0 when absent. On Postgres it's int(recent_synced_at) from climate_sync; on
|
||||||
it's the freshness token for every payload that grades recent or future days."""
|
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))
|
||||||
try:
|
try:
|
||||||
return int(os.path.getmtime(_rf_cache_path(cell_id)))
|
return int(os.path.getmtime(_rf_cache_path(cell_id)))
|
||||||
except OSError:
|
except OSError:
|
||||||
|
|
@ -644,17 +731,17 @@ def get_recent_forecast(cell: dict) -> pl.DataFrame:
|
||||||
|
|
||||||
|
|
||||||
def load_cached_recent_forecast(cell: dict) -> "pl.DataFrame | None":
|
def load_cached_recent_forecast(cell: dict) -> "pl.DataFrame | None":
|
||||||
"""Recent+forecast from the parquet cache ONLY — never fetches upstream, and
|
"""Recent+forecast from the cache ONLY — never fetches upstream, and unlike the
|
||||||
unlike the loader below it does not care how stale the file is. The sibling of
|
loader below it does not care how stale the record is. The sibling of
|
||||||
load_cached_history, for the same reason: the homepage precompute sweeps every
|
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
|
cached city and must not spend a single upstream request doing it. None when
|
||||||
the cell has no cached recent/forecast record."""
|
the cell has no cached recent/forecast record."""
|
||||||
path = _rf_cache_path(cell["id"])
|
hit = _read_recent_backed(cell["id"])
|
||||||
if not os.path.exists(path):
|
if hit is None:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
return _derive_metrics(_with_doy(_normalize_read(pl.read_parquet(path))))
|
return _derive_metrics(_with_doy(hit[0]))
|
||||||
except Exception: # noqa: BLE001 - a truncated/corrupt cache file is a miss, not a crash
|
except Exception: # noqa: BLE001 - a truncated/corrupt cache record is a miss, not a crash
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -665,11 +752,12 @@ def _load_recent_forecast(cell: dict) -> pl.DataFrame:
|
||||||
a cell needs just one upstream forecast request per hour (plus the ~monthly
|
a cell needs just one upstream forecast request per hour (plus the ~monthly
|
||||||
archive fetch). Cached per cell for one hour so it still follows model updates.
|
archive fetch). Cached per cell for one hour so it still follows model updates.
|
||||||
"""
|
"""
|
||||||
path = _rf_cache_path(cell["id"])
|
cell_id = cell["id"]
|
||||||
if os.path.exists(path):
|
hit = _read_recent_backed(cell_id)
|
||||||
age_h = (time.time() - os.path.getmtime(path)) / 3600.0
|
if hit is not None:
|
||||||
if age_h < FORECAST_TTL_HOURS:
|
cached, age_s = hit
|
||||||
return _with_doy(_normalize_read(pl.read_parquet(path)))
|
if age_s / 3600.0 < FORECAST_TTL_HOURS:
|
||||||
|
return _with_doy(cached)
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
"latitude": cell["center_lat"],
|
"latitude": cell["center_lat"],
|
||||||
|
|
@ -696,10 +784,10 @@ def _load_recent_forecast(cell: dict) -> pl.DataFrame:
|
||||||
# Last resort: serve the stale cache if we have one. An hours-old bundle
|
# Last resort: serve the stale cache if we have one. An hours-old bundle
|
||||||
# (which still carries the recent observed days MET Norway lacks) beats a
|
# (which still carries the recent observed days MET Norway lacks) beats a
|
||||||
# hard failure, mirroring _load_history's stale-serve. Return WITHOUT
|
# hard failure, mirroring _load_history's stale-serve. Return WITHOUT
|
||||||
# rewriting it, so its mtime stays old and the next request still retries
|
# rewriting it, so recent_stamp stays old and the next request still
|
||||||
# upstream first rather than serving this as if it were fresh.
|
# retries upstream first rather than serving this as if it were fresh.
|
||||||
if os.path.exists(path):
|
if hit is not None:
|
||||||
return _with_doy(_normalize_read(pl.read_parquet(path)))
|
return _with_doy(hit[0])
|
||||||
# No cache either: surface the original error, classifying an Open-Meteo
|
# No cache either: surface the original error, classifying an Open-Meteo
|
||||||
# rate limit as the typed, daily-aware WeatherUnavailable (the archive
|
# rate limit as the typed, daily-aware WeatherUnavailable (the archive
|
||||||
# path classifies its own inside _load_history).
|
# path classifies its own inside _load_history).
|
||||||
|
|
@ -707,7 +795,7 @@ def _load_recent_forecast(cell: dict) -> pl.DataFrame:
|
||||||
daily = "daily" in _rate_limit_reason(e).lower()
|
daily = "daily" in _rate_limit_reason(e).lower()
|
||||||
raise WeatherUnavailable(limit_message(daily), daily=daily) from e
|
raise WeatherUnavailable(limit_message(daily), daily=daily) from e
|
||||||
raise
|
raise
|
||||||
_write_cache(df, path)
|
_write_recent_backed(cell_id, df)
|
||||||
return df
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
289
data/climate_store.py
Normal file
289
data/climate_store.py
Normal file
|
|
@ -0,0 +1,289 @@
|
||||||
|
"""Raw climate record store — TimescaleDB hypertables (Postgres) behind polars.
|
||||||
|
|
||||||
|
The per-cell daily record (the multi-decade archive history and the hourly
|
||||||
|
recent+forecast bundle) is the *source of truth* for grading. On Postgres it
|
||||||
|
lives in two tables managed by Alembic (``backend/alembic/versions``):
|
||||||
|
|
||||||
|
* ``climate_history`` — a TimescaleDB **hypertable** (durable/LOGGED) of the full
|
||||||
|
daily archive, keyed ``(cell_id, date)``. Expensive to refetch (45 years,
|
||||||
|
rate-limited upstream), so it is durable, not a throwaway cache.
|
||||||
|
* ``climate_recent`` — a plain table holding the recent-observations + forward
|
||||||
|
forecast bundle (includes *future* dates), fully rewritten each refresh.
|
||||||
|
* ``climate_sync`` — per-cell freshness (epoch seconds), replacing the parquet
|
||||||
|
file mtimes that used to drive the topup cadence, the forecast TTL, and the
|
||||||
|
``recent_stamp`` token embedded in derived-payload validity (see web/views.py).
|
||||||
|
|
||||||
|
This module is the Postgres backend only. ``climate.py`` keeps the parquet cache
|
||||||
|
as the backend when ``THERMOGRAPH_DATABASE_URL`` is *not* a Postgres URL (dev,
|
||||||
|
tests, offline tooling) — the dialect switch mirrors ``data/store.py`` and
|
||||||
|
``accounts/db.py`` exactly, which is what keeps the test suite Postgres-free.
|
||||||
|
|
||||||
|
Every helper is **fail-soft**: a connection or query that fails is swallowed and
|
||||||
|
returns "absent" (``None`` / ``0.0``). Unlike ``store.py`` (which degrades to
|
||||||
|
recompute-from-parquet), a miss here degrades to *fetch-from-upstream* — prod has
|
||||||
|
no parquet fallback, and ``climate.py``'s loaders already treat "no cache" as
|
||||||
|
"go fetch".
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import polars as pl
|
||||||
|
|
||||||
|
# Bump when a new persisted metric column is added to the daily record. A cell
|
||||||
|
# whose stored rows predate the bump reads back as schema-incomplete (see
|
||||||
|
# read_history), so climate.py refetches to fill the new column — the Postgres
|
||||||
|
# analogue of the parquet NEW_COLS presence check.
|
||||||
|
COLS_VERSION = 1
|
||||||
|
|
||||||
|
# The persisted daily columns, in a stable order. ``humid`` is the RAW relative
|
||||||
|
# humidity the archive returns; absolute humidity + wet-bulb are derived at the
|
||||||
|
# read boundary in climate._derive_metrics, never stored (so the store matches
|
||||||
|
# what the parquet write path persisted). ``feels`` IS stored. ``doy`` is not —
|
||||||
|
# climate._with_doy recomputes it at read time.
|
||||||
|
COLS = ("date", "tmax", "tmin", "precip", "wind", "gust", "humid", "fmax", "fmin", "feels")
|
||||||
|
|
||||||
|
# Explicit polars schema so a frame read back from Postgres has the exact dtypes
|
||||||
|
# the parquet path produced (pl.Date + Float64), even when the result is empty —
|
||||||
|
# so grading.py / scoring.py / web/views.py are untouched by the backend switch.
|
||||||
|
_SCHEMA: dict[str, pl.DataType] = {
|
||||||
|
"date": pl.Date,
|
||||||
|
**{c: pl.Float64 for c in COLS if c != "date"},
|
||||||
|
}
|
||||||
|
|
||||||
|
DATABASE_URL = os.environ.get("THERMOGRAPH_DATABASE_URL", "").strip()
|
||||||
|
IS_POSTGRES = DATABASE_URL.startswith("postgresql")
|
||||||
|
|
||||||
|
|
||||||
|
def is_postgres() -> bool:
|
||||||
|
"""True when the climate record is served from Postgres (prod), False when it
|
||||||
|
falls back to the parquet cache (dev / tests / offline)."""
|
||||||
|
return IS_POSTGRES
|
||||||
|
|
||||||
|
|
||||||
|
def _libpq_dsn(url: str) -> str:
|
||||||
|
"""A plain libpq URL psycopg accepts: drop the SQLAlchemy driver suffix
|
||||||
|
(``postgresql+asyncpg://`` / ``+psycopg://`` -> ``postgresql://``)."""
|
||||||
|
return url.replace("+asyncpg", "").replace("+psycopg", "")
|
||||||
|
|
||||||
|
|
||||||
|
_PG_DSN = _libpq_dsn(DATABASE_URL) if IS_POSTGRES else ""
|
||||||
|
|
||||||
|
# Per-thread connection: uvicorn serves on a thread pool and psycopg connections
|
||||||
|
# aren't thread-safe, so each thread lazily opens its own (mirrors store.py).
|
||||||
|
_local = threading.local()
|
||||||
|
|
||||||
|
|
||||||
|
def _conn():
|
||||||
|
"""Thread-local autocommit psycopg connection, or None when Postgres is off or
|
||||||
|
unreachable. A dropped connection is retired so the next call reconnects."""
|
||||||
|
if not IS_POSTGRES:
|
||||||
|
return None
|
||||||
|
conn = getattr(_local, "conn", None)
|
||||||
|
if conn is not None:
|
||||||
|
if getattr(conn, "closed", False):
|
||||||
|
_local.conn = None
|
||||||
|
else:
|
||||||
|
return conn
|
||||||
|
try:
|
||||||
|
import psycopg # local import: only the Postgres path needs it
|
||||||
|
conn = psycopg.connect(_PG_DSN, autocommit=True)
|
||||||
|
_local.conn = conn
|
||||||
|
return conn
|
||||||
|
except Exception: # noqa: BLE001 - the store is optional; callers fetch on miss
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# --- freshness (climate_sync) -----------------------------------------------
|
||||||
|
|
||||||
|
def _get_sync(conn, cell_id):
|
||||||
|
"""(history_synced_at, recent_synced_at, cols_version) for a cell, or None."""
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT history_synced_at, recent_synced_at, cols_version "
|
||||||
|
"FROM climate_sync WHERE cell_id = %s",
|
||||||
|
(cell_id,),
|
||||||
|
).fetchone()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _bump_sync(conn, cell_id, *, history=None, recent=None, cols_version=None) -> None:
|
||||||
|
"""Upsert one cell's freshness. A None field leaves the stored value unchanged
|
||||||
|
on update (COALESCE), so a recent-bundle write never disturbs history_synced_at
|
||||||
|
and vice-versa. On insert, cols_version defaults to 1."""
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO climate_sync (cell_id, history_synced_at, recent_synced_at, cols_version)
|
||||||
|
VALUES (%(c)s, %(h)s, %(r)s, COALESCE(%(v)s, 1))
|
||||||
|
ON CONFLICT (cell_id) DO UPDATE SET
|
||||||
|
history_synced_at = COALESCE(%(h)s, climate_sync.history_synced_at),
|
||||||
|
recent_synced_at = COALESCE(%(r)s, climate_sync.recent_synced_at),
|
||||||
|
cols_version = COALESCE(%(v)s, climate_sync.cols_version)
|
||||||
|
""",
|
||||||
|
{"c": cell_id, "h": history, "r": recent, "v": cols_version},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def history_synced_at(cell_id: str) -> float:
|
||||||
|
"""Epoch seconds of the cell's last history write/topup; 0.0 if absent."""
|
||||||
|
conn = _conn()
|
||||||
|
if conn is None:
|
||||||
|
return 0.0
|
||||||
|
try:
|
||||||
|
row = _get_sync(conn, cell_id)
|
||||||
|
return float(row[0]) if row and row[0] is not None else 0.0
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def recent_synced_at(cell_id: str) -> float:
|
||||||
|
"""Epoch seconds of the cell's last recent-bundle write; 0.0 if absent. Backs
|
||||||
|
climate.recent_stamp, so it must advance only on a real rewrite (see write_recent)."""
|
||||||
|
conn = _conn()
|
||||||
|
if conn is None:
|
||||||
|
return 0.0
|
||||||
|
try:
|
||||||
|
row = _get_sync(conn, cell_id)
|
||||||
|
return float(row[1]) if row and row[1] is not None else 0.0
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def cols_version(cell_id: str) -> int:
|
||||||
|
"""The stored schema version for a cell's history; 0 if absent."""
|
||||||
|
conn = _conn()
|
||||||
|
if conn is None:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
row = _get_sync(conn, cell_id)
|
||||||
|
return int(row[2]) if row and row[2] is not None else 0
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# --- reads ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _read_frame(conn, table: str, cell_id: str) -> pl.DataFrame:
|
||||||
|
"""A cell's rows from ``table`` as a polars frame with the canonical schema.
|
||||||
|
``table`` is a module constant, never user input."""
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT {', '.join(COLS)} FROM {table} WHERE cell_id = %s ORDER BY date",
|
||||||
|
(cell_id,),
|
||||||
|
).fetchall()
|
||||||
|
return pl.DataFrame(rows, schema=_SCHEMA, orient="row")
|
||||||
|
|
||||||
|
|
||||||
|
def read_history(cell_id: str) -> "tuple[pl.DataFrame, float] | None":
|
||||||
|
"""(history frame, history_synced_at) for a cell, or None when there is no
|
||||||
|
schema-complete cached record. A stored cols_version below COLS_VERSION reads
|
||||||
|
as absent, so climate.py refetches to add the new column(s)."""
|
||||||
|
conn = _conn()
|
||||||
|
if conn is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
sync = _get_sync(conn, cell_id)
|
||||||
|
if sync is None or sync[0] is None:
|
||||||
|
return None
|
||||||
|
if (sync[2] or 0) < COLS_VERSION:
|
||||||
|
return None
|
||||||
|
df = _read_frame(conn, "climate_history", cell_id)
|
||||||
|
if df.is_empty():
|
||||||
|
return None
|
||||||
|
return df, float(sync[0])
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def read_history_raw(cell_id: str) -> "pl.DataFrame | None":
|
||||||
|
"""A cell's history rows regardless of schema version — for the stale-serve
|
||||||
|
fallback when every upstream fetch fails (the Postgres analogue of reading the
|
||||||
|
parquet file directly, bypassing the NEW_COLS completeness check). None when
|
||||||
|
there are no rows."""
|
||||||
|
conn = _conn()
|
||||||
|
if conn is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
df = _read_frame(conn, "climate_history", cell_id)
|
||||||
|
return None if df.is_empty() else df
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def read_recent(cell_id: str) -> "tuple[pl.DataFrame, float] | None":
|
||||||
|
"""(recent+forecast frame, recent_synced_at) for a cell, or None when absent."""
|
||||||
|
conn = _conn()
|
||||||
|
if conn is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
sync = _get_sync(conn, cell_id)
|
||||||
|
if sync is None or sync[1] is None:
|
||||||
|
return None
|
||||||
|
df = _read_frame(conn, "climate_recent", cell_id)
|
||||||
|
if df.is_empty():
|
||||||
|
return None
|
||||||
|
return df, float(sync[1])
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# --- writes -----------------------------------------------------------------
|
||||||
|
|
||||||
|
def _copy_rows(cur, table: str, cell_id: str, df: pl.DataFrame) -> None:
|
||||||
|
"""COPY a cell's frame into ``table`` (text format). NaN was already folded to
|
||||||
|
null upstream in climate._finalize_frame, so nulls copy cleanly."""
|
||||||
|
with cur.copy(f"COPY {table} (cell_id, {', '.join(COLS)}) FROM STDIN") as copy:
|
||||||
|
for row in df.select(COLS).iter_rows():
|
||||||
|
copy.write_row((cell_id, *row))
|
||||||
|
|
||||||
|
|
||||||
|
def write_history(cell_id: str, df: pl.DataFrame, synced_at: float) -> None:
|
||||||
|
"""Upsert a cell's history rows and stamp history_synced_at. Idempotent: a
|
||||||
|
re-fetched day supersedes its stored duplicate (ON CONFLICT DO UPDATE), which
|
||||||
|
mirrors the parquet unique(subset="date", keep="last") merge. Best-effort."""
|
||||||
|
conn = _conn()
|
||||||
|
if conn is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with conn.transaction():
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TEMP TABLE _hist_stage (LIKE climate_history) ON COMMIT DROP")
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
_copy_rows(cur, "_hist_stage", cell_id, df)
|
||||||
|
set_cols = ", ".join(f"{c} = EXCLUDED.{c}" for c in COLS if c != "date")
|
||||||
|
conn.execute(
|
||||||
|
f"INSERT INTO climate_history (cell_id, {', '.join(COLS)}) "
|
||||||
|
f"SELECT cell_id, {', '.join(COLS)} FROM _hist_stage "
|
||||||
|
f"ON CONFLICT (cell_id, date) DO UPDATE SET {set_cols}")
|
||||||
|
_bump_sync(conn, cell_id, history=synced_at, cols_version=COLS_VERSION)
|
||||||
|
except Exception: # noqa: BLE001 - failing to cache must not fail the request
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def touch_history(cell_id: str, synced_at: float) -> None:
|
||||||
|
"""Bump only history_synced_at — the analogue of ``os.utime`` on the parquet
|
||||||
|
file when the archive tail is already current, so the hourly topup timer resets
|
||||||
|
without a rewrite. Best-effort."""
|
||||||
|
conn = _conn()
|
||||||
|
if conn is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
_bump_sync(conn, cell_id, history=synced_at)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def write_recent(cell_id: str, df: pl.DataFrame, synced_at: float) -> None:
|
||||||
|
"""Replace a cell's recent+forecast bundle (delete + COPY in one transaction)
|
||||||
|
and stamp recent_synced_at. The window slides and forecast values change each
|
||||||
|
refresh, so a full replace is the simplest correct write. Call ONLY on a real
|
||||||
|
rewrite — recent_synced_at feeds recent_stamp, which must not advance when a
|
||||||
|
stale bundle is served without a refetch. Best-effort."""
|
||||||
|
conn = _conn()
|
||||||
|
if conn is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with conn.transaction():
|
||||||
|
conn.execute("DELETE FROM climate_recent WHERE cell_id = %s", (cell_id,))
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
_copy_rows(cur, "climate_recent", cell_id, df)
|
||||||
|
_bump_sync(conn, cell_id, recent=synced_at)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
96
migrate_cache_to_pg.py
Normal file
96
migrate_cache_to_pg.py
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
"""One-shot backfill of the parquet climate cache into the TimescaleDB tables.
|
||||||
|
|
||||||
|
Run ONCE during the cutover, after ``alembic upgrade head`` has created the
|
||||||
|
``climate_history`` / ``climate_recent`` / ``climate_sync`` tables. It loads every
|
||||||
|
cached cell's parquet record into Postgres so a freshly-cut-over server serves
|
||||||
|
history/recent from the database immediately instead of refetching decades of
|
||||||
|
archive (which the upstream rate limit would throttle hard).
|
||||||
|
|
||||||
|
Idempotent and resumable: a cell whose stored sync timestamp already matches its
|
||||||
|
parquet mtime is skipped, and history rows upsert (``ON CONFLICT``), so re-running
|
||||||
|
after an interruption only does the missing work.
|
||||||
|
|
||||||
|
Key detail: rows are read RAW (``pl.read_parquet`` + ``_normalize_read``), NOT via
|
||||||
|
``climate.load_cached_*`` — the loaders apply ``_derive_metrics``, which replaces
|
||||||
|
raw relative humidity with absolute humidity. The DB must store the raw record the
|
||||||
|
live write path persists, so absolute humidity / wet-bulb stay read-time
|
||||||
|
derivations. The per-cell ``synced_at`` is set to the parquet FILE MTIME (not now),
|
||||||
|
so ``recent_stamp`` — hence every derived-payload validity token — is unchanged
|
||||||
|
across the cutover.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
THERMOGRAPH_DATABASE_URL=postgresql+psycopg://thermograph:PW@127.0.0.1:5432/thermograph \\
|
||||||
|
python migrate_cache_to_pg.py # reads data/cache/*.parquet
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import polars as pl
|
||||||
|
|
||||||
|
from data import climate
|
||||||
|
from data import climate_store
|
||||||
|
from data import grid
|
||||||
|
|
||||||
|
|
||||||
|
def _is_schema_complete(df: pl.DataFrame) -> bool:
|
||||||
|
"""The same NEW_COLS guard climate._read_history_cache applies: a record missing
|
||||||
|
any post-original column predates the wind/humidity features and is skipped so
|
||||||
|
the server refetches it lazily (with the current schema) on first touch."""
|
||||||
|
return all(c in df.columns for c in climate.NEW_COLS)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate() -> int:
|
||||||
|
if not climate_store.is_postgres():
|
||||||
|
sys.exit("THERMOGRAPH_DATABASE_URL must be a postgresql URL (the migration target)")
|
||||||
|
if not os.path.isdir(climate.CACHE_DIR):
|
||||||
|
print("No parquet cache directory yet — nothing to migrate.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
hist = recent = current = skipped = 0
|
||||||
|
for fname in sorted(os.listdir(climate.CACHE_DIR)):
|
||||||
|
if not fname.endswith(".parquet"):
|
||||||
|
continue
|
||||||
|
path = os.path.join(climate.CACHE_DIR, fname)
|
||||||
|
is_recent = fname.endswith("_rf.parquet")
|
||||||
|
cell_id = fname[:-len("_rf.parquet")] if is_recent else fname[:-len(".parquet")]
|
||||||
|
try:
|
||||||
|
grid.from_id(cell_id) # validate the id is a real cell (skip stray files)
|
||||||
|
except ValueError:
|
||||||
|
print(f" {fname}: unrecognized cache filename — skipped")
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
mtime = os.path.getmtime(path)
|
||||||
|
synced = (climate_store.recent_synced_at(cell_id) if is_recent
|
||||||
|
else climate_store.history_synced_at(cell_id))
|
||||||
|
if synced >= mtime:
|
||||||
|
current += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
df = climate._normalize_read(pl.read_parquet(path))
|
||||||
|
except Exception as e: # noqa: BLE001 - a truncated/corrupt file is a skip, not a crash
|
||||||
|
print(f" {fname}: unreadable ({e}) — skipped")
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if is_recent:
|
||||||
|
climate_store.write_recent(cell_id, df, mtime)
|
||||||
|
recent += 1
|
||||||
|
print(f" {cell_id}: recent bundle ({df.height} rows)")
|
||||||
|
else:
|
||||||
|
if not _is_schema_complete(df):
|
||||||
|
print(f" {cell_id}: pre-schema history — skipped (refetches lazily)")
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
climate_store.write_history(cell_id, df, mtime)
|
||||||
|
hist += 1
|
||||||
|
print(f" {cell_id}: history ({df.height} rows)")
|
||||||
|
|
||||||
|
print(f"{hist} history + {recent} recent loaded, {current} already current, "
|
||||||
|
f"{skipped} skipped.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(migrate())
|
||||||
152
tests/data/test_climate_store.py
Normal file
152
tests/data/test_climate_store.py
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
"""The climate cache backend switch (data/climate_store.py + climate.py dispatch).
|
||||||
|
|
||||||
|
Two layers:
|
||||||
|
|
||||||
|
* **Hermetic** (always run): with no THERMOGRAPH_DATABASE_URL the store falls back
|
||||||
|
to the parquet cache, so ``is_postgres()`` is False, ``recent_stamp`` reads the
|
||||||
|
file mtime, and the backend helpers round-trip through parquet. This is the path
|
||||||
|
CI exercises — no Postgres required.
|
||||||
|
* **Gated** (skipped unless ``THERMOGRAPH_TEST_DATABASE_URL`` points at a reachable
|
||||||
|
Postgres): round-trips history/recent frames through the real psycopg + COPY
|
||||||
|
bridge and asserts dtype fidelity, upsert keep-last, and the recent_stamp token
|
||||||
|
semantics.
|
||||||
|
"""
|
||||||
|
import datetime
|
||||||
|
import os
|
||||||
|
|
||||||
|
import polars as pl
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from data import climate
|
||||||
|
from data import climate_store
|
||||||
|
|
||||||
|
|
||||||
|
def _hist_frame(n=5, tmax0=70.0):
|
||||||
|
"""A raw daily frame with the persisted columns (no doy — the write path drops it)."""
|
||||||
|
d0 = datetime.date(1995, 1, 1)
|
||||||
|
cols = {"date": [d0 + datetime.timedelta(days=i) for i in range(n)]}
|
||||||
|
for c in ("tmax", "tmin", "precip", "wind", "gust", "humid", "fmax", "fmin", "feels"):
|
||||||
|
cols[c] = [float(tmax0 + i) for i in range(n)]
|
||||||
|
return pl.DataFrame(cols)
|
||||||
|
|
||||||
|
|
||||||
|
# --- hermetic (parquet fallback) --------------------------------------------
|
||||||
|
|
||||||
|
def test_selects_parquet_when_no_database_url():
|
||||||
|
# The test env sets no THERMOGRAPH_DATABASE_URL (see conftest), so the climate
|
||||||
|
# record is served from parquet, not Postgres.
|
||||||
|
assert climate_store.is_postgres() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_recent_stamp_reads_mtime_on_parquet(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(climate, "CACHE_DIR", str(tmp_path))
|
||||||
|
cell_id = "12_-34"
|
||||||
|
assert climate.recent_stamp(cell_id) == 0 # absent -> 0
|
||||||
|
climate._write_recent_backed(cell_id, _hist_frame())
|
||||||
|
stamp = climate.recent_stamp(cell_id)
|
||||||
|
assert stamp == int(os.path.getmtime(climate._rf_cache_path(cell_id)))
|
||||||
|
assert stamp > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_backend_roundtrips_through_parquet(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(climate, "CACHE_DIR", str(tmp_path))
|
||||||
|
cell_id = "5_6"
|
||||||
|
assert climate._read_history_backed(cell_id) is None # nothing cached yet
|
||||||
|
climate._write_history_backed(cell_id, _hist_frame(n=4))
|
||||||
|
hit = climate._read_history_backed(cell_id)
|
||||||
|
assert hit is not None
|
||||||
|
df, age_s = hit
|
||||||
|
assert df.height == 4 and "doy" not in df.columns
|
||||||
|
assert age_s >= 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_recent_backend_roundtrips_through_parquet(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(climate, "CACHE_DIR", str(tmp_path))
|
||||||
|
cell_id = "7_8"
|
||||||
|
assert climate._read_recent_backed(cell_id) is None
|
||||||
|
climate._write_recent_backed(cell_id, _hist_frame(n=3))
|
||||||
|
hit = climate._read_recent_backed(cell_id)
|
||||||
|
assert hit is not None and hit[0].height == 3
|
||||||
|
|
||||||
|
|
||||||
|
# --- gated Postgres integration ---------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def pg_store():
|
||||||
|
"""Point climate_store at THERMOGRAPH_TEST_DATABASE_URL for one test, creating
|
||||||
|
the tables (plain — the bridge logic doesn't need the hypertable) and wiping
|
||||||
|
them. Restores the module's dialect state afterwards."""
|
||||||
|
url = os.environ.get("THERMOGRAPH_TEST_DATABASE_URL", "").strip()
|
||||||
|
if not url:
|
||||||
|
pytest.skip("set THERMOGRAPH_TEST_DATABASE_URL to run the Postgres bridge test")
|
||||||
|
import psycopg
|
||||||
|
|
||||||
|
saved = (climate_store.IS_POSTGRES, climate_store._PG_DSN)
|
||||||
|
climate_store.IS_POSTGRES = True
|
||||||
|
climate_store._PG_DSN = climate_store._libpq_dsn(url)
|
||||||
|
if hasattr(climate_store._local, "conn"):
|
||||||
|
del climate_store._local.conn
|
||||||
|
|
||||||
|
ddl_cols = ", ".join(f"{c} DOUBLE PRECISION" for c in climate_store.COLS if c != "date")
|
||||||
|
with psycopg.connect(climate_store._PG_DSN, autocommit=True) as conn:
|
||||||
|
for tbl in ("climate_history", "climate_recent"):
|
||||||
|
conn.execute(f"CREATE TABLE IF NOT EXISTS {tbl} "
|
||||||
|
f"(cell_id TEXT NOT NULL, date DATE NOT NULL, {ddl_cols}, "
|
||||||
|
f"PRIMARY KEY (cell_id, date))")
|
||||||
|
conn.execute("CREATE TABLE IF NOT EXISTS climate_sync "
|
||||||
|
"(cell_id TEXT PRIMARY KEY, history_synced_at DOUBLE PRECISION, "
|
||||||
|
"recent_synced_at DOUBLE PRECISION, cols_version SMALLINT NOT NULL DEFAULT 1)")
|
||||||
|
conn.execute("TRUNCATE climate_history, climate_recent, climate_sync")
|
||||||
|
|
||||||
|
yield climate_store
|
||||||
|
|
||||||
|
climate_store.IS_POSTGRES, climate_store._PG_DSN = saved
|
||||||
|
if hasattr(climate_store._local, "conn"):
|
||||||
|
del climate_store._local.conn
|
||||||
|
|
||||||
|
|
||||||
|
def test_pg_history_roundtrip_and_dtypes(pg_store):
|
||||||
|
pg_store.write_history("1_2", _hist_frame(n=5, tmax0=70.0), 1000.0)
|
||||||
|
hit = pg_store.read_history("1_2")
|
||||||
|
assert hit is not None
|
||||||
|
df, synced = hit
|
||||||
|
assert synced == 1000.0
|
||||||
|
assert df.schema["date"] == pl.Date and df.schema["tmax"] == pl.Float64
|
||||||
|
assert "doy" not in df.columns
|
||||||
|
assert df.height == 5 and df["tmax"].to_list() == [70, 71, 72, 73, 74]
|
||||||
|
|
||||||
|
|
||||||
|
def test_pg_history_upsert_keeps_last(pg_store):
|
||||||
|
pg_store.write_history("1_2", _hist_frame(n=3, tmax0=10.0), 1000.0)
|
||||||
|
# Re-write day 2 with a new value + append a 4th day.
|
||||||
|
d0 = datetime.date(1995, 1, 1)
|
||||||
|
tail = pl.DataFrame({
|
||||||
|
"date": [d0 + datetime.timedelta(days=1), d0 + datetime.timedelta(days=3)],
|
||||||
|
**{c: [999.0, 40.0] for c in
|
||||||
|
("tmax", "tmin", "precip", "wind", "gust", "humid", "fmax", "fmin", "feels")},
|
||||||
|
})
|
||||||
|
pg_store.write_history("1_2", tail, 2000.0)
|
||||||
|
df, synced = pg_store.read_history("1_2")
|
||||||
|
assert synced == 2000.0 and df.height == 4
|
||||||
|
assert df.filter(pl.col("date") == d0 + datetime.timedelta(days=1))["tmax"].item() == 999.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_pg_recent_stamp_advances_only_on_write(pg_store):
|
||||||
|
assert pg_store.recent_synced_at("1_2") == 0.0
|
||||||
|
pg_store.write_recent("1_2", _hist_frame(n=4), 5000.0)
|
||||||
|
assert pg_store.recent_synced_at("1_2") == 5000.0
|
||||||
|
# A history write must not disturb the recent stamp, and vice-versa.
|
||||||
|
pg_store.write_history("1_2", _hist_frame(n=2), 6000.0)
|
||||||
|
assert pg_store.recent_synced_at("1_2") == 5000.0
|
||||||
|
assert pg_store.history_synced_at("1_2") == 6000.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_pg_cols_version_gate(pg_store):
|
||||||
|
pg_store.write_history("1_2", _hist_frame(n=2), 1000.0)
|
||||||
|
assert pg_store.read_history("1_2") is not None
|
||||||
|
pg_store.COLS_VERSION = pg_store.COLS_VERSION + 1
|
||||||
|
try:
|
||||||
|
assert pg_store.read_history("1_2") is None # stale schema -> refetch
|
||||||
|
assert pg_store.read_history_raw("1_2") is not None # raw ignores the gate
|
||||||
|
finally:
|
||||||
|
pg_store.COLS_VERSION -= 1
|
||||||
Loading…
Reference in a new issue