"""Server-rendered, crawlable content pages (climate hub / per-city / month /
records / glossary / about) plus robots.txt and sitemap.xml.
These are the SEO surface: real URLs with the climate stats in the HTML, rendered
with Jinja2 from the same builders the API uses, linking into the interactive tool.
Registered on the app (via register()) BEFORE the StaticFiles mount so they win.
"""
import datetime
import hashlib
import json
import os
import polars as pl
from fastapi import HTTPException, Request, Response
from fastapi.responses import PlainTextResponse
from jinja2 import Environment, FileSystemLoader, select_autoescape
import cities
import climate
import grading
import grid
from views import OBS_COLS
_BASE = os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/")
BASE = f"/{_BASE}" if _BASE else ""
TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), "templates")
_env = Environment(
loader=FileSystemLoader(TEMPLATES_DIR),
autoescape=select_autoescape(["html", "xml", "j2"]),
trim_blocks=True,
lstrip_blocks=True,
)
MONTHS = ["january", "february", "march", "april", "may", "june",
"july", "august", "september", "october", "november", "december"]
MONTHS_TITLE = [m.capitalize() for m in MONTHS]
MONTH_INDEX = {m: i + 1 for i, m in enumerate(MONTHS)}
# Display labels for the graded metrics (order = how they appear on the page).
METRIC_LABELS = [
("tmax", "High"), ("tmin", "Low"), ("feels", "Feels-like"),
("humid", "Humidity"), ("wind", "Wind"), ("gust", "Gust"), ("precip", "Precip"),
]
_TEMP_METRICS = {"tmax", "tmin", "feels"}
def _c(f: float) -> int:
return round((f - 32) * 5 / 9)
def _temp(f) -> str:
"""A Fahrenheit value shown in both units: '72°F (22°C)'."""
if f is None:
return "—"
return f"{round(f)}°F ({_c(f)}°C)"
def _fmt(metric: str, v) -> str:
if v is None:
return "—"
if metric in _TEMP_METRICS:
return _temp(v)
if metric == "precip":
return f"{v:.2f} in"
if metric == "humid":
return f"{v:.1f} g/m³"
return f"{round(v)} mph" # wind, gust
def _month_doy(month_idx: int) -> int:
return datetime.date(2001, month_idx, 15).timetuple().tm_yday
# --- helpers -----------------------------------------------------------------
def origin(request: Request) -> str:
proto = request.headers.get("x-forwarded-proto") or request.url.scheme
host = request.headers.get("host") or request.url.netloc
return f"{proto}://{host}"
def _respond_html(request: Request, template: str, **ctx) -> Response:
o = origin(request)
html = _env.get_template(template).render(base=BASE, origin=o, base_url=f"{o}{BASE}", **ctx)
etag = f'W/"{hashlib.sha1(html.encode()).hexdigest()[:20]}"'
inm = request.headers.get("if-none-match")
if inm and etag in {t.strip() for t in inm.split(",")}:
return Response(status_code=304, headers={"ETag": etag})
return Response(html, media_type="text/html", headers={"ETag": etag})
# --- robots.txt & sitemap.xml -------------------------------------------------
def robots_txt(request: Request) -> Response:
base_url = f"{origin(request)}{BASE}"
body = (
"User-agent: *\n"
"Allow: /\n"
f"Disallow: {BASE}/api/\n" # JSON endpoints, nothing to index
f"Disallow: {BASE}/alerts\n" # per-user, requires login
f"Sitemap: {base_url}/sitemap.xml\n"
)
return PlainTextResponse(body)
def sitemap_xml(request: Request) -> Response:
base_url = f"{origin(request)}{BASE}"
today = datetime.date.today().isoformat()
urls: list[tuple[str, str, str]] = [] # (loc, changefreq, priority)
def add(path: str, changefreq: str, priority: str) -> None:
urls.append((f"{base_url}{path}", changefreq, priority))
add("/", "daily", "1.0")
for p in ("/climate", "/about", "/glossary", "/calendar", "/compare", "/legend"):
add(p, "weekly", "0.6")
for slug in cities.all_slugs():
add(f"/climate/{slug}", "daily", "0.8")
add(f"/climate/{slug}/records", "monthly", "0.5")
for m in MONTHS:
add(f"/climate/{slug}/{m}", "monthly", "0.5")
parts = ['',
'']
for loc, cf, pr in urls:
parts.append(
f"{loc}{today}"
f"{cf}{pr}"
)
parts.append("")
return Response("\n".join(parts), media_type="application/xml")
# --- per-city climate pages ---------------------------------------------------
def _history_for(cell: dict):
"""Cached archive for a cell, fetching once if missing (self-heals like the
notifier). Returns a polars frame or None."""
hist = climate.load_cached_history(cell)
if hist is not None and not hist.is_empty():
return hist
try:
hist, _ = climate.get_history(cell)
except Exception: # noqa: BLE001 - upstream unavailable: caller renders a 503
return None
return hist if (hist is not None and not hist.is_empty()) else None
def _monthly_normals(history) -> list[dict]:
"""One row per month: average high/low and average precip, from the ±7-day
climatology around each month's 15th."""
rows = []
for i, name in enumerate(MONTHS_TITLE, start=1):
clim = grading.climatology(history, _month_doy(i))
tmax, tmin, precip = clim.get("tmax"), clim.get("tmin"), clim.get("precip")
rows.append({
"name": name,
"slug": MONTHS[i - 1],
"high": _temp(tmax["mean"]) if tmax else "—",
"high_f": tmax["mean"] if tmax else None,
"low": _temp(tmin["mean"]) if tmin else "—",
"low_f": tmin["mean"] if tmin else None,
"precip": f"{precip['mean']:.2f} in" if precip else "—",
"precip_v": precip["mean"] if precip else None,
})
return rows
def _today_vs_normal(history, cell) -> dict | None:
"""Grade the latest recorded day against its climatology, for the hero block."""
try:
recent = climate.get_recent_forecast(cell)
except Exception: # noqa: BLE001
return None
if recent is None or recent.is_empty() or "date" not in recent.columns:
return None
today = datetime.date.today()
observed = recent.filter(pl.col("date") <= today).sort("date")
if observed.is_empty():
return None
row = observed.row(observed.height - 1, named=True)
obs = {k: row[k] for k in OBS_COLS if k in row}
graded = grading.grade_day(history, row["date"], obs)
cards = []
for key, label in METRIC_LABELS:
g = graded.get(key)
if not g:
continue
cards.append({
"label": label, "metric": key,
"value": _fmt(key, g.get("value")),
"percentile": g.get("percentile"),
"grade": g.get("grade"), "cls": g.get("class"),
})
date = row["date"]
return {
"date": date.isoformat() if hasattr(date, "isoformat") else str(date),
"cards": cards,
}
def _city_context(request, city, cell, history) -> dict:
name = city["name"]
display = cities.display_name(city)
years = history["date"].dt.year()
year_range = [int(years.min()), int(years.max())]
months = _monthly_normals(history)
warmest = max((m for m in months if m["high_f"] is not None), key=lambda m: m["high_f"], default=None)
coldest = min((m for m in months if m["low_f"] is not None), key=lambda m: m["low_f"], default=None)
wettest = max((m for m in months if m["precip_v"] is not None), key=lambda m: m["precip_v"], default=None)
records = grading.all_time_records(history)
tool_hash = f"{city['lat']:.5f},{city['lon']:.5f}"
breadcrumb = [("Home", f"{BASE}/"), ("Climate", f"{BASE}/climate")]
if city.get("country"):
breadcrumb.append((city["country"], None))
breadcrumb.append((name, None))
o = origin(request)
page_url = f"{o}{BASE}/climate/{city['slug']}"
jsonld = {
"@context": "https://schema.org",
"@graph": [
{"@type": "Dataset",
"name": f"{display} climate normals and records",
"description": f"Average temperatures, precipitation and record highs and lows for "
f"{display}, from ~{year_range[1] - year_range[0]} years of daily climate history.",
"url": page_url,
"temporalCoverage": f"{year_range[0]}/{year_range[1]}",
"spatialCoverage": {"@type": "Place", "name": display,
"geo": {"@type": "GeoCoordinates",
"latitude": city["lat"], "longitude": city["lon"]}},
"creator": {"@type": "Organization", "name": "Thermograph"},
"isBasedOn": "https://open-meteo.com/ (ERA5 reanalysis)"},
{"@type": "BreadcrumbList",
"itemListElement": [
{"@type": "ListItem", "position": i + 1, "name": nm,
**({"item": f"{o}{href}"} if href else {})}
for i, (nm, href) in enumerate(breadcrumb)]},
],
}
return {
"section": "climate",
"city": city, "display": display, "name": name,
"year_range": year_range, "n_years": year_range[1] - year_range[0],
"months": months, "warmest": warmest, "coldest": coldest, "wettest": wettest,
"records": records, "today": _today_vs_normal(history, cell),
"tool_hash": tool_hash,
"breadcrumb": breadcrumb,
"canonical_path": f"/climate/{city['slug']}",
"page_title": f"{display} climate: average temperatures, records & how today compares",
"page_description": (f"Average monthly high and low temperatures, rainfall, and all-time records "
f"for {display}, plus how today's weather compares — graded against "
f"~{year_range[1] - year_range[0]} years of local climate history."),
"jsonld_str": json.dumps(jsonld, ensure_ascii=False, separators=(",", ":")),
}
def _resolve_city(slug: str):
"""(city, cell, history) for a slug, or raise 404 (unknown) / 503 (warming)."""
city = cities.get(slug)
if city is None:
raise HTTPException(status_code=404, detail="Unknown city.")
cell = grid.snap(city["lat"], city["lon"])
history = _history_for(cell)
if history is None:
raise HTTPException(status_code=503, detail="Climate data is warming up; please retry shortly.")
return city, cell, history
def city_page(request: Request, slug: str) -> Response:
city, cell, history = _resolve_city(slug)
return _respond_html(request, "city.html.j2", **_city_context(request, city, cell, history))
# --- month & records pages ----------------------------------------------------
def _month_context(request, city, history, month_idx: int) -> dict:
display = cities.display_name(city)
name = city["name"]
month_name = MONTHS_TITLE[month_idx - 1]
month_slug = MONTHS[month_idx - 1]
years = history["date"].dt.year()
year_range = [int(years.min()), int(years.max())]
clim = grading.climatology(history, _month_doy(month_idx))
tmax, tmin, precip = clim.get("tmax"), clim.get("tmin"), clim.get("precip")
mdf = history.filter(pl.col("date").dt.month() == month_idx)
mrec = grading.all_time_records(mdf) if not mdf.is_empty() else {}
stats = []
if tmax:
stats.append(("Average high", _temp(tmax["mean"])))
stats.append(("Typical high range", f"{_temp(tmax['p10'])} to {_temp(tmax['p90'])}"))
if tmin:
stats.append(("Average low", _temp(tmin["mean"])))
stats.append(("Typical low range", f"{_temp(tmin['p10'])} to {_temp(tmin['p90'])}"))
if precip:
stats.append(("Average daily precipitation", f"{precip['mean']:.2f} in"))
records = []
if mrec.get("tmax"):
records.append(("Warmest on record", _temp(mrec["tmax"]["max"]), mrec["tmax"]["max_date"]))
if mrec.get("tmin"):
records.append(("Coldest on record", _temp(mrec["tmin"]["min"]), mrec["tmin"]["min_date"]))
prev_i = 12 if month_idx == 1 else month_idx - 1
next_i = 1 if month_idx == 12 else month_idx + 1
breadcrumb = [("Home", f"{BASE}/"), ("Climate", f"{BASE}/climate"),
(name, f"{BASE}/climate/{city['slug']}"), (month_name, None)]
return {
"section": "climate", "city": city, "display": display, "name": name,
"month_name": month_name, "month_slug": month_slug,
"year_range": year_range, "n_years": year_range[1] - year_range[0],
"avg_high": _temp(tmax["mean"]) if tmax else "—",
"avg_low": _temp(tmin["mean"]) if tmin else "—",
"stats": stats, "records": records,
"tool_hash": f"{city['lat']:.5f},{city['lon']:.5f}",
"prev": {"name": MONTHS_TITLE[prev_i - 1], "slug": MONTHS[prev_i - 1]},
"next": {"name": MONTHS_TITLE[next_i - 1], "slug": MONTHS[next_i - 1]},
"breadcrumb": breadcrumb,
"canonical_path": f"/climate/{city['slug']}/{month_slug}",
"page_title": f"Average weather in {display} in {month_name}",
"page_description": (f"Average high and low temperatures, typical range, records and rainfall for "
f"{display} in {month_name}, from ~{year_range[1] - year_range[0]} years of local records."),
}
def month_page(request: Request, slug: str, month: str) -> Response:
if month not in MONTH_INDEX:
raise HTTPException(status_code=404, detail="Unknown month.")
city, cell, history = _resolve_city(slug)
return _respond_html(request, "month.html.j2", **_month_context(request, city, history, MONTH_INDEX[month]))
def _records_context(request, city, history) -> dict:
display = cities.display_name(city)
name = city["name"]
years = history["date"].dt.year()
year_range = [int(years.min()), int(years.max())]
rec = grading.all_time_records(history)
rows = []
for key, label in METRIC_LABELS:
r = rec.get(key)
if not r:
continue
rows.append({
"label": label,
"high": _fmt(key, r["max"]), "high_date": r["max_date"],
"low": _fmt(key, r["min"]), "low_date": r["min_date"],
})
breadcrumb = [("Home", f"{BASE}/"), ("Climate", f"{BASE}/climate"),
(name, f"{BASE}/climate/{city['slug']}"), ("Records", None)]
return {
"section": "climate", "city": city, "display": display, "name": name,
"year_range": year_range, "n_years": year_range[1] - year_range[0],
"rows": rows,
"canonical_path": f"/climate/{city['slug']}/records",
"breadcrumb": breadcrumb,
"page_title": f"{display} weather records: hottest and coldest days on record",
"page_description": (f"All-time record high and low temperatures (and the dates they occurred) for "
f"{display}, from ~{year_range[1] - year_range[0]} years of daily climate history."),
}
def records_page(request: Request, slug: str) -> Response:
city, cell, history = _resolve_city(slug)
return _respond_html(request, "records.html.j2", **_records_context(request, city, history))
# --- hub / glossary / about ---------------------------------------------------
def _breadcrumb(*items):
return list(items)
def hub_page(request: Request) -> Response:
groups = cities.by_country()
ctx = {
"section": "climate",
"groups": groups,
"n_cities": sum(len(v) for v in groups.values()),
"n_countries": len(groups),
"canonical_path": "/climate",
"breadcrumb": [("Home", f"{BASE}/"), ("Climate", None)],
"page_title": "City climate pages — averages, records and how today compares",
"page_description": ("Browse climate pages for hundreds of cities worldwide: average temperatures by "
"month, all-time records, and how today's weather compares to local history."),
}
return _respond_html(request, "hub.html.j2", **ctx)
# Weather-terms glossary. Each entry: slug -> (term, short definition, body HTML).
GLOSSARY: dict[str, dict] = {
"climate-normal": {
"term": "Climate normal",
"short": "The typical value of a weather metric for a place and time of year, averaged over decades.",
"body": "A climate normal is the long-term average of a weather variable (say, the daily high) "
"for a specific place and time of year. Thermograph builds each day's normal from every "
"historical day within ±7 days of that day-of-year, across ~45 years — so a day is judged "
"against its own season, not a single annual average.",
},
"percentile": {
"term": "Percentile",
"short": "Where a value ranks within a distribution — the 90th percentile is warmer than 90% of days.",
"body": "A percentile says where a value falls within a range of past values. If today's high is at "
"the 97th percentile, only about 3% of comparable days in this location's history were warmer. "
"Thermograph grades every day by its percentile against the local ±7-day seasonal distribution.",
},
"temperature-anomaly": {
"term": "Temperature anomaly",
"short": "How far a temperature departs from normal — the difference from the long-term average.",
"body": "A temperature anomaly is how much warmer or colder it is than the local normal for the "
"time of year. Thermograph expresses the same idea as a percentile and a grade (from “Below "
"Normal” to “Near Record”), so an anomaly is easy to read at a glance for any location.",
},
"feels-like": {
"term": "Feels-like temperature",
"short": "What the air actually feels like once humidity and wind are accounted for.",
"body": "Feels-like (apparent temperature) combines air temperature with humidity and wind. In heat "
"it uses the heat index; in cold it uses "
"wind chill. Thermograph grades feels-like against its "
"own local history, so “Near Record” means extreme for that place.",
},
"heat-index": {
"term": "Heat index",
"short": "How hot it feels when humidity is factored into the air temperature.",
"body": "The heat index is the apparent temperature on a hot, humid day: high humidity slows sweat "
"evaporation, so it feels hotter than the thermometer reads. Thermograph folds it into the "
"feels-like metric and grades how unusual it is locally.",
},
"wind-chill": {
"term": "Wind chill",
"short": "How cold it feels when wind is factored into the air temperature.",
"body": "Wind chill is the apparent temperature on a cold, windy day: wind strips away body heat, so "
"it feels colder than the air temperature. It's the cold-weather half of "
"feels-like.",
},
"humidity": {
"term": "Absolute humidity",
"short": "The actual mass of water vapor in the air, in grams per cubic meter.",
"body": "Thermograph reports absolute humidity (g/m³) — the real amount of water vapor in the "
"air — rather than relative humidity, which shifts with temperature. Graded against local history, "
"it shows genuinely muggy or unusually dry days.",
},
"reanalysis": {
"term": "Reanalysis (ERA5)",
"short": "A gridded, physically-consistent record of past weather for anywhere on Earth.",
"body": "A reanalysis blends historical observations with a weather model to produce a consistent "
"record of past conditions everywhere — even where no station exists. Thermograph's ~45-year history "
"comes from ECMWF's ERA5 reanalysis (via Open-Meteo), which is why it works for any point on Earth.",
},
}
def _glossary_body(entry: dict) -> str:
return entry["body"].replace("{base}", BASE)
def glossary_index(request: Request) -> Response:
ctx = {
"terms": [{"slug": s, **e} for s, e in GLOSSARY.items()],
"canonical_path": "/glossary",
"breadcrumb": [("Home", f"{BASE}/"), ("Glossary", None)],
"page_title": "Weather & climate glossary — heat index, feels-like, percentile, and more",
"page_description": ("Plain-language definitions of weather and climate terms: climate normal, percentile, "
"temperature anomaly, feels-like, heat index, wind chill, humidity, and reanalysis."),
}
return _respond_html(request, "glossary.html.j2", **ctx)
def glossary_term(request: Request, term: str) -> Response:
entry = GLOSSARY.get(term)
if entry is None:
raise HTTPException(status_code=404, detail="Unknown term.")
ctx = {
"term": entry["term"], "body": _glossary_body(entry),
"canonical_path": f"/glossary/{term}",
"breadcrumb": [("Home", f"{BASE}/"), ("Glossary", f"{BASE}/glossary"), (entry["term"], None)],
"page_title": f"{entry['term']} — what it means | Thermograph",
"page_description": entry["short"],
"others": [{"slug": s, "term": e["term"]} for s, e in GLOSSARY.items() if s != term],
}
return _respond_html(request, "glossary_term.html.j2", **ctx)
def about_page(request: Request) -> Response:
ctx = {
"canonical_path": "/about",
"breadcrumb": [("Home", f"{BASE}/"), ("About", None)],
"page_title": "About Thermograph — how the weather grades are calculated",
"page_description": ("How Thermograph works: ~45 years of ERA5 climate history, a ±7-day seasonal "
"window, and empirical percentiles that grade each day relative to its own location."),
}
return _respond_html(request, "about.html.j2", **ctx)
# --- registration ------------------------------------------------------------
def register(app) -> None:
"""Attach all content routes. Call from app.py BEFORE the StaticFiles mount."""
app.add_api_route(f"{BASE}/robots.txt", robots_txt, methods=["GET"], include_in_schema=False)
app.add_api_route(f"{BASE}/sitemap.xml", sitemap_xml, methods=["GET"], include_in_schema=False)
app.add_api_route(f"{BASE}/about", about_page, methods=["GET", "HEAD"], include_in_schema=False)
app.add_api_route(f"{BASE}/glossary", glossary_index, methods=["GET", "HEAD"], include_in_schema=False)
app.add_api_route(f"{BASE}/glossary/{{term}}", glossary_term, methods=["GET", "HEAD"], include_in_schema=False)
# Hub before /climate/{slug} so the literal path wins.
app.add_api_route(f"{BASE}/climate", hub_page, methods=["GET", "HEAD"], include_in_schema=False)
app.add_api_route(f"{BASE}/climate/{{slug}}", city_page, methods=["GET", "HEAD"], include_in_schema=False)
# Records before the {month} param so the literal path wins.
app.add_api_route(f"{BASE}/climate/{{slug}}/records", records_page, methods=["GET", "HEAD"], include_in_schema=False)
app.add_api_route(f"{BASE}/climate/{{slug}}/{{month}}", month_page, methods=["GET", "HEAD"], include_in_schema=False)