173 lines
5.3 KiB
Python
173 lines
5.3 KiB
Python
"""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),
|
|
}
|