diff --git a/content.py b/content.py index 36338d2..9fba2b9 100644 --- a/content.py +++ b/content.py @@ -133,17 +133,9 @@ def _range_bar(low_f, high_f) -> dict | None: def _ordinal(n) -> str: - """96 -> '96th'. Percentiles read as ordinals in prose ('in the 96th - percentile'), and the frontend's shared.js ord() does the same client-side.""" - try: - i = int(round(float(n))) - except (TypeError, ValueError): - return "—" - if 10 <= i % 100 <= 20: - suffix = "th" - else: - suffix = {1: "st", 2: "nd", 3: "rd"}.get(i % 10, "th") - return f"{i}{suffix}" + """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 diff --git a/grading.py b/grading.py index 39fb5d7..b47008c 100644 --- a/grading.py +++ b/grading.py @@ -8,6 +8,8 @@ empirical percentile and mapped to a human-readable grade. import datetime import warnings +import math + import numpy as np import polars as pl @@ -82,6 +84,29 @@ _TEMP_LADDER = _ladder_from(TEMP_BANDS) _RAIN_LADDER = _ladder_from(RAIN_BANDS, bottom_lo=0) +def pct_ordinal(pct) -> str: + """A percentile as a display ordinal: 66.4 -> '66th', 99.6 -> '99th'. + + Floored into 1..99 on purpose. An empirical percentile is a rank against the + sample, so rounding can land on 100 (or 0), and "100th percentile" reads as a + measurement error rather than "as extreme as it has ever been". The band label + ("Near Record") carries the how-extreme part. + + Canonical for every surface — the frontend mirrors it as pctOrd() in + shared.js, so the Day page, the calendar tooltip, the chart, the city pages + and the homepage strip all say the same thing about the same reading. + """ + try: + # floor(x + 0.5), NOT round(): Python's round() is half-to-even, so it + # gives 16 for 16.5 while JavaScript's Math.round gives 17 — the same + # reading would then read "16th" server-side and "17th" client-side. + n = min(99, max(1, math.floor(float(pct) + 0.5))) + 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 _band(pct: float, bands) -> tuple[str, str]: # The top tier is strict (pct > its threshold): "Near Record" high means # strictly beyond the 99th percentile — the top <1% — mirroring the diff --git a/homepage.py b/homepage.py index f729039..a38205f 100644 --- a/homepage.py +++ b/homepage.py @@ -96,16 +96,8 @@ def _short_date(date_s: str) -> str: def _pct_label(pct: float) -> str: - """A percentile as an ordinal, clamped to 1..99. - - An empirical percentile can round to 100 (or 0), and "100th percentile" reads - as a measurement error rather than "as hot as it has ever been". Flooring into - the real 1-99 range keeps it honest — the value and the band label carry the - "how extreme" part anyway. - """ - n = min(99, max(1, int(round(pct)))) - suffix = "th" if 10 <= n % 100 <= 20 else {1: "st", 2: "nd", 3: "rd"}.get(n % 10, "th") - return f"{n}{suffix}" + """Kept as a name the feed builder reads; the rule itself lives in grading.""" + return grading.pct_ordinal(pct) def _grade_label(grade: str | None, tail: str) -> str: diff --git a/templates/city.html.j2 b/templates/city.html.j2 index dcbf276..5eee6ed 100644 --- a/templates/city.html.j2 +++ b/templates/city.html.j2 @@ -31,7 +31,7 @@