| """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: |
| |
| |
| 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) |
| |
| |
| 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 |
|
|
|
|
| @lru_cache(maxsize=16) |
| 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)) |
|
|
|
|
| @lru_cache(maxsize=16) |
| 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: |
| |
| |
| |
| |
| 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)) |
| |
| |
| dataset["geopotential_at_surface"].attrs.setdefault("units", "m**2 s**-2") |
| dataset["land_sea_mask"].attrs.setdefault("units", "dimensionless") |
| return dataset |
|
|