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
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:
parent
52cfe5b77a
commit
d4a00099f2
2 changed files with 60 additions and 85 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue