Follow the unit system for precipitation and wind too (#190)
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 <span class="precip" data-precip-in> / <span class="wind" data-wind-mph>, 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
This commit is contained in:
parent
5a921cf01d
commit
5cf517e8ea
2 changed files with 113 additions and 6 deletions
51
content.py
51
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('<span class="precip" data-precip-in="{:.3f}">{}</span>').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('<span class="wind" data-wind-mph="{:.1f}">{}</span>').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({
|
||||
|
|
|
|||
|
|
@ -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'<span class="precip" data-precip-in="([\d.]+)">([\d.]+) (in|mm)</span>')
|
||||
|
||||
|
||||
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 '<span class="wind" data-wind-mph="10.0">10 mph</span>' == 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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue