Commit graph

12 commits

Author SHA1 Message Date
Emi Griffith
8260948dd8 Serve stale forecast cache when both Open-Meteo and MET Norway fail (#116)
Complete the forecast fallback chain: Open-Meteo → MET Norway → stale cache. When
both live sources are unavailable, serve the last cached recent/forecast bundle
even if it is past its 1-hour TTL — an hours-old bundle (which still carries the
recent observed days MET Norway lacks) beats a hard 503, mirroring the archive
path's stale-serve in _load_history.

The stale bundle is returned WITHOUT rewriting it, so its mtime stays old and the
next request retries the live sources first rather than treating the stale copy as
fresh. Only when there is no cache at all does the typed WeatherUnavailable surface.
2026-07-16 05:41:51 +00:00
Emi Griffith
6927a53ea0 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
Emi Griffith
c3bacdce0c 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
Emi Griffith
576a239723 Compare/calendar mobile polish: overflow fix, sheet load button, 6-yr default, country in names (#80)
- Fix the mobile width blow-out: the distribution grid item (.cmp-dist-row) had no
  min-width:0, so each row expanded to the 9–10-column connected bar chart's
  min-content and widened the whole page (zoom-out, clipped filter sheet). Add
  grid-template-columns: minmax(0,1fr) + min-width:0 so the strip scrolls inside its
  own .ct-strip, and tighten .ct-col to 34px on phones.
- Add a Load/Refresh button inside the compare filter sheet's date-range block, wired
  to the same refresh()/isDirty(); editing the range on mobile no longer needs the
  sheet closed. The existing button stays in the controls (loads added places).
- Default date range on both pages is now January six years back → the present. On
  the calendar this is an explicit chunked range (the months=24 path is server-capped
  at ~2yr).
- Names now include country: reverse_geocode appends it to the place label; revgeo
  cache key gets a v2 prefix and PAYLOAD_VER bumps to p2 so labels/payloads
  repopulate.
- Location names read as a hierarchy: compact chips show just the lead segment (full
  name in the title/aria), and each ranked card shows the lead segment bold over a
  muted "city · region · country" line.

Frontend + a small backend name/cache change; no schema migration.
2026-07-12 06:32:10 +00:00
7aaad17603 Deduplicate the data-layer plumbing in climate.py and grading.py (#46)
climate.py repeated the same four mechanical patterns:
- the Open-Meteo daily params dict (3x) -> _om_daily_params(cell, **window)
- the doy attach line (6x) -> _with_doy
- makedirs + drop-doy + zstd to_parquet (3x) -> _write_cache
- the identical _to_frame/_nasa_to_frame tail (feels-like, valid-day
  filter, doy) -> _finalize_frame

grading.py encoded the tier tables twice — TEMP_BANDS/RAIN_BANDS plus the
hand-aligned _TEMP_LADDER/_RAIN_LADDER ('kept aligned' by comment). The
ladders are now derived from the bands (_ladder_from; verified
byte-identical to the old tables before landing), so tier boundaries have
exactly one definition. grade_range's inline dry-streak walk is replaced
with the existing dry_streaks(); its per-(doy,var) sample cache now also
memoizes the window mask per doy instead of recomputing it once per
metric (9x per day-of-year).

New tests pin the refactor: _to_frame schema/day-filter/missing-series
tolerance, the combined feels-like side selection, NASA unit conversions
and fill-sentinel handling, _om_daily_params windows, and _write_cache
stripping the derived doy column.
2026-07-11 20:05:57 +00:00
7c5b5137f1 Classify weather-fetch failures with a typed exception (#44)
Rate-limit handling crossed the climate→app boundary as prose: climate
raised RuntimeError(cooldown text), and app._weather_fetch_error re-parsed
it by keyword ('429'/'rate-limited'/'daily'/'tomorrow') while also calling
climate's private _is_rate_limit/_rate_limit_reason — and the user-facing
daily-quota copy existed verbatim in both modules.

climate now raises WeatherUnavailable (a RuntimeError subclass carrying
the user-facing text and a daily flag): from _load_history when both
sources fail with no stale cache, and from the forecast fetch on a 429.
limit_message() is the single home of the rate-limit copy; is_rate_limit
is public for the one remaining raw-429 fallback. app maps the typed
error to 503 by isinstance — no message parsing, no private imports.

The burst-limit copy is unified on 'The weather service is rate-limited…'
(the archive path previously said 'weather archive' internally but the
API always rewrote it; user-visible text is unchanged).

Tests: daily-quota 503 carries the 'tomorrow' copy, an unclassified raw
429 still maps to a clean 503, and a genuine fault stays a 502 with the
raw error.
2026-07-11 19:53:46 +00:00
f149c8fd4f Typo-tolerant location search suggestions (#33)
Add /api/v2/suggest and wire the location picker's search box to it as a
debounced type-ahead: top-5 place suggestions that tolerate a single-letter
typo (substituted, missing, or extra letter, or two adjacent letters swapped)
anywhere in the query, including the first character.

- backend/places.py: local place index built from a GeoNames cities dump
  (downloaded once into data/geonames/, loaded in a background thread; the
  app boots and serves without it). Exact-prefix matches rank first by
  population, then names one edit away; a token vocabulary respells one
  mistyped word against known place-name tokens ("pest seattle" ->
  "west seattle") for retry against the upstream geocoder, which covers
  neighbourhood-level places the dump lacks. THERMOGRAPH_CITIES picks the
  dump (default cities1000).
- /suggest blends local and upstream results by population with an exactness
  boost, so "Seatle" (a Cumbrian hamlet) can't outrank Seattle when the
  query is one edit from the city, while "munchen" still surfaces Munich via
  upstream (the index only knows English names). Upstream lookups are
  memoized and skipped entirely when the index answers convincingly.
- mappicker.js: debounced (250ms) live suggestions with abort + sequence
  guards against stale responses, arrow-key navigation, Enter-picks-highlight,
  Escape dismissing the list before closing the overlay. Submit goes through
  the same typo-tolerant endpoint.
- climate.geocode results now carry population (used for ranking).
2026-07-11 15:36:14 +00:00
e819079bda Worldwide coverage: grade any point on Earth (#32)
Remove the US+Canada bounding box so every endpoint accepts any lat/lon.
The grading pipeline was already global-ready (ERA5 archive, timezone=auto,
day-of-year climatology), so opening it up is mostly deleting the guard —
plus the edge cases that only exist once the whole globe is in play:

- grid.py: snap() wraps longitude into [-180, 180) and clamps latitude, and
  cell centers are normalized so the polar row and the cells straddling the
  antimeridian always report valid coordinates to the weather/geocoding APIs.
  snap() and from_id() now share one _cell() builder, making id round-trips
  exact by construction (verified with a 300k-point global sweep).
- nav.js: neighbor-cell prefetch skips rows past the poles and wraps
  longitudes across the dateline instead of sending out-of-range queries.
- Nominatim reverse geocoding requests accept-language=en so place labels
  render in one script worldwide (matching the forward geocoder).
- mappicker: search suggestions are no longer filtered to US/CA, the
  placeholder and default map view are worldwide.
- calendar: season filter labels flip for southern-hemisphere locations
  (Dec-Feb shows as Summer); the underlying month groups are unchanged, so
  saved filter selections keep meaning the same months.

Verified end-to-end on a scratch server: Tokyo and Sydney grade with real
labels, a Fiji cell on the antimeridian's east edge builds and serves warm
hits from the derived store, and prefetch=1 on a cold cell still answers
204 without spending weather-API quota.
2026-07-11 15:02:28 +00:00
7358d9eb48 Fix compare place names (Nominatim burst) + weekly duplicate location name (#29)
Compare was the only page loading several cells at once, so its concurrent
reverse-geocode calls burst past Nominatim's ~1 req/sec limit, got rate-limited,
and cached a null label — leaving those locations stuck on bare coordinates.

- climate.py: serialize + rate-limit reverse_geocode behind a lock (>=1.1s between
  Nominatim calls, re-checking the cache under the lock), so concurrent callers
  resolve reliably instead of bursting.
- store.py: shorten the reverse-geocode miss TTL 1 day -> 1 hour so a transient
  null retries soon; add a `cache` flag to put_payload.
- app.py: don't persist a calendar payload whose place failed to resolve, so the
  coordinates fallback can't stick for the life of the token.

Weekly showed the place name twice (top label beside the Find button AND the
results <h2>). Keep the <h2> (it carries the coords + climatology context) as the
single name display; the top label now only holds the transient coordinates
written on selection and is hidden once the named results render.


Claude-Session: https://claude.ai/code/session_01B3Q2EkHHnTUX2BfhV5q1Zc

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 09:03:57 +00:00
e01d2e0eb8 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
28e6b5301f Lead location labels with neighbourhood; drop ±7-day window jargon (#18)
Reverse-geocode at zoom 14 and prefer neighbourhood/suburb ahead of the
city so labels read 'Gowanus, New York' when OSM has that detail, falling
back to the city (and county) when it doesn't.

Replace the recurring '±7-day window' phrasing in the UI with plain
language ('a typical day', 'days around this date', 'typical climate').
2026-07-11 00:02:19 -07:00
Emi Griffith
8d90dcb456 Initial commit: app +
VPS deploy pipeline
2026-07-10 17:29:47 -07:00