2026-07-11 00:29:47 +00:00
|
|
|
"""Thermograph API — grade recent local weather against ~45 years of climatology."""
|
|
|
|
|
import datetime
|
|
|
|
|
import os
|
|
|
|
|
import time
|
|
|
|
|
|
|
|
|
|
import pandas as pd
|
|
|
|
|
from fastapi import APIRouter, FastAPI, HTTPException, Query
|
|
|
|
|
from fastapi.responses import FileResponse, RedirectResponse
|
|
|
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
|
|
|
|
|
|
import audit
|
|
|
|
|
import climate
|
|
|
|
|
import grading
|
|
|
|
|
import grid
|
|
|
|
|
|
|
|
|
|
FRONTEND_DIR = os.path.join(os.path.dirname(__file__), "..", "frontend")
|
|
|
|
|
|
|
|
|
|
# Everything (pages, assets, API) is served under this base path so the app can
|
|
|
|
|
# live at https://<host>/thermograph/ behind a shared domain. Override with the
|
|
|
|
|
# THERMOGRAPH_BASE env var. The frontend uses base-relative URLs, so it follows
|
|
|
|
|
# this automatically without hardcoding the prefix.
|
|
|
|
|
BASE = "/" + os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/")
|
|
|
|
|
|
|
|
|
|
# Observed values pulled from a daily record row for grading. Includes the
|
|
|
|
|
# temperature-scale metrics (tmax/tmin/feels/wind/gust) plus precip; a column may
|
|
|
|
|
# be absent on an older cache, so only carry the ones present.
|
|
|
|
|
OBS_COLS = ("tmax", "tmin", "precip", "feels", "humid", "wind", "gust")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _obs_from_row(row) -> dict:
|
|
|
|
|
return {k: row[k] for k in OBS_COLS if k in row}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _weather_fetch_error(e) -> HTTPException:
|
|
|
|
|
"""A clean, retryable message for a rate limit; the raw error otherwise."""
|
|
|
|
|
low = str(e).lower()
|
|
|
|
|
reason = (climate._rate_limit_reason(e) or "").lower()
|
|
|
|
|
blob = low + " " + reason
|
|
|
|
|
if climate._is_rate_limit(e) or "429" in low or "rate-limited" in low or "limit" in reason:
|
|
|
|
|
if "daily" in blob or "tomorrow" in blob: # daily quota exhausted — resets tomorrow
|
|
|
|
|
return HTTPException(
|
|
|
|
|
status_code=503,
|
|
|
|
|
detail="Open-Meteo's daily request limit is exhausted — new locations will work again "
|
|
|
|
|
"tomorrow. Places you've already viewed still work.",
|
|
|
|
|
)
|
|
|
|
|
return HTTPException(
|
|
|
|
|
status_code=503,
|
|
|
|
|
detail="The weather service is rate-limited right now — please try again in a minute.",
|
|
|
|
|
)
|
|
|
|
|
return HTTPException(status_code=502, detail=f"weather data fetch failed: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _grade_rows(history, rows_df) -> list[dict]:
|
|
|
|
|
"""Grade each daily row against its own ±7-day climatology window."""
|
|
|
|
|
return [grading.grade_day(history, row["date"], _obs_from_row(row))
|
|
|
|
|
for _, row in rows_df.iterrows()]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _attach_dry_streaks(graded: list[dict], *precip_frames) -> None:
|
|
|
|
|
"""Attach `dsr` (days since last measurable rain) to each graded day, computed
|
|
|
|
|
over a combined, de-duplicated precip series so streaks stay continuous across
|
|
|
|
|
the history / recent / forecast sources."""
|
|
|
|
|
frames = [f[["date", "precip"]] for f in precip_frames if f is not None and not f.empty]
|
|
|
|
|
if not frames:
|
|
|
|
|
return
|
|
|
|
|
combined = (pd.concat(frames)
|
|
|
|
|
.drop_duplicates(subset="date", keep="last")
|
|
|
|
|
.sort_values("date"))
|
|
|
|
|
dsr_map = grading.dry_streaks(combined["date"].values, combined["precip"].values)
|
|
|
|
|
for g in graded:
|
|
|
|
|
g["dsr"] = dsr_map.get(g["date"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app = FastAPI(title="Thermograph", version="0.2.0")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.middleware("http")
|
|
|
|
|
async def no_store_static(request, call_next):
|
|
|
|
|
"""Serve the frontend with no-store so phones/browsers always pick up the
|
|
|
|
|
latest HTML/JS/CSS instead of a stale cached copy. This is a LAN dev tool, so
|
|
|
|
|
skipping the cache entirely is fine and avoids "my change isn't showing" bugs."""
|
|
|
|
|
response = await call_next(request)
|
|
|
|
|
path = request.url.path
|
Scope app to /thermograph, free the domain root for a portfolio (#10)
- Drop the app-level "/" redirect so the FastAPI app no longer claims the
domain root; it stays scoped to THERMOGRAPH_BASE (/thermograph). Root now
404s at the app, leaving it for another service behind the proxy.
- Caddyfile: serve a static portfolio at / and reverse-proxy /thermograph* to
uvicorn, on emigriffith.dev with automatic Let's Encrypt TLS.
- Default THERMOGRAPH_BASE to /thermograph in the env example to match. (#11)
* Add dev CI/CD pipeline deploying to a LAN server (#6)
* Weekly page: no-rain visuals adopt the dry-streak look
On the Weekly page, no-rain (0") days now warm with the dry-streak ramp
(tan→red, deepening to a 14-day cap) instead of a flat blue/tan, matching
the Dry chart and calendar's dry-period language:
- Precip trend chart: no-rain day dots colored by drynessColor(dsr).
- "Daily, graded" timeline strip: Rain-row dry cells tinted by dsr via a
new optional color override on rdCell.
Rain days keep their intensity-tier colors; unknown streaks fall back to
the prior flat color, so nothing regresses.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VP23wKmjS2ozk1g5a9g1Z
* Calendar totals: compact per-category status lines
Replace the stacked share bar + labeled percentage chips above the
calendar grid with a compact deviation strip: one thin status-colored
line per category (height scaled to the tallest non-median category),
with each share printed above it in that category's own status color.
The median tier — the neutral "Normal" center of the diverging
temperature scale — draws no line, just a faint baseline tick, so it
reads as the reference the other categories deviate from. Precip and
dry-streak have no natural median, so every category there keeps a line.
Percentage text is lifted toward the theme text color (color-mix) so
even the darkest/lightest tiers stay legible in both light and dark.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Calendar totals strip: wider bars, 0.1%-precision small shares (#5)
Widen the per-category deviation bars (3px→10px) so each reads
clearly, and print sub-1% shares as e.g. "0.4%" instead of "<1%".
Claude-Session: https://claude.ai/code/session_019VP23wKmjS2ozk1g5a9g1Z
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* Weekly timeline: label dry streak instead of a dot
On the precip row of the recent/forecast timeline, dry days now show the
running dry-streak count ("3d" = 3 days since measurable rain), tinted by
the same dryness ramp as the chart, rather than a bare "·".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Add dev CI/CD pipeline deploying to a LAN server
PRs into dev run a build + boot/health check, auto-merge on green, and
deploy the merged branch to a self-hosted runner on the LAN box, which
runs the app as a sudo-free systemd --user service on 0.0.0.0:8137.
- ci-cd.yml: build -> auto-merge -> deploy (self-hosted)
- deploy-dev.yml: deploy on direct pushes / manual dispatch
- deploy-dev.sh + thermograph-dev.service + provision-dev-lan.sh
- CLAUDE.md: dev is the PR base branch; commit/PR message conventions
- DEPLOY-DEV.md: pipeline docs
* Match runner service name in docs to the installed user unit
* Pin CI Python to 3.12 for prebuilt pyarrow/pandas wheels
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* Build LAN dev venv on pinned Python 3.12 via uv (#7)
This machine's default python3 is 3.14, which has no prebuilt pyarrow/pandas
wheels, so plain venv + pip fell back to a failing source build. Use uv to pin
the interpreter (fetching a managed CPython 3.12 if needed), independent of the
runner's PATH and pyenv state.
* Add comfort-temperature compare view; simplify Feels calendar filter (#9)
Compare page (frontend/compare.{html,js} + /thermograph/compare route):
line up several places over a date range and rank which best matches a
comfort temperature. Per location it pulls the same daily record the
Calendar uses and, per day, takes a chosen temperature (daytime high /
daily mean / overnight low / feels-like) against the comfort target. A
day "hits comfort" when it lands within an adjustable band; otherwise it
counts as colder or warmer and the average miss is tracked. Results are
ranked by comfort-day share (tie-broken by the smaller typical miss),
with a diverging below/comfort/above bar and per-bucket stats. Comfort,
band and judged temperature re-rank instantly client-side; only the
location set or date range trigger a data load (shared calendar cache).
Feels calendar filter: drop the comfort-temperature slider and the
client-side felt-high/felt-low re-pick. The Feels metric now colors by
the server's combined feels-like value like the other metrics, so its
tab matches the rest of the metric selector.
* Scope app to /thermograph, free the domain root for a portfolio (#10)
- Drop the app-level "/" redirect so the FastAPI app no longer claims the
domain root; it stays scoped to THERMOGRAPH_BASE (/thermograph). Root now
404s at the app, leaving it for another service behind the proxy.
- Caddyfile: serve a static portfolio at / and reverse-proxy /thermograph* to
uvicorn, on emigriffith.dev with automatic Let's Encrypt TLS.
- Default THERMOGRAPH_BASE to /thermograph in the env example to match.
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 03:03:39 +00:00
|
|
|
pages = (BASE, f"{BASE}/", f"{BASE}/calendar", f"{BASE}/day", f"{BASE}/compare", f"{BASE}/legend")
|
2026-07-11 00:29:47 +00:00
|
|
|
if path.endswith((".js", ".css", ".html")) or path in pages:
|
|
|
|
|
response.headers["Cache-Control"] = "no-store, must-revalidate"
|
|
|
|
|
return response
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def api_geocode(q: str = Query(..., min_length=1)):
|
|
|
|
|
try:
|
|
|
|
|
return {"results": climate.geocode(q)}
|
|
|
|
|
except Exception as e: # noqa: BLE001 - surface upstream failures to the client
|
|
|
|
|
raise HTTPException(status_code=502, detail=f"geocoding failed: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def api_grade(
|
|
|
|
|
lat: float = Query(..., ge=-90, le=90),
|
|
|
|
|
lon: float = Query(..., ge=-180, le=180),
|
|
|
|
|
date: str | None = Query(None, description="target date YYYY-MM-DD (default today)"),
|
|
|
|
|
days: int = Query(14, ge=1, le=60, description="how many recent days to grade"),
|
|
|
|
|
):
|
|
|
|
|
if not grid.in_north_america(lat, lon):
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=400,
|
|
|
|
|
detail="Location is outside the supported US + Canada region.",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
target = (
|
|
|
|
|
pd.Timestamp(date).normalize()
|
|
|
|
|
if date
|
|
|
|
|
else pd.Timestamp(datetime.date.today())
|
|
|
|
|
)
|
|
|
|
|
cell = grid.snap(lat, lon)
|
|
|
|
|
|
|
|
|
|
with audit.RunAudit(
|
|
|
|
|
endpoint="grade",
|
|
|
|
|
lat=round(lat, 4),
|
|
|
|
|
lon=round(lon, 4),
|
|
|
|
|
target_date=target.date().isoformat(),
|
|
|
|
|
recent_days=days,
|
|
|
|
|
cell_id=cell["id"],
|
|
|
|
|
) as run:
|
|
|
|
|
try:
|
|
|
|
|
with run.phase("history"):
|
|
|
|
|
history, cache_meta = climate.get_history(cell)
|
|
|
|
|
with run.phase("recent"):
|
|
|
|
|
recent = climate.get_recent_forecast(cell)
|
|
|
|
|
except Exception as e: # noqa: BLE001
|
|
|
|
|
raise _weather_fetch_error(e)
|
|
|
|
|
|
|
|
|
|
if history.empty:
|
|
|
|
|
raise HTTPException(status_code=404, detail="No historical data for this cell.")
|
|
|
|
|
|
|
|
|
|
with run.phase("grading"):
|
|
|
|
|
# Only grade observed days up to and including the target date (last `days`).
|
|
|
|
|
recent = recent[recent["date"] <= target].sort_values("date").tail(days)
|
|
|
|
|
graded = _grade_rows(history, recent)
|
|
|
|
|
_attach_dry_streaks(graded, history, recent)
|
|
|
|
|
graded.reverse() # newest first for display
|
|
|
|
|
climo = grading.climatology(history, int(target.dayofyear))
|
|
|
|
|
|
|
|
|
|
with run.phase("reverse_geocode"):
|
|
|
|
|
place = climate.reverse_geocode(cell["center_lat"], cell["center_lon"])
|
|
|
|
|
|
|
|
|
|
# Summarize what this run actually covered. A fresh history fetch pulls the
|
|
|
|
|
# full ~45-year archive (run_type "full"); a cache hit only fetched the
|
|
|
|
|
# recent window (run_type "partial").
|
|
|
|
|
years = pd.to_datetime(history["date"]).dt.year
|
|
|
|
|
full = not cache_meta.get("cached", False)
|
|
|
|
|
run.set(
|
|
|
|
|
run_type="full" if full else "partial",
|
|
|
|
|
history_source="fetch" if full else "cache",
|
|
|
|
|
history_rows=int(len(history)),
|
|
|
|
|
history_years=int(years.max() - years.min() + 1),
|
|
|
|
|
history_span=[int(years.min()), int(years.max())],
|
|
|
|
|
cache_age_days=cache_meta.get("cache_age_days"),
|
|
|
|
|
graded_days=len(graded),
|
|
|
|
|
place_found=place is not None,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"cell": cell,
|
|
|
|
|
"place": place,
|
|
|
|
|
"target_date": target.date().isoformat(),
|
|
|
|
|
"cache": cache_meta,
|
|
|
|
|
"climatology": climo,
|
|
|
|
|
"recent": graded,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
CAL_MAX_SPAN_DAYS = 732 # ~2 years — the most a single calendar request will grade
|
|
|
|
|
# (the frontend splits longer spans into 2-year chunks)
|
|
|
|
|
|
|
|
|
|
# Short-lived in-memory cache of full calendar responses, keyed by (cell, span).
|
|
|
|
|
# Lets the home page prefetch the calendar in the background so navigating to it is
|
|
|
|
|
# instant, and makes repeat calendar loads free. Bounded + TTL'd.
|
|
|
|
|
CAL_CACHE_TTL = 600 # seconds (10 min)
|
|
|
|
|
_CAL_CACHE: dict[tuple, tuple[float, dict]] = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _cal_cache_get(key):
|
|
|
|
|
hit = _CAL_CACHE.get(key)
|
|
|
|
|
if hit and (time.time() - hit[0]) < CAL_CACHE_TTL:
|
|
|
|
|
return hit[1]
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _cal_cache_put(key, resp):
|
|
|
|
|
_CAL_CACHE[key] = (time.time(), resp)
|
|
|
|
|
if len(_CAL_CACHE) > 64: # evict the oldest entry to stay bounded
|
|
|
|
|
_CAL_CACHE.pop(min(_CAL_CACHE, key=lambda k: _CAL_CACHE[k][0]), None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def api_calendar(
|
|
|
|
|
lat: float = Query(..., ge=-90, le=90),
|
|
|
|
|
lon: float = Query(..., ge=-180, le=180),
|
|
|
|
|
start: str | None = Query(None, description="first date YYYY-MM-DD (overrides months)"),
|
|
|
|
|
end: str | None = Query(None, description="last date YYYY-MM-DD (default = latest available)"),
|
|
|
|
|
months: int = Query(24, ge=1, le=24, description="months back to grade when no start given"),
|
|
|
|
|
):
|
|
|
|
|
"""Grade every day over a date range (default: last 2 years) for the calendar view.
|
|
|
|
|
|
|
|
|
|
A custom [start, end] range is supported and capped at ~2 years per request;
|
|
|
|
|
the frontend splits longer spans into successive 2-year chunks. Without a
|
|
|
|
|
start it falls back to `months` back from `end`. Grades against the already-
|
|
|
|
|
cached history record (which reaches to ~6 days ago), so no extra fetch is
|
|
|
|
|
needed. Returns a compact per-day shape (see grading.grade_range). New in API v2.
|
|
|
|
|
"""
|
|
|
|
|
if not grid.in_north_america(lat, lon):
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=400,
|
|
|
|
|
detail="Location is outside the supported US + Canada region.",
|
|
|
|
|
)
|
|
|
|
|
cell = grid.snap(lat, lon)
|
|
|
|
|
|
|
|
|
|
with audit.RunAudit(
|
|
|
|
|
endpoint="calendar",
|
|
|
|
|
lat=round(lat, 4),
|
|
|
|
|
lon=round(lon, 4),
|
|
|
|
|
recent_days=months * 31, # approximate span graded
|
|
|
|
|
cell_id=cell["id"],
|
|
|
|
|
) as run:
|
|
|
|
|
try:
|
|
|
|
|
with run.phase("history"):
|
|
|
|
|
history, cache_meta = climate.get_history(cell)
|
|
|
|
|
except Exception as e: # noqa: BLE001
|
|
|
|
|
raise _weather_fetch_error(e)
|
|
|
|
|
if history.empty:
|
|
|
|
|
raise HTTPException(status_code=404, detail="No historical data for this cell.")
|
|
|
|
|
|
|
|
|
|
first = pd.Timestamp(history["date"].min()).normalize()
|
|
|
|
|
last = pd.Timestamp(history["date"].max()).normalize()
|
|
|
|
|
end_ts = min(pd.Timestamp(end).normalize(), last) if end else last
|
|
|
|
|
if start:
|
|
|
|
|
start_ts = pd.Timestamp(start).normalize()
|
|
|
|
|
else:
|
|
|
|
|
# Default: whole months back landing on the SAME day-of-month as the end,
|
|
|
|
|
# so the two range endpoints line up (e.g. 2024-06-29 → 2026-06-29).
|
|
|
|
|
start_ts = (end_ts - pd.DateOffset(months=months)).normalize()
|
|
|
|
|
# Cap the graded span at ~2 years, and keep it inside the available record.
|
|
|
|
|
min_start = end_ts - pd.Timedelta(days=CAL_MAX_SPAN_DAYS - 1)
|
|
|
|
|
start_ts = max(start_ts, min_start, first)
|
|
|
|
|
start_ts = min(start_ts, end_ts)
|
|
|
|
|
|
|
|
|
|
cache_key = (cell["id"], start_ts.date().isoformat(), end_ts.date().isoformat())
|
|
|
|
|
cached = _cal_cache_get(cache_key)
|
|
|
|
|
if cached is not None:
|
|
|
|
|
run.set(run_type="cache", history_source="cache", graded_days=len(cached["days"]))
|
|
|
|
|
return cached
|
|
|
|
|
|
|
|
|
|
with run.phase("grading"):
|
|
|
|
|
days = grading.grade_range(history, start_ts, end_ts)
|
|
|
|
|
|
|
|
|
|
with run.phase("reverse_geocode"):
|
|
|
|
|
place = climate.reverse_geocode(cell["center_lat"], cell["center_lon"])
|
|
|
|
|
|
|
|
|
|
full = not cache_meta.get("cached", False)
|
|
|
|
|
run.set(
|
|
|
|
|
run_type="full" if full else "partial",
|
|
|
|
|
history_source="fetch" if full else "cache",
|
|
|
|
|
graded_days=len(days),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
resp = {
|
|
|
|
|
"api_version": "v2",
|
|
|
|
|
"cell": cell,
|
|
|
|
|
"place": place,
|
|
|
|
|
"range": {"start": start_ts.date().isoformat(), "end": end_ts.date().isoformat()},
|
|
|
|
|
"months": months,
|
|
|
|
|
"days": days,
|
|
|
|
|
}
|
|
|
|
|
_cal_cache_put(cache_key, resp)
|
|
|
|
|
return resp
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def api_day(
|
|
|
|
|
lat: float = Query(..., ge=-90, le=90),
|
|
|
|
|
lon: float = Query(..., ge=-180, le=180),
|
|
|
|
|
date: str | None = Query(None, description="target date YYYY-MM-DD (default = latest available)"),
|
|
|
|
|
):
|
|
|
|
|
"""Full percentile breakdown for a single day: the value at every tier boundary
|
|
|
|
|
in that day-of-year's ±7-day window, plus where the observed values land.
|
|
|
|
|
|
|
|
|
|
Powers the single-day detail page. Grades against the cached history record;
|
|
|
|
|
for a date newer than the cache it pulls the recent window for the observation.
|
|
|
|
|
New in API v2.
|
|
|
|
|
"""
|
|
|
|
|
if not grid.in_north_america(lat, lon):
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=400,
|
|
|
|
|
detail="Location is outside the supported US + Canada region.",
|
|
|
|
|
)
|
|
|
|
|
cell = grid.snap(lat, lon)
|
|
|
|
|
|
|
|
|
|
with audit.RunAudit(
|
|
|
|
|
endpoint="day",
|
|
|
|
|
lat=round(lat, 4),
|
|
|
|
|
lon=round(lon, 4),
|
|
|
|
|
cell_id=cell["id"],
|
|
|
|
|
) as run:
|
|
|
|
|
with run.phase("history"):
|
|
|
|
|
history, cache_meta = climate.get_history(cell)
|
|
|
|
|
if history.empty:
|
|
|
|
|
raise HTTPException(status_code=404, detail="No historical data for this cell.")
|
|
|
|
|
|
|
|
|
|
last = pd.Timestamp(history["date"].max()).normalize()
|
|
|
|
|
target = pd.Timestamp(date).normalize() if date else last
|
|
|
|
|
run.set(target_date=target.date().isoformat())
|
|
|
|
|
|
|
|
|
|
# Observed values for the target date: prefer the cached history record; if
|
|
|
|
|
# the date is newer than the cache (last few days), fetch the recent window.
|
|
|
|
|
obs = None
|
|
|
|
|
hit = history[history["date"] == target]
|
|
|
|
|
if not hit.empty:
|
|
|
|
|
obs = _obs_from_row(hit.iloc[0])
|
|
|
|
|
elif target > last:
|
|
|
|
|
try:
|
|
|
|
|
with run.phase("recent"):
|
|
|
|
|
recent = climate.get_recent_forecast(cell)
|
|
|
|
|
rr = recent[recent["date"] == target]
|
|
|
|
|
if not rr.empty:
|
|
|
|
|
obs = _obs_from_row(rr.iloc[0])
|
|
|
|
|
except Exception: # noqa: BLE001 - detail page still works from climatology alone
|
|
|
|
|
obs = None
|
|
|
|
|
|
|
|
|
|
with run.phase("detail"):
|
|
|
|
|
detail = grading.day_detail(history, target, obs)
|
|
|
|
|
|
|
|
|
|
with run.phase("reverse_geocode"):
|
|
|
|
|
place = climate.reverse_geocode(cell["center_lat"], cell["center_lon"])
|
|
|
|
|
|
|
|
|
|
full = not cache_meta.get("cached", False)
|
|
|
|
|
run.set(
|
|
|
|
|
run_type="full" if full else "partial",
|
|
|
|
|
history_source="fetch" if full else "cache",
|
|
|
|
|
has_observation=obs is not None,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"api_version": "v2",
|
|
|
|
|
"cell": cell,
|
|
|
|
|
"place": place,
|
|
|
|
|
"latest": last.date().isoformat(),
|
|
|
|
|
"detail": detail,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def api_forecast(
|
|
|
|
|
lat: float = Query(..., ge=-90, le=90),
|
|
|
|
|
lon: float = Query(..., ge=-180, le=180),
|
|
|
|
|
days: int = Query(7, ge=1, le=14, description="how many forecast days ahead to grade"),
|
|
|
|
|
):
|
|
|
|
|
"""Grade the next `days` of forecast weather against local climatology.
|
|
|
|
|
|
|
|
|
|
Same shape as /grade (so the frontend renders it identically), but `recent`
|
|
|
|
|
holds the forward forecast — furthest-out day first. The forecast is fetched
|
|
|
|
|
fresh and cached only ~1 hour (see climate.get_recent_forecast) to track updates.
|
|
|
|
|
New in API v2.
|
|
|
|
|
"""
|
|
|
|
|
if not grid.in_north_america(lat, lon):
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=400,
|
|
|
|
|
detail="Location is outside the supported US + Canada region.",
|
|
|
|
|
)
|
|
|
|
|
cell = grid.snap(lat, lon)
|
|
|
|
|
|
|
|
|
|
with audit.RunAudit(endpoint="forecast", lat=round(lat, 4), lon=round(lon, 4),
|
|
|
|
|
recent_days=days, cell_id=cell["id"]) as run:
|
|
|
|
|
try:
|
|
|
|
|
with run.phase("history"):
|
|
|
|
|
history, _ = climate.get_history(cell)
|
|
|
|
|
with run.phase("forecast"):
|
|
|
|
|
fc = climate.get_recent_forecast(cell)
|
|
|
|
|
except Exception as e: # noqa: BLE001
|
|
|
|
|
raise _weather_fetch_error(e)
|
|
|
|
|
|
|
|
|
|
if history.empty:
|
|
|
|
|
raise HTTPException(status_code=404, detail="No historical data for this cell.")
|
|
|
|
|
|
|
|
|
|
today = pd.Timestamp(datetime.date.today())
|
|
|
|
|
# Strictly future days (tomorrow onward), earliest→latest, capped at `days`.
|
|
|
|
|
future = fc[fc["date"] > today].sort_values("date").head(days)
|
|
|
|
|
|
|
|
|
|
with run.phase("grading"):
|
|
|
|
|
graded = _grade_rows(history, future)
|
|
|
|
|
_attach_dry_streaks(graded, history, fc)
|
|
|
|
|
graded.reverse() # furthest-out first, so the chart reverses to L→R chronological
|
|
|
|
|
climo = grading.climatology(history, int(today.dayofyear))
|
|
|
|
|
|
|
|
|
|
with run.phase("reverse_geocode"):
|
|
|
|
|
place = climate.reverse_geocode(cell["center_lat"], cell["center_lon"])
|
|
|
|
|
|
|
|
|
|
run.set(run_type="partial", graded_days=len(graded), place_found=place is not None)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"api_version": "v2",
|
|
|
|
|
"cell": cell,
|
|
|
|
|
"place": place,
|
|
|
|
|
"target_date": today.date().isoformat(),
|
|
|
|
|
"forecast": True,
|
|
|
|
|
"climatology": climo,
|
|
|
|
|
"recent": graded,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- API versioning --------------------------------------------------------
|
|
|
|
|
# Non-backward-compatible additions land in a new version; every version stays
|
|
|
|
|
# mounted and served simultaneously. v1 is the original contract (grade/geocode);
|
|
|
|
|
# v2 adds the calendar. The unversioned /api/* paths are kept as aliases of v1 so
|
|
|
|
|
# existing clients keep working.
|
|
|
|
|
v1 = APIRouter(tags=["v1"])
|
|
|
|
|
v1.add_api_route("/geocode", api_geocode, methods=["GET"])
|
|
|
|
|
v1.add_api_route("/grade", api_grade, methods=["GET"])
|
|
|
|
|
|
|
|
|
|
v2 = APIRouter(tags=["v2"])
|
|
|
|
|
v2.add_api_route("/geocode", api_geocode, methods=["GET"])
|
|
|
|
|
v2.add_api_route("/grade", api_grade, methods=["GET"])
|
|
|
|
|
v2.add_api_route("/calendar", api_calendar, methods=["GET"])
|
|
|
|
|
v2.add_api_route("/day", api_day, methods=["GET"])
|
|
|
|
|
v2.add_api_route("/forecast", api_forecast, methods=["GET"])
|
|
|
|
|
|
|
|
|
|
app.include_router(v1, prefix=f"{BASE}/api") # legacy unversioned == v1 (backward compatible)
|
|
|
|
|
app.include_router(v1, prefix=f"{BASE}/api/v1")
|
|
|
|
|
app.include_router(v2, prefix=f"{BASE}/api/v2")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- static frontend (served under BASE) -----------------------------------
|
|
|
|
|
def _page(name):
|
|
|
|
|
return FileResponse(os.path.join(FRONTEND_DIR, name))
|
|
|
|
|
|
|
|
|
|
|
Scope app to /thermograph, free the domain root for a portfolio (#10)
- Drop the app-level "/" redirect so the FastAPI app no longer claims the
domain root; it stays scoped to THERMOGRAPH_BASE (/thermograph). Root now
404s at the app, leaving it for another service behind the proxy.
- Caddyfile: serve a static portfolio at / and reverse-proxy /thermograph* to
uvicorn, on emigriffith.dev with automatic Let's Encrypt TLS.
- Default THERMOGRAPH_BASE to /thermograph in the env example to match. (#11)
* Add dev CI/CD pipeline deploying to a LAN server (#6)
* Weekly page: no-rain visuals adopt the dry-streak look
On the Weekly page, no-rain (0") days now warm with the dry-streak ramp
(tan→red, deepening to a 14-day cap) instead of a flat blue/tan, matching
the Dry chart and calendar's dry-period language:
- Precip trend chart: no-rain day dots colored by drynessColor(dsr).
- "Daily, graded" timeline strip: Rain-row dry cells tinted by dsr via a
new optional color override on rdCell.
Rain days keep their intensity-tier colors; unknown streaks fall back to
the prior flat color, so nothing regresses.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VP23wKmjS2ozk1g5a9g1Z
* Calendar totals: compact per-category status lines
Replace the stacked share bar + labeled percentage chips above the
calendar grid with a compact deviation strip: one thin status-colored
line per category (height scaled to the tallest non-median category),
with each share printed above it in that category's own status color.
The median tier — the neutral "Normal" center of the diverging
temperature scale — draws no line, just a faint baseline tick, so it
reads as the reference the other categories deviate from. Precip and
dry-streak have no natural median, so every category there keeps a line.
Percentage text is lifted toward the theme text color (color-mix) so
even the darkest/lightest tiers stay legible in both light and dark.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Calendar totals strip: wider bars, 0.1%-precision small shares (#5)
Widen the per-category deviation bars (3px→10px) so each reads
clearly, and print sub-1% shares as e.g. "0.4%" instead of "<1%".
Claude-Session: https://claude.ai/code/session_019VP23wKmjS2ozk1g5a9g1Z
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* Weekly timeline: label dry streak instead of a dot
On the precip row of the recent/forecast timeline, dry days now show the
running dry-streak count ("3d" = 3 days since measurable rain), tinted by
the same dryness ramp as the chart, rather than a bare "·".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Add dev CI/CD pipeline deploying to a LAN server
PRs into dev run a build + boot/health check, auto-merge on green, and
deploy the merged branch to a self-hosted runner on the LAN box, which
runs the app as a sudo-free systemd --user service on 0.0.0.0:8137.
- ci-cd.yml: build -> auto-merge -> deploy (self-hosted)
- deploy-dev.yml: deploy on direct pushes / manual dispatch
- deploy-dev.sh + thermograph-dev.service + provision-dev-lan.sh
- CLAUDE.md: dev is the PR base branch; commit/PR message conventions
- DEPLOY-DEV.md: pipeline docs
* Match runner service name in docs to the installed user unit
* Pin CI Python to 3.12 for prebuilt pyarrow/pandas wheels
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* Build LAN dev venv on pinned Python 3.12 via uv (#7)
This machine's default python3 is 3.14, which has no prebuilt pyarrow/pandas
wheels, so plain venv + pip fell back to a failing source build. Use uv to pin
the interpreter (fetching a managed CPython 3.12 if needed), independent of the
runner's PATH and pyenv state.
* Add comfort-temperature compare view; simplify Feels calendar filter (#9)
Compare page (frontend/compare.{html,js} + /thermograph/compare route):
line up several places over a date range and rank which best matches a
comfort temperature. Per location it pulls the same daily record the
Calendar uses and, per day, takes a chosen temperature (daytime high /
daily mean / overnight low / feels-like) against the comfort target. A
day "hits comfort" when it lands within an adjustable band; otherwise it
counts as colder or warmer and the average miss is tracked. Results are
ranked by comfort-day share (tie-broken by the smaller typical miss),
with a diverging below/comfort/above bar and per-bucket stats. Comfort,
band and judged temperature re-rank instantly client-side; only the
location set or date range trigger a data load (shared calendar cache).
Feels calendar filter: drop the comfort-temperature slider and the
client-side felt-high/felt-low re-pick. The Feels metric now colors by
the server's combined feels-like value like the other metrics, so its
tab matches the rest of the metric selector.
* Scope app to /thermograph, free the domain root for a portfolio (#10)
- Drop the app-level "/" redirect so the FastAPI app no longer claims the
domain root; it stays scoped to THERMOGRAPH_BASE (/thermograph). Root now
404s at the app, leaving it for another service behind the proxy.
- Caddyfile: serve a static portfolio at / and reverse-proxy /thermograph* to
uvicorn, on emigriffith.dev with automatic Let's Encrypt TLS.
- Default THERMOGRAPH_BASE to /thermograph in the env example to match.
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 03:03:39 +00:00
|
|
|
# The un-slashed base redirects to BASE/ so the frontend's relative asset URLs
|
|
|
|
|
# resolve correctly. The app stays scoped to BASE and never claims "/", leaving
|
|
|
|
|
# the domain root free for another app (e.g. a portfolio) to own via the proxy.
|
2026-07-11 00:29:47 +00:00
|
|
|
app.add_api_route(BASE, lambda: RedirectResponse(url=f"{BASE}/"), methods=["GET"], include_in_schema=False)
|
|
|
|
|
app.add_api_route(f"{BASE}/", lambda: _page("index.html"), methods=["GET"], include_in_schema=False)
|
|
|
|
|
app.add_api_route(f"{BASE}/calendar", lambda: _page("calendar.html"), methods=["GET"], include_in_schema=False)
|
|
|
|
|
app.add_api_route(f"{BASE}/day", lambda: _page("day.html"), methods=["GET"], include_in_schema=False)
|
Scope app to /thermograph, free the domain root for a portfolio (#10)
- Drop the app-level "/" redirect so the FastAPI app no longer claims the
domain root; it stays scoped to THERMOGRAPH_BASE (/thermograph). Root now
404s at the app, leaving it for another service behind the proxy.
- Caddyfile: serve a static portfolio at / and reverse-proxy /thermograph* to
uvicorn, on emigriffith.dev with automatic Let's Encrypt TLS.
- Default THERMOGRAPH_BASE to /thermograph in the env example to match. (#11)
* Add dev CI/CD pipeline deploying to a LAN server (#6)
* Weekly page: no-rain visuals adopt the dry-streak look
On the Weekly page, no-rain (0") days now warm with the dry-streak ramp
(tan→red, deepening to a 14-day cap) instead of a flat blue/tan, matching
the Dry chart and calendar's dry-period language:
- Precip trend chart: no-rain day dots colored by drynessColor(dsr).
- "Daily, graded" timeline strip: Rain-row dry cells tinted by dsr via a
new optional color override on rdCell.
Rain days keep their intensity-tier colors; unknown streaks fall back to
the prior flat color, so nothing regresses.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VP23wKmjS2ozk1g5a9g1Z
* Calendar totals: compact per-category status lines
Replace the stacked share bar + labeled percentage chips above the
calendar grid with a compact deviation strip: one thin status-colored
line per category (height scaled to the tallest non-median category),
with each share printed above it in that category's own status color.
The median tier — the neutral "Normal" center of the diverging
temperature scale — draws no line, just a faint baseline tick, so it
reads as the reference the other categories deviate from. Precip and
dry-streak have no natural median, so every category there keeps a line.
Percentage text is lifted toward the theme text color (color-mix) so
even the darkest/lightest tiers stay legible in both light and dark.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Calendar totals strip: wider bars, 0.1%-precision small shares (#5)
Widen the per-category deviation bars (3px→10px) so each reads
clearly, and print sub-1% shares as e.g. "0.4%" instead of "<1%".
Claude-Session: https://claude.ai/code/session_019VP23wKmjS2ozk1g5a9g1Z
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* Weekly timeline: label dry streak instead of a dot
On the precip row of the recent/forecast timeline, dry days now show the
running dry-streak count ("3d" = 3 days since measurable rain), tinted by
the same dryness ramp as the chart, rather than a bare "·".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Add dev CI/CD pipeline deploying to a LAN server
PRs into dev run a build + boot/health check, auto-merge on green, and
deploy the merged branch to a self-hosted runner on the LAN box, which
runs the app as a sudo-free systemd --user service on 0.0.0.0:8137.
- ci-cd.yml: build -> auto-merge -> deploy (self-hosted)
- deploy-dev.yml: deploy on direct pushes / manual dispatch
- deploy-dev.sh + thermograph-dev.service + provision-dev-lan.sh
- CLAUDE.md: dev is the PR base branch; commit/PR message conventions
- DEPLOY-DEV.md: pipeline docs
* Match runner service name in docs to the installed user unit
* Pin CI Python to 3.12 for prebuilt pyarrow/pandas wheels
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* Build LAN dev venv on pinned Python 3.12 via uv (#7)
This machine's default python3 is 3.14, which has no prebuilt pyarrow/pandas
wheels, so plain venv + pip fell back to a failing source build. Use uv to pin
the interpreter (fetching a managed CPython 3.12 if needed), independent of the
runner's PATH and pyenv state.
* Add comfort-temperature compare view; simplify Feels calendar filter (#9)
Compare page (frontend/compare.{html,js} + /thermograph/compare route):
line up several places over a date range and rank which best matches a
comfort temperature. Per location it pulls the same daily record the
Calendar uses and, per day, takes a chosen temperature (daytime high /
daily mean / overnight low / feels-like) against the comfort target. A
day "hits comfort" when it lands within an adjustable band; otherwise it
counts as colder or warmer and the average miss is tracked. Results are
ranked by comfort-day share (tie-broken by the smaller typical miss),
with a diverging below/comfort/above bar and per-bucket stats. Comfort,
band and judged temperature re-rank instantly client-side; only the
location set or date range trigger a data load (shared calendar cache).
Feels calendar filter: drop the comfort-temperature slider and the
client-side felt-high/felt-low re-pick. The Feels metric now colors by
the server's combined feels-like value like the other metrics, so its
tab matches the rest of the metric selector.
* Scope app to /thermograph, free the domain root for a portfolio (#10)
- Drop the app-level "/" redirect so the FastAPI app no longer claims the
domain root; it stays scoped to THERMOGRAPH_BASE (/thermograph). Root now
404s at the app, leaving it for another service behind the proxy.
- Caddyfile: serve a static portfolio at / and reverse-proxy /thermograph* to
uvicorn, on emigriffith.dev with automatic Let's Encrypt TLS.
- Default THERMOGRAPH_BASE to /thermograph in the env example to match.
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 03:03:39 +00:00
|
|
|
app.add_api_route(f"{BASE}/compare", lambda: _page("compare.html"), methods=["GET"], include_in_schema=False)
|
2026-07-11 00:29:47 +00:00
|
|
|
app.add_api_route(f"{BASE}/legend", lambda: _page("legend.html"), methods=["GET"], include_in_schema=False)
|
|
|
|
|
|
|
|
|
|
# Everything else under BASE (app.js, style.css, nav.js, …) is a static asset.
|
|
|
|
|
# Registered last so the explicit page routes above win.
|
|
|
|
|
app.mount(BASE, StaticFiles(directory=FRONTEND_DIR), name="static")
|