thermograph/backend/tests/data/test_meteostat.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

96 lines
4 KiB
Python

"""Unit tests for the Meteostat gust supplier — all hermetic (no network): the
station index and per-station daily fetch are monkeypatched, and the pure parse
helpers are fed literal payloads."""
import datetime
import json
import numpy as np
import polars as pl
import pytest
from data import meteostat
def _frame(gust=(None, None, None), wind=(10.0, 20.0, None)):
return pl.DataFrame(
{
"date": [datetime.date(2020, 1, 1), datetime.date(2020, 1, 2),
datetime.date(2020, 1, 3)],
"wind": list(wind),
"gust": pl.Series(list(gust), dtype=pl.Float64),
}
)
def test_parse_daily_csv_converts_kmh_to_mph_and_skips_blanks():
csv = "\n".join([
"2020-01-01,,,,,,,,50.0,,", # wpgt (col 8) = 50 km/h
"2020-01-02,,,,,,,,,,", # no gust -> skipped
"2020-01-03,,,,,,,,80.0,,", # wpgt = 80 km/h
"bad-date,,,,,,,,50.0,,", # unparseable date -> skipped
"x,y", # too few columns -> skipped
])
out = meteostat._parse_daily_csv(csv)
assert set(out) == {datetime.date(2020, 1, 1), datetime.date(2020, 1, 3)}
assert out[datetime.date(2020, 1, 1)] == pytest.approx(50.0 * meteostat.KMH_TO_MPH)
assert out[datetime.date(2020, 1, 3)] == pytest.approx(80.0 * meteostat.KMH_TO_MPH)
def test_parse_stations_skips_entries_without_id_or_coords():
raw = json.dumps([
{"id": "A", "location": {"latitude": 47.6, "longitude": -122.3}},
{"id": "B", "location": {"latitude": 51.5, "longitude": -0.1}},
{"id": "C", "location": {}}, # no coords -> skip
{"location": {"latitude": 1, "longitude": 2}}, # no id -> skip
]).encode()
ids, lats, lons = meteostat._parse_stations(raw)
assert ids == ["A", "B"]
assert lats.tolist() == [47.6, 51.5]
assert lons.tolist() == [-122.3, -0.1]
def test_nearest_station_picks_closest_within_range(monkeypatch):
monkeypatch.setattr(meteostat, "_STATIONS",
(["seattle", "london"],
np.array([47.6, 51.5]), np.array([-122.3, -0.1])))
assert meteostat.nearest_station(47.61, -122.31) == "seattle"
# Middle of the Pacific — both stations are far beyond MAX_STATION_KM.
assert meteostat.nearest_station(0.0, -160.0) is None
def test_fill_gusts_prefers_measured_then_estimates(monkeypatch):
monkeypatch.setattr(meteostat, "nearest_station", lambda lat, lon: "s1")
monkeypatch.setattr(meteostat, "daily_gusts",
lambda sid: {datetime.date(2020, 1, 1): 33.0})
out = meteostat.fill_gusts(47.6, -122.3, _frame())
gust = out["gust"].to_list()
assert gust[0] == pytest.approx(33.0) # measured wins
assert gust[1] == pytest.approx(20.0 * meteostat.GUST_FACTOR) # estimated from wind
assert gust[2] is None # no wind -> no estimate
def test_fill_gusts_estimates_everywhere_with_no_station(monkeypatch):
monkeypatch.setattr(meteostat, "nearest_station", lambda lat, lon: None)
out = meteostat.fill_gusts(0.0, 0.0, _frame())
gust = out["gust"].to_list()
assert gust[0] == pytest.approx(10.0 * meteostat.GUST_FACTOR)
assert gust[1] == pytest.approx(20.0 * meteostat.GUST_FACTOR)
assert gust[2] is None
def test_fill_gusts_degrades_on_fetch_error(monkeypatch):
def _boom(lat, lon):
raise RuntimeError("meteostat down")
monkeypatch.setattr(meteostat, "nearest_station", _boom)
# Must not raise — falls back to pure estimation.
out = meteostat.fill_gusts(47.6, -122.3, _frame())
assert out["gust"].to_list()[1] == pytest.approx(20.0 * meteostat.GUST_FACTOR)
def test_fill_gusts_noop_when_source_has_gusts(monkeypatch):
def _fail(*a, **k):
raise AssertionError("must not consult Meteostat when gusts already present")
monkeypatch.setattr(meteostat, "nearest_station", _fail)
df = _frame(gust=(40.0, 41.0, 42.0))
out = meteostat.fill_gusts(47.6, -122.3, df)
assert out["gust"].to_list() == [40.0, 41.0, 42.0]