The live history path now tries NASA POWER first (keyless, independent of Open-Meteo) and falls back to the Open-Meteo archive only when NASA is unavailable — the reverse of before. Gusts NASA lacks are filled from Meteostat inside _fetch_history_nasa (added in the prior change). Both sources must still return a plausibly-full span (MIN_ARCHIVE_DAYS) before being cached as complete; a short/partial response is rejected and the other source is tried. _fetch_history_nasa now accepts a start/end range so it also serves the recent tail top-up: _topup_tail fetches NASA-first via the new _fetch_history_tail helper (Open-Meteo range as fallback, skipped during its rate-limit cooldown), removing the last per-active-cell Open-Meteo call from the history path. The Open-Meteo cooldown no longer gates the top-up, since NASA is not subject to it. Open-Meteo remains wired as the fallback (deletion is a later step). Tests updated to the new source order plus tail-fetch coverage.
369 lines
16 KiB
Python
369 lines
16 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],
|
|
# Sunshine vs daylight seconds -> `sun` fraction (full sun, half, quarter).
|
|
"sunshine_duration": [43200.0, 21600.0, 10800.0],
|
|
"daylight_duration": [43200.0, 43200.0, 43200.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", "sun", "doy"}
|
|
|
|
|
|
def test_to_frame_derives_sun_fraction():
|
|
df = climate._to_frame(_om_daily())
|
|
# sun = sunshine_duration / daylight_duration, clamped to [0, 1].
|
|
assert df["sun"].to_list() == [1.0, 0.5]
|
|
|
|
|
|
def test_to_frame_sun_null_without_sunshine():
|
|
daily = _om_daily()
|
|
del daily["sunshine_duration"], daily["daylight_duration"]
|
|
df = climate._to_frame(daily)
|
|
assert "sun" in df.columns and df["sun"].is_null().all()
|
|
|
|
|
|
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_finalize_frame_backfills_absent_optional_columns():
|
|
# A source frame carrying only the required base columns is finalized into the
|
|
# full schema, every absent optional metric added as an all-null column.
|
|
bare = pl.DataFrame({
|
|
"date": [datetime.date(2026, 6, 1), datetime.date(2026, 6, 2)],
|
|
"tmax": [80.0, 82.0], "tmin": [60.0, 61.0], "precip": [0.0, 0.1],
|
|
})
|
|
df = climate._finalize_frame(bare)
|
|
for c in climate.OPTIONAL_COLS:
|
|
assert c in df.columns and df[c].is_null().all()
|
|
assert all(c in df.columns for c in climate.NEW_COLS) # incl. derived `feels`
|
|
|
|
|
|
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_nasa_is_primary_history_source(monkeypatch, tmp_path):
|
|
"""NASA POWER is the primary history source: a full NASA frame is accepted and
|
|
Open-Meteo is never consulted."""
|
|
monkeypatch.setattr(climate, "CACHE_DIR", str(tmp_path))
|
|
monkeypatch.setattr(climate, "_archive_cooldown_until", 0.0)
|
|
cell = {"id": "nasa_primary", "center_lat": 47.6, "center_lon": -122.3}
|
|
|
|
full = _full_history_frame()
|
|
monkeypatch.setattr(climate, "_fetch_history_nasa", lambda c: full)
|
|
def _om_should_not_run(c): raise AssertionError("Open-Meteo should not be called")
|
|
monkeypatch.setattr(climate, "_fetch_history", _om_should_not_run)
|
|
|
|
df, meta = climate._load_history(cell)
|
|
assert meta["source"] == "nasa-power"
|
|
assert df.height == full.height
|
|
|
|
|
|
def test_falls_back_to_open_meteo_when_nasa_unavailable(monkeypatch, tmp_path):
|
|
"""When NASA POWER fails, the load falls back to the Open-Meteo archive."""
|
|
monkeypatch.setattr(climate, "CACHE_DIR", str(tmp_path))
|
|
monkeypatch.setattr(climate, "_archive_cooldown_until", 0.0)
|
|
cell = {"id": "om_fallback", "center_lat": 47.6, "center_lon": -122.3}
|
|
|
|
full = _full_history_frame()
|
|
def _nasa_down(c): raise RuntimeError("NASA POWER outage")
|
|
monkeypatch.setattr(climate, "_fetch_history_nasa", _nasa_down)
|
|
monkeypatch.setattr(climate, "_fetch_history", lambda c: full)
|
|
|
|
df, meta = climate._load_history(cell)
|
|
assert meta["source"] == "open-meteo"
|
|
assert df.height == full.height
|
|
|
|
|
|
def test_short_nasa_is_rejected_and_falls_back_to_open_meteo(monkeypatch, tmp_path):
|
|
"""A too-short NASA response (a partial/unavailable point) is rejected rather than
|
|
cached as a complete record, and Open-Meteo serves instead."""
|
|
monkeypatch.setattr(climate, "CACHE_DIR", str(tmp_path))
|
|
monkeypatch.setattr(climate, "_archive_cooldown_until", 0.0)
|
|
cell = {"id": "short_nasa", "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_nasa", lambda c: short)
|
|
monkeypatch.setattr(climate, "_fetch_history", lambda c: full)
|
|
|
|
df, meta = climate._load_history(cell)
|
|
assert meta["source"] == "open-meteo" # rejected the short NASA response
|
|
assert df.height == full.height
|
|
|
|
|
|
def test_history_tail_prefers_nasa(monkeypatch):
|
|
"""The tail top-up fetches NASA POWER first; Open-Meteo's range fetch is not
|
|
touched when NASA succeeds."""
|
|
cell = {"center_lat": 1.0, "center_lon": 2.0}
|
|
sentinel = object()
|
|
monkeypatch.setattr(climate, "_fetch_history_nasa",
|
|
lambda c, start=None, end=None: sentinel)
|
|
def _om_should_not_run(c, s, e): raise AssertionError("Open-Meteo tail should not run")
|
|
monkeypatch.setattr(climate, "_fetch_history_range", _om_should_not_run)
|
|
|
|
assert climate._fetch_history_tail(cell, "2026-06-01", "2026-06-10") is sentinel
|
|
|
|
|
|
def test_history_tail_falls_back_to_open_meteo(monkeypatch):
|
|
"""When NASA fails and Open-Meteo isn't cooling down, the tail falls back to the
|
|
Open-Meteo archive range."""
|
|
monkeypatch.setattr(climate, "_archive_cooldown_until", 0.0)
|
|
cell = {"center_lat": 1.0, "center_lon": 2.0}
|
|
def _nasa_down(c, start=None, end=None): raise RuntimeError("NASA down")
|
|
monkeypatch.setattr(climate, "_fetch_history_nasa", _nasa_down)
|
|
sentinel = object()
|
|
monkeypatch.setattr(climate, "_fetch_history_range", lambda c, s, e: sentinel)
|
|
|
|
assert climate._fetch_history_tail(cell, "2026-06-01", "2026-06-10") is sentinel
|