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:
Emi Griffith 2026-07-20 06:16:56 -07:00 committed by GitHub
parent f504f7bda9
commit 4ff12905a1
2 changed files with 45 additions and 3 deletions

View file

@ -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"])

View file

@ -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)