Download scripts/common.py from OneScience-Group/NeuralGCM: direct link, hf CLI and curl.
- Browser
- Download file 16.3 kB
-
https://huggingface.co/OneScience-Group/NeuralGCM/resolve/main/scripts/common.py
- Command line
-
hf download hf://OneScience-Group/NeuralGCM/scripts/common.py
-
curl -L -o common.py https://huggingface.co/OneScience-Group/NeuralGCM/resolve/main/scripts/common.py
16.3 kB
| """Shared config, channel and OneScience ERA5Dataset helpers.""" | |
| from __future__ import annotations | |
| import json | |
| import sys | |
| from functools import lru_cache | |
| from pathlib import Path | |
| from typing import Any | |
| import yaml | |
| PROJECT_ROOT = Path(__file__).resolve().parents[1] | |
| SYNTHETIC_GENERATOR_VERSION = "neuralgcm-hydrostatic-v2" | |
| def load_config(path: str | Path | None = None) -> dict[str, Any]: | |
| path = Path(path or PROJECT_ROOT / "conf/config.yaml") | |
| with path.open(encoding="utf-8") as handle: | |
| return yaml.safe_load(handle) | |
| def resolve_path(value: str | Path, config_path: str | Path | None = None) -> Path: | |
| path = Path(value).expanduser() | |
| if path.is_absolute(): | |
| return path | |
| base = Path(config_path or PROJECT_ROOT / "conf/config.yaml").resolve().parent.parent | |
| return base / path | |
| def channel_order(config: dict[str, Any]) -> list[str]: | |
| return list(config["data"]["channel_order"]) | |
| def pressure_levels(config: dict[str, Any]) -> list[int]: | |
| return list(config["model"]["pressure_levels_hpa"]) | |
| def as_time_major_frames(value: Any, *, name: str = "frames"): | |
| """Normalize OneScience ERA5Dataset output to ``(T, C, H, W)``. | |
| ERA5Dataset squeezes the leading time dimension when ``output_steps=1``; | |
| callers must restore it before indexing forecast frames. Input frames are | |
| allowed to remain ``(C, H, W)`` and should not use this helper. | |
| """ | |
| import numpy as np | |
| if hasattr(value, "detach"): | |
| value = value.detach().cpu().numpy() | |
| value = np.asarray(value) | |
| if value.ndim == 3: | |
| value = value[None, ...] | |
| if value.ndim != 4: | |
| raise ValueError( | |
| f"{name} must have shape (T,C,H,W) or (C,H,W), got {value.shape}" | |
| ) | |
| return value | |
| def load_era5_dataset(config: dict[str, Any], years: list[int], *, input_steps: int | None = None, output_steps: int | None = None): | |
| """Construct the required OneScience ERA5Dataset, without replacing it.""" | |
| try: | |
| from onescience.datapipes.climate import ERA5Dataset | |
| except Exception as exc: | |
| # Source-tree fallback mirrors the earth examples and keeps this | |
| # project usable before OneScience is installed as a wheel. | |
| local_src = Path("/public/home/yangzt01/onescience/src") | |
| if local_src.exists() and str(local_src) not in sys.path: | |
| sys.path.insert(0, str(local_src)) | |
| try: | |
| from onescience.datapipes.climate import ERA5Dataset | |
| except Exception as fallback_exc: | |
| raise RuntimeError( | |
| "OneScience ERA5Dataset import failed; load OneScience and its " | |
| f"runtime modules first: {type(fallback_exc).__name__}: {fallback_exc}" | |
| ) from fallback_exc | |
| data_dir = resolve_path(config["data"]["data_dir"]) | |
| return ERA5Dataset( | |
| dataset_dir=str(data_dir), | |
| used_years=years, | |
| used_variables=channel_order(config), | |
| input_steps=input_steps or int(config["data"]["input_steps"]), | |
| output_steps=output_steps or int(config["data"]["output_steps"]), | |
| normalize=bool(config["data"].get("normalize", False)), | |
| ) | |
| def era5_data_is_synthetic(config: dict[str, Any], years: list[int]) -> bool: | |
| """Return true only when every requested HDF5 file declares synthetic data.""" | |
| import h5py | |
| data_dir = resolve_path(config["data"]["data_dir"]) / "data" | |
| paths = [data_dir / f"{year}.h5" for year in years] | |
| if not paths or any(not path.exists() for path in paths): | |
| return False | |
| try: | |
| for path in paths: | |
| with h5py.File(path, "r") as handle: | |
| fields = handle[config["data"].get("field_key", "fields")] | |
| if not bool(fields.attrs.get("synthetic", False)): | |
| return False | |
| except (KeyError, OSError): | |
| return False | |
| return True | |
| def validate_synthetic_era5_version( | |
| config: dict[str, Any], years: list[int] | |
| ) -> None: | |
| """Reject obsolete virtual fields that are known to destabilize the model.""" | |
| import h5py | |
| data_dir = resolve_path(config["data"]["data_dir"]) / "data" | |
| for year in years: | |
| path = data_dir / f"{year}.h5" | |
| with h5py.File(path, "r") as handle: | |
| fields = handle[config["data"].get("field_key", "fields")] | |
| if not bool(fields.attrs.get("synthetic", False)): | |
| continue | |
| version = fields.attrs.get("generator_version") | |
| if isinstance(version, bytes): | |
| version = version.decode() | |
| if version != SYNTHETIC_GENERATOR_VERSION: | |
| raise RuntimeError( | |
| f"Synthetic ERA5 file {path} uses obsolete generator_version=" | |
| f"{version!r}; expected {SYNTHETIC_GENERATOR_VERSION!r}. " | |
| "Regenerate it with scripts/fake_data.py before running a " | |
| "NeuralGCM rollout." | |
| ) | |
| def write_json(path: Path, payload: dict[str, Any]) -> None: | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| path.write_text(json.dumps(payload, indent=2, default=str) + "\n", encoding="utf-8") | |
| def era5_sample_to_xarray(sample: Any, config: dict[str, Any], *, timestamp: Any): | |
| """Convert one ERA5Dataset frame to the official NeuralGCM xarray contract. | |
| The HDF5 loader returns flattened channels in ``[C, latitude, longitude]``; | |
| official NeuralGCM expects named variables with pressure ``level`` and | |
| explicit latitude/longitude coordinates. Spatial interpolation to the | |
| configured native grid is performed before the model API sees the data. | |
| """ | |
| import numpy as np | |
| import xarray as xr | |
| invar = sample[0] | |
| if hasattr(invar, "detach"): | |
| invar = invar.detach().cpu().numpy() | |
| channels = channel_order(config) | |
| levels = pressure_levels(config) | |
| height, width = invar.shape[-2:] | |
| lat = np.linspace(90.0, -90.0, height, dtype=np.float32) | |
| lon = np.linspace(0.0, 360.0, width, endpoint=False, dtype=np.float32) | |
| # ERA5Dataset stores (latitude, longitude), while NeuralGCM's xarray API | |
| # expects (longitude, latitude) for horizontal fields. | |
| dataset = xr.Dataset(coords={"latitude": lat, "longitude": lon, "time": [np.datetime64(timestamp)]}) | |
| grouped: dict[str, list[tuple[int, Any]]] = {} | |
| for index, name in enumerate(channels): | |
| if name in {"sea_ice_cover", "sea_surface_temperature"}: | |
| values = xr.DataArray(invar[index].T, dims=("longitude", "latitude"), coords={"latitude": lat, "longitude": lon}) | |
| else: | |
| base, _, suffix = name.rpartition("_") | |
| if not suffix.isdigit() or base not in config["model"]["input_variables"] + config["model"].get("optional_input_variables", []): | |
| continue | |
| values = xr.DataArray(invar[index].T, dims=("longitude", "latitude"), coords={"latitude": lat, "longitude": lon}).expand_dims(level=[int(suffix)]) | |
| values = values.expand_dims(time=[np.datetime64(timestamp)]) | |
| grouped.setdefault(base, []).append((int(suffix), values)) | |
| continue | |
| values = values.expand_dims(time=[np.datetime64(timestamp)]) | |
| dataset[name] = values | |
| for base, entries in grouped.items(): | |
| entries.sort(key=lambda item: levels.index(item[0]) if item[0] in levels else item[0]) | |
| merged = xr.concat([value for _, value in entries], dim="level") | |
| dataset[base] = merged.transpose("time", "level", "longitude", "latitude") if "time" in merged.dims else merged.transpose("level", "longitude", "latitude") | |
| return dataset | |
| def era5_frames_to_xarray( | |
| frames: Any, | |
| config: dict[str, Any], | |
| *, | |
| start_time: Any, | |
| ): | |
| """Vectorized ERA5 ``(T,C,H,W)`` to NeuralGCM xarray conversion. | |
| This is equivalent to concatenating ``era5_sample_to_xarray`` outputs, but | |
| constructs every multi-level variable in one operation. It avoids hundreds | |
| of small DataArray allocations per training window. | |
| """ | |
| import numpy as np | |
| import xarray as xr | |
| frames = as_time_major_frames(frames, name="ERA5 trajectory") | |
| channels = channel_order(config) | |
| if frames.shape[1] != len(channels): | |
| raise ValueError( | |
| f"ERA5 trajectory has {frames.shape[1]} channels, expected " | |
| f"{len(channels)}" | |
| ) | |
| n_time, _, height, width = frames.shape | |
| lat = np.linspace(90.0, -90.0, height, dtype=np.float32) | |
| lon = np.linspace(0.0, 360.0, width, endpoint=False, dtype=np.float32) | |
| step_hours = int(config["data"].get("time_step_hours", 6)) | |
| times = np.datetime64(start_time) + np.arange(n_time) * np.timedelta64(step_hours, "h") | |
| coords = {"time": times, "latitude": lat, "longitude": lon} | |
| dataset = xr.Dataset(coords=coords) | |
| level_indices: dict[str, list[tuple[int, int]]] = {} | |
| allowed = set(config["model"]["input_variables"]) | |
| allowed.update(config["model"].get("optional_input_variables", [])) | |
| for channel_index, name in enumerate(channels): | |
| if name in {"sea_ice_cover", "sea_surface_temperature"}: | |
| dataset[name] = ( | |
| ("time", "longitude", "latitude"), | |
| np.asarray(frames[:, channel_index]).transpose(0, 2, 1), | |
| ) | |
| continue | |
| base, _, suffix = name.rpartition("_") | |
| if suffix.isdigit() and base in allowed: | |
| level_indices.setdefault(base, []).append((int(suffix), channel_index)) | |
| configured_levels = pressure_levels(config) | |
| for base, entries in level_indices.items(): | |
| entries.sort( | |
| key=lambda item: configured_levels.index(item[0]) | |
| if item[0] in configured_levels | |
| else item[0] | |
| ) | |
| indices = [index for _, index in entries] | |
| levels = [level for level, _ in entries] | |
| values = np.asarray(frames[:, indices]).transpose(0, 1, 3, 2) | |
| dataset[base] = ( | |
| ("time", "level", "longitude", "latitude"), | |
| values, | |
| ) | |
| dataset = dataset.assign_coords(level=np.asarray(levels)) | |
| return dataset | |
| def _target_grid(mode: str): | |
| from dinosaur import spherical_harmonic | |
| targets = { | |
| "weather_forecast": spherical_harmonic.Grid.TL255, | |
| "climate_scale": spherical_harmonic.Grid.TL127, | |
| "forecast_2_8_deg": spherical_harmonic.Grid.TL63, | |
| "stochastic_1_4_deg": spherical_harmonic.Grid.TL127, | |
| } | |
| try: | |
| return targets[mode]() | |
| except KeyError as exc: | |
| raise ValueError(f"Unknown model mode {mode!r}") from exc | |
| def _profile_regridder( | |
| height: int, | |
| width: int, | |
| mode: str, | |
| latitude_spacing: str, | |
| longitude_offset: float, | |
| ): | |
| """Construct and cache the profile's conservative regridder.""" | |
| from dinosaur import horizontal_interpolation, spherical_harmonic | |
| source_grid = spherical_harmonic.Grid( | |
| latitude_nodes=height, | |
| longitude_nodes=width, | |
| latitude_spacing=latitude_spacing, | |
| longitude_offset=longitude_offset, | |
| ) | |
| return horizontal_interpolation.ConservativeRegridder( | |
| source_grid, _target_grid(mode), skipna=True | |
| ) | |
| def regrid_for_neuralgcm(dataset: Any, official_model: Any): | |
| """Conservatively regrid ERA5 fields to the checkpoint's Gaussian grid.""" | |
| from dinosaur import horizontal_interpolation | |
| from dinosaur import spherical_harmonic | |
| from dinosaur import xarray_utils | |
| source_grid = spherical_harmonic.Grid( | |
| latitude_nodes=dataset.sizes["latitude"], | |
| longitude_nodes=dataset.sizes["longitude"], | |
| latitude_spacing=xarray_utils.infer_latitude_spacing(dataset.latitude), | |
| longitude_offset=xarray_utils.infer_longitude_offset(dataset.longitude), | |
| ) | |
| regridder = horizontal_interpolation.ConservativeRegridder( | |
| source_grid, official_model.data_coords.horizontal, skipna=True | |
| ) | |
| regridded = xarray_utils.regrid(dataset, regridder) | |
| return xarray_utils.fill_nan_with_nearest(regridded) | |
| def regrid_for_profile(dataset: Any, mode: str): | |
| """Regrid to the Gaussian data grid selected by an official Gin profile.""" | |
| from dinosaur import xarray_utils | |
| regridder = _profile_regridder( | |
| dataset.sizes["latitude"], | |
| dataset.sizes["longitude"], | |
| mode, | |
| xarray_utils.infer_latitude_spacing(dataset.latitude), | |
| float(xarray_utils.infer_longitude_offset(dataset.longitude)), | |
| ) | |
| return xarray_utils.fill_nan_with_nearest(xarray_utils.regrid(dataset, regridder)) | |
| def _load_static_features( | |
| path_text: str, | |
| mode: str | None, | |
| target_height: int, | |
| target_width: int, | |
| ): | |
| """Load and, only when necessary, regrid a reusable static dataset.""" | |
| import xarray as xr | |
| with xr.open_dataset(path_text) as source: | |
| static = source[["geopotential_at_surface", "land_sea_mask"]].load() | |
| source_shape = ( | |
| static.sizes.get("latitude"), | |
| static.sizes.get("longitude"), | |
| ) | |
| if source_shape != (target_height, target_width): | |
| if mode is None: | |
| return None | |
| static = regrid_for_profile(static, mode) | |
| if ( | |
| static.sizes.get("latitude"), | |
| static.sizes.get("longitude"), | |
| ) != (target_height, target_width): | |
| return None | |
| return static | |
| def add_static_features( | |
| dataset: Any, | |
| config: dict[str, Any] | None = None, | |
| *, | |
| mode: str | None = None, | |
| prefer_profile: bool = True, | |
| ): | |
| """Attach official profile static fields, with a synthetic fallback. | |
| Callers attach fields after regridding the dynamic ERA5 trajectory. This | |
| preserves the exact Gaussian-grid topography and land/sea mask bundled in | |
| the official checkpoints. ``data.static_file`` remains a source-grid | |
| fallback for installations that do not carry the released checkpoints. | |
| """ | |
| import numpy as np | |
| required = ("geopotential_at_surface", "land_sea_mask") | |
| if config is not None and not set(required).issubset(dataset): | |
| data_cfg = config.get("data", {}) | |
| profile_path = ( | |
| data_cfg.get("static_files", {}).get(mode) if mode else None | |
| ) | |
| fallback_path = data_cfg.get("static_file") | |
| candidates = [] | |
| if not prefer_profile and fallback_path: | |
| candidates.append(fallback_path) | |
| if mode: | |
| if profile_path: | |
| candidates.append(profile_path) | |
| if prefer_profile and fallback_path: | |
| candidates.append(fallback_path) | |
| for value in candidates: | |
| static_path = resolve_path(value) | |
| if not static_path.exists(): | |
| continue | |
| static = _load_static_features( | |
| str(static_path.resolve()), | |
| mode, | |
| int(dataset.sizes["latitude"]), | |
| int(dataset.sizes["longitude"]), | |
| ) | |
| if static is None: | |
| continue | |
| for name in required: | |
| if name not in dataset: | |
| # Both arrays are on the same profile Gaussian grid. Assign | |
| # by position rather than xarray label alignment: checkpoint | |
| # coordinates are float64 while regridded ERA5 coordinates | |
| # can be float32, and exact-label alignment would inject NaN. | |
| values = static[name].transpose("longitude", "latitude") | |
| dataset[name] = ( | |
| ("longitude", "latitude"), | |
| np.asarray(values.values), | |
| ) | |
| dataset[name].attrs.update(values.attrs) | |
| dataset.attrs["static_features_source"] = str(static_path) | |
| break | |
| if "geopotential_at_surface" not in dataset: | |
| dataset["geopotential_at_surface"] = (("longitude", "latitude"), np.zeros((dataset.sizes["longitude"], dataset.sizes["latitude"]), np.float32)) | |
| if "land_sea_mask" not in dataset: | |
| dataset["land_sea_mask"] = (("longitude", "latitude"), np.zeros((dataset.sizes["longitude"], dataset.sizes["latitude"]), np.float32)) | |
| # Gin FloatDataFeatures parses units from these static fields exactly as in | |
| # the official ERA5 pipeline. | |
| dataset["geopotential_at_surface"].attrs.setdefault("units", "m**2 s**-2") | |
| dataset["land_sea_mask"].attrs.setdefault("units", "dimensionless") | |
| return dataset | |