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
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:
commit
150029075e
2 changed files with 45 additions and 5 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)."""
|
||||
|
|
|
|||
Loading…
Reference in a new issue