"""Auto-update the CREST_data temperature forcing from NARR (task: NARR temp). Source: NCEP North American Regional Reanalysis 2-m air temperature (GDEX d608000, https://gdex.ucar.edu/datasets/d608000/ — 32 km Lambert Conformal, 3-hourly, GRIB1). We pull it from NOAA PSL's netCDF mirror of the SAME dataset (one yearly file per variable, anonymous HTTPS, updated ~weekly): https://psl.noaa.gov/thredds/fileServer/Datasets/NARR/monolevel/air.2m.YYYY.nc (downloads.psl.noaa.gov served the same files until it began answering 403 for the whole NARR tree on 2026-10-03; it stays as a fallback mirror) because the GDEX form is 0.5–1 GB multi-variable GRIB tars per ~8 days, i.e. ~2.6 GB of download per month to extract a single band. Each 3-hourly Lambert field is bilinearly resampled onto the canonical NLDAS-heritage 0.125° temp grid (forcing.TEMP_GRID — so every temp PQF in the store shares one geometry, which the [TEMPForcing] DEM= extrapolation grid is built against), converted K→°C, and linearly interpolated to HOURLY members (EF5 zero-fills missing hourly files, which would freeze SNOW17 at 0 °C). Members use the generic name temp_YYYYMMDDHH.pqf; existing NLDAS members are never touched — the updater only APPENDS missing hours to each month tar. Run on the local machine (needs netCDF4/pyproj/numpy + HF token): python scripts/update_temp_narr.py [--months 2026-06,2026-07] [--dry-run] Default: scan from BACKFILL_START (or the last 4 months, whichever is later) through the current month. """ from __future__ import annotations import argparse import io import json import os import re import sys import tarfile import tempfile import time from datetime import datetime, timedelta, timezone import numpy as np sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) import truststore truststore.inject_into_ssl() import urllib.request from hf_data.forcing import TEMP_GRID, VARS, _write_pqf # noqa: E402 HF_REPO = "vincewin/CREST_data" PSL_URLS = ("https://psl.noaa.gov/thredds/fileServer/Datasets/NARR/monolevel/air.2m.{year}.nc", "https://downloads.psl.noaa.gov/Datasets/NARR/monolevel/air.2m.{year}.nc") TOKEN_PATH = r"C:\Users\chenm\Documents\EF5\CREST_token.txt" DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_narr_cache") BACKFILL_START = (2026, 6) # NLDAS members end 2026-06-23 12z _MEMBER_RES = [re.compile(r"NLDAS_FORA0125_H\.A(\d{8})\.(\d{2})00\."), re.compile(r"temp_(\d{10})\.pqf$")] def _member_hours(names) -> set[datetime]: """Hours already present in a tar (NLDAS or generic member names).""" hours = set() for n in names: m = _MEMBER_RES[0].search(n) if m: hours.add(datetime.strptime(m.group(1) + m.group(2), "%Y%m%d%H")) continue m = _MEMBER_RES[1].search(n) if m: hours.add(datetime.strptime(m.group(1), "%Y%m%d%H")) return hours def _download_year(year: int) -> str | None: """Fetch air.2m..nc if PSL has a newer copy than our cache.""" os.makedirs(DATA_DIR, exist_ok=True) dest = os.path.join(DATA_DIR, f"air.2m.{year}.nc") meta = dest + ".meta.json" url = remote = None for u in (m.format(year=year) for m in PSL_URLS): try: req = urllib.request.Request(u, method="HEAD") h = urllib.request.urlopen(req, timeout=60) url, remote = u, {"len": h.headers.get("Content-Length"), "mod": h.headers.get("Last-Modified")} break except Exception as e: print(f" [narr] HEAD {u} failed: {e}") if url is None: # no mirror answered: the cached copy is all we have — say so loudly, # a silent fallback hid the 2026-10-03 source move print(f" [narr] WARNING: no NARR mirror reachable for {year}; " f"{'using cached copy' if os.path.exists(dest) else 'no cached copy'}") return dest if os.path.exists(dest) else None if os.path.exists(dest) and os.path.exists(meta): if json.load(open(meta)) == remote: return dest print(f" [narr] downloading {url} ({int(remote['len'] or 0)/1e6:.0f} MB)…") # PSL rate-limits bursts (HTTP 429 on 2026-10-07, 21 min after it # republished the year file) — back off and retry transient errors # instead of failing the whole self-heal until the next cooldown for attempt, wait in enumerate((30, 120, 300, None)): try: urllib.request.urlretrieve(url, dest + ".part") break except Exception as e: code = getattr(e, "code", None) transient = code is None or code == 429 or code >= 500 if wait is None or not transient: raise ra = getattr(e, "headers", None) and e.headers.get("Retry-After") if ra and str(ra).isdigit(): wait = max(wait, min(int(ra), 900)) print(f" [narr] download failed ({e}); retry {attempt + 1}/3 in {wait} s") time.sleep(wait) os.replace(dest + ".part", dest) json.dump(remote, open(meta, "w")) return dest class NarrYear: """air.2m..nc with lazy bilinear resampling onto TEMP_GRID.""" def __init__(self, path: str): import netCDF4 self.ds = netCDF4.Dataset(path) t = self.ds.variables["time"] import cftime # noqa: F401 (num2date backend) import netCDF4 as nc4 # PSL's 2026-10-07 rewrite of air.2m.2026.nc (HDF5) holds Jan-Jul as # DAILY records, 1,732 masked (empty) time slots where August belongs, # and only September 3-hourly. A masked stamp crashed num2date; a daily # record read as a 00Z instant would be silently wrong. Index only # records that sit inside a 3-hourly run. tv = t[:] mask = np.ma.getmaskarray(tv) vals = np.ma.getdata(tv).astype("float64") good = np.nonzero(~mask)[0] gv = vals[good] step3 = np.zeros(len(good), bool) if len(good) > 1: d = np.diff(gv) nxt3 = np.isclose(d, 3.0) step3[:-1] |= nxt3 # opens/continues a 3-h step # reached by a 3-h step and not opening a daily run: a daily record # stamped D 00Z sits exactly 3 h after D-1 21Z (residual ambiguity: # a lone daily record at the very END of a file looks the same) step3[1:] |= nxt3 & np.append(~np.isclose(d[1:], 24.0), True) dates = nc4.num2date(gv[step3], t.units) if step3.any() else [] self.hours = {datetime(d.year, d.month, d.day, d.hour): int(i) for i, d in zip(good[step3], dates)} n_off = int((~step3).sum()) if mask.any() or n_off: print(f" [narr] {os.path.basename(path)}: {len(self.hours)} 3-hourly records " f"usable, {int(mask.sum())} empty time slots, {n_off} non-3-hourly " f"(e.g. daily) records ignored") self.air = self.ds.variables["air"] gm = self.ds.variables["Lambert_Conformal"] x = self.ds.variables["x"][:].astype("float64") y = self.ds.variables["y"][:].astype("float64") from pyproj import CRS, Transformer crs = CRS.from_cf({k: getattr(gm, k) for k in gm.ncattrs()}) tr = Transformer.from_crs("EPSG:4326", crs, always_xy=True) xll, yll, cell, nr, nc = TEMP_GRID[:5] lons = xll + (np.arange(nc) + 0.5) * cell lats = yll + (np.arange(nr) + 0.5) * cell LON, LAT = np.meshgrid(lons, lats[::-1]) # row 0 = north X, Y = tr.transform(LON, LAT) self.fi = (Y - y[0]) / (y[1] - y[0]) self.fj = (X - x[0]) / (x[1] - x[0]) self._cache: dict[datetime, np.ndarray] = {} def field(self, when: datetime) -> np.ndarray | None: """°C field on TEMP_GRID at a 3-hourly NARR step, or None.""" if when in self._cache: return self._cache[when] idx = self.hours.get(when) if idx is None: return None a = np.ma.filled(self.air[idx, :, :].astype("float64"), np.nan) - 273.15 if not np.isfinite(a).any(): return None # empty record: no data, not a nodata grid i0 = np.clip(np.floor(self.fi).astype(int), 0, a.shape[0] - 2) j0 = np.clip(np.floor(self.fj).astype(int), 0, a.shape[1] - 2) di = np.clip(self.fi - i0, 0, 1) dj = np.clip(self.fj - j0, 0, 1) v = (a[i0, j0] * (1 - di) * (1 - dj) + a[i0 + 1, j0] * di * (1 - dj) + a[i0, j0 + 1] * (1 - di) * dj + a[i0 + 1, j0 + 1] * di * dj) out = np.where(np.isfinite(v), v, TEMP_GRID[5]).astype("float32") if len(self._cache) > 4: self._cache.clear() self._cache[when] = out return out def close(self): self.ds.close() def hourly_field(years: dict[int, NarrYear], when: datetime) -> np.ndarray | None: """Linear interpolation between the two bracketing 3-hourly NARR steps.""" t0 = when.replace(hour=when.hour - when.hour % 3) ny = years.get(t0.year) f0 = ny.field(t0) if ny else None if f0 is None: return None if when == t0: return f0 t1 = t0 + timedelta(hours=3) ny1 = years.get(t1.year) f1 = ny1.field(t1) if ny1 else None if f1 is None: return None # can't bracket -> leave for the next run w = (when - t0).total_seconds() / 10800.0 bad = (f0 == TEMP_GRID[5]) | (f1 == TEMP_GRID[5]) v = ((1 - w) * f0 + w * f1).astype("float32") v[bad] = TEMP_GRID[5] return v def update_month(year: int, month: int, years: dict[int, NarrYear], api, dry_run: bool) -> str: from huggingface_hub import hf_hub_download cfg = VARS["temp"] ref = datetime(year, month, 1) tar_repo_path = ref.strftime(cfg.month_fmt) old_members: list[tuple[tarfile.TarInfo, bytes]] = [] have: set[datetime] = set() try: local = hf_hub_download(HF_REPO, tar_repo_path, repo_type="dataset") with tarfile.open(local) as tf: for ti in tf.getmembers(): old_members.append((ti, tf.extractfile(ti).read())) have = _member_hours([ti.name for ti, _ in old_members]) except Exception: pass # brand-new month tar now = datetime.now(timezone.utc).replace(tzinfo=None) end = min(now, datetime(year + (month == 12), month % 12 + 1, 1)) want, t = [], ref while t < end: if t not in have: want.append(t) t += timedelta(hours=1) if not want: return f"{year}-{month:02d}: complete ({len(have)} hours), nothing to do" new: list[tuple[str, bytes]] = [] xll, yll, cell, nr, nc = TEMP_GRID[:5] top = yll + nr * cell for t in want: v = hourly_field(years, t) if v is None: continue buf = os.path.join(tempfile.gettempdir(), "narr_member.pqf") _write_pqf(buf, v, xll, yll, cell, TEMP_GRID[5]) new.append((t.strftime(cfg.out_fmt), open(buf, "rb").read())) if not new: src_end = max((max(ny.hours) for ny in years.values() if ny.hours), default=None) if src_end is not None and src_end >= end: return (f"{year}-{month:02d}: SOURCE GAP — {len(want)} hour(s) missing and " f"NARR has later data (to {src_end:%Y-%m-%d %H}Z) but none for them") return (f"{year}-{month:02d}: {len(want)} hour(s) missing but NARR has " f"no data for them yet (lag)") if dry_run: return f"{year}-{month:02d}: would add {len(new)} NARR hour(s) to {len(have)} existing" out_tar = os.path.join(tempfile.gettempdir(), f"temp_{year}_{month:02d}.tar") with tarfile.open(out_tar, "w") as tf: for ti, data in old_members: tf.addfile(ti, io.BytesIO(data)) for name, data in sorted(new): ti = tarfile.TarInfo(name=name) ti.size = len(data) ti.mtime = int(datetime.now(timezone.utc).timestamp()) tf.addfile(ti, io.BytesIO(data)) from huggingface_hub import CommitOperationAdd api.create_commit( repo_id=HF_REPO, repo_type="dataset", operations=[CommitOperationAdd(path_in_repo=tar_repo_path, path_or_fileobj=out_tar)], commit_message=f"temp {year}-{month:02d}: +{len(new)} NARR-derived " f"hourly member(s) (32 km air.2m -> 0.125deg, K->C, " f"3h->1h linear)") os.remove(out_tar) return f"{year}-{month:02d}: uploaded +{len(new)} NARR hour(s) ({len(have)} kept)" def main(): ap = argparse.ArgumentParser() ap.add_argument("--months", help="comma list like 2026-06,2026-07") ap.add_argument("--dry-run", action="store_true") args = ap.parse_args() now = datetime.now(timezone.utc) if args.months: months = [tuple(int(x) for x in m.split("-")) for m in args.months.split(",")] else: months = [] back4 = now - timedelta(days=123) # rescan the last ~4 months y, m = max(BACKFILL_START, (back4.year, back4.month)) while (y, m) <= (now.year, now.month): months.append((y, m)) y, m = (y + 1, 1) if m == 12 else (y, m + 1) years: dict[int, NarrYear] = {} need_years = {y for y, _ in months} | {y + 1 for y, mo in months if mo == 12} for yr in sorted(need_years): p = _download_year(yr) if p: try: years[yr] = NarrYear(p) except Exception as e: print(f" [narr] cannot open {p}: {e}") if not years: print("no NARR data available — aborting") return 1 from huggingface_hub import HfApi tok = os.environ.get("HF_TOKEN") or ( open(TOKEN_PATH).read().strip() if os.path.exists(TOKEN_PATH) else None) api = HfApi(token=tok) for y, m in months: try: print(update_month(y, m, years, api, args.dry_run)) except Exception as e: print(f"{y}-{m:02d}: FAILED — {e}") for ny in years.values(): ny.close() return 0 if __name__ == "__main__": sys.exit(main())