Subtree-merge thermograph-backend nasa-primary-flip into backend/
Conflict resolved (UNVERIFIED -- tests not runnable locally, docker blocked): tests/data/test_climate.py keeps BOTH appended test blocks (dev's forecast-cooldown tests + this branch's NASA-primary tests). Same conflict exists merging this branch into the split repo's dev. # Conflicts: # backend/tests/data/test_climate.py
This commit is contained in:
commit
ffcb25885f
2 changed files with 94 additions and 37 deletions
|
|
@ -585,16 +585,22 @@ def _nasa_to_frame(param: dict) -> pl.DataFrame:
|
||||||
return _finalize_approximated(df)
|
return _finalize_approximated(df)
|
||||||
|
|
||||||
|
|
||||||
def _fetch_history_nasa(cell: dict) -> pl.DataFrame:
|
def _fetch_history_nasa(cell: dict, start: str = NASA_START,
|
||||||
"""Backup history fetch from NASA POWER (used when Open-Meteo is unavailable)."""
|
end: "str | None" = None) -> pl.DataFrame:
|
||||||
|
"""Primary history fetch from NASA POWER (keyless, independent of Open-Meteo).
|
||||||
|
|
||||||
|
`start`/`end` accept NASA's YYYYMMDD or ISO YYYY-MM-DD; `end` defaults to the
|
||||||
|
archive-latency cutoff. Serves both the full multi-decade pull and the tail
|
||||||
|
top-up range. NASA carries no gusts, so they're filled from Meteostat below."""
|
||||||
|
if end is None:
|
||||||
end = (datetime.date.today() - datetime.timedelta(days=ARCHIVE_LATENCY_DAYS)).strftime("%Y%m%d")
|
end = (datetime.date.today() - datetime.timedelta(days=ARCHIVE_LATENCY_DAYS)).strftime("%Y%m%d")
|
||||||
params = {
|
params = {
|
||||||
"parameters": "T2M_MAX,T2M_MIN,PRECTOTCORR,WS10M_MAX,RH2M",
|
"parameters": "T2M_MAX,T2M_MIN,PRECTOTCORR,WS10M_MAX,RH2M",
|
||||||
"community": "RE",
|
"community": "RE",
|
||||||
"latitude": cell["center_lat"],
|
"latitude": cell["center_lat"],
|
||||||
"longitude": cell["center_lon"],
|
"longitude": cell["center_lon"],
|
||||||
"start": NASA_START,
|
"start": start.replace("-", ""),
|
||||||
"end": end,
|
"end": end.replace("-", ""),
|
||||||
"format": "JSON",
|
"format": "JSON",
|
||||||
}
|
}
|
||||||
r = _request(NASA_POWER_URL, params, ARCHIVE_FETCH_TIMEOUTS, phase="history_nasa")
|
r = _request(NASA_POWER_URL, params, ARCHIVE_FETCH_TIMEOUTS, phase="history_nasa")
|
||||||
|
|
@ -688,10 +694,21 @@ def _read_history_cache(path):
|
||||||
return df, time.time() - os.path.getmtime(path)
|
return df, time.time() - os.path.getmtime(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_history_tail(cell: dict, start_date: str, end_date: str) -> pl.DataFrame:
|
||||||
|
"""Fetch a recent history range to top up the cached tail — NASA POWER primary,
|
||||||
|
Open-Meteo archive fallback (mirroring the full-history source order). The
|
||||||
|
Open-Meteo fallback is skipped while it's in a rate-limit cooldown."""
|
||||||
|
try:
|
||||||
|
return _fetch_history_nasa(cell, start=start_date, end=end_date)
|
||||||
|
except Exception: # noqa: BLE001 - NASA unavailable; try Open-Meteo unless it's cooling down
|
||||||
|
if time.time() < _archive_cooldown_until:
|
||||||
|
raise
|
||||||
|
return _fetch_history_range(cell, start_date, end_date)
|
||||||
|
|
||||||
|
|
||||||
def _topup_tail(cell: dict, df: pl.DataFrame) -> pl.DataFrame:
|
def _topup_tail(cell: dict, df: pl.DataFrame) -> pl.DataFrame:
|
||||||
"""Append newly-available archive days to a cached record. Best-effort and
|
"""Append newly-available archive days to a cached record. Best-effort and
|
||||||
serialized per cell; a small incremental fetch, not the full multi-decade pull."""
|
serialized per cell; a small incremental fetch, not the full multi-decade pull."""
|
||||||
global _archive_cooldown_until
|
|
||||||
cell_id = cell["id"]
|
cell_id = cell["id"]
|
||||||
with _cell_lock(cell_id):
|
with _cell_lock(cell_id):
|
||||||
fresh = _read_history_backed(cell_id) # another thread may have just refreshed it
|
fresh = _read_history_backed(cell_id) # another thread may have just refreshed it
|
||||||
|
|
@ -699,15 +716,13 @@ def _topup_tail(cell: dict, df: pl.DataFrame) -> pl.DataFrame:
|
||||||
df, age_s = fresh
|
df, age_s = fresh
|
||||||
if age_s < HISTORY_TOPUP_INTERVAL:
|
if age_s < HISTORY_TOPUP_INTERVAL:
|
||||||
return df
|
return df
|
||||||
if time.time() < _archive_cooldown_until:
|
|
||||||
return df
|
|
||||||
expected = datetime.date.today() - datetime.timedelta(days=ARCHIVE_LATENCY_DAYS)
|
expected = datetime.date.today() - datetime.timedelta(days=ARCHIVE_LATENCY_DAYS)
|
||||||
cached_max = df["date"].max()
|
cached_max = df["date"].max()
|
||||||
if cached_max >= expected:
|
if cached_max >= expected:
|
||||||
_touch_history_backed(cell_id) # tail already current — reset the hourly timer
|
_touch_history_backed(cell_id) # tail already current — reset the hourly timer
|
||||||
return df
|
return df
|
||||||
try:
|
try:
|
||||||
recent = _fetch_history_range(
|
recent = _fetch_history_tail(
|
||||||
cell, (cached_max + datetime.timedelta(days=1)).isoformat(), expected.isoformat())
|
cell, (cached_max + datetime.timedelta(days=1)).isoformat(), expected.isoformat())
|
||||||
except Exception as e: # noqa: BLE001 - keep the cached record on failure
|
except Exception as e: # noqa: BLE001 - keep the cached record on failure
|
||||||
if is_rate_limit(e):
|
if is_rate_limit(e):
|
||||||
|
|
@ -772,30 +787,30 @@ def _load_history(cell: dict) -> tuple[pl.DataFrame, dict]:
|
||||||
def _serve_stale():
|
def _serve_stale():
|
||||||
return _with_doy(stale), {"cached": True, "stale": True, "cache_age_days": None}
|
return _with_doy(stale), {"cached": True, "stale": True, "cache_age_days": None}
|
||||||
|
|
||||||
# Fetch. Open-Meteo is primary (richer: gusts + apparent temp); NASA POWER is
|
# Fetch. NASA POWER is the primary source (keyless, independent of Open-Meteo);
|
||||||
# the backup when Open-Meteo is unavailable (network error or its daily limit).
|
# the gusts it lacks are filled from Meteostat inside _fetch_history_nasa.
|
||||||
# Skip Open-Meteo entirely while it's in a rate-limit cooldown.
|
# Open-Meteo is the fallback when NASA is unavailable — still guarded by its
|
||||||
|
# rate-limit cooldown. Both sources must return a plausibly-full span before
|
||||||
|
# being accepted and cached as a complete record (a short/partial response is
|
||||||
|
# rejected rather than held indefinitely).
|
||||||
df = None
|
df = None
|
||||||
source = None
|
source = None
|
||||||
if time.time() >= _archive_cooldown_until:
|
try:
|
||||||
|
fetched = _fetch_history_nasa(cell)
|
||||||
|
if fetched.height >= MIN_ARCHIVE_DAYS:
|
||||||
|
df = fetched
|
||||||
|
source = "nasa-power"
|
||||||
|
except Exception: # noqa: BLE001 - primary unavailable; fall through to Open-Meteo
|
||||||
|
pass
|
||||||
|
if df is None and time.time() >= _archive_cooldown_until:
|
||||||
try:
|
try:
|
||||||
fetched = _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:
|
if fetched.height >= MIN_ARCHIVE_DAYS:
|
||||||
df = fetched
|
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):
|
||||||
_note_rate_limit(e)
|
_note_rate_limit(e)
|
||||||
if df is None:
|
|
||||||
try:
|
|
||||||
df = _fetch_history_nasa(cell)
|
|
||||||
source = "nasa-power"
|
|
||||||
except Exception: # noqa: BLE001 - backup unavailable too
|
|
||||||
df = None
|
|
||||||
if df is None:
|
if df is None:
|
||||||
if stale is not None:
|
if stale is not None:
|
||||||
return _serve_stale()
|
return _serve_stale()
|
||||||
|
|
|
||||||
|
|
@ -292,40 +292,82 @@ def _full_history_frame(n=4000):
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
||||||
def test_short_archive_is_rejected_and_falls_back_to_nasa(monkeypatch, tmp_path):
|
def test_nasa_is_primary_history_source(monkeypatch, tmp_path):
|
||||||
"""A self-hosted archive that isn't backfilled yet returns too few days; the load
|
"""NASA POWER is the primary history source: a full NASA frame is accepted and
|
||||||
must reject it (not cache a near-empty history as complete) and use NASA instead."""
|
Open-Meteo is never consulted."""
|
||||||
monkeypatch.setattr(climate, "CACHE_DIR", str(tmp_path))
|
monkeypatch.setattr(climate, "CACHE_DIR", str(tmp_path))
|
||||||
monkeypatch.setattr(climate, "_archive_cooldown_until", 0.0)
|
monkeypatch.setattr(climate, "_archive_cooldown_until", 0.0)
|
||||||
cell = {"id": "short_cell", "center_lat": 47.6, "center_lon": -122.3}
|
cell = {"id": "nasa_primary", "center_lat": 47.6, "center_lon": -122.3}
|
||||||
|
|
||||||
short = climate._to_frame(_om_daily(3)) # 2 usable days « threshold
|
full = _full_history_frame()
|
||||||
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)
|
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)
|
df, meta = climate._load_history(cell)
|
||||||
assert meta["source"] == "nasa-power" # rejected the short archive
|
assert meta["source"] == "nasa-power"
|
||||||
assert df.height == full.height
|
assert df.height == full.height
|
||||||
|
|
||||||
|
|
||||||
def test_full_archive_is_accepted(monkeypatch, tmp_path):
|
def test_falls_back_to_open_meteo_when_nasa_unavailable(monkeypatch, tmp_path):
|
||||||
"""A backfilled archive (enough days) is accepted and served as open-meteo."""
|
"""When NASA POWER fails, the load falls back to the Open-Meteo archive."""
|
||||||
monkeypatch.setattr(climate, "CACHE_DIR", str(tmp_path))
|
monkeypatch.setattr(climate, "CACHE_DIR", str(tmp_path))
|
||||||
monkeypatch.setattr(climate, "_archive_cooldown_until", 0.0)
|
monkeypatch.setattr(climate, "_archive_cooldown_until", 0.0)
|
||||||
cell = {"id": "full_cell", "center_lat": 47.6, "center_lon": -122.3}
|
cell = {"id": "om_fallback", "center_lat": 47.6, "center_lon": -122.3}
|
||||||
|
|
||||||
full = _full_history_frame()
|
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)
|
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)
|
df, meta = climate._load_history(cell)
|
||||||
assert meta["source"] == "open-meteo"
|
assert meta["source"] == "open-meteo"
|
||||||
assert df.height == full.height
|
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
|
||||||
|
|
||||||
# --- forecast cooldown (mirrors the archive path's, tracked separately) -----
|
# --- forecast cooldown (mirrors the archive path's, tracked separately) -----
|
||||||
|
|
||||||
def test_forecast_cooldown_skips_open_meteo_and_falls_to_metno(monkeypatch, tmp_path):
|
def test_forecast_cooldown_skips_open_meteo_and_falls_to_metno(monkeypatch, tmp_path):
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue