From 5cf517e8ea2452fd192fc494724928a295854f34 Mon Sep 17 00:00:00 2001 From: Emi Griffith Date: Sun, 19 Jul 2026 12:03:41 -0700 Subject: [PATCH] Follow the unit system for precipitation and wind too (#190) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defaulting climate pages to the city's temperature convention left every page half-converted: a Delhi reader got °C next to "9 mph" and "0.00 in". One flag now drives every measure — picking °C also gives mm and km/h. Absolute humidity stays g/m³ in both systems; there is no imperial unit for it anyone would recognise, so converting it would only make the page worse. units.js becomes the single source for all of it. The formatting logic existed in three independent copies — shared.js's fmtPrecip/fmtWind/fmtHumid, a second set inside fmtMetricVal, and a third baked into chart.js's series labels, axis ticks and tooltip — which is why the units drifted apart in the first place. shared.js now re-exports from units.js so its importers are unchanged, and chart.js carries a per-series unit `kind` in place of sniffing for a "°" suffix, which could not have extended to three unit families. Server-rendered pages gain the carrier they were missing: precipitation and wind now render as / , the same contract temperature already had, so climate.js can repaint them on toggle and the attribute stays imperial as the source of truth. Precision follows the unit: millimetres get one decimal where inches get two. 0.04 in and 1.0 mm are the same amount, and "1.02 mm" would advertise precision the source doesn't have. Wind rounds half-up to match Math.round, as temperature already does. The weekly table's wind and rain cells are bare numbers to keep the grid narrow, so their row labels now carry the unit and rebuild per render. Static copy that names a unit — the legend's "(mph)", "(inches)", and a "(°F)" that had been wrong for °C readers since the country defaults landed — is marked up with data-unit-label and painted from the same source. The chart keeps its imperial domain; only labels convert, so point positions and the fan geometry are untouched. Push and email bodies are still imperial-only (notify.py): they are composed server-side with no client to repaint them and no per-user unit stored, which is the same limitation temperature already has there. Left for a follow-up. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8 --- content.py | 51 ++++++++++++++++++++++++++++---- tests/test_content.py | 68 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 6 deletions(-) diff --git a/content.py b/content.py index fc31e2d..844c6c4 100644 --- a/content.py +++ b/content.py @@ -69,6 +69,11 @@ _TEMP_METRICS = {"tmax", "tmin", "feels"} F_COUNTRIES = frozenset({"US", "PR", "GU", "VI", "AS", "MP", "UM", "BS", "BZ", "KY", "PW", "FM", "MH", "LR"}) +# One flag drives every measure: a °C page also gets mm and km/h. Mirrors the same +# decision in frontend/units.js. +MM_PER_IN = 25.4 +KMH_PER_MPH = 1.609344 + # The unit the current page renders temperatures in. None = °F, and also means # "this page has no city", which is what keeps the interactive pages on the # client-side locale default instead of being pinned server-side. @@ -156,6 +161,38 @@ def _temp_text(f) -> str: return f"{_shown(f)}°{_letter()}" +def _precip(v): + """Precipitation as a client-convertible span, the same contract as _temp(): + text in the page's unit, data-precip-in always inches.""" + if v is None: + return "—" + return Markup('{}').format( + v, _precip_text(v)) + + +def _precip_text(v) -> str: + """Millimetres get one decimal and inches two — 0.04 in and 1.0 mm are the same + amount, so '1.02 mm' would advertise precision the source doesn't have.""" + if v is None: + return "—" + return f"{v * MM_PER_IN:.1f} mm" if _UNIT.get() == "C" else f"{v:.2f} in" + + +def _wind(v): + """Wind/gust as a client-convertible span; data-wind-mph is always mph.""" + 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") + + def _month_year(date_s: str) -> str: """'1995-07-13' -> 'Jul 1995'. Meta descriptions have ~155 characters to spend, so a record's date gives up its day.""" @@ -186,10 +223,12 @@ def _fmt(metric: str, v) -> str: if metric in _TEMP_METRICS: return _temp(v) if metric == "precip": - return f"{v:.2f} in" + return _precip(v) if metric == "humid": + # Absolute humidity is g/m³ in both systems — there is no imperial unit for + # it anyone would recognise — so it is never converted. return f"{v:.1f} g/m³" - return f"{round(v)} mph" # wind, gust + return _wind(v) # wind, gust # Absolute-temperature colour tiers (°F upper bounds) mapped to the site's diverging @@ -390,7 +429,7 @@ def _monthly_normals(history) -> list[dict]: "low_f": low_f, "range_lo_f": rng_lo, "range_hi_f": rng_hi, - "precip": f"{precip['mean']:.2f} in" if precip else "—", + "precip": _precip(precip["mean"]) if precip else "—", "precip_v": precip["mean"] if precip else None, "bar": _range_bar(rng_lo, rng_hi), }) @@ -584,7 +623,7 @@ def _month_context(request, city, history, month_idx: int) -> dict: stats.append(("Average low", _temp(tmin["mean"]))) stats.append(("Typical low range", _temp(tmin['p10']) + " to " + _temp(tmin['p90']))) if precip: - stats.append(("Average daily precipitation", f"{precip['mean']:.2f} in")) + stats.append(("Average daily precipitation", _precip(precip["mean"]))) # Record high and low for every metric in this calendar month (mirrors the # all-time records cards, but scoped to the month). Precip is special-cased: # a per-day "record low" is just zero, and the dry-streak helper would wrongly @@ -609,8 +648,8 @@ def _month_context(request, city, history, month_idx: int) -> dict: lo, hi = totals.row(0, named=True), totals.row(totals.height - 1, named=True) records.append({ "label": label, - "high": f"{hi['total']:.1f} in", "high_date": str(hi["yr"]), "high_tag": "Wettest", - "low": f"{lo['total']:.1f} in", "low_date": str(lo["yr"]), "low_tag": "Driest", + "high": _precip(hi["total"]), "high_date": str(hi["yr"]), "high_tag": "Wettest", + "low": _precip(lo["total"]), "low_date": str(lo["yr"]), "low_tag": "Driest", }) else: records.append({ diff --git a/tests/test_content.py b/tests/test_content.py index 9311328..5b59e19 100644 --- a/tests/test_content.py +++ b/tests/test_content.py @@ -304,6 +304,74 @@ def test_units_default_to_the_citys_country(client): assert 'data-unit-default="F"' in us +_PRECIP_SPAN = re.compile(r'([\d.]+) (in|mm)') + + +def test_precip_and_wind_follow_the_citys_unit(client): + # One toggle drives every measure: a °C page shows mm and km/h, not °C mixed + # with inches and mph. The data-* attribute stays imperial in both, so + # climate.js converts from the same source of truth it does for temperature. + gb = client.get(f"{B}/climate/{SLUG}/records").text # London, GB + us = client.get(f"{B}/climate/seattle-washington-us/records").text + + gb_p, us_p = _PRECIP_SPAN.findall(gb), _PRECIP_SPAN.findall(us) + assert gb_p and us_p + + assert {u for _raw, _shown, u in gb_p} == {"mm"} + assert {u for _raw, _shown, u in us_p} == {"in"} + + # The conversion is real, not just a relabel. + for raw, shown, _u in gb_p: + assert abs(float(shown) - float(raw) * 25.4) < 0.05, (raw, shown) + # On an imperial page the attribute and the text agree. + for raw, shown, _u in us_p: + assert abs(float(shown) - float(raw)) < 0.005, (raw, shown) + + +def test_wind_and_humidity_render_per_unit_system(): + # The synthetic history carries only tmax/tmin/precip, so wind and humidity + # never reach a rendered page in tests — exercise the renderers directly + # rather than assert on a page that structurally cannot show them. + import content + with content._unit_scope("F"): + assert content._wind_text(10) == "10 mph" + assert '10 mph' == content._wind(10) + assert content._fmt("humid", 7.75) == "7.8 g/m³" + with content._unit_scope("C"): + # 10 mph -> 16.09 km/h, rounded half-up like JS. + assert content._wind_text(10) == "16 km/h" + # data-wind-mph stays imperial: it is what climate.js converts from. + assert 'data-wind-mph="10.0"' in content._wind(10) + # Absolute humidity has no imperial equivalent anyone would recognise, so + # it reads the same in both systems. + assert content._fmt("humid", 7.75) == "7.8 g/m³" + + +def test_precip_precision_differs_by_unit(): + # 0.04 in and 1.0 mm are the same amount; rendering "1.02 mm" would advertise + # precision the source doesn't have. + import content + with content._unit_scope("F"): + assert content._precip_text(0.04) == "0.04 in" + with content._unit_scope("C"): + assert content._precip_text(0.04) == "1.0 mm" + assert content._precip_text(1.81) == "46.0 mm" + + +def test_measure_conversion_constants_match_the_frontend(): + # Same drift risk as the country list: two hand-maintained copies. + import os + import content + units_js = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + os.pardir, "frontend", "units.js") + with open(units_js, encoding="utf-8") as f: + src = f.read() + mm = float(re.search(r"MM_PER_IN\s*=\s*([\d.]+)", src).group(1)) + kmh = float(re.search(r"KMH_PER_MPH\s*=\s*([\d.]+)", src).group(1)) + assert mm == content.MM_PER_IN + assert kmh == content.KMH_PER_MPH + + def test_unit_default_absent_without_a_city(client): # No city -> no attribute -> units.js keeps today's locale behaviour. Pinning # these to "F" would silently regress the interactive pages.