Reject a not-yet-backfilled archive instead of caching it as complete (#225)

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.
This commit is contained in:
Emi Griffith 2026-07-20 07:02:36 -07:00 committed by GitHub
parent 4ff12905a1
commit 1fbf3a29b0
2 changed files with 60 additions and 2 deletions

View file

@ -23,6 +23,12 @@ CACHE_DIR = os.path.join(paths.DATA_DIR, "cache")
MAX_ATTEMPTS = 3 # per upstream call, before giving up MAX_ATTEMPTS = 3 # per upstream call, before giving up
START_DATE = "1980-01-01" # ERA5 reaches back to 1940; 1980 = 45 yrs, fast + robust START_DATE = "1980-01-01" # ERA5 reaches back to 1940; 1980 = 45 yrs, fast + robust
ARCHIVE_LATENCY_DAYS = 6 # ERA5 archive lags real time by a few days ARCHIVE_LATENCY_DAYS = 6 # ERA5 archive lags real time by a few days
# A real archive spans decades (~16k daily rows from START_DATE). A self-hosted
# Open-Meteo instance that isn't backfilled yet answers with all-null values or only
# its recent sync window, which _finalize_frame reduces to a near-empty frame. Below
# this many days we treat the archive as "not ready" — fall through to the NASA backup
# and do NOT cache the short result as a complete, indefinitely-held record.
MIN_ARCHIVE_DAYS = 3650 # ~10 yrs: far above any sync window, far below a full archive
# History rarely changes, so the full multi-decade archive is fetched once and # History rarely changes, so the full multi-decade archive is fetched once and
# cached indefinitely (refetched only to add new metric columns). Only the recent # cached indefinitely (refetched only to add new metric columns). Only the recent
# tail is refreshed — a small incremental fetch, at most hourly. # tail is refreshed — a small incremental fetch, at most hourly.
@ -585,7 +591,13 @@ def _load_history(cell: dict) -> tuple[pl.DataFrame, dict]:
source = None source = None
if time.time() >= _archive_cooldown_until: if time.time() >= _archive_cooldown_until:
try: try:
df = _fetch_history(cell) fetched = _fetch_history(cell)
# Guard a self-hosted archive that isn't backfilled yet: an all-null or
# recent-only response reduces to a near-empty frame, which would
# otherwise be cached indefinitely as a complete record and never
# fall to NASA. Require a plausibly-full span before accepting it.
if fetched.height >= MIN_ARCHIVE_DAYS:
df = fetched
source = "open-meteo" source = "open-meteo"
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
if is_rate_limit(e): if is_rate_limit(e):

View file

@ -249,3 +249,49 @@ def test_daily_params_carry_no_model_by_default():
p = climate._om_daily_params({"center_lat": 1.0, "center_lon": 2.0}, past_days=7) p = climate._om_daily_params({"center_lat": 1.0, "center_lon": 2.0}, past_days=7)
assert "models" not in p assert "models" not in p
assert climate.ARCHIVE_URL.endswith("/v1/archive") # default (no env override in tests) 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