Drop the in-progress local day from the Open-Meteo recent/forecast fallback
Open-Meteo's daily high/low for the current day aggregates only the hours elapsed so far, so grading it reads a still-unfolding day as complete and produces spurious extremes — a cool morning served as a 1st-percentile record-low high. The MET Norway primary already guards this with its diurnal-coverage gate; the Open-Meteo fallback had no equivalent, so a fleet-wide failover onto it exposed the bug. Use the utc_offset_seconds Open-Meteo reports for a timezone=auto request to identify the cell's local today and exclude it from the bundle. Past days are complete and future days are whole-day forecasts, so only today is dropped; the day lands in the record once it is over. When no offset is reported the guard is skipped rather than guessing a date.
This commit is contained in:
parent
248eeee110
commit
078aaab759
2 changed files with 70 additions and 2 deletions
|
|
@ -937,9 +937,29 @@ def _fetch_recent_forecast(cell: dict) -> pl.DataFrame:
|
||||||
.sort("date"))
|
.sort("date"))
|
||||||
|
|
||||||
|
|
||||||
|
def _om_local_today(payload: dict) -> "datetime.date | None":
|
||||||
|
"""The calendar date it is *now* at the cell, from the UTC offset Open-Meteo
|
||||||
|
reports for a ``timezone=auto`` request. None when the offset is absent, which
|
||||||
|
tells the caller to skip the in-progress-day guard rather than guess a date."""
|
||||||
|
offset = payload.get("utc_offset_seconds")
|
||||||
|
if offset is None:
|
||||||
|
return None
|
||||||
|
return (datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
+ datetime.timedelta(seconds=int(offset))).date()
|
||||||
|
|
||||||
|
|
||||||
def _fetch_recent_forecast_om(cell: dict) -> pl.DataFrame:
|
def _fetch_recent_forecast_om(cell: dict) -> pl.DataFrame:
|
||||||
"""Fallback recent+forecast bundle from the Open-Meteo forecast API (the former
|
"""Fallback recent+forecast bundle from the Open-Meteo forecast API (the former
|
||||||
primary): recent past + forward days in one call."""
|
primary): recent past + forward days in one call.
|
||||||
|
|
||||||
|
The cell's in-progress *local* day is dropped from the bundle. Open-Meteo's
|
||||||
|
daily high/low for today aggregates only the hours elapsed so far, so grading it
|
||||||
|
reads a still-unfolding day as complete and produces spurious extremes (a cool
|
||||||
|
morning served as a record-low high, seen live at the 1st percentile). This is
|
||||||
|
the same failure the MET Norway path guards against with its diurnal-coverage
|
||||||
|
gate (see _metno_to_frame); the fallback needs the equivalent. Past days are
|
||||||
|
complete and future days are whole-day forecasts, so only today is excluded —
|
||||||
|
the day lands in the record once it is over."""
|
||||||
params = {
|
params = {
|
||||||
"latitude": cell["center_lat"],
|
"latitude": cell["center_lat"],
|
||||||
"longitude": cell["center_lon"],
|
"longitude": cell["center_lon"],
|
||||||
|
|
@ -952,7 +972,12 @@ def _fetch_recent_forecast_om(cell: dict) -> pl.DataFrame:
|
||||||
"forecast_days": FORECAST_DAYS,
|
"forecast_days": FORECAST_DAYS,
|
||||||
}
|
}
|
||||||
r = _request(FORECAST_URL, params, 60, phase="recent_forecast_fetch")
|
r = _request(FORECAST_URL, params, 60, phase="recent_forecast_fetch")
|
||||||
return _to_frame(r.json()["daily"])
|
payload = r.json()
|
||||||
|
df = _to_frame(payload["daily"])
|
||||||
|
local_today = _om_local_today(payload)
|
||||||
|
if local_today is not None:
|
||||||
|
df = df.filter(pl.col("date") != local_today)
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
def _load_recent_forecast(cell: dict) -> pl.DataFrame:
|
def _load_recent_forecast(cell: dict) -> pl.DataFrame:
|
||||||
|
|
|
||||||
|
|
@ -208,6 +208,49 @@ def test_recent_forecast_falls_back_to_open_meteo(monkeypatch, tmp_path):
|
||||||
assert df.height == om.height
|
assert df.height == om.height
|
||||||
|
|
||||||
|
|
||||||
|
def test_recent_forecast_om_drops_the_in_progress_local_day(monkeypatch):
|
||||||
|
"""The Open-Meteo fallback must not grade the cell's in-progress local day: its
|
||||||
|
daily high/low is only a partial aggregate of the hours elapsed so far (a cool
|
||||||
|
morning would read as a record-low high). Past days and future forecast days
|
||||||
|
survive; today — per the UTC offset Open-Meteo reports for a timezone=auto
|
||||||
|
request — is dropped, matching the MET path's diurnal-coverage gate."""
|
||||||
|
offset = 3 * 3600 # UTC+3, e.g. Europe/Vilnius (the reported Ringaudai incident)
|
||||||
|
local_today = (datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
+ datetime.timedelta(seconds=offset)).date()
|
||||||
|
days = [local_today + datetime.timedelta(days=n) for n in (-2, -1, 0, 1)]
|
||||||
|
daily = {
|
||||||
|
"time": [d.isoformat() for d in days],
|
||||||
|
"temperature_2m_max": [80.0, 82.0, 61.0, 84.0], # today's 61 is the partial value
|
||||||
|
"temperature_2m_min": [60.0, 61.0, 57.0, 62.0],
|
||||||
|
"precipitation_sum": [0.0, 0.0, 0.0, 0.0],
|
||||||
|
}
|
||||||
|
payload = {"utc_offset_seconds": offset, "daily": daily}
|
||||||
|
|
||||||
|
class Resp:
|
||||||
|
def json(self): return payload
|
||||||
|
monkeypatch.setattr(climate, "_request", lambda *a, **k: Resp())
|
||||||
|
|
||||||
|
got = climate._fetch_recent_forecast_om(
|
||||||
|
{"center_lat": 54.9, "center_lon": 23.8})["date"].to_list()
|
||||||
|
assert local_today not in got # partial today dropped
|
||||||
|
assert local_today - datetime.timedelta(days=1) in got # yesterday kept
|
||||||
|
assert local_today + datetime.timedelta(days=1) in got # tomorrow's forecast kept
|
||||||
|
assert len(got) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_recent_forecast_om_keeps_all_days_without_a_utc_offset(monkeypatch):
|
||||||
|
"""No UTC offset reported -> skip the in-progress-day guard rather than guess a
|
||||||
|
date, so the bundle passes through as before (only the usual null-day filter)."""
|
||||||
|
payload = {"daily": _om_daily(3)} # no utc_offset_seconds
|
||||||
|
|
||||||
|
class Resp:
|
||||||
|
def json(self): return payload
|
||||||
|
monkeypatch.setattr(climate, "_request", lambda *a, **k: Resp())
|
||||||
|
|
||||||
|
df = climate._fetch_recent_forecast_om({"center_lat": 1.0, "center_lon": 2.0})
|
||||||
|
assert df.height == climate._to_frame(_om_daily(3)).height
|
||||||
|
|
||||||
|
|
||||||
def test_recent_forecast_serves_stale_cache_when_all_sources_fail(monkeypatch, tmp_path):
|
def test_recent_forecast_serves_stale_cache_when_all_sources_fail(monkeypatch, tmp_path):
|
||||||
"""With every source down (the NASA + MET Norway primary and the Open-Meteo
|
"""With every source down (the NASA + MET Norway primary and the Open-Meteo
|
||||||
fallback), an existing (stale) cache is served rather than failing — and it is NOT
|
fallback), an existing (stale) cache is served rather than failing — and it is NOT
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue