diff --git a/data/climate.py b/data/climate.py index ca8dc4e..3a7fb54 100644 --- a/data/climate.py +++ b/data/climate.py @@ -29,7 +29,16 @@ ARCHIVE_LATENCY_DAYS = 6 # ERA5 archive lags real time by a few days HISTORY_TOPUP_INTERVAL = 3600 # seconds between recent-tail refresh attempts FORECAST_TTL_HOURS = 1 # refetch the forward forecast hourly to track updates -ARCHIVE_URL = "https://archive-api.open-meteo.com/v1/archive" +# The archive (historical) endpoint. Overridable so it can point at a self-hosted +# Open-Meteo instance (ERA5 in object storage) instead of the rate-limited public +# API; the response shape is identical either way. +ARCHIVE_URL = os.environ.get("THERMOGRAPH_ARCHIVE_URL", + "https://archive-api.open-meteo.com/v1/archive") +# The historical model: the ERA5 "seamless" blend — 0.1° ERA5-Land for +# temperature/precip/humidity/wind, 0.25° ERA5 for wind gusts (which ERA5-Land +# lacks). This is the public API's default; sent explicitly so a self-hosted +# instance serves the same 0.1° resolution. Archive requests only, not forecast. +ARCHIVE_MODEL = "era5_seamless" FORECAST_URL = "https://api.open-meteo.com/v1/forecast" # Backup archive when Open-Meteo is unavailable (e.g. its daily rate limit). NASA # POWER is free + keyless, global, daily from 1981. It lacks gusts + apparent temp, @@ -324,7 +333,7 @@ def _to_frame(daily: dict) -> pl.DataFrame: def _fetch_history(cell: dict) -> pl.DataFrame: end = (datetime.date.today() - datetime.timedelta(days=ARCHIVE_LATENCY_DAYS)).isoformat() - params = _om_daily_params(cell, start_date=START_DATE, end_date=end) + params = _om_daily_params(cell, start_date=START_DATE, end_date=end, models=ARCHIVE_MODEL) r = _request(ARCHIVE_URL, params, 180, phase="history_fetch") return _to_frame(r.json()["daily"]) @@ -470,7 +479,7 @@ def _fetch_forecast_metno(cell: dict) -> pl.DataFrame: def _fetch_history_range(cell: dict, start_date: str, end_date: str) -> pl.DataFrame: """Fetch just a date range of archive history (used to top up the recent tail).""" - params = _om_daily_params(cell, start_date=start_date, end_date=end_date) + params = _om_daily_params(cell, start_date=start_date, end_date=end_date, models=ARCHIVE_MODEL) r = _request(ARCHIVE_URL, params, 60, phase="history_topup") return _to_frame(r.json()["daily"]) diff --git a/tests/data/test_climate.py b/tests/data/test_climate.py index 929cd19..4a43439 100644 --- a/tests/data/test_climate.py +++ b/tests/data/test_climate.py @@ -216,3 +216,36 @@ def test_derive_metrics_adds_wetbulb_and_absolute_humidity(): 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 + + +def test_history_fetch_targets_archive_url_with_seamless_model(monkeypatch): + """Historical fetches hit ARCHIVE_URL (env-overridable for self-hosting) and pin + the ERA5 seamless model, so a self-hosted Open-Meteo serves the same 0.1° blend.""" + seen = {} + + class Resp: + def json(self): return {"daily": _om_daily(3)} + + def fake_request(url, params, timeout, *, phase, headers=None, attempts=climate.MAX_ATTEMPTS): + seen["url"], seen["params"], seen["phase"] = url, dict(params), phase + return Resp() + + monkeypatch.setattr(climate, "_request", fake_request) + cell = {"center_lat": 47.6, "center_lon": -122.3} + + climate._fetch_history(cell) + assert seen["url"] == climate.ARCHIVE_URL + assert seen["params"]["models"] == "era5_seamless" + assert seen["phase"] == "history_fetch" + + climate._fetch_history_range(cell, "2026-06-01", "2026-06-10") # tail top-up + assert seen["url"] == climate.ARCHIVE_URL + assert seen["params"]["models"] == "era5_seamless" + + +def test_daily_params_carry_no_model_by_default(): + # The shared param builder (also used by the forecast path) stays model-free; + # only the archive fetches add models=era5_seamless. + p = climate._om_daily_params({"center_lat": 1.0, "center_lon": 2.0}, past_days=7) + assert "models" not in p + assert climate.ARCHIVE_URL.endswith("/v1/archive") # default (no env override in tests)