thermograph/app.py

446 lines
18 KiB
Python
Raw Normal View History

"""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")
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.
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)
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")