thermograph/backend/tests/test_seed_era5.py
emi 790b6bf0dc
All checks were successful
secrets-guard / encrypted (push) Successful in 8s
Build + push backend image (Forgejo registry) / build-push (push) Successful in 1m4s
Deploy backend to LAN dev server / build (push) Successful in 1m12s
Deploy backend to LAN dev server / deploy (push) Successful in 17s
PR build (required check) / changes (pull_request) Successful in 7s
secrets-guard / encrypted (pull_request) Successful in 6s
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-backend (pull_request) Successful in 54s
PR build (required check) / gate (pull_request) Successful in 3s
Migrate off the Open-Meteo weather API (Phases 0-4)
Steady-state usage of the Open-Meteo API is removed; it remains only as a dormant fallback. Geocoding -> local GeoNames + Nominatim; wind gusts -> Meteostat; history -> NASA POWER (curated cells seeded with keyless ERA5); recent+forecast -> NASA range + MET Norway. Plus a drift-check tool comparing NASA vs Open-Meteo. All keyless, no new infra. Backend suite green in-image (gate).
2026-07-23 14:34:51 +00:00

72 lines
2.9 KiB
Python

"""Unit tests for the ERA5 seed's hourly→daily transform (hermetic — no network, no
icechunk/xarray). The S3 access layer (_open_store/fetch_point) is deliberately not
tested here; it must be verified with `seed_era5.py --dry-run` on the seed box."""
import datetime
import math
import polars as pl
import pytest
import seed_era5
_MS_TO_MPH = 2.2369362920544
_M_TO_IN = 39.37007874
def _rh(t_c, td_c):
es = math.exp(17.625 * t_c / (243.04 + t_c))
e = math.exp(17.625 * td_c / (243.04 + td_c))
return min(100.0, 100.0 * e / es)
def _hourly(rows):
"""rows: list of (time, t2m_K, d2m_K, tp_m, u10, v10, i10fg)."""
cols = ["time", "t2m", "d2m", "tp", "u10", "v10", "i10fg"]
return pl.DataFrame({c: [r[i] for r in rows] for i, c in enumerate(cols)})
def test_hourly_to_daily_aggregates_and_converts():
d1 = datetime.datetime(2020, 1, 1)
d2 = datetime.datetime(2020, 1, 2)
hourly = _hourly([
# day 1: 10°C sat, then 20°C / 10°C dew; winds 5 m/s then calm; gusts 10/12
(d1.replace(hour=0), 283.15, 283.15, 0.001, 3.0, 4.0, 10.0),
(d1.replace(hour=1), 293.15, 283.15, 0.002, 0.0, 0.0, 12.0),
# day 2: 0°C sat; wind 10 m/s; gust 20
(d2.replace(hour=0), 273.15, 273.15, 0.0, 6.0, 8.0, 20.0),
])
daily = seed_era5.hourly_to_daily(hourly).sort("date")
assert daily["date"].to_list() == [datetime.date(2020, 1, 1), datetime.date(2020, 1, 2)]
r0 = daily.row(0, named=True)
assert r0["tmax"] == pytest.approx(68.0) # 20°C
assert r0["tmin"] == pytest.approx(50.0) # 10°C
assert r0["precip"] == pytest.approx(0.003 * _M_TO_IN)
assert r0["wind"] == pytest.approx(5.0 * _MS_TO_MPH) # max(5, 0)
assert r0["gust"] == pytest.approx(12.0 * _MS_TO_MPH) # max(10, 12)
assert r0["humid"] == pytest.approx((_rh(10, 10) + _rh(20, 10)) / 2)
r1 = daily.row(1, named=True)
assert r1["tmin"] == pytest.approx(32.0) # 0°C
assert r1["wind"] == pytest.approx(10.0 * _MS_TO_MPH) # sqrt(6²+8²)
def test_saturated_air_reads_full_humidity():
hourly = _hourly([(datetime.datetime(2021, 6, 1), 293.15, 293.15, 0.0, 1.0, 1.0, 2.0)])
daily = seed_era5.hourly_to_daily(hourly)
assert daily.row(0, named=True)["humid"] == pytest.approx(100.0)
def test_finalize_produces_the_store_schema():
"""The daily frame, run through climate._finalize_approximated (as seed_cell does),
yields the persisted columns incl. derived feels."""
from data import climate
hourly = _hourly([
(datetime.datetime(2019, 7, 1, 0), 300.0, 290.0, 0.0, 2.0, 2.0, 5.0),
(datetime.datetime(2019, 7, 1, 1), 305.0, 291.0, 0.001, 3.0, 1.0, 7.0),
])
frame = climate._finalize_approximated(seed_era5.hourly_to_daily(hourly))
for col in ("date", "tmax", "tmin", "precip", "wind", "gust", "humid",
"fmax", "fmin", "feels"):
assert col in frame.columns
assert frame.height == 1