thermograph/tests/test_climate.py
Emi Griffith 8260948dd8 Serve stale forecast cache when both Open-Meteo and MET Norway fail (#116)
Complete the forecast fallback chain: Open-Meteo → MET Norway → stale cache. When
both live sources are unavailable, serve the last cached recent/forecast bundle
even if it is past its 1-hour TTL — an hours-old bundle (which still carries the
recent observed days MET Norway lacks) beats a hard 503, mirroring the archive
path's stale-serve in _load_history.

The stale bundle is returned WITHOUT rewriting it, so its mtime stays old and the
next request retries the live sources first rather than treating the stale copy as
fresh. Only when there is no cache at all does the typed WeatherUnavailable surface.
2026-07-16 05:41:51 +00:00

174 lines
7.9 KiB
Python

"""The source→frame mappings and cache-write path (pure parts of climate.py —
no network)."""
import datetime
import polars as pl
import climate
def _om_daily(n=3):
"""A minimal Open-Meteo daily response."""
return {
"time": [f"2026-06-{d:02d}" for d in range(1, n + 1)],
"temperature_2m_max": [80.0, 90.0, None],
"temperature_2m_min": [60.0, 70.0, 55.0],
"precipitation_sum": [0.0, 0.25, 0.1],
"wind_speed_10m_max": [10.0, 20.0, 5.0],
"wind_gusts_10m_max": [15.0, 30.0, 8.0],
"apparent_temperature_max": [82.0, 95.0, 70.0],
"apparent_temperature_min": [58.0, 68.0, 50.0],
"relative_humidity_2m_mean": [50.0, 60.0, 70.0],
}
def test_to_frame_schema_and_day_filter():
df = climate._to_frame(_om_daily())
# The None tmax day is dropped: a usable climate day needs a real high/low.
assert len(df) == 2
assert df["doy"].dtype == pl.Int16
assert set(df.columns) == {"date", "tmax", "tmin", "precip", "wind", "gust",
"humid", "fmax", "fmin", "feels", "doy"}
def test_to_frame_tolerates_missing_series():
daily = _om_daily()
del daily["wind_gusts_10m_max"], daily["relative_humidity_2m_mean"]
df = climate._to_frame(daily)
assert df["gust"].is_null().all() and df["humid"].is_null().all()
assert df["tmax"].is_not_null().all() # required series unaffected
def test_combined_feels_picks_the_extreme_side():
df = climate._to_frame(_om_daily())
# Day 2: fmax 95 is 30 past the 65°F comfort point vs fmin 68 only 3 below —
# the hot side wins; both days here are hot-side days.
assert df.filter(pl.col("date") == datetime.date(2026, 6, 2))["feels"].item() == 95.0
def test_combined_feels_falls_back_to_the_present_side():
"""When one apparent-temperature side is missing, `feels` uses whichever side
is present (the null-vs-value coalesce must reproduce the old NaN fallback)."""
daily = _om_daily()
daily["apparent_temperature_max"] = [None, None, None] # hot side absent
df = climate._to_frame(daily)
row = df.filter(pl.col("date") == datetime.date(2026, 6, 1)).row(0, named=True)
assert row["feels"] == 58.0 # falls back to apparent low
def test_nasa_to_frame_converts_units_and_fills():
param = {
"T2M_MAX": {"20260601": 30.0, "20260602": -999.0}, # °C; -999 = missing
"T2M_MIN": {"20260601": 20.0, "20260602": 15.0},
"PRECTOTCORR": {"20260601": 25.4, "20260602": 0.0}, # mm
"WS10M_MAX": {"20260601": 10.0, "20260602": 5.0}, # m/s
"RH2M": {"20260601": 50.0, "20260602": 60.0},
}
df = climate._nasa_to_frame(param)
assert len(df) == 1 # the missing-tmax day dropped
row = df.row(0, named=True)
assert row["tmax"] == 86.0 # 30°C
assert row["precip"] == 1.0 # 25.4mm = 1in
assert round(row["wind"], 1) == 22.4 # 10 m/s in mph
assert row["gust"] is None # POWER has no gusts (missing -> null)
assert row["fmax"] > row["tmax"] # ≥80°F: the NWS heat index applies
def _metno_step(time, t, rh, wind, p1=None, p6=None):
"""One MET Norway timeseries step (instant details + optional precip blocks)."""
data = {"instant": {"details": {
"air_temperature": t, "relative_humidity": rh, "wind_speed": wind}}}
if p1 is not None:
data["next_1_hours"] = {"details": {"precipitation_amount": p1}}
if p6 is not None:
data["next_6_hours"] = {"details": {"precipitation_amount": p6}}
return {"time": time, "data": data}
def test_metno_to_frame_aggregates_daily_and_converts_units():
props = {"timeseries": [
# Day 1: two hourly steps. The first also carries a next_6_hours block, which
# must be IGNORED (next_1_hours wins) so precip isn't double-counted.
_metno_step("2026-07-16T00:00:00Z", 20.0, 50.0, 2.0, p1=0.5, p6=3.0),
_metno_step("2026-07-16T01:00:00Z", 25.0, 60.0, 4.0, p1=0.5),
# Day 2: a 6-hourly step (only next_6_hours) + an instant-only tail step.
_metno_step("2026-07-17T00:00:00Z", 10.0, 80.0, 10.0, p6=6.0),
_metno_step("2026-07-17T06:00:00Z", 12.0, 70.0, 8.0),
]}
df = climate._metno_to_frame(props)
assert len(df) == 2
d1 = df.filter(pl.col("date") == datetime.date(2026, 7, 16)).row(0, named=True)
assert d1["tmax"] == 77.0 and d1["tmin"] == 68.0 # 25°C / 20°C -> °F
assert round(d1["precip"], 4) == round(1.0 / 25.4, 4) # 0.5+0.5 mm (not +3.0) -> in
assert round(d1["wind"], 1) == 8.9 # max 4 m/s -> mph
assert d1["gust"] is None # MET Norway has no gusts
assert d1["feels"] is not None
d2 = df.filter(pl.col("date") == datetime.date(2026, 7, 17)).row(0, named=True)
assert round(d2["precip"], 4) == round(6.0 / 25.4, 4) # only the 6-hour block
def test_recent_forecast_falls_back_to_metno(monkeypatch, tmp_path):
"""When Open-Meteo's forecast API fails, the MET Norway backup serves the frame."""
monkeypatch.setattr(climate, "CACHE_DIR", str(tmp_path))
cell = {"id": "fallback_cell", "center_lat": 47.6062, "center_lon": -122.3321}
met = {"timeseries": [
_metno_step("2026-07-16T00:00:00Z", 18.0, 55.0, 3.0, p1=0.0),
_metno_step("2026-07-17T00:00:00Z", 22.0, 45.0, 5.0, p6=1.0),
]}
class Resp:
def json(self): return {"properties": met}
def fake_request(url, params, timeout, *, phase, headers=None, attempts=climate.MAX_ATTEMPTS):
if url == climate.FORECAST_URL:
raise RuntimeError("open-meteo forecast outage")
assert url == climate.METNO_URL
assert headers and headers.get("User-Agent"), "MET Norway needs a User-Agent"
return Resp()
monkeypatch.setattr(climate, "_request", fake_request)
df = climate._load_recent_forecast(cell)
assert len(df) == 2 and df["date"].max() == datetime.date(2026, 7, 17)
def test_recent_forecast_serves_stale_cache_when_all_sources_fail(monkeypatch, tmp_path):
"""With Open-Meteo AND MET Norway both down, an existing (stale) cache is served
rather than failing — and it is NOT rewritten, so its mtime stays old and the
next request still retries upstream first."""
import os
import time
monkeypatch.setattr(climate, "CACHE_DIR", str(tmp_path))
cell = {"id": "stale_cell", "center_lat": 47.6, "center_lon": -122.3}
# Seed an rf cache, then age it well past the TTL so it counts as stale.
stale = climate._to_frame(_om_daily())
path = climate._rf_cache_path(cell["id"])
climate._write_cache(stale, path)
old = time.time() - (climate.FORECAST_TTL_HOURS + 5) * 3600
os.utime(path, (old, old))
def all_down(url, params, timeout, *, phase, headers=None, attempts=climate.MAX_ATTEMPTS):
raise RuntimeError(f"{phase} down")
monkeypatch.setattr(climate, "_request", all_down)
df = climate._load_recent_forecast(cell)
assert df.height == stale.height # served the stale cache
assert abs(os.path.getmtime(path) - old) < 2 # not rewritten (mtime unchanged)
def test_om_daily_params_carries_the_window():
cell = {"center_lat": 47.6, "center_lon": -122.3}
p = climate._om_daily_params(cell, start_date="2026-01-01", end_date="2026-02-01")
assert p["latitude"] == 47.6 and p["daily"] == climate.DAILY_VARS
assert p["temperature_unit"] == "fahrenheit"
assert p["start_date"] == "2026-01-01" and p["end_date"] == "2026-02-01"
p2 = climate._om_daily_params(cell, past_days=25, forecast_days=8)
assert p2["past_days"] == 25 and "start_date" not in p2
def test_write_cache_strips_the_derived_doy(tmp_path):
df = climate._to_frame(_om_daily())
path = str(tmp_path / "cell.parquet")
climate._write_cache(df, path)
stored = pl.read_parquet(path)
assert "doy" not in stored.columns
assert len(stored) == len(df)