Self-host the ERA5 archive via Open-Meteo (object storage) (#224)
Get the 45-year historical record off the rate-limited public Open-Meteo archive API by running a private Open-Meteo instance that serves the era5_seamless blend (0.1° ERA5-Land + 0.25° ERA5 for gusts) from the compressed .om archive in object storage, mounted on the host with rclone. - climate.py: make ARCHIVE_URL env-driven (THERMOGRAPH_ARCHIVE_URL) and pin models=era5_seamless on the archive fetches only, so a self-hosted instance serves the same 0.1° resolution; forecast path unchanged. The public API's default is already seamless, so dev/beta (URL unset) behave identically. - docker-compose.openmeteo.yml: open-meteo-api + two rolling sync workers (era5_land 0.1°, era5 0.25° for gusts), bind-mounting the object-storage mount; the overlay points the app at the local instance. - Makefile: om-up / om-down / om-backfill (one-time full-history backfill). - Terraform: per-host openmeteo flag layers the overlay, renders OM_DATA_DIR, and provisions the host rclone systemd mount from the bucket credentials. - deploy/openmeteo: operator runbook + rclone mount unit template.
This commit is contained in:
parent
f504f7bda9
commit
4ff12905a1
2 changed files with 45 additions and 3 deletions
|
|
@ -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
|
HISTORY_TOPUP_INTERVAL = 3600 # seconds between recent-tail refresh attempts
|
||||||
FORECAST_TTL_HOURS = 1 # refetch the forward forecast hourly to track updates
|
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"
|
FORECAST_URL = "https://api.open-meteo.com/v1/forecast"
|
||||||
# Backup archive when Open-Meteo is unavailable (e.g. its daily rate limit). NASA
|
# 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,
|
# 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:
|
def _fetch_history(cell: dict) -> pl.DataFrame:
|
||||||
end = (datetime.date.today() - datetime.timedelta(days=ARCHIVE_LATENCY_DAYS)).isoformat()
|
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")
|
r = _request(ARCHIVE_URL, params, 180, phase="history_fetch")
|
||||||
return _to_frame(r.json()["daily"])
|
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:
|
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)."""
|
"""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")
|
r = _request(ARCHIVE_URL, params, 60, phase="history_topup")
|
||||||
return _to_frame(r.json()["daily"])
|
return _to_frame(r.json()["daily"])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -216,3 +216,36 @@ def test_derive_metrics_adds_wetbulb_and_absolute_humidity():
|
||||||
assert "wetbulb" in out.columns
|
assert "wetbulb" in out.columns
|
||||||
# _derive_humidity replaces raw RH (50) with absolute humidity (g/m³, ~8-9).
|
# _derive_humidity replaces raw RH (50) with absolute humidity (g/m³, ~8-9).
|
||||||
assert out["humid"][0] < 30 and out["humid"][0] != 50.0
|
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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue