Source code for neural_lam.datastore.mdp

"""Datastore implementation wrapping ``mllam-data-prep`` outputs."""

# Standard library
import copy
import functools
import warnings
from datetime import timedelta
from functools import cached_property
from pathlib import Path

# Third-party
import cartopy.crs as ccrs
import mllam_data_prep as mdp
import numpy as np
import xarray as xr
from loguru import logger
from numpy import ndarray

# Local
from ..utils.logging import log_on_rank_zero
from .base import BaseDatastore, CartesianGridShape


[docs] class MDPDatastore(BaseDatastore): """ Datastore class for datasets made with the mllam_data_prep library (https://github.com/mllam/mllam-data-prep). This class wraps the `mllam_data_prep` library to do the necessary transforms to create the different categories (state/forcing/static) of data, with the actual transform to do being specified in the configuration file. """ SHORT_NAME = "mdp" _ds: xr.Dataset def __init__( self, config_path: str | Path, n_boundary_points: int = 30, reuse_existing: bool = True, ) -> None: """ Construct a new MDPDatastore from the configuration file at `config_path`. A boundary mask is created with `n_boundary_points` boundary points. If `reuse_existing` is True, the dataset is loaded from a zarr file if it exists (unless the config has been modified since the zarr was created), otherwise it is created from the configuration file. Parameters ---------- config_path : str or Path The path to the configuration file, this will be fed to the `mllam_data_prep.Config.from_yaml_file` method to then call `mllam_data_prep.create_dataset` to create the dataset. n_boundary_points : int Width, in grid points, of the boundary along each x/y edge of the domain. 0 gives no boundary points. reuse_existing : bool Whether to reuse an existing dataset zarr file if it exists and its creation date is newer than the configuration file. Raises ------ ValueError If the dataset does not contain all of the required train/val/test splits. """ self._config_path = Path(config_path).resolve() self._root_path = self._config_path.parent self._config = mdp.Config.from_yaml_file(self._config_path) fp_ds = self._root_path / self._config_path.name.replace( ".yaml", ".zarr" ) _ds = None if reuse_existing and fp_ds.exists(): # check that the zarr directory is newer than the config file if fp_ds.stat().st_mtime < self._config_path.stat().st_mtime: logger.warning( "Config file has been modified since zarr was created. " f"The old zarr archive (in {fp_ds}) will be used." "To generate new zarr-archive, move the old one first." ) _ds = xr.open_zarr(fp_ds, consolidated=True) if _ds is None: _ds = mdp.create_dataset(config=self._config_for_creation()) _ds.to_zarr(fp_ds) if "state" not in _ds and "forcing" not in _ds: raise ValueError( f"Datastore at {self._config_path} contains neither 'state' " "nor 'forcing' data. At least one is required." ) self._ds = _ds self._n_boundary_points = n_boundary_points self.is_ensemble = ( "state" in self._ds and "ensemble_member" in self._ds["state"].dims ) self.has_ensemble_forcing = ( "forcing" in self._ds and "ensemble_member" in self._ds["forcing"].dims ) log_on_rank_zero( "The loaded datastore contains the following features:" ) for category in ["state", "forcing", "static"]: if len(self.get_vars_names(category)) > 0: var_names = self.get_vars_names(category) log_on_rank_zero(f" {category:<8s}: {' '.join(var_names)}") # check that all three train/val/test splits are available required_splits = ["train", "val", "test"] available_splits = list(self._ds.splits.split_name.values) if not all(split in available_splits for split in required_splits): raise ValueError( f"Missing required splits: {required_splits} in available " f"splits: {available_splits}" ) log_on_rank_zero("With the following splits (over time):") for split in required_splits: da_split = self._ds.splits.sel(split_name=split) da_split_start = da_split.sel(split_part="start").load().item() da_split_end = da_split.sel(split_part="end").load().item() log_on_rank_zero( f" {split:<8s}: {da_split_start} to {da_split_end}" ) # find out the dimension order for the stacking to grid-index dim_order = None for input_dataset in self._config.inputs.values(): dim_order_ = input_dataset.dim_mapping["grid_index"].dims if dim_order is None: dim_order = dim_order_ else: assert dim_order == dim_order_, ( "all inputs must have the same dimension order" ) if dim_order is None: raise ValueError("Could not determine dim_order from inputs.") self.spatial_coordinates = dim_order def _config_for_creation(self) -> mdp.Config: """Return the config to build the dataset from, with a relative `interior_dataset_config_path` made relative to this config file rather than the working directory. Returns ------- mdp.Config The config, with any cropping path made absolute. """ config = copy.deepcopy(self._config) domain_cropping = config.output.domain_cropping if domain_cropping is not None: interior_path = Path(domain_cropping.interior_dataset_config_path) if not interior_path.is_absolute(): domain_cropping.interior_dataset_config_path = str( self._root_path / interior_path ) return config @property def root_path(self) -> Path: """The root path of the dataset. Returns ------- Path The root path of the dataset. """ return self._root_path @property def config(self) -> mdp.Config: """The configuration of the dataset. Returns ------- mdp.Config The configuration of the dataset. """ return self._config @property def step_length(self) -> timedelta: """The length of the time steps as a time interval. Returns ------- timedelta The length of the time steps as a datetime.timedelta object. """ da_dt = self._ds["time"].diff("time") total_sec = da_dt.dt.total_seconds().isel(time=0).astype(int) return timedelta(seconds=int(total_sec.item()))
[docs] def get_vars_units(self, category: str) -> list[str]: """Return the units of the variables in the given category. Parameters ---------- category : str The category of the dataset (state/forcing/static). Returns ------- list[str] The units of the variables in the given category. """ if category not in self._ds: if category in ("forcing", "static"): warnings.warn(f"no {category} data found in datastore") return [] return self._ds[f"{category}_feature_units"].values.tolist()
[docs] def get_vars_names(self, category: str) -> list[str]: """Return the names of the variables in the given category. Parameters ---------- category : str The category of the dataset (state/forcing/static). Returns ------- list[str] The names of the variables in the given category. """ if category not in self._ds: if category in ("forcing", "static"): warnings.warn(f"no {category} data found in datastore") return [] return self._ds[f"{category}_feature"].values.tolist()
[docs] def get_vars_long_names(self, category: str) -> list[str]: """ Return the long names of the variables in the given category. Parameters ---------- category : str The category of the dataset (state/forcing/static). Returns ------- list[str] The long names of the variables in the given category. """ if category not in self._ds: if category in ("forcing", "static"): warnings.warn(f"no {category} data found in datastore") return [] return self._ds[f"{category}_feature_long_name"].values.tolist()
[docs] def get_num_data_vars(self, category: str) -> int: """Return the number of variables in the given category. Parameters ---------- category : str The category of the dataset (state/forcing/static). Returns ------- int The number of variables in the given category. """ return len(self.get_vars_names(category))
[docs] def get_dataarray( self, category: str, split: str | None, standardize: bool = False, ) -> xr.DataArray | None: """ Return the processed data (as a single `xr.DataArray`) for the given category of data and test/train/val-split that covers all the data (in space and time) of a given category (state/forcing/static). "state" is the only required category, for other categories, the method will return `None` if the category is not found in the datastore. The returned dataarray will at minimum have dimensions of `(grid_index, {category}_feature)` so that any spatial dimensions have been stacked into a single dimension and all variables and levels have been stacked into a single feature dimension named by the `category` of data being loaded. For categories of data that have a time dimension (i.e. not static data), the dataarray will additionally have `(analysis_time, elapsed_forecast_duration)` dimensions if `is_forecast` is True, or `(time)` if `is_forecast` is False. If we have multiple ensemble members of state data, the returned state dataarray will have an additional `ensemble_member` dimension. If `has_ensemble_forcing=True`, the returned forcing dataarray will also have an additional `ensemble_member` dimension. Parameters ---------- category : str The category of the dataset (state/forcing/static). split : str The time split to filter the dataset (train/val/test). standardize: bool If the dataarray should be returned standardized Returns ------- xr.DataArray or None The xarray DataArray object with processed dataset. """ if category not in self._ds and category in ("forcing", "static"): warnings.warn(f"no {category} data found in datastore") return None da_category = self._ds[category] # set units on x y coordinates if missing geographic_units = { "longitude": "degrees_east", "latitude": "degrees_north", "lon": "degrees_east", "lat": "degrees_north", } for coord in self.spatial_coordinates: if "units" not in da_category[coord].attrs: da_category[coord].attrs["units"] = geographic_units.get( coord, "m" ) # set multi-index for grid-index da_category = da_category.set_index(grid_index=self.spatial_coordinates) if "time" in da_category.dims: t_start = ( self._ds.splits.sel(split_name=split) .sel(split_part="start") .load() .item() ) t_end = ( self._ds.splits.sel(split_name=split) .sel(split_part="end") .load() .item() ) da_category = da_category.sel(time=slice(t_start, t_end)) dim_order = self.expected_dim_order(category=category) da_category = da_category.transpose(*dim_order) if standardize: return self._standardize_datarray(da_category, category=category) return da_category
[docs] def get_standardization_dataarray(self, category: str) -> xr.Dataset: """ Return the standardization dataarray for the given category. This should contain a `{category}_mean` and `{category}_std` variable for each variable in the category. For `category=="state"`, the dataarray should also contain a `state_diff_mean_standardized` and `state_diff_std_standardized` variable for the one-step differences of the state variables. Parameters ---------- category : str The category of the dataset (state/forcing/static). Returns ------- xr.Dataset The standardization dataarray for the given category, with variables for the mean and standard deviation of the variables (and differences for state variables). """ ops = ["mean", "std"] split = "train" stats_variables = { f"{category}__{split}__{op}": f"{category}_{op}" for op in ops } ds_stats = self._ds[stats_variables.keys()].rename(stats_variables) # Add standardized state diff stats if category == "state": ds_stats = ds_stats.assign( { f"state_diff_{op}_standardized": self._ds[ f"state__{split}__diff_{op}" ] / ds_stats["state_std"] for op in ops } ) return ds_stats
@cached_property def boundary_mask(self) -> xr.DataArray: """ Produce a 0/1 mask for the boundary points of the dataset, these will sit at the edges of the domain (in x/y extent) and will be used to mask out the boundary points from the loss function and to overwrite the boundary points from the prediction. For now this is created when the mask is requested, but in the future this could be saved to the zarr file. Returns ------- xr.DataArray A 0/1 mask for the boundary points of the dataset, where 1 is a boundary point and 0 is not. Raises ------ NotImplementedError If the datastore provides no ``state`` data. """ if "state" not in self._ds: raise NotImplementedError( "`boundary_mask` is only defined for datastores with `state` " "data." ) ds_unstacked = self.unstack_grid_coords(da_or_ds=self._ds) da_state_variable = ( ds_unstacked["state"].isel(time=0).isel(state_feature=0) ) da_domain_allzero = xr.zeros_like(da_state_variable) # slice(n, -n) is empty (not "the whole array") when n == 0 n = self._n_boundary_points interior_slice = slice(n, -n) if n > 0 else slice(None) ds_unstacked["boundary_mask"] = da_domain_allzero.isel( x=interior_slice, y=interior_slice, ) ds_unstacked["boundary_mask"] = ds_unstacked.boundary_mask.fillna( 1 ).astype(int) boundary_mask = self.stack_grid_coords( da_or_ds=ds_unstacked.boundary_mask ) assert isinstance(boundary_mask, xr.DataArray) return boundary_mask @property def coords_projection(self) -> ccrs.Projection: """ Return the projection of the coordinates. NOTE: currently this expects the projection information to be in the `extra` section of the configuration file, with a `projection` key containing a `class_name` and `kwargs` for constructing the `cartopy.crs.Projection` object. This is a temporary solution until the projection information can be parsed in the produced dataset itself. `mllam-data-prep` ignores the contents of the `extra` section of the config file which is why we need to check that the necessary parts are there. Returns ------- ccrs.Projection The projection of the coordinates. Raises ------ ValueError If the `projection` entry is missing from the `extra` section of the config, or if its `class_name` or `kwargs` keys are missing. """ if "projection" not in self._config.extra: raise ValueError( "projection information not found in the configuration file " f"({self._config_path}). Please add the projection information" "to the `extra` section of the config, by adding a " "`projection` key with the class name and kwargs of the " "projection." ) projection_info = self._config.extra["projection"] if "class_name" not in projection_info: raise ValueError( "class_name not found in the projection information. Please " "add the class name of the projection to the `projection` key " "in the `extra` section of the config." ) if "kwargs" not in projection_info: raise ValueError( "kwargs not found in the projection information. Please add " "the keyword arguments of the projection to the `projection` " "key in the `extra` section of the config." ) class_name = projection_info["class_name"] ProjectionClass = getattr(ccrs, class_name) # need to copy otherwise we modify the dict stored in the dataclass # in-place kwargs = copy.deepcopy(projection_info["kwargs"]) globe_kwargs = kwargs.pop("globe", {}) if len(globe_kwargs) > 0: kwargs["globe"] = ccrs.Globe(**globe_kwargs) return ProjectionClass(**kwargs) @cached_property def _grid_xy_counts(self) -> tuple[int, int, int]: """Number of distinct ``x`` values, ``y`` values and ``(x, y)`` pairs, read from the coordinates since unstacking is gated on `is_on_rectilinear_grid`, which is derived from these. Returns ------- tuple[int, int, int] Number of distinct ``x`` values, ``y`` values and ``(x, y)`` pairs. """ category = "state" if "state" in self._ds else "forcing" da_cat = self._ds[category] xdim, ydim = self.spatial_coordinates xs = da_cat[xdim].values ys = da_cat[ydim].values n_pairs = np.unique(np.stack([xs, ys], axis=1), axis=0).shape[0] return np.unique(xs).size, np.unique(ys).size, n_pairs @cached_property def grid_shape_state(self) -> CartesianGridShape: """The shape of the cartesian grid for the state variables. Returns ------- CartesianGridShape The shape of the cartesian grid for the state variables. Raises ------ NotImplementedError If the datastore is not on a rectilinear grid, e.g. after ``domain_cropping``. Use `num_grid_points` instead. """ self._check_on_rectilinear_grid("grid_shape_state") n_x, n_y, _ = self._grid_xy_counts return CartesianGridShape(x=n_x, y=n_y)
[docs] def get_xy(self, category: str, stacked: bool) -> ndarray: """ Return the x, y coordinates of the dataset (i.e. the Cartesian coordinates of the regular gridded dataset). Parameters ---------- category : str The category of the dataset (state/forcing/static). stacked : bool Whether to stack the x, y coordinates. `stacked=False` requires `is_on_rectilinear_grid`. Returns ------- np.ndarray The x, y coordinates of the dataset, returned differently based on the value of `stacked`: * `stacked==True`: shape `(num_grid_nodes, 2)`, with num_grid_nodes=N_x*N_y on a rectilinear grid. * `stacked==False`: shape `(N_x, N_y, 2)` """ xdim, ydim = self.spatial_coordinates if stacked and not self.is_on_rectilinear_grid: # There is no x/y grid to unstack onto, so return the # coordinates of the grid points in `grid_index` order da_cat = self._ds[category] return np.stack([da_cat[xdim].values, da_cat[ydim].values], axis=1) # assume variables are stored in dimensions [grid_index, ...] ds_category = self.unstack_grid_coords(da_or_ds=self._ds[category]) da_xs = ds_category[xdim] da_ys = ds_category[ydim] assert da_xs.ndim == da_ys.ndim == 1, ( f"{xdim} and {ydim} coordinates must be 1D" ) da_x, da_y = xr.broadcast(da_xs, da_ys) da_xy = xr.concat([da_x, da_y], dim="grid_coord") if stacked: da_xy = da_xy.stack(grid_index=self.spatial_coordinates).transpose( "grid_index", "grid_coord", ) else: dims = [ xdim, ydim, "grid_coord", ] da_xy = da_xy.transpose(*dims) return da_xy.values
[docs] @functools.lru_cache def get_lat_lon(self, category: str) -> np.ndarray: """ Return the longitude, latitude coordinates of the dataset as numpy array for a given category of data. Override in MDP to use lat/lons directly from xr.Dataset, if available. Parameters ---------- category : str The category of the dataset (state/forcing/static). Returns ------- np.ndarray The longitude, latitude coordinates of the dataset with shape `[n_grid_points, 2]`. """ # Check first if lat/lon saved in ds lookup_ds = self._ds if "latitude" in lookup_ds.coords and "longitude" in lookup_ds.coords: lon = lookup_ds.longitude lat = lookup_ds.latitude elif "lat" in lookup_ds.coords and "lon" in lookup_ds.coords: lon = lookup_ds.lon lat = lookup_ds.lat else: # Not saved, use method from BaseDatastore to derive from x/y return super().get_lat_lon(category) coords = np.stack((lon.values, lat.values), axis=1) return coords
@property def is_on_rectilinear_grid(self) -> bool: """Whether ``grid_index`` can be unstacked into a complete 2D ``x``/``y`` rectilinear grid, i.e. every combination of the distinct ``x`` and ``y`` values is present exactly once. The spacing need not be uniform. A datastore cropped with ``domain_cropping`` is not. Returns ------- bool ``True`` when every cell of the ``x``/``y`` grid is present exactly once. """ n_x, n_y, n_pairs = self._grid_xy_counts return self.num_grid_points == n_pairs == n_x * n_y @property def num_grid_points(self) -> int: """Return the number of grid points in the dataset. Returns ------- int The number of grid points in the dataset. """ return len(self._ds.grid_index)