thermograph/content.py
Emi Griffith 344721757a Report rain in whole millimetres; lift tier spans out of the bars (#192)
Two fixes to how measurements read.

Rain goes back to millimetres, rendered as whole numbers — that is how rainfall
is reported, and a tenth of a millimetre is below what the source resolves.
Inches keep two decimals, since a whole inch of rain is a lot to round to.

The distribution strip's low-high span moves from inside each bar to just under
the average, above it. A column is ~38px on a phone, so the old "Max 62° / Min
17°" pill clipped — it had already been shrunk to 8px to cope, and on a
high-rainfall city with longer values it lost both ends of the label, because the
text is centred and overflow-hidden. Above the bar it has the full column width
and needs no scrim to stay legible over nine tier colours.

The span is now bare numbers ("17-62"), since the average directly above it
carries the unit; repeating " g/m³" on both ends is what made it too wide in the
first place. It uses the same precision as that average, or the two lines
disagree ("52mm" over "12.7-136.91"). Sub-1 inch values drop their leading zero
so precip's ten columns still fit a phone.

With nothing inside the bars, the height floor drops from 30px to 14px: it only
has to keep a non-empty bucket visible now, so the heights encode frequency more
honestly. Category names get 2px of side padding, which stops long neighbours
("Light-Mod" beside "Moderate") reading as one word.

Verified at 390/412/1920 in both themes across all four calendar metrics and the
compare strips, in imperial and metric: nothing clips.

Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:28:58 +00:00

1018 lines
45 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 contextlib
import contextvars
import datetime
import functools
import hashlib
import json
import math
import os
import polars as pl
from fastapi import HTTPException, Request, Response
from fastapi.responses import PlainTextResponse
from jinja2 import Environment, FileSystemLoader, select_autoescape
from markupsafe import Markup
import cities
import city_events
import climate
import grading
import grid
import homepage
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)}
# Meteorological seasons as (northern-hemisphere label, southern-hemisphere label,
# month numbers, span text). The same three months are one season everywhere — only
# the name flips across the equator (DecFeb is winter in the north, summer in the
# south), so a city's latitude picks the label.
SEASONS = [
("Winter", "Summer", [12, 1, 2], "DecFeb"),
("Spring", "Autumn", [3, 4, 5], "MarMay"),
("Summer", "Winter", [6, 7, 8], "JunAug"),
("Autumn", "Spring", [9, 10, 11], "SepNov"),
]
# 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"}
# Countries that actually use Fahrenheit. Mirrors F_REGIONS in frontend/units.js —
# the client applies it to the visitor's locale, we apply it to the city's country.
# A test asserts the two lists stay identical.
F_COUNTRIES = frozenset({"US", "PR", "GU", "VI", "AS", "MP", "UM",
"BS", "BZ", "KY", "PW", "FM", "MH", "LR"})
# One flag drives every measure: a °C page also gets mm and km/h. Mirrors the same
# decision in frontend/units.js.
MM_PER_IN = 25.4
KMH_PER_MPH = 1.609344
# The unit the current page renders temperatures in. None = °F, and also means
# "this page has no city", which is what keeps the interactive pages on the
# client-side locale default instead of being pinned server-side.
#
# A ContextVar rather than a parameter because _temp() is reached from both sides:
# templates call it as a Jinja global, and the context builders call it directly
# (the records tables are pre-rendered into strings in Python). Threading a `unit`
# argument would mean touching ~20 call sites across three levels of nesting, where
# forgetting one yields a silently wrong unit rather than an error.
_UNIT: contextvars.ContextVar[str | None] = contextvars.ContextVar("display_unit", default=None)
@contextlib.contextmanager
def _unit_scope(unit: str | None):
"""Render temperatures in `unit` for the duration of the block.
The reset is not optional: the page handlers are sync `def`, so Starlette runs
them on a recycled threadpool thread, and a value left set would leak into
whatever request that thread serves next — including pages with no city.
"""
token = _UNIT.set(unit)
try:
yield
finally:
_UNIT.reset(token)
def unit_for_country(code: str | None) -> str:
"""The temperature unit a reader in this country expects."""
return "F" if (code or "").upper() in F_COUNTRIES else "C"
def _round_half_up(x: float) -> int:
"""Round like JS's Math.round, not like Python's round().
Python rounds halves to even, JS rounds them up. Where they disagree the
server would render one number and climate.js would repaint a different one —
a visible flicker for exactly the visitor whose unit already matched.
"""
return math.floor(x + 0.5)
def _c(f: float) -> int:
return _round_half_up((f - 32) * 5 / 9)
def _shown(f: float) -> int:
"""The number to print, in the active unit."""
return _c(f) if _UNIT.get() == "C" else _round_half_up(f)
def _letter() -> str:
return "C" if _UNIT.get() == "C" else "F"
def _temp(f):
"""A Fahrenheit temperature as a client-convertible span, e.g.
'<span class="temp" data-temp-f="72.3">72°F</span>'. The text is rendered in
the page's unit (the city's country convention — see _unit_scope), so crawlers
and no-JS visitors get the local convention rather than a conversion they have
to do themselves; climate.js repaints on toggle. data-temp-f stays Fahrenheit
either way — it is the conversion source of truth. '' for a missing value."""
if f is None:
return ""
return Markup('<span class="temp" data-temp-f="{:.1f}">{}°{}</span>').format(
f, _shown(f), _letter())
def _temp_bare(f):
"""Like _temp() but with no unit letter ('72°'), for the range strip where the
axis already implies the unit. Still carries data-temp-f so it converts."""
if f is None:
return ""
return Markup('<span class="temp" data-temp-f="{:.1f}" data-bare>{}°</span>').format(
f, _shown(f))
def _temp_text(f) -> str:
"""'72°F' as plain text. The span _temp() returns can't go in a
<meta content="">, and a meta description is the one place a temperature is
never converted client-side — it is what the search snippet quotes, which is
why it has to be in the city's own unit at render time."""
if f is None:
return ""
return f"{_shown(f)}°{_letter()}"
def _precip(v):
"""Precipitation as a client-convertible span, the same contract as _temp():
text in the page's unit, data-precip-in always inches."""
if v is None:
return ""
return Markup('<span class="precip" data-precip-in="{:.3f}">{}</span>').format(
v, _precip_text(v))
def _precip_text(v) -> str:
"""Whole millimetres — that is how rainfall is reported, and a tenth of a
millimetre is below what the source resolves. Inches keep two decimals, since
a whole inch of rain is a lot to round to."""
if v is None:
return ""
return (f"{_round_half_up(v * MM_PER_IN)} mm" if _UNIT.get() == "C"
else f"{v:.2f} in")
def _wind(v):
"""Wind/gust as a client-convertible span; data-wind-mph is always mph."""
if v is None:
return ""
return Markup('<span class="wind" data-wind-mph="{:.1f}">{}</span>').format(
v, _wind_text(v))
def _wind_text(v) -> str:
if v is None:
return ""
return (f"{_round_half_up(v * KMH_PER_MPH)} km/h" if _UNIT.get() == "C"
else f"{_round_half_up(v)} mph")
def _month_year(date_s: str) -> str:
"""'1995-07-13' -> 'Jul 1995'. Meta descriptions have ~155 characters to
spend, so a record's date gives up its day."""
try:
return datetime.date.fromisoformat(str(date_s)).strftime("%b %Y")
except (ValueError, TypeError):
return str(date_s)
def _clamp_desc(text: str, limit: int = 155) -> str:
"""Meta descriptions are cut at ~155 characters in the SERP. Trim on a word
boundary so we choose where the sentence ends rather than Google doing it."""
text = " ".join(text.split())
if len(text) <= limit:
return text
return text[:limit].rsplit(" ", 1)[0].rstrip(",;—-") + ""
def _titled(text: str, suffix: str = " · Thermograph", limit: int = 60) -> str:
"""Brand the title only when it costs nothing — the payload ("… in July",
"hottest & coldest days") has to survive truncation first."""
return text + suffix if len(text) + len(suffix) <= limit else text
def _fmt(metric: str, v) -> str:
if v is None:
return ""
if metric in _TEMP_METRICS:
return _temp(v)
if metric == "precip":
return _precip(v)
if metric == "humid":
# Absolute humidity is g/m³ in both systems — there is no imperial unit for
# it anyone would recognise — so it is never converted.
return f"{v:.1f} g/m³"
return _wind(v) # wind, gust
# Absolute-temperature colour tiers (°F upper bounds) mapped to the site's diverging
# cold→hot palette — the same 9 tiers the interactive grader uses. Colouring records
# and normals by these turns the tables into a heat map in the site's own visual
# language, rather than a plain grid.
_TEMP_TIERS = [
(20, "rec-cold"), (32, "very-cold"), (45, "cold"), (58, "cool"),
(70, "normal"), (80, "warm"), (90, "hot"), (100, "very-hot"),
]
# °F axis for the monthly temperature-range strip on the city page.
_AXIS_LO, _AXIS_HI = -10.0, 115.0
def temp_class(f) -> str:
"""Diverging-palette tier name for an absolute Fahrenheit temperature (or 'none')."""
if f is None:
return "none"
for upper, cls in _TEMP_TIERS:
if f < upper:
return cls
return "rec-hot"
def _range_bar(low_f, high_f) -> dict | None:
"""Geometry for one month's low→high bar on the shared _AXIS_LO.._AXIS_HI axis:
left offset + width as percentages, and the tier colour at each end (for a
gradient fill). None when a value is missing."""
if low_f is None or high_f is None:
return None
lo = max(_AXIS_LO, min(_AXIS_HI, low_f))
hi = max(_AXIS_LO, min(_AXIS_HI, high_f))
span = _AXIS_HI - _AXIS_LO
return {
"left": round((lo - _AXIS_LO) / span * 100, 1),
"width": round(max(2.0, (hi - lo) / span * 100), 1),
"c1": temp_class(low_f), "c2": temp_class(high_f),
}
def _ordinal(n) -> str:
"""Percentile -> display ordinal. Delegates to grading so every surface
(Day page, calendar, chart, city pages, homepage strip) agrees."""
return grading.pct_ordinal(n)
_env.globals["temp_class"] = temp_class
_env.globals["temp"] = _temp
_env.globals["temp_bare"] = _temp_bare
_env.filters["ordinal"] = _ordinal
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 _breadcrumb_jsonld(o: str, breadcrumb: list[tuple[str, str | None]]) -> dict:
"""BreadcrumbList structured data. Google requires ``item`` on every
ListItem except the last, so unlinked intermediate crumbs (e.g. the
country, which has no page of its own) are omitted here even though the
visible breadcrumb shows them."""
crumbs = [c for c in breadcrumb[:-1] if c[1]] + breadcrumb[-1:]
return {"@type": "BreadcrumbList",
"itemListElement": [
{"@type": "ListItem", "position": i + 1, "name": nm,
**({"item": f"{o}{href}"} if href else {})}
for i, (nm, href) in enumerate(crumbs)]}
def _respond_html(request: Request, template: str, **ctx) -> Response:
o = origin(request)
# Taken from the same ContextVar the numbers were rendered through, so the
# attribute units.js reads can't drift from what the page actually says.
# None on any page without a city -> base.html.j2 omits it entirely.
ctx.setdefault("unit_default", _UNIT.get())
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_entries() -> list[tuple[str, str, str]]:
"""Every indexable page as (path, changefreq, priority). Paths are relative to
BASE. Shared by the sitemap and IndexNow so the two never drift."""
entries = [("/", "daily", "1.0")]
for p in ("/climate", "/about", "/glossary", "/calendar", "/compare", "/legend"):
entries.append((p, "weekly", "0.6"))
for slug in cities.all_slugs():
entries.append((f"/climate/{slug}", "daily", "0.8"))
entries.append((f"/climate/{slug}/records", "monthly", "0.5"))
for m in MONTHS:
entries.append((f"/climate/{slug}/{m}", "monthly", "0.5"))
return entries
def public_paths() -> list[str]:
"""BASE-relative paths of every indexable page — for IndexNow submission."""
return [p for p, _, _ in _sitemap_entries()]
@functools.lru_cache(maxsize=1)
def _content_lastmod() -> str:
"""A stable 'content last built' date for <lastmod> — the newest mtime of the
city list and this module (both change on a content/code rebuild, and the
process restarts on deploy). Honest and stable, unlike a per-request today()
that churns every fetch and trains crawlers to ignore lastmod entirely."""
paths = [os.path.join(os.path.dirname(__file__), "cities.json"), __file__]
mtimes = [os.path.getmtime(p) for p in paths if os.path.exists(p)]
day = datetime.date.fromtimestamp(max(mtimes)) if mtimes else datetime.date.today()
return day.isoformat()
def sitemap_xml(request: Request) -> Response:
base_url = f"{origin(request)}{BASE}"
lastmod = _content_lastmod()
parts = ['<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">']
for path, cf, pr in _sitemap_entries():
parts.append(
f"<url><loc>{base_url}{path}</loc><lastmod>{lastmod}</lastmod>"
f"<changefreq>{cf}</changefreq><priority>{pr}</priority></url>"
)
parts.append("</urlset>")
return Response("\n".join(parts), media_type="application/xml")
def head_verify_html() -> Markup:
"""Search-engine ownership-verification <meta> tags, from env (empty when
unset). Injected into every page's <head> — Google verifies the homepage."""
metas = []
google = os.environ.get("THERMOGRAPH_GOOGLE_VERIFY", "").strip()
bing = os.environ.get("THERMOGRAPH_BING_VERIFY", "").strip()
if google:
metas.append(Markup('<meta name="google-site-verification" content="{}">').format(google))
if bing:
metas.append(Markup('<meta name="msvalidate.01" content="{}">').format(bing))
return Markup("\n ").join(metas)
_env.globals["head_verify"] = head_verify_html
# --- 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")
high_f = tmax["mean"] if tmax else None
low_f = tmin["mean"] if tmin else None
# The range strip spans the typical spread: 10th-percentile daily low to
# 90th-percentile daily high (the band most days fall within).
rng_lo = tmin["p10"] if tmin else None
rng_hi = tmax["p90"] if tmax else None
rows.append({
"name": name,
"slug": MONTHS[i - 1],
"high": _temp(tmax["mean"]) if tmax else "",
"high_f": high_f,
"low": _temp(tmin["mean"]) if tmin else "",
"low_f": low_f,
"range_lo_f": rng_lo,
"range_hi_f": rng_hi,
"precip": _precip(precip["mean"]) if precip else "",
"precip_v": precip["mean"] if precip else None,
"bar": _range_bar(rng_lo, rng_hi),
})
return rows
def _extreme(metric_rec, key: str) -> dict | None:
"""One extreme of a metric — key 'max' (warmest) or 'min' (coldest) — as the
two-unit value, its heat-map tier, and the date it occurred."""
if not metric_rec:
return None
v = metric_rec[key]
return {"txt": _temp(v), "f": v, "cls": temp_class(v), "date": metric_rec[f"{key}_date"]}
def _period_records(history, months: list[int]) -> dict:
"""For a set of calendar months, the record *warmest and coldest* of BOTH the
daytime high (tmax) and the overnight low (tmin) — four extremes with dates.
So each metric shows both ends: the daytime high's hottest day and the coldest a
day ever stayed (its record-low high), and the overnight low's mildest night and
its record low. Reuses grading.all_time_records on the month-filtered archive."""
if len(months) == 1:
sub = history.filter(pl.col("date").dt.month() == months[0])
else:
sub = history.filter(pl.col("date").dt.month().is_in(months))
rec = grading.all_time_records(sub) if not sub.is_empty() else {}
tmax, tmin = rec.get("tmax"), rec.get("tmin")
return {
"high": {"warm": _extreme(tmax, "max"), "cold": _extreme(tmax, "min")},
"low": {"warm": _extreme(tmin, "max"), "cold": _extreme(tmin, "min")},
}
def _monthly_records(history) -> list[dict]:
"""Record high and low for each of the 12 months (each row links to its month page)."""
return [
{"name": name, "slug": MONTHS[i - 1], **_period_records(history, [i])}
for i, name in enumerate(MONTHS_TITLE, start=1)
]
def _seasonal_records(history, lat: float) -> list[dict]:
"""Record high and low for each meteorological season, labelled for the city's
hemisphere (DecFeb reads as winter north of the equator, summer south of it)."""
south = lat < 0
return [
{"name": (south_lbl if south else north_lbl), "span": span,
**_period_records(history, months)}
for north_lbl, south_lbl, months, span in SEASONS
]
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)
title = cities.title_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}"
# Unique editorial blurb (Wikipedia, CC BY-SA) so the page isn't just templated
# stats, and a travel/comfort CTA that pre-fills this city on the compare page.
flavor = cities.flavor(city["slug"])
event = city_events.get(city["slug"]) # hand-curated; None → page falls back to the blurb
compare_url = f"{BASE}/compare#loc={city['lat']:.4f},{city['lon']:.4f}"
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)"},
_breadcrumb_jsonld(o, 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, "flavor": flavor, "event": event, "compare_url": compare_url,
"breadcrumb": breadcrumb,
"canonical_path": f"/climate/{city['slug']}",
"page_title": _titled(f"{title} climate: daily normals, records & how unusual it is now"),
"page_description": _clamp_desc(
f"{title} averages highs of {_temp_text(warmest['high_f']) if warmest else ''} in "
f"{warmest['name'] if warmest else 'summer'} and lows of "
f"{_temp_text(coldest['low_f']) if coldest else ''} in "
f"{coldest['name'] if coldest else 'winter'}. "
f"Every day graded against {year_range[1] - year_range[0]} years of local 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)
# The scope has to cover the context builder too, not just the render: it is
# evaluated as an argument, so it runs first — and it is where most of the
# page's temperatures are formatted.
with _unit_scope(unit_for_country(city.get("country_code"))):
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)
title = cities.title_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", _temp(tmax['p10']) + " to " + _temp(tmax['p90'])))
if tmin:
stats.append(("Average low", _temp(tmin["mean"])))
stats.append(("Typical low range", _temp(tmin['p10']) + " to " + _temp(tmin['p90'])))
if precip:
stats.append(("Average daily precipitation", _precip(precip["mean"])))
# Record high and low for every metric in this calendar month (mirrors the
# all-time records cards, but scoped to the month). Precip is special-cased:
# a per-day "record low" is just zero, and the dry-streak helper would wrongly
# bridge year boundaries on month-filtered rows, so the wettest/driest sides
# show this month's largest and smallest total accumulation, dated to the year.
records = []
for key, label in METRIC_LABELS:
r = mrec.get(key)
if not r:
continue
if key == "precip":
# Only whole months count — a partial current month would otherwise win
# "driest" on a fraction of its rainfall.
totals = (mdf.filter(pl.col("precip").is_not_null())
.group_by(pl.col("date").dt.year().alias("yr"))
.agg(pl.col("precip").sum().alias("total"),
pl.len().alias("days"))
.filter(pl.col("days") >= 26)
.sort("total"))
if totals.is_empty():
continue
lo, hi = totals.row(0, named=True), totals.row(totals.height - 1, named=True)
records.append({
"label": label,
"high": _precip(hi["total"]), "high_date": str(hi["yr"]), "high_tag": "Wettest",
"low": _precip(lo["total"]), "low_date": str(lo["yr"]), "low_tag": "Driest",
})
else:
records.append({
"label": label,
"high": _fmt(key, r["max"]), "high_date": r["max_date"], "high_tag": "Highest",
"low": _fmt(key, r["min"]), "low_date": r["min_date"], "low_tag": "Lowest",
})
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 "",
"avg_high_cls": temp_class(tmax["mean"]) if tmax else "none",
"avg_low_cls": temp_class(tmin["mean"]) if tmin else "none",
"stats": stats, "records": records,
"tool_hash": f"{city['lat']:.5f},{city['lon']:.5f}",
"compare_url": f"{BASE}/compare#loc={city['lat']:.4f},{city['lon']:.4f}",
"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": _titled(f"{title} in {month_name}: normal weather & records"),
"page_description": _clamp_desc(
f"{title} averages {_temp_text(tmax['mean']) if tmax else ''} highs and "
f"{_temp_text(tmin['mean']) if tmin else ''} lows in {month_name}. "
f"Every day graded against {year_range[1] - year_range[0]} years of local history."),
}
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)
with _unit_scope(unit_for_country(city.get("country_code"))):
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)
title = cities.title_name(city)
name = city["name"]
years = history["date"].dt.year()
year_range = [int(years.min()), int(years.max())]
n_years = year_range[1] - year_range[0]
rec = grading.all_time_records(history)
# The two numbers the meta description leads with — the same all-time extremes
# the page's own lede sentence quotes.
hi_rec = (rec.get("tmax") or {}).get("max")
hi_date = (rec.get("tmax") or {}).get("max_date")
lo_rec = (rec.get("tmin") or {}).get("min")
lo_date = (rec.get("tmin") or {}).get("min_date")
rows = []
for key, label in METRIC_LABELS:
r = rec.get(key)
if not r:
continue
if key == "precip":
# "Record low" rain is meaningless (it's just 0), so the low side shows
# the longest dry streak and the date it began instead.
days, dry_start = grading.longest_dry_streak(history)
low = f"{days}-day dry spell" if days else ""
low_date = dry_start or ""
else:
low, low_date = _fmt(key, r["min"]), r["min_date"]
is_temp = key in _TEMP_METRICS
rows.append({
"label": label,
"high": _fmt(key, r["max"]), "high_date": r["max_date"],
"high_f": r["max"] if is_temp else None,
"low": low, "low_date": low_date,
"low_f": r["min"] if is_temp else None,
})
monthly = _monthly_records(history)
seasonal = _seasonal_records(history, city["lat"])
hemisphere = "Southern" if city["lat"] < 0 else "Northern"
breadcrumb = [("Home", f"{BASE}/"), ("Climate", f"{BASE}/climate"),
(name, f"{BASE}/climate/{city['slug']}"), ("Records", None)]
o = origin(request)
page_url = f"{o}{BASE}/climate/{city['slug']}/records"
jsonld = {
"@context": "https://schema.org",
"@graph": [
{"@type": "Dataset",
"name": f"{display} monthly and seasonal weather records",
"description": f"Record high and low temperatures for {display} by month and by "
f"meteorological season, with the dates they occurred, from ~{n_years} "
f"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)"},
_breadcrumb_jsonld(o, breadcrumb),
],
}
return {
"section": "climate", "city": city, "display": display, "name": name,
"year_range": year_range, "n_years": n_years,
"rows": rows, "monthly": monthly, "seasonal": seasonal,
"hemisphere": hemisphere, "all_time": rec,
"canonical_path": f"/climate/{city['slug']}/records",
"breadcrumb": breadcrumb,
"page_title": _titled(
f"{title} weather records: hottest & coldest days since {year_range[0]}"),
"page_description": _clamp_desc(
f"{title}'s hottest day hit {_temp_text(hi_rec)}"
f"{f' ({_month_year(hi_date)})' if hi_date else ''}; its coldest fell to "
f"{_temp_text(lo_rec)}{f' ({_month_year(lo_date)})' if lo_date else ''}. "
f"Every day graded against {n_years} years of local history."),
"jsonld_str": json.dumps(jsonld, ensure_ascii=False, separators=(",", ":")),
}
def records_page(request: Request, slug: str) -> Response:
city, cell, history = _resolve_city(slug)
with _unit_scope(unit_for_country(city.get("country_code"))):
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 <b>climate normal</b> 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 &plusmn;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 <b>percentile</b> 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 &plusmn;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 <b>temperature anomaly</b> 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": "<b>Feels-like</b> (apparent temperature) combines air temperature with humidity and wind. In heat "
"it uses the <a href=\"{base}/glossary/heat-index\">heat index</a>; in cold it uses "
"<a href=\"{base}/glossary/wind-chill\">wind chill</a>. Thermograph grades feels-like against its "
"own local history, so “Near Record” means extreme <i>for that place</i>.",
},
"heat-index": {
"term": "Heat index",
"short": "How hot it feels when humidity is factored into the air temperature.",
"body": "The <b>heat index</b> 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 "
"<a href=\"{base}/glossary/feels-like\">feels-like</a> 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": "<b>Wind chill</b> 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 "
"<a href=\"{base}/glossary/feels-like\">feels-like</a>.",
},
"humidity": {
"term": "Absolute humidity",
"short": "The actual mass of water vapor in the air, in grams per cubic meter.",
"body": "Thermograph reports <b>absolute humidity</b> (g/m&sup3;) — 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 <b>reanalysis</b> 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 <b>ERA5</b> 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 &amp; 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 &plusmn;7-day seasonal "
"window, and empirical percentiles that grade each day relative to its own location."),
}
return _respond_html(request, "about.html.j2", **ctx)
def privacy_page(request: Request) -> Response:
ctx = {
"canonical_path": "/privacy",
"breadcrumb": [("Home", f"{BASE}/"), ("Privacy", None)],
"page_title": "Privacy — Thermograph",
"page_description": ("What Thermograph does and does not collect: no tracking, no ads, "
"no account required, and location that never leaves your browser."),
}
return _respond_html(request, "privacy.html.j2", **ctx)
# --- homepage -----------------------------------------------------------------
# The homepage is the Weekly tool plus the distribution surfaces around it. It is
# server-rendered like the SEO pages (rather than a static file with placeholder
# substitution) so a cold visitor with no JS still gets the headline, a real
# graded example, the records strip and the city links.
_HOME_JSONLD = {
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "Thermograph",
"applicationCategory": "WeatherApplication",
"operatingSystem": "Web, iOS, Android",
"isAccessibleForFree": True,
"offers": {"@type": "Offer", "price": "0", "priceCurrency": "USD"},
"description": ("How unusual is your weather? Any day, anywhere on Earth — graded "
"against 45 years of that place's own history."),
}
# 12 city chips, chosen for geographic spread rather than raw population, so the
# strip reads as "anywhere on Earth" and seeds crawl paths across the hub.
HOME_CITY_SLUGS = (
"new-york-city-new-york-us", "london-england-gb", "tokyo-jp",
"sydney-new-south-wales-au", "sao-paulo-br", "lagos-ng",
"mumbai-maharashtra-in", "mexico-city-mx", "cairo-eg",
"toronto-ontario-ca", "berlin-state-of-berlin-de", "seattle-washington-us",
)
def _home_cities() -> list[dict]:
"""The chip set, skipping any slug not in the routable city list so a
regenerated cities.json can never 404 a homepage link."""
out = []
for slug in HOME_CITY_SLUGS:
city = cities.get(slug)
if city:
out.append({"slug": slug, "name": city["name"]})
return out
def home_page(request: Request) -> Response:
feed = homepage.load()
stale = bool(feed) and homepage.is_stale(feed)
unusual = None
ranked: list = []
if feed:
ranked = feed.get("ranked") or []
pick = (feed.get("picks") or {}).get("extreme")
if pick:
unusual = dict(pick, is_default=True)
ctx = {
"section": "home",
# The hero headline owns the page's sole h1, so the brand degrades to a <p>.
"brand_tag": "p",
# The tool needs the wide app column, not the 880px reading column.
"main_class": "",
"canonical_path": "/",
"unusual": unusual,
"stale": stale,
"ranked": ranked,
"cities": _home_cities(),
"jsonld_str": Markup(json.dumps({**_HOME_JSONLD, "url": f"{origin(request)}{BASE}/"})),
}
return _respond_html(request, "home.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)
# IndexNow ownership key, served as a text file at the site root (/{key}.txt)
# so Bing/DuckDuckGo/Yandex can verify our submissions. The key is fixed at
# startup, so registering its literal path here is safe.
import indexnow
_inkey = indexnow.key()
def _indexnow_key_file() -> Response:
return PlainTextResponse(_inkey + "\n")
app.add_api_route(
f"{BASE}/{_inkey}.txt", _indexnow_key_file,
methods=["GET", "HEAD"], include_in_schema=False,
)
# The homepage. Registered here (not as a static file in app.py) so it is
# server-rendered from the same Jinja environment as the SEO pages.
app.add_api_route(f"{BASE}/", home_page, methods=["GET", "HEAD"], 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}/privacy", privacy_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)