"""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('{}°{}').format( f, _shown(f), _letter()) def temp_bare(f) -> Markup | str: if f is None: return "—" return Markup('{}°').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('{}').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('{}').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), }