Render every percentile through one rule (#186)

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.
This commit is contained in:
Emi Griffith 2026-07-18 02:08:28 -07:00 committed by GitHub
parent 63ba5ab44e
commit 7d17d8bc4f
5 changed files with 62 additions and 22 deletions

View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -31,7 +31,7 @@
<div class="today-card" style="border-left-color: var(--{{ c.cls }})">
<span class="tc-label">{{ c.label }}</span>
<span class="tc-value">{{ c.value }}</span>
{% if c.percentile is not none %}<span class="tc-grade">{{ c.grade }} · {{ c.percentile }}th pct</span>
{% if c.percentile is not none %}<span class="tc-grade">{{ c.grade }} · {{ c.percentile|ordinal }} pct</span>
{% else %}<span class="tc-grade">{{ c.grade }}</span>{% endif %}
</div>
{% endfor %}

View file

@ -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 '<span class="logo">' in head.split("<a href=", 1)[1], \
f"{path}: the mark sits outside the home link"
# Percentiles are shown on the Day page, the calendar tooltip, the chart, the
# city pages and the homepage strip. They all have to say the same thing about
# the same reading, so the rule lives in grading.pct_ordinal() and every surface
# defers to it (the frontend mirrors it as pctOrd in shared.js).
def test_pct_ordinal_rule():
assert grading.pct_ordinal(66.4) == "66th"
assert grading.pct_ordinal(1.2) == "1st"
assert grading.pct_ordinal(22) == "22nd"
assert grading.pct_ordinal(3) == "3rd"
assert grading.pct_ordinal(11) == "11th"
# Floored into 1..99: an empirical percentile is a rank against the sample,
# so "100th percentile" reads as a measurement error.
assert grading.pct_ordinal(99.6) == "99th"
assert grading.pct_ordinal(100) == "99th"
assert grading.pct_ordinal(0) == "1st"
assert grading.pct_ordinal(None) == ""
def test_city_page_percentiles_are_real_ordinals(client):
"""The city page used to render `{{ c.percentile }}th pct` against a float,
producing "97.1th pct" and "1th pct" on every one of the ~1000 city pages."""
html = client.get(f"{B}/climate/{SLUG}").text
assert "th pct" in html or "st pct" in html or "nd pct" in html or "rd pct" in html
# No float ordinals, no impossible ones, no "1th"/"2th"/"3th".
for bad in re.findall(r"\d+\.\d+(?:st|nd|rd|th)|100th|\b0th|\b\d*[123]th", html):
raise AssertionError(f"malformed percentile ordinal: {bad!r}")