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:
parent
63ba5ab44e
commit
7d17d8bc4f
5 changed files with 62 additions and 22 deletions
14
content.py
14
content.py
|
|
@ -133,17 +133,9 @@ def _range_bar(low_f, high_f) -> dict | None:
|
||||||
|
|
||||||
|
|
||||||
def _ordinal(n) -> str:
|
def _ordinal(n) -> str:
|
||||||
"""96 -> '96th'. Percentiles read as ordinals in prose ('in the 96th
|
"""Percentile -> display ordinal. Delegates to grading so every surface
|
||||||
percentile'), and the frontend's shared.js ord() does the same client-side."""
|
(Day page, calendar, chart, city pages, homepage strip) agrees."""
|
||||||
try:
|
return grading.pct_ordinal(n)
|
||||||
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}"
|
|
||||||
|
|
||||||
|
|
||||||
_env.globals["temp_class"] = temp_class
|
_env.globals["temp_class"] = temp_class
|
||||||
|
|
|
||||||
25
grading.py
25
grading.py
|
|
@ -8,6 +8,8 @@ empirical percentile and mapped to a human-readable grade.
|
||||||
import datetime
|
import datetime
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import polars as pl
|
import polars as pl
|
||||||
|
|
||||||
|
|
@ -82,6 +84,29 @@ _TEMP_LADDER = _ladder_from(TEMP_BANDS)
|
||||||
_RAIN_LADDER = _ladder_from(RAIN_BANDS, bottom_lo=0)
|
_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]:
|
def _band(pct: float, bands) -> tuple[str, str]:
|
||||||
# The top tier is strict (pct > its threshold): "Near Record" high means
|
# The top tier is strict (pct > its threshold): "Near Record" high means
|
||||||
# strictly beyond the 99th percentile — the top <1% — mirroring the
|
# strictly beyond the 99th percentile — the top <1% — mirroring the
|
||||||
|
|
|
||||||
12
homepage.py
12
homepage.py
|
|
@ -96,16 +96,8 @@ def _short_date(date_s: str) -> str:
|
||||||
|
|
||||||
|
|
||||||
def _pct_label(pct: float) -> str:
|
def _pct_label(pct: float) -> str:
|
||||||
"""A percentile as an ordinal, clamped to 1..99.
|
"""Kept as a name the feed builder reads; the rule itself lives in grading."""
|
||||||
|
return grading.pct_ordinal(pct)
|
||||||
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}"
|
|
||||||
|
|
||||||
|
|
||||||
def _grade_label(grade: str | None, tail: str) -> str:
|
def _grade_label(grade: str | None, tail: str) -> str:
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@
|
||||||
<div class="today-card" style="border-left-color: var(--{{ c.cls }})">
|
<div class="today-card" style="border-left-color: var(--{{ c.cls }})">
|
||||||
<span class="tc-label">{{ c.label }}</span>
|
<span class="tc-label">{{ c.label }}</span>
|
||||||
<span class="tc-value">{{ c.value }}</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 %}
|
{% else %}<span class="tc-grade">{{ c.grade }}</span>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
"""Tests for the crawlable SEO content pages: the city set, robots/sitemap, and
|
"""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
|
that the server-rendered pages contain the stats in the HTML (with the weather
|
||||||
layer faked, like test_api.py)."""
|
layer faked, like test_api.py)."""
|
||||||
|
import re
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
import app as appmod
|
import app as appmod
|
||||||
import cities
|
import cities
|
||||||
import climate
|
import climate
|
||||||
|
import grading
|
||||||
|
|
||||||
# BASE defaults to /thermograph in tests (THERMOGRAPH_BASE unset).
|
# BASE defaults to /thermograph in tests (THERMOGRAPH_BASE unset).
|
||||||
B = "/thermograph"
|
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 href in ("./", f"{B}/"), f"{path}: brand links to {href!r}, not home"
|
||||||
assert '<span class="logo">' in head.split("<a href=", 1)[1], \
|
assert '<span class="logo">' in head.split("<a href=", 1)[1], \
|
||||||
f"{path}: the mark sits outside the home link"
|
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}")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue