Source code for neural_lam.datastore.base

"""Abstract base classes describing Neural-LAM datastore APIs."""

# Standard library
import abc
import collections
import dataclasses
import functools
from datetime import timedelta
from functools import cached_property
from pathlib import Path
from typing import overload

# Third-party
import cartopy.crs as ccrs
import numpy as np
import xarray as xr
from pandas.core.indexes.multi import MultiIndex


[docs] @dataclasses.dataclass class CartesianGridShape: """Dataclass to store the shape of a grid.""" x: int y: int
[docs] class BaseDatastore(abc.ABC): """ Base class for weather data used in the neural-lam package. A datastore defines the interface for accessing weather data by providing methods to access the data in a processed format that can be used for training and evaluation of neural networks. NOTE: All methods return either primitive types, `numpy.ndarray`, `xarray.DataArray` or `xarray.Dataset` objects, not `pytorch.Tensor` objects. Conversion to `pytorch.Tensor` objects should be done in the `weather_dataset.WeatherDataset` class (which inherits from `torch.utils.data.Dataset` and uses the datastore to access the data). Forecast vs analysis data ------------------------- If the datastore is used to represent forecast rather than analysis data, then the ``is_forecast`` attribute should be set to True, and returned data from ``get_dataarray`` is assumed to have `analysis_time` and `forecast_time` dimensions (rather than just `time`). Ensemble vs deterministic data ------------------------------ If the datastore is used to present an ensemble of state realisations, for example for forecast ensembles, then the `is_ensemble` attribute should be set to `True` and returned state data from ``get_dataarray`` is expected to have an `ensemble_member` dimension. If each ensemble member has its own forcing values, then `has_ensemble_forcing` should be set to `True`, and returned forcing data from ``get_dataarray`` is expected to have an `ensemble_member` dimension; otherwise forcing data is expected not to have one. Grid index ---------- All methods that return data specific to a grid point (like ``get_dataarray``) should have a single dimension named `grid_index` that represents the spatial grid index of the data. The actual x, y coordinates of the grid points should be stored in the `x` and `y` coordinates of the dataarray or dataset with the `grid_index` dimension as the coordinate for each of the `x` and `y` coordinates. Rectilinear grid ---------------- A datastore whose `grid_index` can be reshaped back into a complete 2D `x`/`y` grid, with possibly non-uniform spacing, should set `is_on_rectilinear_grid` to `True` and implement `grid_shape_state`. `grid_shape_state`, `stack_grid_coords` and `unstack_grid_coords` raise `NotImplementedError` otherwise. """ is_ensemble: bool = False has_ensemble_forcing: bool = False is_forecast: bool = False spatial_coordinates: tuple[str, str] = ("x", "y") @property @abc.abstractmethod def root_path(self) -> Path: """ The root path to the datastore. It is relative to this that any derived files (for example the graph components) are stored. Returns ------- pathlib.Path The root path to the datastore. """ @property @abc.abstractmethod def config(self) -> collections.abc.Mapping: """The configuration of the datastore. Returns ------- collections.abc.Mapping The configuration of the datastore, any dict like object can be returned. """ @property @abc.abstractmethod def step_length(self) -> timedelta: """The step length of the dataset as a time interval. Returns ------- datetime.timedelta The step length of the dataset. """
[docs] @abc.abstractmethod def get_vars_units(self, category: str) -> list[str]: """Get the units of the variables in the given category. Parameters ---------- category : str The category of the variables (state/forcing/static). Returns ------- list[str] The units of the variables. """
[docs] @abc.abstractmethod def get_vars_names(self, category: str) -> list[str]: """Get the names of the variables in the given category. Parameters ---------- category : str The category of the variables (state/forcing/static). Returns ------- list[str] The names of the variables. """
[docs] @abc.abstractmethod def get_vars_long_names(self, category: str) -> list[str]: """Get the long names of the variables in the given category. Parameters ---------- category : str The category of the variables (state/forcing/static). Returns ------- list[str] The long names of the variables. """
[docs] @abc.abstractmethod def get_num_data_vars(self, category: str) -> int: """Get the number of data variables in the given category. Parameters ---------- category : str The category of the variables (state/forcing/static). Returns ------- int The number of data variables. """
[docs] @abc.abstractmethod def get_standardization_dataarray(self, category: str) -> xr.Dataset: """ Return the standardization (i.e. scaling to mean of 0.0 and standard deviation of 1.0) 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. The returned dataarray should at least have dimensions of `({category}_feature)`, but can also include for example `grid_index` (if the standardization is done per grid point for example). 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). """
def _standardize_datarray( self, da: xr.DataArray, category: str ) -> xr.DataArray: """ Helper function to standardize a dataarray before returning it. Parameters ---------- da: xr.DataArray The dataarray to standardize category : str The category of the dataarray (state/forcing/static), to load standardization statistics for. Returns ------- xr.Dataarray The standardized dataarray """ standard_da = self.get_standardization_dataarray(category=category) mean = standard_da[f"{category}_mean"] std = standard_da[f"{category}_std"] eps = np.finfo(std.dtype).eps return (da - mean) / std.where(std > eps, other=eps)
[docs] @abc.abstractmethod 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). A datastore must be able to return for the "state" category, but "forcing" and "static" are optional (in which case the method should return `None`). For the "static" category the `split` is allowed to be `None` because the static data is the same for all splits. The returned dataarray is expected to 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 is expected 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 is expected to have an additional `ensemble_member` dimension. If `has_ensemble_forcing=True`, the returned forcing dataarray is expected to have an additional `ensemble_member` dimension; otherwise it is expected not to have one. 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. """
@cached_property @abc.abstractmethod def boundary_mask(self) -> xr.DataArray: """ Return the boundary mask for the dataset, with spatial dimensions stacked. Where the value is 1, the grid point is a boundary point, and where the value is 0, the grid point is not a boundary point. Returns ------- xr.DataArray The boundary mask for the dataset, with dimensions `('grid_index',)`. """
[docs] @abc.abstractmethod def get_xy(self, category: str, stacked: bool) -> np.ndarray: """ Return the x, y coordinates of the dataset as a numpy arrays for a given category of data. Parameters ---------- category : str The category of the dataset (state/forcing/static). stacked : bool Whether to stack the x, y coordinates. `stacked=False` is only meaningful for grid points on a regular-grid. Returns ------- np.ndarray The x, y coordinates of the dataset with shape `[n_grid_points, 2]`. """
@property @abc.abstractmethod def coords_projection(self) -> ccrs.Projection: """Return the projection object for the coordinates. The projection object is used to plot the coordinates on a map. Returns ------- cartopy.crs.Projection: The projection object. """
[docs] @functools.lru_cache def get_xy_extent(self, category: str) -> list[float]: """ Return the extent of the x, y coordinates for a given category of data. The extent should be returned as a list of 4 floats with `[xmin, xmax, ymin, ymax]` which can then be used to set the extent of a plot. Parameters ---------- category : str The category of the dataset (state/forcing/static). Returns ------- list[float] The extent of the x, y coordinates. """ xy = self.get_xy(category, stacked=True) extent = [ xy[:, 0].min(), xy[:, 0].max(), xy[:, 1].min(), xy[:, 1].max(), ] return [float(v) for v in extent]
[docs] @functools.lru_cache def get_lat_lon(self, category: str) -> np.ndarray: """ Return stacked longitude/latitude pairs for the requested category. """ xy = self.get_xy(category=category, stacked=True) if xy.size == 0: return xy lon_lat = ccrs.PlateCarree().transform_points( self.coords_projection, xy[:, 0], xy[:, 1], )[:, :2] return lon_lat
@property def num_grid_points(self) -> int: """Return the number of grid points in the dataset. Derived from `grid_shape_state`, so datastores not on a rectilinear grid must override this. Returns ------- int The number of grid points in the dataset. Raises ------ NotImplementedError If the datastore is not on a rectilinear grid and does not override this. """ if not self.is_on_rectilinear_grid: raise NotImplementedError( f"{type(self).__name__} is not on a rectilinear grid, " "so `num_grid_points` cannot be derived from " "`grid_shape_state` and must be overridden." ) return self.grid_shape_state.x * self.grid_shape_state.y @cached_property @abc.abstractmethod def state_feature_weights_values(self) -> list[float]: """ Return the weights for each state feature as a list of floats. The weights are defined by the user in a config file for the datastore. Implementations must assert that there is one weight for each state feature. The weights can be used to scale the loss function for each state variable. Returns ------- list of float The weight for each state feature. """
[docs] @functools.lru_cache def expected_dim_order( self, category: str | None = None, ) -> tuple[str, ...]: """ Return the expected dimension order for the dataarray or dataset returned by `get_dataarray` for the given category of data. The dimension order is the order of the dimensions in the dataarray or dataset, and is used to check that the data is in the expected format. This is necessary so that when stacking and unstacking the spatial grid we can ensure that the dimension order is the same as what is returned from `get_dataarray`. And also ensures that downstream uses of a datastore (e.g. WeatherDataset) sees the data in a common structure. If the category is None, then the it assumed that data only represents a 1D scalar field varying with grid-index. The order is constructed to match the order in `pytorch.Tensor` objects that will be constructed from the data so that the last two dimensions are always the grid-index and feature dimensions (i.e. the order is `[..., grid_index, {category}_feature]`), with any time-related and ensemble-number dimension(s) coming before these two. Parameters ---------- category : str The category of the dataset (state/forcing/static). Returns ------- list[str] The expected dimension order for the dataarray or dataset. """ dim_order = [] if category is not None: if category != "static": # static data does not vary in time if self.is_forecast: dim_order.extend( [ "analysis_time", "elapsed_forecast_duration", ] ) elif not self.is_forecast: dim_order.append("time") if ( category == "state" and self.is_ensemble or category == "forcing" and self.has_ensemble_forcing ): dim_order.append("ensemble_member") dim_order.append("grid_index") if category is not None: dim_order.append(f"{category}_feature") return tuple(dim_order)
@property def is_on_rectilinear_grid(self) -> bool: """Whether ``grid_index`` can be unstacked into a complete 2D ``x``/``y`` rectilinear grid. The spacing need not be uniform. Returns ------- bool ``False`` unless a subclass opts in. """ return False @cached_property def grid_shape_state(self) -> CartesianGridShape: """The shape of the grid for the state variables. Returns ------- CartesianGridShape: The shape of the grid for the state variables, which has `x` and `y` attributes. Raises ------ NotImplementedError If the datastore is not on a rectilinear grid. """ raise NotImplementedError( f"{type(self).__name__} does not define `grid_shape_state`; " "this is only available for datastores where " "`is_on_rectilinear_grid` is True." ) def _check_on_rectilinear_grid(self, operation: str) -> None: """Guard an operation that requires a complete 2D ``x``/``y`` grid. Parameters ---------- operation : str Name of the calling method, used in the error message. Raises ------ NotImplementedError If the datastore's grid points do not form a complete 2D grid. """ if not self.is_on_rectilinear_grid: raise NotImplementedError( f"`{operation}` requires the grid points to form a complete " "2D grid (`is_on_rectilinear_grid`); " f"{type(self).__name__} reports that they do not." ) @overload def unstack_grid_coords(self, da_or_ds: xr.DataArray) -> xr.DataArray: """Unstack spatial grid coordinates of DataArray.""" @overload def unstack_grid_coords(self, da_or_ds: xr.Dataset) -> xr.Dataset: """Unstack spatial grid coordinates of Dataset."""
[docs] def unstack_grid_coords( self, da_or_ds: xr.DataArray | xr.Dataset ) -> xr.DataArray | xr.Dataset: """ Unstack the spatial grid coordinates from `grid_index` into separate `x` and `y` dimensions to create a 2D grid (if the spatial coordinates have different names, those are used instead). Only performs unstacking if the data is currently stacked (has grid_index dimension). Parameters ---------- da_or_ds : xr.DataArray or xr.Dataset The dataarray or dataset to unstack the grid coordinates of. Returns ------- xr.DataArray or xr.Dataset The dataarray or dataset with the grid coordinates unstacked. Raises ------ NotImplementedError If the datastore is not on a rectilinear grid. """ # Return original data if already unstacked (no grid_index dimension) if "grid_index" not in da_or_ds.dims: return da_or_ds self._check_on_rectilinear_grid("unstack_grid_coords") # Check whether `grid_index` is a multi-index if not isinstance(da_or_ds.indexes.get("grid_index"), MultiIndex): da_or_ds = da_or_ds.set_index(grid_index=self.spatial_coordinates) da_or_ds_unstacked = da_or_ds.unstack("grid_index") # Ensure that the x, y dimensions are in the correct order dims = list(da_or_ds_unstacked.dims) xy_dim_order = [d for d in dims if d in self.spatial_coordinates] if xy_dim_order != self.spatial_coordinates: # work out where the first spatial coordinate is located # so that we can insert the second spatial coordinate next to it in # the correct order. Although this looks verbose, it ensures that # we don't change the order of any other dimensions. first_xy_dim_index = min( dims.index(self.spatial_coordinates[0]), dims.index(self.spatial_coordinates[1]), ) new_dim_order = list(dims) new_dim_order.remove(self.spatial_coordinates[0]) new_dim_order.remove(self.spatial_coordinates[1]) new_dim_order.insert( first_xy_dim_index, self.spatial_coordinates[0] ) new_dim_order.insert( first_xy_dim_index + 1, self.spatial_coordinates[1] ) da_or_ds_unstacked = da_or_ds_unstacked.transpose(*new_dim_order) return da_or_ds_unstacked
@overload def stack_grid_coords(self, da_or_ds: xr.DataArray) -> xr.DataArray: """Stack spatial grid coordinates of DataArray.""" @overload def stack_grid_coords(self, da_or_ds: xr.Dataset) -> xr.Dataset: """Stack spatial grid coordinates of Dataset."""
[docs] def stack_grid_coords( self, da_or_ds: xr.DataArray | xr.Dataset ) -> xr.DataArray | xr.Dataset: """ Stack the spatial grid coordinates (x and y) into a single `grid_index` dimension. Only performs stacking if the data is currently unstacked (has x and y dimensions). Parameters ---------- da_or_ds : xr.DataArray or xr.Dataset The dataarray or dataset to stack the grid coordinates of. Returns ------- xr.DataArray or xr.Dataset The dataarray or dataset with the grid coordinates stacked. Raises ------ NotImplementedError If the datastore is not on a rectilinear grid. """ # Return original data if already stacked (has grid_index dimension) if "grid_index" in da_or_ds.dims: return da_or_ds self._check_on_rectilinear_grid("stack_grid_coords") da_or_ds_stacked = da_or_ds.stack(grid_index=self.spatial_coordinates) # infer what category of data the array represents by finding the # dimension named in the format `{category}_feature` category = None for dim in da_or_ds_stacked.dims: if isinstance(dim, str) and dim.endswith("_feature"): if category is not None: raise ValueError( "Multiple dimensions ending with '_feature' found in " f"dataarray: {da_or_ds_stacked}. Cannot infer category." ) category = dim.split("_")[0] dim_order = self.expected_dim_order(category=category) return da_or_ds_stacked.transpose(*dim_order)