thermograph/frontend/format.py

174 lines
5.3 KiB
Python
Raw Normal View History

"""Unit-aware formatting for the SSR templates — ported from
backend/web/content.py, unchanged in behavior (still Markup-wrapped spans for
the templates), but operating on raw floats the content API returns instead of
live polars frames. The ContextVar-based active-unit scoping is unchanged: the
templates and context builders both need it (see content.py's `_unit_scope`),
and threading a `unit` parameter through every call site risks a silently
wrong unit at the one site someone forgets, same reasoning as the original.
"""
import contextlib
import contextvars
import math
from markupsafe import Markup
# Countries that actually use Fahrenheit. Mirrors backend/api/content_payloads.py's
# F_COUNTRIES / frontend/units.js's F_REGIONS -- a test asserts all three stay
# identical.
F_COUNTRIES = frozenset({"US", "PR", "GU", "VI", "AS", "MP", "UM",
"BS", "BZ", "KY", "PW", "FM", "MH", "LR"})
MM_PER_IN = 25.4
KMH_PER_MPH = 1.609344
# Absolute-temperature colour tiers (°F upper bounds), the same 9 tiers the
# interactive grader uses.
_TEMP_TIERS = [
(20, "rec-cold"), (32, "very-cold"), (45, "cold"), (58, "cool"),
(70, "normal"), (80, "warm"), (90, "hot"), (100, "very-hot"),
]
_AXIS_LO, _AXIS_HI = -10.0, 115.0
_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 -- see content.py's page handlers (sync def -> a recycled
threadpool thread) for why a leaked value would bleed into the next
request."""
token = _UNIT.set(unit)
try:
yield
finally:
_UNIT.reset(token)
def unit_for_country(code: str | None) -> str:
return "F" if (code or "").upper() in F_COUNTRIES else "C"
def current_unit() -> str | None:
return _UNIT.get()
def _round_half_up(x: float) -> int:
"""Round like JS's Math.round, not Python's round() (which rounds halves
to even) -- so the server and climate.js's repaint never visibly disagree."""
return math.floor(x + 0.5)
def _c(f: float) -> int:
return _round_half_up((f - 32) * 5 / 9)
def _shown(f: float) -> int:
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) -> Markup | str:
if f is None:
return ""
return Markup('<span class="temp" data-temp-f="{:.1f}">{}°{}</span>').format(
f, _shown(f), _letter())
def temp_bare(f) -> Markup | str:
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:
if f is None:
return ""
return f"{_shown(f)}°{_letter()}"
def precip(v) -> Markup | str:
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:
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) -> Markup | str:
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")
_TEMP_METRICS = {"tmax", "tmin", "feels"}
def fmt(metric: str, v) -> Markup | str:
if v is None:
return ""
if metric in _TEMP_METRICS:
return temp(v)
if metric == "precip":
return precip(v)
if metric == "humid":
return f"{v:.1f} g/m³"
return wind(v) # wind, gust
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 pct_ordinal(pct) -> str:
"""A percentile as a display ordinal: 66.4 -> '66th', 99.6 -> '99th'.
Floored into 1..99 -- an empirical percentile is a rank against the sample,
so rounding can land on 100 (or 0). Ported from backend/data/grading.py's
pct_ordinal, which stays canonical for the in-process (Day page, calendar,
chart) surfaces; this is the SSR-side copy, same formula."""
try:
n = min(99, max(1, _round_half_up(float(pct))))
except (TypeError, ValueError):
return ""
suffix = "th" if 10 <= n % 100 <= 20 else {1: "st", 2: "nd", 3: "rd"}.get(n % 10, "th")
return f"{n}{suffix}"
def range_bar(low_f, high_f) -> dict | None:
"""Geometry for one month's low->high bar on the shared axis: left offset +
width as percentages, and the tier colour at each end. None when 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),
}