A self-hosted Open-Meteo instance that isn't backfilled yet answers historical requests with all-null values (or only its recent sync window), which _finalize_frame reduces to a near-empty frame. That frame is not None, so the loader would accept it as source=open-meteo and cache it indefinitely as a complete record — never falling back to NASA and never self-healing after the backfill lands. Require at least MIN_ARCHIVE_DAYS before accepting an archive result; below that, fall through to the NASA backup and don't cache the short frame. Guards the prod cutover window.
297 lines
13 KiB
Python
297 lines
13 KiB
Python
"""The source→frame mappings and cache-write path (pure parts of climate.py —
|
|
no network)."""
|
|
import datetime
|
|
|
|
import polars as pl
|
|
import pytest
|
|
|
|
from data 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)
|
|
|
|
|
|
def _wb_frame(tmax, humid, tmin=None):
|
|
"""Frame with raw RH (`humid`) for the wet-bulb / humidity derivations."""
|
|
n = len(tmax)
|
|
return pl.DataFrame({
|
|
"date": [datetime.date(2026, 7, 1) + datetime.timedelta(days=i) for i in range(n)],
|
|
"tmax": [float(x) for x in tmax],
|
|
"tmin": [float(t) for t in (tmin or [x - 15 for x in tmax])],
|
|
"humid": [float(h) for h in humid],
|
|
})
|
|
|
|
|
|
def test_wetbulb_stull_spot_values():
|
|
# Stull (2011): 20 °C / 50% -> ~13.7 °C; 30 °C / 80% -> ~27.2 °C. In °F here.
|
|
df = climate._derive_wetbulb(_wb_frame([68.0, 86.0], [50.0, 80.0]))
|
|
wb = df["wetbulb"].to_list()
|
|
assert wb[0] == pytest.approx((13.7 * 9 / 5) + 32, abs=0.6)
|
|
assert wb[1] == pytest.approx((27.2 * 9 / 5) + 32, abs=0.6)
|
|
|
|
|
|
def test_wetbulb_never_exceeds_dry_bulb():
|
|
df = climate._derive_wetbulb(_wb_frame([40.0, 60.0, 85.0, 100.0], [20.0, 55.0, 70.0, 95.0]))
|
|
for tmax, wb in zip(df["tmax"], df["wetbulb"]):
|
|
assert wb is not None and wb <= tmax + 0.05
|
|
|
|
|
|
def test_wetbulb_null_outside_validity_range():
|
|
# RH 3% (< 5) and a scorching 130 °F (> 50 °C) both fall outside Stull's fit.
|
|
df = climate._derive_wetbulb(_wb_frame([70.0, 130.0], [3.0, 40.0]))
|
|
assert df["wetbulb"].to_list() == [None, None]
|
|
|
|
|
|
def test_wetbulb_noop_without_humidity():
|
|
df = pl.DataFrame({"tmax": [70.0], "tmin": [55.0]})
|
|
assert "wetbulb" not in climate._derive_wetbulb(df).columns
|
|
|
|
|
|
def test_derive_metrics_adds_wetbulb_and_absolute_humidity():
|
|
out = climate._derive_metrics(_wb_frame([68.0], [50.0]))
|
|
assert "wetbulb" in out.columns
|
|
# _derive_humidity replaces raw RH (50) with absolute humidity (g/m³, ~8-9).
|
|
assert out["humid"][0] < 30 and out["humid"][0] != 50.0
|
|
|
|
|
|
def test_history_fetch_targets_archive_url_with_seamless_model(monkeypatch):
|
|
"""Historical fetches hit ARCHIVE_URL (env-overridable for self-hosting) and pin
|
|
the ERA5 seamless model, so a self-hosted Open-Meteo serves the same 0.1° blend."""
|
|
seen = {}
|
|
|
|
class Resp:
|
|
def json(self): return {"daily": _om_daily(3)}
|
|
|
|
def fake_request(url, params, timeout, *, phase, headers=None, attempts=climate.MAX_ATTEMPTS):
|
|
seen["url"], seen["params"], seen["phase"] = url, dict(params), phase
|
|
return Resp()
|
|
|
|
monkeypatch.setattr(climate, "_request", fake_request)
|
|
cell = {"center_lat": 47.6, "center_lon": -122.3}
|
|
|
|
climate._fetch_history(cell)
|
|
assert seen["url"] == climate.ARCHIVE_URL
|
|
assert seen["params"]["models"] == "era5_seamless"
|
|
assert seen["phase"] == "history_fetch"
|
|
|
|
climate._fetch_history_range(cell, "2026-06-01", "2026-06-10") # tail top-up
|
|
assert seen["url"] == climate.ARCHIVE_URL
|
|
assert seen["params"]["models"] == "era5_seamless"
|
|
|
|
|
|
def test_daily_params_carry_no_model_by_default():
|
|
# The shared param builder (also used by the forecast path) stays model-free;
|
|
# only the archive fetches add models=era5_seamless.
|
|
p = climate._om_daily_params({"center_lat": 1.0, "center_lon": 2.0}, past_days=7)
|
|
assert "models" not in p
|
|
assert climate.ARCHIVE_URL.endswith("/v1/archive") # default (no env override in tests)
|
|
|
|
|
|
def _full_history_frame(n=4000):
|
|
"""A plausibly-full archive frame (>= MIN_ARCHIVE_DAYS rows), standard columns."""
|
|
start = datetime.date(1990, 1, 1)
|
|
dates = [start + datetime.timedelta(days=i) for i in range(n)]
|
|
return climate._finalize_frame(pl.DataFrame({
|
|
"date": dates,
|
|
"tmax": [70.0] * n, "tmin": [50.0] * n, "precip": [0.0] * n,
|
|
"wind": [5.0] * n, "gust": [8.0] * n, "humid": [60.0] * n,
|
|
"fmax": [70.0] * n, "fmin": [50.0] * n,
|
|
}))
|
|
|
|
|
|
def test_short_archive_is_rejected_and_falls_back_to_nasa(monkeypatch, tmp_path):
|
|
"""A self-hosted archive that isn't backfilled yet returns too few days; the load
|
|
must reject it (not cache a near-empty history as complete) and use NASA instead."""
|
|
monkeypatch.setattr(climate, "CACHE_DIR", str(tmp_path))
|
|
monkeypatch.setattr(climate, "_archive_cooldown_until", 0.0)
|
|
cell = {"id": "short_cell", "center_lat": 47.6, "center_lon": -122.3}
|
|
|
|
short = climate._to_frame(_om_daily(3)) # 2 usable days « threshold
|
|
full = _full_history_frame() # >= MIN_ARCHIVE_DAYS
|
|
assert short.height < climate.MIN_ARCHIVE_DAYS <= full.height
|
|
monkeypatch.setattr(climate, "_fetch_history", lambda c: short)
|
|
monkeypatch.setattr(climate, "_fetch_history_nasa", lambda c: full)
|
|
|
|
df, meta = climate._load_history(cell)
|
|
assert meta["source"] == "nasa-power" # rejected the short archive
|
|
assert df.height == full.height
|
|
|
|
|
|
def test_full_archive_is_accepted(monkeypatch, tmp_path):
|
|
"""A backfilled archive (enough days) is accepted and served as open-meteo."""
|
|
monkeypatch.setattr(climate, "CACHE_DIR", str(tmp_path))
|
|
monkeypatch.setattr(climate, "_archive_cooldown_until", 0.0)
|
|
cell = {"id": "full_cell", "center_lat": 47.6, "center_lon": -122.3}
|
|
|
|
full = _full_history_frame()
|
|
monkeypatch.setattr(climate, "_fetch_history", lambda c: full)
|
|
def _nasa_should_not_run(c): raise AssertionError("NASA should not be called")
|
|
monkeypatch.setattr(climate, "_fetch_history_nasa", _nasa_should_not_run)
|
|
|
|
df, meta = climate._load_history(cell)
|
|
assert meta["source"] == "open-meteo"
|
|
assert df.height == full.height
|