NeuralGCM / scripts /common.py
yzt15806542928's picture
Upload folder using huggingface_hub
f4a39ee verified
Raw
History Blame Contribute Delete
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
@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:
# 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