climate: stop dropping the current day from the Open-Meteo bundle (#92)
All checks were successful
secrets-guard / encrypted (push) Successful in 10s
Build + push images (Forgejo registry) / build-push (frontend) (push) Successful in 12s
shell-lint / shellcheck (push) Successful in 9s
secrets-guard / encrypted (pull_request) Successful in 13s
shell-lint / shellcheck (pull_request) Successful in 15s
PR build (required check) / changes (pull_request) Successful in 19s
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / validate-observability (pull_request) Has been skipped
Build + push images (Forgejo registry) / build-push (backend) (push) Successful in 1m13s
PR build (required check) / build-backend (pull_request) Successful in 1m6s
PR build (required check) / gate (pull_request) Successful in 1s

This commit is contained in:
emi 2026-07-25 16:56:21 +00:00
parent 52cfe5b77a
commit d4a00099f2
2 changed files with 60 additions and 85 deletions

View file

@ -936,29 +936,27 @@ 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 """Primary recent+forecast bundle from the Open-Meteo forecast API: recent past
primary): recent past + forward days in one call. and forward days in one call, covering today + 7 (see FORECAST_DAYS).
The cell's in-progress *local* day is dropped from the bundle. Open-Meteo's The cell's in-progress *local* day is KEPT. An earlier revision dropped it on
daily high/low for today aggregates only the hours elapsed so far, so grading it the assumption that Open-Meteo's daily high/low for today aggregates only the
reads a still-unfolding day as complete and produces spurious extremes (a cool hours elapsed so far true of the MET Norway series, which genuinely starts
morning served as a record-low high, seen live at the 1st percentile). This is mid-day and is gated on diurnal coverage (see _metno_to_frame), but NOT of this
the same failure the MET Norway path guards against with its diurnal-coverage endpoint: Open-Meteo backfills the rest of today from the model run, so today's
gate (see _metno_to_frame); the fallback needs the equivalent. Past days are daily value spans the whole local day exactly as tomorrow's does.
complete and future days are whole-day forecasts, so only today is excluded
the day lands in the record once it is over.""" Verified against the live API at four cells spanning 01:00-19:00 local: the
reported daily max equals the max over all 24 hourly values, and diverges from
the elapsed-hours-only max wherever the day still has hours left (Los Angeles
at 09:16 local reported 93.9F, the whole-day figure, where the hours elapsed
topped out at 76.1F). Re-check it that way before reintroducing any exclusion.
Dropping today cost every freshly-synced cell its current day a hole between
a complete yesterday and a forecast tomorrow, which the UI renders as a missing
column. A day that genuinely cannot be graded, because it came back with no
high/low, is already dropped upstream by _to_frame."""
params = { params = {
"latitude": cell["center_lat"], "latitude": cell["center_lat"],
"longitude": cell["center_lon"], "longitude": cell["center_lon"],
@ -971,12 +969,7 @@ 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")
payload = r.json() return _to_frame(r.json()["daily"])
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:

View file

@ -215,20 +215,20 @@ def test_recent_forecast_fallback_merges_archive_past_and_metno_forward(monkeypa
datetime.date(2026, 7, 16), datetime.date(2026, 7, 17)] datetime.date(2026, 7, 16), datetime.date(2026, 7, 17)]
def test_recent_forecast_om_drops_the_in_progress_local_day(monkeypatch): def test_recent_forecast_om_keeps_the_in_progress_local_day(monkeypatch):
"""The Open-Meteo fallback must not grade the cell's in-progress local day: its """Today must survive the Open-Meteo bundle. Unlike the MET Norway series (which
daily high/low is only a partial aggregate of the hours elapsed so far (a cool starts mid-day and is gated on diurnal coverage), this endpoint backfills the
morning would read as a record-low high). Past days and future forecast days rest of today from the model run, so today's high/low is a whole-day value of
survive; today per the UTC offset Open-Meteo reports for a timezone=auto the same kind as tomorrow's. Dropping it left a hole between a complete
request is dropped, matching the MET path's diurnal-coverage gate.""" yesterday and a forecast tomorrow, which the UI renders as a missing column."""
offset = 3 * 3600 # UTC+3, e.g. Europe/Vilnius (the reported Ringaudai incident) offset = 3 * 3600 # UTC+3, e.g. Europe/Vilnius (the reported Ringaudai cell)
local_today = (datetime.datetime.now(datetime.timezone.utc) local_today = (datetime.datetime.now(datetime.timezone.utc)
+ datetime.timedelta(seconds=offset)).date() + datetime.timedelta(seconds=offset)).date()
days = [local_today + datetime.timedelta(days=n) for n in (-2, -1, 0, 1)] days = [local_today + datetime.timedelta(days=n) for n in (-2, -1, 0, 1)]
daily = { daily = {
"time": [d.isoformat() for d in days], "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_max": [80.0, 82.0, 76.1, 84.0],
"temperature_2m_min": [60.0, 61.0, 57.0, 62.0], "temperature_2m_min": [60.0, 61.0, 55.9, 62.0],
"precipitation_sum": [0.0, 0.0, 0.0, 0.0], "precipitation_sum": [0.0, 0.0, 0.0, 0.0],
} }
payload = {"utc_offset_seconds": offset, "daily": daily} payload = {"utc_offset_seconds": offset, "daily": daily}
@ -237,60 +237,42 @@ def test_recent_forecast_om_drops_the_in_progress_local_day(monkeypatch):
def json(self): return payload def json(self): return payload
monkeypatch.setattr(climate, "_request", lambda *a, **k: Resp()) monkeypatch.setattr(climate, "_request", lambda *a, **k: Resp())
got = climate._fetch_recent_forecast_om({"center_lat": 54.9, "center_lon": 23.8})
assert got["date"].to_list() == days # every day present, no gap at today
row = got.filter(pl.col("date") == local_today)
assert row["tmax"].item() == 76.1 # today's whole-day high, ungraded-down
assert row["tmin"].item() == 55.9
def test_recent_forecast_om_still_drops_a_today_with_no_high_low(monkeypatch):
"""The one case today is excluded: it came back with no high/low and so cannot be
graded at all. _to_frame enforces that for every day, today included."""
offset = 3 * 3600
local_today = (datetime.datetime.now(datetime.timezone.utc)
+ datetime.timedelta(seconds=offset)).date()
days = [local_today + datetime.timedelta(days=n) for n in (-1, 0, 1)]
daily = {
"time": [d.isoformat() for d in days],
"temperature_2m_max": [82.0, None, 84.0],
"temperature_2m_min": [61.0, None, 62.0],
"precipitation_sum": [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( got = climate._fetch_recent_forecast_om(
{"center_lat": 54.9, "center_lon": 23.8})["date"].to_list() {"center_lat": 54.9, "center_lon": 23.8})["date"].to_list()
assert local_today not in got # partial today dropped assert local_today not in got
assert local_today - datetime.timedelta(days=1) in got # yesterday kept assert got == [local_today - datetime.timedelta(days=1),
assert local_today + datetime.timedelta(days=1) in got # tomorrow's forecast kept local_today + datetime.timedelta(days=1)]
assert len(got) == 3
def test_recent_forecast_om_keeps_all_days_without_a_utc_offset(monkeypatch): 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 """No UTC offset reported changes nothing: the bundle passes through with only
date, so the bundle passes through as before (only the usual null-day filter).""" 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_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 payload = {"daily": _om_daily(3)} # no utc_offset_seconds
class Resp: class Resp: