Merge pull request 'Promote dev to main: MET forecast coverage gate' (#64) from dev into main
All checks were successful
secrets-guard / encrypted (push) Successful in 9s
shell-lint / shellcheck (push) Successful in 16s
secrets-guard / encrypted (pull_request) Successful in 11s
shell-lint / shellcheck (pull_request) Successful in 13s
Deploy backend to beta VPS / deploy (push) Successful in 51s
Build + push backend image (Forgejo registry) / build-push (push) Successful in 1m22s

This commit is contained in:
emi 2026-07-24 22:26:20 +00:00
commit 150029075e
2 changed files with 45 additions and 5 deletions

View file

@ -626,7 +626,9 @@ def _metno_to_frame(props: dict) -> pl.DataFrame:
day = step["time"][:10]
data = step.get("data", {})
inst = data.get("instant", {}).get("details", {})
a = agg.setdefault(day, {"t": [], "rh": [], "wind": [], "precip": None})
a = agg.setdefault(day, {"t": [], "rh": [], "wind": [], "precip": None,
"hours": []})
a["hours"].append(int(step["time"][11:13]))
if (t := inst.get("air_temperature")) is not None:
a["t"].append(t)
if (rh := inst.get("relative_humidity")) is not None:
@ -642,7 +644,17 @@ def _metno_to_frame(props: dict) -> pl.DataFrame:
if p is not None:
a["precip"] = (a["precip"] or 0.0) + p
dates = sorted(agg)
# Coverage gate: a day's min/max are only real if its samples span most of
# the day. MET's series starts mid-today and thins to 6-hourly then sparser
# in the tail, so without this the current day is built from the remaining
# afternoon hours (seen live: a served "overnight low" of 114.8°F graded at
# the ~100th percentile) and the final day can collapse to one sample with
# tmin == tmax. 18h keeps full hourly days and the 0/6/12/18 6-hourly shape;
# it drops partial today and the degenerate tail rather than grading them.
MIN_SPAN_HOURS = 18
dates = [d for d in sorted(agg)
if agg[d]["hours"]
and max(agg[d]["hours"]) - min(agg[d]["hours"]) >= MIN_SPAN_HOURS]
c2f = lambda c: c * 9.0 / 5.0 + 32.0
df = pl.DataFrame({
"date": dates,

View file

@ -117,13 +117,17 @@ def _metno_step(time, t, rh, wind, p1=None, p6=None):
def test_metno_to_frame_aggregates_daily_and_converts_units():
props = {"timeseries": [
# Day 1: two hourly steps. The first also carries a next_6_hours block, which
# must be IGNORED (next_1_hours wins) so precip isn't double-counted.
# Day 1: hourly steps spanning the day (the coverage gate needs >= 18h).
# The first also carries a next_6_hours block, which must be IGNORED
# (next_1_hours wins) so precip isn't double-counted.
_metno_step("2026-07-16T00:00:00Z", 20.0, 50.0, 2.0, p1=0.5, p6=3.0),
_metno_step("2026-07-16T01:00:00Z", 25.0, 60.0, 4.0, p1=0.5),
# Day 2: a 6-hourly step (only next_6_hours) + an instant-only tail step.
_metno_step("2026-07-16T18:00:00Z", 22.0, 55.0, 1.0),
# Day 2: the far-term 6-hourly shape (0/6/12/18) with one next_6_hours block.
_metno_step("2026-07-17T00:00:00Z", 10.0, 80.0, 10.0, p6=6.0),
_metno_step("2026-07-17T06:00:00Z", 12.0, 70.0, 8.0),
_metno_step("2026-07-17T12:00:00Z", 14.0, 60.0, 6.0),
_metno_step("2026-07-17T18:00:00Z", 11.0, 75.0, 7.0),
]}
df = climate._metno_to_frame(props)
assert len(df) == 2
@ -137,6 +141,30 @@ def test_metno_to_frame_aggregates_daily_and_converts_units():
assert round(d2["precip"], 4) == round(6.0 / 25.4, 4) # only the 6-hour block
def test_metno_to_frame_drops_days_without_diurnal_coverage():
"""Partial days must not be graded: MET's series starts mid-today (the
remaining afternoon hours would masquerade as the day's extremes — seen
live as a ~100th-percentile 'overnight low') and its final day can be a
single sample with tmin == tmax. Both shapes are dropped; the 6-hourly
0/6/12/18 far-term shape (18h span) survives."""
props = {"timeseries": [
# "Today", started at 21:00 — only evening remains: dropped.
_metno_step("2026-07-20T21:00:00Z", 46.0, 20.0, 2.0),
_metno_step("2026-07-20T22:00:00Z", 45.0, 22.0, 2.0),
# Full 6-hourly day: kept.
_metno_step("2026-07-21T00:00:00Z", 30.0, 40.0, 3.0),
_metno_step("2026-07-21T06:00:00Z", 28.0, 50.0, 3.0),
_metno_step("2026-07-21T12:00:00Z", 38.0, 30.0, 4.0),
_metno_step("2026-07-21T18:00:00Z", 34.0, 35.0, 3.0),
# Degenerate tail day, one sample: dropped (would grade tmin == tmax).
_metno_step("2026-07-22T00:00:00Z", 29.0, 45.0, 3.0),
]}
df = climate._metno_to_frame(props)
assert df["date"].to_list() == [datetime.date(2026, 7, 21)]
row = df.row(0, named=True)
assert row["tmax"] != row["tmin"]
def test_recent_forecast_merges_nasa_past_and_metno_forward(monkeypatch, tmp_path):
"""The recent/forecast bundle is built from a NASA POWER recent-past range plus the
MET Norway forward forecast, merged by date (Open-Meteo not consulted)."""