"""The source→frame mappings and cache-write path (pure parts of climate.py — no network).""" import datetime import polars as pl import pytest import climate def _om_daily(n=3): """A minimal Open-Meteo daily response.""" return { "time": [f"2026-06-{d:02d}" for d in range(1, n + 1)], "temperature_2m_max": [80.0, 90.0, None], "temperature_2m_min": [60.0, 70.0, 55.0], "precipitation_sum": [0.0, 0.25, 0.1], "wind_speed_10m_max": [10.0, 20.0, 5.0], "wind_gusts_10m_max": [15.0, 30.0, 8.0], "apparent_temperature_max": [82.0, 95.0, 70.0], "apparent_temperature_min": [58.0, 68.0, 50.0], "relative_humidity_2m_mean": [50.0, 60.0, 70.0], } def test_to_frame_schema_and_day_filter(): df = climate._to_frame(_om_daily()) # The None tmax day is dropped: a usable climate day needs a real high/low. assert len(df) == 2 assert df["doy"].dtype == pl.Int16 assert set(df.columns) == {"date", "tmax", "tmin", "precip", "wind", "gust", "humid", "fmax", "fmin", "feels", "doy"} def test_to_frame_tolerates_missing_series(): daily = _om_daily() del daily["wind_gusts_10m_max"], daily["relative_humidity_2m_mean"] df = climate._to_frame(daily) assert df["gust"].is_null().all() and df["humid"].is_null().all() assert df["tmax"].is_not_null().all() # required series unaffected def test_combined_feels_picks_the_extreme_side(): df = climate._to_frame(_om_daily()) # Day 2: fmax 95 is 30 past the 65°F comfort point vs fmin 68 only 3 below — # the hot side wins; both days here are hot-side days. assert df.filter(pl.col("date") == datetime.date(2026, 6, 2))["feels"].item() == 95.0 def test_combined_feels_falls_back_to_the_present_side(): """When one apparent-temperature side is missing, `feels` uses whichever side is present (the null-vs-value coalesce must reproduce the old NaN fallback).""" daily = _om_daily() daily["apparent_temperature_max"] = [None, None, None] # hot side absent df = climate._to_frame(daily) row = df.filter(pl.col("date") == datetime.date(2026, 6, 1)).row(0, named=True) assert row["feels"] == 58.0 # falls back to apparent low def test_nasa_to_frame_converts_units_and_fills(): param = { "T2M_MAX": {"20260601": 30.0, "20260602": -999.0}, # °C; -999 = missing "T2M_MIN": {"20260601": 20.0, "20260602": 15.0}, "PRECTOTCORR": {"20260601": 25.4, "20260602": 0.0}, # mm "WS10M_MAX": {"20260601": 10.0, "20260602": 5.0}, # m/s "RH2M": {"20260601": 50.0, "20260602": 60.0}, } df = climate._nasa_to_frame(param) assert len(df) == 1 # the missing-tmax day dropped row = df.row(0, named=True) assert row["tmax"] == 86.0 # 30°C assert row["precip"] == 1.0 # 25.4mm = 1in assert round(row["wind"], 1) == 22.4 # 10 m/s in mph assert row["gust"] is None # POWER has no gusts (missing -> null) assert row["fmax"] > row["tmax"] # ≥80°F: the NWS heat index applies def _metno_step(time, t, rh, wind, p1=None, p6=None): """One MET Norway timeseries step (instant details + optional precip blocks).""" data = {"instant": {"details": { "air_temperature": t, "relative_humidity": rh, "wind_speed": wind}}} if p1 is not None: data["next_1_hours"] = {"details": {"precipitation_amount": p1}} if p6 is not None: data["next_6_hours"] = {"details": {"precipitation_amount": p6}} return {"time": time, "data": data} 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. _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-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), ]} df = climate._metno_to_frame(props) assert len(df) == 2 d1 = df.filter(pl.col("date") == datetime.date(2026, 7, 16)).row(0, named=True) assert d1["tmax"] == 77.0 and d1["tmin"] == 68.0 # 25°C / 20°C -> °F assert round(d1["precip"], 4) == round(1.0 / 25.4, 4) # 0.5+0.5 mm (not +3.0) -> in assert round(d1["wind"], 1) == 8.9 # max 4 m/s -> mph assert d1["gust"] is None # MET Norway has no gusts assert d1["feels"] is not None d2 = df.filter(pl.col("date") == datetime.date(2026, 7, 17)).row(0, named=True) assert round(d2["precip"], 4) == round(6.0 / 25.4, 4) # only the 6-hour block def test_recent_forecast_falls_back_to_metno(monkeypatch, tmp_path): """When Open-Meteo's forecast API fails, the MET Norway backup serves the frame.""" monkeypatch.setattr(climate, "CACHE_DIR", str(tmp_path)) cell = {"id": "fallback_cell", "center_lat": 47.6062, "center_lon": -122.3321} met = {"timeseries": [ _metno_step("2026-07-16T00:00:00Z", 18.0, 55.0, 3.0, p1=0.0), _metno_step("2026-07-17T00:00:00Z", 22.0, 45.0, 5.0, p6=1.0), ]} class Resp: def json(self): return {"properties": met} def fake_request(url, params, timeout, *, phase, headers=None, attempts=climate.MAX_ATTEMPTS): if url == climate.FORECAST_URL: raise RuntimeError("open-meteo forecast outage") assert url == climate.METNO_URL assert headers and headers.get("User-Agent"), "MET Norway needs a User-Agent" return Resp() monkeypatch.setattr(climate, "_request", fake_request) df = climate._load_recent_forecast(cell) assert len(df) == 2 and df["date"].max() == datetime.date(2026, 7, 17) def test_recent_forecast_serves_stale_cache_when_all_sources_fail(monkeypatch, tmp_path): """With Open-Meteo AND MET Norway both down, an existing (stale) cache is served rather than failing — and it is NOT rewritten, so its mtime stays old and the next request still retries upstream first.""" import os import time monkeypatch.setattr(climate, "CACHE_DIR", str(tmp_path)) cell = {"id": "stale_cell", "center_lat": 47.6, "center_lon": -122.3} # Seed an rf cache, then age it well past the TTL so it counts as stale. stale = climate._to_frame(_om_daily()) path = climate._rf_cache_path(cell["id"]) climate._write_cache(stale, path) old = time.time() - (climate.FORECAST_TTL_HOURS + 5) * 3600 os.utime(path, (old, old)) def all_down(url, params, timeout, *, phase, headers=None, attempts=climate.MAX_ATTEMPTS): raise RuntimeError(f"{phase} down") monkeypatch.setattr(climate, "_request", all_down) df = climate._load_recent_forecast(cell) assert df.height == stale.height # served the stale cache assert abs(os.path.getmtime(path) - old) < 2 # not rewritten (mtime unchanged) def test_om_daily_params_carries_the_window(): cell = {"center_lat": 47.6, "center_lon": -122.3} p = climate._om_daily_params(cell, start_date="2026-01-01", end_date="2026-02-01") assert p["latitude"] == 47.6 and p["daily"] == climate.DAILY_VARS assert p["temperature_unit"] == "fahrenheit" assert p["start_date"] == "2026-01-01" and p["end_date"] == "2026-02-01" p2 = climate._om_daily_params(cell, past_days=25, forecast_days=8) assert p2["past_days"] == 25 and "start_date" not in p2 def test_write_cache_strips_the_derived_doy(tmp_path): df = climate._to_frame(_om_daily()) path = str(tmp_path / "cell.parquet") climate._write_cache(df, path) stored = pl.read_parquet(path) assert "doy" not in stored.columns assert len(stored) == len(df) def _wb_frame(tmax, humid, tmin=None): """Frame with raw RH (`humid`) for the wet-bulb / humidity derivations.""" n = len(tmax) return pl.DataFrame({ "date": [datetime.date(2026, 7, 1) + datetime.timedelta(days=i) for i in range(n)], "tmax": [float(x) for x in tmax], "tmin": [float(t) for t in (tmin or [x - 15 for x in tmax])], "humid": [float(h) for h in humid], }) def test_wetbulb_stull_spot_values(): # Stull (2011): 20 °C / 50% -> ~13.7 °C; 30 °C / 80% -> ~27.2 °C. In °F here. df = climate._derive_wetbulb(_wb_frame([68.0, 86.0], [50.0, 80.0])) wb = df["wetbulb"].to_list() assert wb[0] == pytest.approx((13.7 * 9 / 5) + 32, abs=0.6) assert wb[1] == pytest.approx((27.2 * 9 / 5) + 32, abs=0.6) def test_wetbulb_never_exceeds_dry_bulb(): df = climate._derive_wetbulb(_wb_frame([40.0, 60.0, 85.0, 100.0], [20.0, 55.0, 70.0, 95.0])) for tmax, wb in zip(df["tmax"], df["wetbulb"]): assert wb is not None and wb <= tmax + 0.05 def test_wetbulb_null_outside_validity_range(): # RH 3% (< 5) and a scorching 130 °F (> 50 °C) both fall outside Stull's fit. df = climate._derive_wetbulb(_wb_frame([70.0, 130.0], [3.0, 40.0])) assert df["wetbulb"].to_list() == [None, None] def test_wetbulb_noop_without_humidity(): df = pl.DataFrame({"tmax": [70.0], "tmin": [55.0]}) assert "wetbulb" not in climate._derive_wetbulb(df).columns def test_derive_metrics_adds_wetbulb_and_absolute_humidity(): out = climate._derive_metrics(_wb_frame([68.0], [50.0])) assert "wetbulb" in out.columns # _derive_humidity replaces raw RH (50) with absolute humidity (g/m³, ~8-9). assert out["humid"][0] < 30 and out["humid"][0] != 50.0