diff --git a/content.py b/content.py index 752649c..7521f21 100644 --- a/content.py +++ b/content.py @@ -38,6 +38,17 @@ MONTHS = ["january", "february", "march", "april", "may", "june", MONTHS_TITLE = [m.capitalize() for m in MONTHS] MONTH_INDEX = {m: i + 1 for i, m in enumerate(MONTHS)} +# Meteorological seasons as (northern-hemisphere label, southern-hemisphere label, +# month numbers, span text). The same three months are one season everywhere — only +# the name flips across the equator (Dec–Feb is winter in the north, summer in the +# south), so a city's latitude picks the label. +SEASONS = [ + ("Winter", "Summer", [12, 1, 2], "Dec–Feb"), + ("Spring", "Autumn", [3, 4, 5], "Mar–May"), + ("Summer", "Winter", [6, 7, 8], "Jun–Aug"), + ("Autumn", "Spring", [9, 10, 11], "Sep–Nov"), +] + # Display labels for the graded metrics (order = how they appear on the page). METRIC_LABELS = [ ("tmax", "High"), ("tmin", "Low"), ("feels", "Feels-like"), @@ -69,6 +80,47 @@ def _fmt(metric: str, v) -> str: return f"{round(v)} mph" # wind, gust +# Absolute-temperature colour tiers (°F upper bounds) mapped to the site's diverging +# cold→hot palette — the same 9 tiers the interactive grader uses. Colouring records +# and normals by these turns the tables into a heat map in the site's own visual +# language, rather than a plain grid. +_TEMP_TIERS = [ + (20, "rec-cold"), (32, "very-cold"), (45, "cold"), (58, "cool"), + (70, "normal"), (80, "warm"), (90, "hot"), (100, "very-hot"), +] +# °F axis for the monthly temperature-range strip on the city page. +_AXIS_LO, _AXIS_HI = -10.0, 115.0 + + +def temp_class(f) -> str: + """Diverging-palette tier name for an absolute Fahrenheit temperature (or 'none').""" + if f is None: + return "none" + for upper, cls in _TEMP_TIERS: + if f < upper: + return cls + return "rec-hot" + + +def _range_bar(low_f, high_f) -> dict | None: + """Geometry for one month's low→high bar on the shared _AXIS_LO.._AXIS_HI axis: + left offset + width as percentages, and the tier colour at each end (for a + gradient fill). None when a value is missing.""" + if low_f is None or high_f is None: + return None + lo = max(_AXIS_LO, min(_AXIS_HI, low_f)) + hi = max(_AXIS_LO, min(_AXIS_HI, high_f)) + span = _AXIS_HI - _AXIS_LO + return { + "left": round((lo - _AXIS_LO) / span * 100, 1), + "width": round(max(2.0, (hi - lo) / span * 100), 1), + "c1": temp_class(low_f), "c2": temp_class(high_f), + } + + +_env.globals["temp_class"] = temp_class + + def _month_doy(month_idx: int) -> int: return datetime.date(2001, month_idx, 15).timetuple().tm_yday @@ -152,19 +204,61 @@ def _monthly_normals(history) -> list[dict]: for i, name in enumerate(MONTHS_TITLE, start=1): clim = grading.climatology(history, _month_doy(i)) tmax, tmin, precip = clim.get("tmax"), clim.get("tmin"), clim.get("precip") + high_f = tmax["mean"] if tmax else None + low_f = tmin["mean"] if tmin else None rows.append({ "name": name, "slug": MONTHS[i - 1], "high": _temp(tmax["mean"]) if tmax else "—", - "high_f": tmax["mean"] if tmax else None, + "high_f": high_f, "low": _temp(tmin["mean"]) if tmin else "—", - "low_f": tmin["mean"] if tmin else None, + "low_f": low_f, "precip": f"{precip['mean']:.2f} in" if precip else "—", "precip_v": precip["mean"] if precip else None, + "bar": _range_bar(low_f, high_f), }) return rows +def _period_records(history, months: list[int]) -> dict: + """Record high (tmax) and record low (tmin), with the dates they occurred, over a + set of calendar months — the shared primitive behind the monthly and seasonal + records tables. Reuses grading.all_time_records on the month-filtered archive.""" + if len(months) == 1: + sub = history.filter(pl.col("date").dt.month() == months[0]) + else: + sub = history.filter(pl.col("date").dt.month().is_in(months)) + rec = grading.all_time_records(sub) if not sub.is_empty() else {} + tmax, tmin = rec.get("tmax"), rec.get("tmin") + return { + "high": _temp(tmax["max"]) if tmax else "—", + "high_f": tmax["max"] if tmax else None, + "high_date": tmax["max_date"] if tmax else "—", + "low": _temp(tmin["min"]) if tmin else "—", + "low_f": tmin["min"] if tmin else None, + "low_date": tmin["min_date"] if tmin else "—", + } + + +def _monthly_records(history) -> list[dict]: + """Record high and low for each of the 12 months (each row links to its month page).""" + return [ + {"name": name, "slug": MONTHS[i - 1], **_period_records(history, [i])} + for i, name in enumerate(MONTHS_TITLE, start=1) + ] + + +def _seasonal_records(history, lat: float) -> list[dict]: + """Record high and low for each meteorological season, labelled for the city's + hemisphere (Dec–Feb reads as winter north of the equator, summer south of it).""" + south = lat < 0 + return [ + {"name": (south_lbl if south else north_lbl), "span": span, + **_period_records(history, months)} + for north_lbl, south_lbl, months, span in SEASONS + ] + + def _today_vs_normal(history, cell) -> dict | None: """Grade the latest recorded day against its climatology, for the hero block.""" try: @@ -302,9 +396,11 @@ def _month_context(request, city, history, month_idx: int) -> dict: stats.append(("Average daily precipitation", f"{precip['mean']:.2f} in")) records = [] if mrec.get("tmax"): - records.append(("Warmest on record", _temp(mrec["tmax"]["max"]), mrec["tmax"]["max_date"])) + records.append({"label": "Warmest on record", "value": _temp(mrec["tmax"]["max"]), + "date": mrec["tmax"]["max_date"], "cls": temp_class(mrec["tmax"]["max"])}) if mrec.get("tmin"): - records.append(("Coldest on record", _temp(mrec["tmin"]["min"]), mrec["tmin"]["min_date"])) + records.append({"label": "Coldest on record", "value": _temp(mrec["tmin"]["min"]), + "date": mrec["tmin"]["min_date"], "cls": temp_class(mrec["tmin"]["min"])}) prev_i = 12 if month_idx == 1 else month_idx - 1 next_i = 1 if month_idx == 12 else month_idx + 1 @@ -316,6 +412,8 @@ def _month_context(request, city, history, month_idx: int) -> dict: "year_range": year_range, "n_years": year_range[1] - year_range[0], "avg_high": _temp(tmax["mean"]) if tmax else "—", "avg_low": _temp(tmin["mean"]) if tmin else "—", + "avg_high_cls": temp_class(tmax["mean"]) if tmax else "none", + "avg_low_cls": temp_class(tmin["mean"]) if tmin else "none", "stats": stats, "records": records, "tool_hash": f"{city['lat']:.5f},{city['lon']:.5f}", "compare_url": f"{BASE}/compare#loc={city['lat']:.4f},{city['lon']:.4f}", @@ -341,6 +439,7 @@ def _records_context(request, city, history) -> dict: name = city["name"] years = history["date"].dt.year() year_range = [int(years.min()), int(years.max())] + n_years = year_range[1] - year_range[0] rec = grading.all_time_records(history) rows = [] for key, label in METRIC_LABELS: @@ -355,22 +454,56 @@ def _records_context(request, city, history) -> dict: low_date = dry_start or "—" else: low, low_date = _fmt(key, r["min"]), r["min_date"] + is_temp = key in _TEMP_METRICS rows.append({ "label": label, "high": _fmt(key, r["max"]), "high_date": r["max_date"], + "high_f": r["max"] if is_temp else None, "low": low, "low_date": low_date, + "low_f": r["min"] if is_temp else None, }) + monthly = _monthly_records(history) + seasonal = _seasonal_records(history, city["lat"]) + hemisphere = "Southern" if city["lat"] < 0 else "Northern" + breadcrumb = [("Home", f"{BASE}/"), ("Climate", f"{BASE}/climate"), (name, f"{BASE}/climate/{city['slug']}"), ("Records", None)] + o = origin(request) + page_url = f"{o}{BASE}/climate/{city['slug']}/records" + jsonld = { + "@context": "https://schema.org", + "@graph": [ + {"@type": "Dataset", + "name": f"{display} monthly and seasonal weather records", + "description": f"Record high and low temperatures for {display} by month and by " + f"meteorological season, with the dates they occurred, from ~{n_years} " + f"years of daily climate history.", + "url": page_url, + "temporalCoverage": f"{year_range[0]}/{year_range[1]}", + "spatialCoverage": {"@type": "Place", "name": display, + "geo": {"@type": "GeoCoordinates", + "latitude": city["lat"], "longitude": city["lon"]}}, + "creator": {"@type": "Organization", "name": "Thermograph"}, + "isBasedOn": "https://open-meteo.com/ (ERA5 reanalysis)"}, + {"@type": "BreadcrumbList", + "itemListElement": [ + {"@type": "ListItem", "position": i + 1, "name": nm, + **({"item": f"{o}{href}"} if href else {})} + for i, (nm, href) in enumerate(breadcrumb)]}, + ], + } return { "section": "climate", "city": city, "display": display, "name": name, - "year_range": year_range, "n_years": year_range[1] - year_range[0], - "rows": rows, + "year_range": year_range, "n_years": n_years, + "rows": rows, "monthly": monthly, "seasonal": seasonal, + "hemisphere": hemisphere, "all_time": rec, "canonical_path": f"/climate/{city['slug']}/records", "breadcrumb": breadcrumb, - "page_title": f"{display} weather records: hottest and coldest days on record", - "page_description": (f"All-time record high and low temperatures (and the dates they occurred) for " - f"{display}, from ~{year_range[1] - year_range[0]} years of daily climate history."), + "page_title": f"{display} weather records: monthly & seasonal record highs and lows", + "page_description": (f"Record high and low temperatures for {display} by month and by season, with the " + f"dates they occurred, plus all-time extremes — from ~{n_years} years of daily " + f"climate history."), + "jsonld_str": json.dumps(jsonld, ensure_ascii=False, separators=(",", ":")), } diff --git a/templates/city.html.j2 b/templates/city.html.j2 index 5d88aed..048a37f 100644 --- a/templates/city.html.j2 +++ b/templates/city.html.j2 @@ -59,12 +59,30 @@ {% for m in months %} {{ m.name }} - {{ m.high }}{{ m.low }}{{ m.precip }} + {{ m.high }} + {{ m.low }} + {{ m.precip }} {% endfor %} + + +

Each bar spans the average daily low to high, coloured cold → hot + on a shared −10 °F to 115 °F scale — so the whole year's rhythm reads at a glance.

diff --git a/templates/month.html.j2 b/templates/month.html.j2 index adc2403..c4fa3c1 100644 --- a/templates/month.html.j2 +++ b/templates/month.html.j2 @@ -9,9 +9,10 @@

Weather in {{ display }} in {{ month_name }}

-

In an average {{ month_name }}, {{ name }} sees a daily high around {{ avg_high }} - and a low around {{ avg_low }}, based on {{ year_range[0] }}–{{ year_range[1] }} of local - climate records.

+

In an average {{ month_name }}, {{ name }} sees a daily high around + {{ avg_high }} and a low around + {{ avg_low }}, based on + {{ year_range[0] }}–{{ year_range[1] }} of local climate records.

@@ -24,7 +25,8 @@ {% if records %}

{{ month_name }} records

{% endif %} diff --git a/templates/records.html.j2 b/templates/records.html.j2 index 8cbb7c0..5b865da 100644 --- a/templates/records.html.j2 +++ b/templates/records.html.j2 @@ -2,6 +2,7 @@ {% block title %}{{ page_title }}{% endblock %} {% block description %}{{ page_description }}{% endblock %} {% block canonical_path %}{{ canonical_path }}{% endblock %} +{% block jsonld %}{% endblock %} {% block content %}
+ + + {% for m in monthly %} + + + + + + {% endfor %} + +
MonthRecord highOnRecord lowOn
{{ m.name }}{{ m.high }}{{ m.high_date }}{{ m.low }}{{ m.low_date }}
- {% endfor %} - -

For rainfall, the “driest” figure is the longest run of consecutive - days without measurable rain, dated to when that dry spell began.

+
+ +
+

Records by season

+

Meteorological seasons for the {{ hemisphere }} Hemisphere — each a three-month span — and the + most extreme {{ name }} has recorded within each.

+
+ + + + {% for s in seasonal %} + + + + + + {% endfor %} + +
SeasonRecord highOnRecord lowOn
{{ s.name }} ({{ s.span }}){{ s.high }}{{ s.high_date }}{{ s.low }}{{ s.low_date }}
+
+
+ +
+

All-time records

+

The most extreme value {{ name }} has recorded for each metric across its entire history.

+
+ {% for r in rows %} +
+

{{ r.label }}

+
+ {% if r.label == 'Precip' %}Wettest{% else %}Highest{% endif %} + {{ r.high }} + {{ r.high_date }} +
+
+ {% if r.label == 'Precip' %}Driest{% else %}Lowest{% endif %} + {{ r.low }} + {{ r.low_date }} +
+
+ {% endfor %} +
+

For rainfall, the “driest” figure is the longest run of consecutive + days without measurable rain, dated to when that dry spell began.

+

Grade {{ name }}'s weather now →

‹ Back to {{ name }} climate

diff --git a/tests/test_content.py b/tests/test_content.py index b4f3c5e..b8954b0 100644 --- a/tests/test_content.py +++ b/tests/test_content.py @@ -100,6 +100,45 @@ def test_month_and_records(client): assert client.get(f"{B}/climate/{SLUG}/notamonth").status_code == 404 +def test_records_page_has_monthly_and_seasonal(client): + b = client.get(f"{B}/climate/{SLUG}/records").text + # Monthly records: all 12 months, each linking to its month page. + assert "Records by month" in b + for month in ("January", "July", "December"): + assert month in b + assert f"/climate/{SLUG}/january" in b + # Seasonal records: labelled for London's Northern Hemisphere, with month spans. + assert "Records by season" in b + assert "Northern Hemisphere" in b + assert 'Winter (Dec–Feb)' in b + assert 'Summer (Jun–Aug)' in b + # All-time table still present, plus structured data and real °F values. + assert "All-time records" in b + assert '"@type":"Dataset"' in b and "°F" in b + # Title/description advertise the new coverage. + assert "monthly" in b.lower() and "seasonal" in b.lower() + + +def test_records_seasons_flip_in_southern_hemisphere(client): + # São Paulo is south of the equator, so Dec–Feb is summer, not winter. + b = client.get(f"{B}/climate/sao-paulo-br/records").text + assert "Southern Hemisphere" in b + assert 'Summer (Dec–Feb)' in b + assert 'Winter (Jun–Aug)' in b + + +def test_climate_pages_are_colour_coded(client): + # Temperatures wear the diverging cold→hot palette (a heat map, not a plain grid), + # and the city page carries the monthly temperature-range strip. + city = client.get(f"{B}/climate/{SLUG}").text + assert "t-cell t-" in city # tinted temperature cells in the normals table + assert "range-fill" in city and "--c1:var(--" in city # the monthly range strip's gradient bars + recs = client.get(f"{B}/climate/{SLUG}/records").text + assert "t-cell t-" in recs + month = client.get(f"{B}/climate/{SLUG}/july").text + assert "t-inline t-" in month # tinted hero numbers + records + + def test_hub_glossary_about(client): assert client.get(f"{B}/climate").status_code == 200 assert client.get(f"{B}/glossary").status_code == 200