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).
96 lines
4 KiB
Python
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]
|