From 248eeee110c2c0b8616ce44299607081da51b748 Mon Sep 17 00:00:00 2001 From: emi Date: Fri, 24 Jul 2026 22:23:51 +0000 Subject: [PATCH] Gate MET forecast days on diurnal sample coverage (#63) --- backend/data/climate.py | 16 ++++++++++++-- backend/tests/data/test_climate.py | 34 +++++++++++++++++++++++++++--- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/backend/data/climate.py b/backend/data/climate.py index b198c23..b1c5e00 100644 --- a/backend/data/climate.py +++ b/backend/data/climate.py @@ -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, diff --git a/backend/tests/data/test_climate.py b/backend/tests/data/test_climate.py index 68d1491..fab8bad 100644 --- a/backend/tests/data/test_climate.py +++ b/backend/tests/data/test_climate.py @@ -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)."""