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
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).
72 lines
2.9 KiB
Python
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
|