From 7d17d8bc4f3a43314cf0b07996ca6f2f820f9e9c Mon Sep 17 00:00:00 2001 From: Emi Griffith Date: Sat, 18 Jul 2026 02:08:28 -0700 Subject: [PATCH] Render every percentile through one rule (#186) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The homepage strip floored percentiles into 1-99 while the Day page rendered "100th pct" for the same reading. Rather than teach the Day page a second copy of the rule, put it in grading.pct_ordinal() and have every surface defer to it: the Day page, the calendar tooltip, the chart labels, the city pages and the strip. Two bugs fell out of unifying them: - The city pages rendered `{{ c.percentile }}th pct` against a float, so all ~1000 of them showed "97.1th pct", "66.4th pct", "16.5th pct" — and would say "1th"/"2th"/"3th" for the low tail. Live in prod. - Python's round() is half-to-even and JavaScript's Math.round is half-up, so 16.5 gave "16th" server-side and "17th" client-side. The Python helper uses floor(x + 0.5) to match the frontend exactly; a cross-language check over every .5 boundary now agrees on all of them. The frontend mirrors the rule as pctOrd() in shared.js, replacing the bare ord() at all five percentile call sites (day.js, calendar.js, chart.js x2, app.js). ord() stays as the general ordinal helper it always was. --- content.py | 14 +++----------- grading.py | 25 +++++++++++++++++++++++++ homepage.py | 12 ++---------- templates/city.html.j2 | 2 +- tests/test_content.py | 31 +++++++++++++++++++++++++++++++ 5 files changed, 62 insertions(+), 22 deletions(-) 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 @@
{{ c.label }} {{ c.value }} - {% if c.percentile is not none %}{{ c.grade }} · {{ c.percentile }}th pct + {% if c.percentile is not none %}{{ c.grade }} · {{ c.percentile|ordinal }} pct {% else %}{{ c.grade }}{% endif %}
{% endfor %} diff --git a/tests/test_content.py b/tests/test_content.py index e0f3eb7..bf9dbb1 100644 --- a/tests/test_content.py +++ b/tests/test_content.py @@ -1,12 +1,15 @@ """Tests for the crawlable SEO content pages: the city set, robots/sitemap, and that the server-rendered pages contain the stats in the HTML (with the weather layer faked, like test_api.py).""" +import re + import pytest from fastapi.testclient import TestClient import app as appmod import cities import climate +import grading # BASE defaults to /thermograph in tests (THERMOGRAPH_BASE unset). B = "/thermograph" @@ -289,3 +292,31 @@ def test_brand_lockup_links_home(client, path): assert href in ("./", f"{B}/"), f"{path}: brand links to {href!r}, not home" assert '