Spaces:
Running on Zero
Running on Zero
Download prep.py from MeteoSwiss/varda-single-1-0: direct link, hf CLI and curl.
- Browser
- Download file 10.9 kB
-
https://huggingface.co/spaces/MeteoSwiss/varda-single-1-0/resolve/main/prep.py
- Command line
-
hf download hf://spaces/MeteoSwiss/varda-single-1-0/prep.py
-
curl -L -o prep.py https://huggingface.co/spaces/MeteoSwiss/varda-single-1-0/resolve/main/prep.py
10.9 kB
| """Prepare Varda-single-1.0 initial conditions in a standalone process. | |
| Fetches the KENDA-CH1 (regional, 1 km) and ECMWF open-data IFS (global) analyses at `init` and | |
| `init - 6h`, preprocesses them exactly like `anemoi-inference run` does, and saves: | |
| * the model input tensor (multi_step_input, variables, values) -> <out> | |
| * the mesh (lat, lon, lam mask) -> <mesh> (if missing) | |
| Running this out-of-process keeps the native GRIB/regridding libraries (eccodes, MIR, atlas…) | |
| out of the ZeroGPU main process, and lets the web app stay responsive meanwhile. | |
| Usage: python prep.py <config.json> <init ISO> <out.npy> <mesh.npz> | |
| """ | |
| import json | |
| import logging | |
| import os | |
| import sys | |
| import threading | |
| import time | |
| from concurrent.futures import Future, ThreadPoolExecutor, wait | |
| from datetime import datetime, timedelta | |
| from eccodes_cosmo_resources import get_definitions_path | |
| os.environ["ECCODES_DEFINITION_PATH"] = str(get_definitions_path()) | |
| os.environ.setdefault("ECCODES_VERSION_CHECK_OFF", "1") | |
| os.environ["CUDA_VISIBLE_DEVICES"] = "" # CPU only | |
| import earthkit.data as ekd # noqa: E402 | |
| import numpy as np # noqa: E402 | |
| import requests # noqa: E402 | |
| from anemoi_plugins_meteoswiss.inference.inputs import oper_kenda_opendata as kenda # noqa: E402 | |
| from anemoi_plugins_meteoswiss.transform.filters.vertical_interpolation import ModelToPressureLevel # noqa: E402 | |
| from anemoi.inference.config.run import RunConfiguration # noqa: E402 | |
| from anemoi.inference.runners import create_runner # noqa: E402 | |
| logging.basicConfig(level=logging.INFO, format="[prep] %(levelname)s %(name)s: %(message)s") | |
| LOG = logging.getLogger("prep") | |
| # The KENDA-CH1 server is throttled per connection (~1-2 MB/s seen from the Space) but supports HTTP | |
| # Range requests, so every file is fetched as CHUNK-sized byte ranges over many connections at once. | |
| CHUNK = 16 * 1024 * 1024 | |
| CONNECTIONS = 32 | |
| DOWNLOAD_RETRIES = 4 | |
| # KENDA-CH1 fields the Varda input needs at each analysis time (logged by the plugin as | |
| # "fetching ... variable(s)"). Only used to start downloads early: anything missing from this | |
| # list is still fetched by the plugin itself, an extra entry just costs bandwidth. | |
| KENDA_VARS = ["P", "PMSL", "PS", "QV", "T", "TD_2M", "T_2M", "U", "U_10M", "V", "V_10M"] | |
| INPUT_TIMES_H = (-6, 0) # the forecaster needs the analyses at init - 6h and init | |
| _pool = ThreadPoolExecutor(64) # one light coordinating thread per file | |
| _chunks = ThreadPoolExecutor(CONNECTIONS) # the actual transfers; FIFO, so files finish in queue order | |
| _inflight: dict[str, Future] = {} # destination path -> download, shared by prefetch and plugin | |
| _inflight_lock = threading.Lock() | |
| _session = threading.local() | |
| def _http() -> requests.Session: | |
| if not hasattr(_session, "s"): # one keep-alive session per thread | |
| _session.s = requests.Session() | |
| return _session.s | |
| def _retrying(what: str, fn): | |
| for attempt in range(1, DOWNLOAD_RETRIES + 1): | |
| try: | |
| return fn() | |
| except Exception as e: # noqa: BLE001 | |
| LOG.warning(" %s: attempt %d/%d failed: %s", what, attempt, DOWNLOAD_RETRIES, e) | |
| if attempt == DOWNLOAD_RETRIES: | |
| raise | |
| time.sleep(3 * attempt) | |
| def _get_range(href: str, fd: int, start: int, end: int) -> None: | |
| """Fetch bytes [start, end] of `href` into `fd` at the same offset.""" | |
| # (connect, read) timeouts: a transfer that sends nothing for 60 s is retried | |
| with _http().get(href, headers={"Range": f"bytes={start}-{end}"}, stream=True, timeout=(30, 60)) as r: | |
| r.raise_for_status() | |
| if r.status_code != 206: | |
| raise IOError(f"server ignored the Range header (HTTP {r.status_code})") | |
| pos = start | |
| for block in r.iter_content(chunk_size=1024 * 1024): | |
| os.pwrite(fd, block, pos) | |
| pos += len(block) | |
| if pos != end + 1: | |
| raise IOError(f"short range {start}-{end}: got {pos - start} bytes") | |
| def _size(href: str) -> int: | |
| # a 1-byte GET rather than HEAD: presigned URLs are often signed for GET only | |
| with _http().get(href, headers={"Range": "bytes=0-0"}, stream=True, timeout=(30, 60)) as r: | |
| r.raise_for_status() | |
| if r.status_code == 206: | |
| return int(r.headers["Content-Range"].rsplit("/", 1)[1]) | |
| return -1 # no Range support | |
| def _download_stream(href: str, part: str) -> None: | |
| with _http().get(href, stream=True, timeout=(30, 60)) as r, open(part, "wb") as f: | |
| r.raise_for_status() | |
| for block in r.iter_content(chunk_size=4 * 1024 * 1024): | |
| f.write(block) | |
| def _download(href: str, path: str) -> None: | |
| """Download `href` to `path` (via a .part file) as parallel byte ranges, each retried on failure.""" | |
| name = os.path.relpath(path, os.path.dirname(os.path.dirname(path))) | |
| part = path + ".part" | |
| t0 = time.time() | |
| size = _retrying(name, lambda: _size(href)) | |
| if size < 0: | |
| _retrying(name, lambda: _download_stream(href, part)) | |
| else: | |
| fd = os.open(part, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644) | |
| try: | |
| os.ftruncate(fd, size) | |
| ranges = [(s, min(s + CHUNK, size) - 1) for s in range(0, size, CHUNK)] | |
| futs = [ | |
| _chunks.submit(_retrying, f"{name}@{s}", lambda s=s, e=e: _get_range(href, fd, s, e)) | |
| for s, e in ranges | |
| ] | |
| wait(futs) # let every range finish before closing fd, even if one failed for good | |
| for f in futs: | |
| f.result() | |
| finally: | |
| os.close(fd) | |
| os.replace(part, path) | |
| dt = time.time() - t0 | |
| mb = os.path.getsize(path) / 1e6 | |
| LOG.info(" %s: %.0f MB in %.0fs (%.1f MB/s)", name, mb, dt, mb / max(dt, 1e-3)) | |
| def _fetch(href: str, path: str) -> Future | None: | |
| """Start downloading `href` to `path` unless it is cached or already being downloaded.""" | |
| with _inflight_lock: | |
| if path in _inflight: | |
| return _inflight[path] | |
| if os.path.exists(path): | |
| return None | |
| os.makedirs(os.path.dirname(path), exist_ok=True) | |
| fut = _inflight[path] = _pool.submit(_download, href, path) | |
| return fut | |
| def _parallel_cached_fields(cache_dir, subdir, hrefs_by_filename): | |
| """Drop-in for the plugin's `_cached_fields`: same on-disk cache layout, but missing KENDA-CH1 | |
| assets (~180-275 MB each for the 3-D fields) download concurrently, reusing prefetches in flight.""" | |
| if not cache_dir: | |
| return _orig_cached_fields(cache_dir, subdir, hrefs_by_filename) | |
| paths = {fn: os.path.join(cache_dir, subdir, fn) for fn in hrefs_by_filename} | |
| t0 = time.time() | |
| futs = [f for f in (_fetch(hrefs_by_filename[fn], p) for fn, p in paths.items()) if f is not None] | |
| for f in futs: | |
| f.result() | |
| if futs: | |
| LOG.info("KENDA-CH1 %s: waited %.0fs for %d download(s)", subdir, time.time() - t0, len(futs)) | |
| return ekd.from_source("file", list(paths.values())) | |
| _orig_cached_fields = kenda._cached_fields | |
| kenda._cached_fields = _parallel_cached_fields | |
| def _m2p_forward_parallel(self, data): | |
| """ModelToPressureLevel.forward with the analysis times interpolated in parallel threads (the GRIB | |
| decoding and numpy work release the GIL): ~1.6x faster than the plugin's serial loop, same output.""" | |
| groups = [ekd.SimpleFieldList(list(g)) for g in data.group_by("valid_datetime")] | |
| if len(groups) < 2: | |
| return _orig_m2p_forward(self, data) | |
| with ThreadPoolExecutor(len(groups)) as pool: | |
| outs = list(pool.map(lambda g: _orig_m2p_forward(self, g), groups)) | |
| # same order as the serial filter: every interpolated field first, then the surface passthrough | |
| fields = [f for o in outs for f in o] | |
| interp = [f for f in fields if f.metadata("typeOfLevel") == "isobaricInhPa"] | |
| rest = [f for f in fields if f.metadata("typeOfLevel") != "isobaricInhPa"] | |
| return ekd.SimpleFieldList(interp + rest) | |
| _orig_m2p_forward = ModelToPressureLevel.forward | |
| ModelToPressureLevel.forward = _m2p_forward_parallel | |
| def _prefetch_kenda(cache_dir: str, init: datetime) -> None: | |
| """Queue every KENDA-CH1 download the run needs (grid constants + both analysis times) at once, | |
| so the t download overlaps decoding t-6h instead of waiting for it. Best effort.""" | |
| try: | |
| for name, href in kenda._collection_static_assets().items(): | |
| _fetch(href, os.path.join(cache_dir, "constants", name)) | |
| for dh in INPUT_TIMES_H: | |
| t = init + timedelta(hours=dh) | |
| hrefs = kenda._hrefs_by_variable(kenda._stac_items(t)) | |
| for v in KENDA_VARS: | |
| if v in hrefs: | |
| _fetch(hrefs[v], os.path.join(cache_dir, "raw", t.strftime("%Y%m%dT%H%M%S"), f"{v}.grib2")) | |
| LOG.info("prefetch: %d KENDA-CH1 download(s) queued", sum(not f.done() for f in _inflight.values())) | |
| except Exception as e: # noqa: BLE001 | |
| LOG.warning("prefetch failed (the plugin will fetch on demand): %s", e) | |
| def _kenda_cache_dir(cfg: dict) -> str | None: | |
| for src in cfg["input"]["cutout"]: | |
| for name, opts in src.get("lam_0", {}).items(): | |
| if name.replace("_", "-") == "oper-kenda-opendata": | |
| return opts.get("cache_dir") | |
| return None | |
| def main(cfg_path: str, init: str, out: str, mesh: str) -> None: | |
| with open(cfg_path) as f: | |
| cfg = json.load(f) | |
| cfg["device"] = "cpu" | |
| date = datetime.fromisoformat(init) | |
| t0 = time.time() | |
| cache_dir = _kenda_cache_dir(cfg) | |
| if cache_dir: | |
| threading.Thread(target=_prefetch_kenda, args=(cache_dir, date), daemon=True).start() | |
| runner = create_runner(RunConfiguration.load(cfg)) | |
| ds = runner.dataset_names[0] | |
| prog = runner.prognostics_inputs[ds].create_input_state(date=date) | |
| LOG.info("prognostics fetched in %.0fs", time.time() - t0) | |
| const = runner.constant_forcings_inputs[ds].create_input_state(date=date) | |
| forc = runner.dynamic_forcings_inputs[ds].create_input_state(date=date) | |
| LOG.info("forcings ready at %.0fs", time.time() - t0) | |
| state = runner._combine_states(prog, const, forc) | |
| tensor = runner.tensor_handlers[ds].prepare_input_tensor(state) | |
| LOG.info("input tensor %s ready in %.0fs", tensor.shape, time.time() - t0) | |
| if not os.path.exists(mesh): | |
| np.savez( | |
| mesh + ".part.npz", | |
| lat=np.asarray(state["latitudes"], dtype=np.float64), | |
| lon=np.asarray(state["longitudes"], dtype=np.float64), | |
| lam=np.asarray(state["_mask"]["lam_0"], dtype=bool), | |
| ) | |
| os.replace(mesh + ".part.npz", mesh) | |
| np.save(out + ".part.npy", tensor.astype(np.float32)) | |
| os.replace(out + ".part.npy", out) | |
| _pool.shutdown(wait=False, cancel_futures=True) | |
| _chunks.shutdown(wait=False, cancel_futures=True) | |
| if __name__ == "__main__": | |
| main(*sys.argv[1:5]) | |