seed_era5.py backfills the curated-city cells with true ERA5 data from the Earthmover Icechunk ERA5 archive on AWS Open Data (anonymous, keyless), so high-traffic pages keep ERA5 fidelity now that NASA POWER is the live primary. It aggregates the hourly ERA5 point series to the daily store schema in polars (no pandas), derives feels-like via the existing heat-index/wind-chill path, and writes straight to the history store via climate._write_history_backed, bypassing the live fetch. ERA5 carries real gusts (i10fg), so seeded cells keep measured gusts rather than the Meteostat fallback. The icechunk/xarray/zarr stack is isolated in requirements-seed.txt (not the app image or CI) and lazy-imported, so the module and its unit-tested hourly->daily transform load without those deps. The S3 access layer targets a young API and is verified via `--dry-run` on the seed box, not in tests. Idempotent by default (skips already-cached cells; --overwrite to reseed).
174 lines
7.7 KiB
Python
174 lines
7.7 KiB
Python
"""One-time ERA5 seed for the curated-city cells (keeps ERA5 fidelity without Open-Meteo).
|
|
|
|
Now that the live history primary is NASA POWER, this backfills the high-traffic
|
|
curated-city cells (backend/cities.json) with true ERA5 data pulled from a public,
|
|
KEYLESS source — the Earthmover Icechunk ERA5 archive on AWS Open Data — so the
|
|
pages people actually visit keep ERA5-quality history. It writes straight into the
|
|
history store (bypassing the live fetch), so a seeded cell is served from cache and
|
|
never falls to NASA on first request.
|
|
|
|
# on a machine with network + the seed deps (see requirements-seed.txt):
|
|
pip install -r requirements-seed.txt
|
|
python seed_era5.py --dry-run # fetch+transform one cell, print, no write
|
|
python seed_era5.py [--limit N] [--overwrite]
|
|
|
|
Idempotent by default: a cell that already has a cached history is skipped (pass
|
|
--overwrite to reseed). Not run in CI or the app image — the icechunk/xarray/zarr
|
|
stack lives only in requirements-seed.txt.
|
|
|
|
IMPORTANT — the S3/Icechunk *access* layer (_open_store / fetch_point) targets a
|
|
young, fast-moving API and the store's exact variable/coordinate names; it is NOT
|
|
exercised by the test suite. VERIFY it with `--dry-run` on the seed box before a
|
|
full run. The hourly→daily transform (hourly_to_daily) IS unit-tested and is the
|
|
part that must be numerically correct.
|
|
|
|
Source: https://registry.opendata.aws/earthmover-era5/ (group="temporal", anonymous)
|
|
"""
|
|
import os
|
|
import sys
|
|
|
|
import polars as pl
|
|
|
|
from data import cities
|
|
from data import climate
|
|
from data import grid
|
|
|
|
# Match the live archive span (climate.START_DATE / ARCHIVE_LATENCY_DAYS).
|
|
START_DATE = climate.START_DATE # "1980-01-01"
|
|
|
|
# Anonymous AWS Open Data Icechunk store; overridable in case the registry moves.
|
|
ERA5_BUCKET = os.environ.get("THERMOGRAPH_ERA5_BUCKET", "earthmover-icechunk-era5")
|
|
ERA5_PREFIX = os.environ.get("THERMOGRAPH_ERA5_PREFIX", "") # verify on the seed box
|
|
ERA5_REGION = os.environ.get("THERMOGRAPH_ERA5_REGION", "us-east-1")
|
|
ERA5_GROUP = os.environ.get("THERMOGRAPH_ERA5_GROUP", "temporal") # time-contiguous layout
|
|
|
|
# ERA5 store variable name -> our short name. ERA5 gusts (i10fg) are real here, so the
|
|
# seed keeps genuine measured gusts rather than the Meteostat/estimate fallback.
|
|
ERA5_VARS = {
|
|
"2m_temperature": "t2m", # K
|
|
"2m_dewpoint_temperature": "d2m", # K (→ RH with t2m)
|
|
"total_precipitation": "tp", # m, hourly accumulation
|
|
"10m_u_component_of_wind": "u10", # m/s
|
|
"10m_v_component_of_wind": "v10", # m/s
|
|
"instantaneous_10m_wind_gust": "i10fg", # m/s
|
|
}
|
|
|
|
_M_TO_IN = 39.37007874
|
|
_MS_TO_MPH = 2.2369362920544
|
|
|
|
|
|
def hourly_to_daily(hourly: pl.DataFrame) -> pl.DataFrame:
|
|
"""Aggregate an hourly ERA5 point frame to the daily schema climate.py stores,
|
|
in the app's units (°F, inches, mph, %RH). Input columns: time (Datetime, UTC),
|
|
t2m/d2m (K), tp (m, per-hour accumulation), u10/v10/i10fg (m/s).
|
|
|
|
Days are UTC calendar days — the reanalysis is UTC, so this is a small offset
|
|
from a local-timezone daily boundary, immaterial for percentile climatology.
|
|
Relative humidity is the Magnus-formula RH from temperature and dewpoint."""
|
|
tc = pl.col("t2m") - 273.15
|
|
tdc = pl.col("d2m") - 273.15
|
|
es = (17.625 * tc / (243.04 + tc)).exp()
|
|
e = (17.625 * tdc / (243.04 + tdc)).exp()
|
|
df = hourly.with_columns(
|
|
pl.col("time").dt.date().alias("date"),
|
|
((pl.col("t2m") - 273.15) * 9.0 / 5.0 + 32.0).alias("t_f"),
|
|
((pl.col("u10") ** 2 + pl.col("v10") ** 2).sqrt() * _MS_TO_MPH).alias("wind_mph"),
|
|
(pl.col("i10fg") * _MS_TO_MPH).alias("gust_mph"),
|
|
(pl.col("tp") * _M_TO_IN).alias("precip_in"),
|
|
(100.0 * e / es).clip(0.0, 100.0).alias("rh"),
|
|
)
|
|
return (
|
|
df.group_by("date")
|
|
.agg(
|
|
pl.col("t_f").max().alias("tmax"),
|
|
pl.col("t_f").min().alias("tmin"),
|
|
pl.col("precip_in").sum().alias("precip"),
|
|
pl.col("wind_mph").max().alias("wind"),
|
|
pl.col("gust_mph").max().alias("gust"),
|
|
pl.col("rh").mean().alias("humid"),
|
|
)
|
|
.sort("date")
|
|
)
|
|
|
|
|
|
def _default_end() -> str:
|
|
import datetime
|
|
return (datetime.date.today()
|
|
- datetime.timedelta(days=climate.ARCHIVE_LATENCY_DAYS)).isoformat()
|
|
|
|
|
|
def _open_store():
|
|
"""Open the anonymous Icechunk ERA5 store (temporal group). Lazy-imports the
|
|
seed-only deps. VERIFY against the current icechunk/xarray API on the seed box —
|
|
this young API and the store's exact paths are not covered by tests."""
|
|
import icechunk # noqa: PLC0415 - seed-only dep (requirements-seed.txt)
|
|
import xarray as xr # noqa: PLC0415
|
|
|
|
storage = icechunk.s3_storage(
|
|
bucket=ERA5_BUCKET, prefix=ERA5_PREFIX, region=ERA5_REGION, anonymous=True)
|
|
repo = icechunk.Repository.open(storage)
|
|
session = repo.readonly_session("main")
|
|
return xr.open_zarr(session.store, group=ERA5_GROUP, consolidated=False)
|
|
|
|
|
|
def fetch_point(ds, lat: float, lon: float, start: str, end: str) -> pl.DataFrame:
|
|
"""Pull the hourly ERA5 series for the nearest grid point over [start, end] into a
|
|
polars frame (no pandas). ERA5 longitudes are 0..360. Coordinate/variable names
|
|
may differ per store revision — verify with --dry-run."""
|
|
sub = (ds[list(ERA5_VARS)]
|
|
.sel(latitude=lat, longitude=lon % 360.0, method="nearest")
|
|
.sel(time=slice(start, end))
|
|
.load())
|
|
data = {"time": sub["time"].values}
|
|
for src, dst in ERA5_VARS.items():
|
|
data[dst] = sub[src].values
|
|
return pl.DataFrame(data)
|
|
|
|
|
|
def seed_cell(ds, cell: dict, start: str, end: str) -> int:
|
|
"""Fetch → daily → finalize → write one cell's ERA5 history. Returns row count."""
|
|
daily = hourly_to_daily(fetch_point(ds, cell["center_lat"], cell["center_lon"], start, end))
|
|
# ERA5 has no apparent-temperature field; approximate fmax/fmin from the NWS heat
|
|
# index / wind chill (same as the NASA/MET paths). _finalize_approximated adds
|
|
# feels + doy and folds NaN→null; the write path strips doy.
|
|
frame = climate._finalize_approximated(daily)
|
|
climate._write_history_backed(cell["id"], frame)
|
|
return frame.height
|
|
|
|
|
|
def main(limit: "int | None" = None, overwrite: bool = False,
|
|
dry_run: bool = False, start: str = START_DATE, end: "str | None" = None) -> None:
|
|
end = end or _default_end()
|
|
ds = _open_store()
|
|
todo = cities.all_cities()
|
|
if limit:
|
|
todo = todo[:limit]
|
|
seeded = skipped = failed = 0
|
|
for i, c in enumerate(todo, 1):
|
|
cell = grid.snap(c["lat"], c["lon"])
|
|
if not overwrite:
|
|
cached = climate.load_cached_history(cell)
|
|
if cached is not None and not cached.is_empty():
|
|
skipped += 1
|
|
continue
|
|
try:
|
|
if dry_run:
|
|
daily = hourly_to_daily(
|
|
fetch_point(ds, cell["center_lat"], cell["center_lon"], start, end))
|
|
print(daily.head())
|
|
print(f"[dry-run] {c['slug']} ({cell['id']}): {daily.height} days "
|
|
f"({start}..{end}); nothing written")
|
|
return
|
|
n = seed_cell(ds, cell, start, end)
|
|
seeded += 1
|
|
print(f"[{i}/{len(todo)}] seeded {c['slug']} ({cell['id']}): {n} days")
|
|
except Exception as e: # noqa: BLE001 - keep going; a failed cell self-heals via NASA
|
|
failed += 1
|
|
print(f"[{i}/{len(todo)}] FAILED {c['slug']}: {e}")
|
|
print(f"done: seeded={seeded} skipped(cached)={skipped} failed={failed}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
argv = sys.argv[1:]
|
|
lim = int(argv[argv.index("--limit") + 1]) if "--limit" in argv else None
|
|
main(limit=lim, overwrite="--overwrite" in argv, dry_run="--dry-run" in argv)
|