Source code for neural_lam.weather_dataset

"""Dataset helpers wrapping Neural-LAM datastores for PyTorch Lightning."""

# Standard library
import datetime
import warnings
from collections.abc import Iterator
from typing import Any, cast

# Third-party
import numpy as np
import pytorch_lightning as pl
import torch
import xarray as xr

# First-party
from neural_lam.datastore.base import BaseDatastore
from neural_lam.utils import (
    apply_time_crop,
    get_integer_time,
    get_time_crop_slice,
    get_time_step,
)


def _format_timedelta(td: np.timedelta64) -> str:
    """Render a timedelta in its largest exact unit, e.g. ``"13 hours"``.

    Parameters
    ----------
    td : np.timedelta64
        The interval to render.

    Returns
    -------
    str
        Value and unit.
    """
    microseconds = int(td / np.timedelta64(1, "us"))
    value, unit = get_integer_time(
        datetime.timedelta(microseconds=microseconds)
    )
    if unit == "unknown":
        return str(td)
    return f"{value} {unit}"


def _latest_usable_launch(
    model_init_time: np.datetime64,
    first_target_time: np.datetime64,
    lead_offset: np.timedelta64,
    lead_step: np.timedelta64,
    num_past_steps: int,
) -> np.datetime64:
    """Latest boundary launch usable for one sample.

    A launch is usable if it starts no later than the model init time and
    still leaves ``num_past_steps`` of lead before the first target.

    Parameters
    ----------
    model_init_time, first_target_time : np.datetime64
        Init time and first target time of the sample.
    lead_offset : np.timedelta64
        First ``elapsed_forecast_duration`` of the boundary forecast, which
        need not be zero.
    lead_step : np.timedelta64
        Spacing between boundary lead times.
    num_past_steps : int
        Past window size, in lead steps.

    Returns
    -------
    np.datetime64
        The latest launch time that could serve this sample.
    """
    past_window_bound = (
        first_target_time - lead_offset - num_past_steps * lead_step
    )
    return min(model_init_time, past_window_bound)


def _check_window_bounds(
    window_start: int,
    window_end: int,
    axis_size: int,
    target_time: np.datetime64,
    num_past_steps: int,
    num_future_steps: int,
    dim_name: str,
) -> None:
    """Raise if a forcing/boundary window runs off either end of its axis,
    which ``isel`` would otherwise silently wrap or truncate.

    Parameters
    ----------
    window_start, window_end : int
        Window start (inclusive) and end (exclusive) index.
    axis_size : int
        Size of the axis the window is taken along.
    target_time : np.datetime64
        Target time of the window, for the error message.
    num_past_steps, num_future_steps : int
        Window size, for the error message.
    dim_name : str
        Name of the axis, for the error message.

    Raises
    ------
    ValueError
        If the window is not fully contained in ``[0, axis_size)``.
    """
    if window_start < 0 or window_end > axis_size:
        raise ValueError(
            f"Forcing/boundary does not cover the window "
            f"[{window_start}, {window_end}) along `{dim_name}` (axis size "
            f"{axis_size}) around target time {target_time}: "
            f"{num_past_steps} steps before and {num_future_steps} steps "
            "after the target are required."
        )


[docs] class WeatherDataset(torch.utils.data.Dataset): """Dataset class for weather data. Loads and processes weather data from a given datastore, optionally with boundary forcing from a separate datastore. See :meth:`__init__` for the full parameter list. """ # NOTE: The number of initial states is fixed at 2 (inspired by GraphCast) INIT_STEPS = 2 def __init__( self, datastore: BaseDatastore, split: str = "train", ar_steps: int = 3, num_past_forcing_steps: int = 1, num_future_forcing_steps: int = 1, num_past_boundary_steps: int = 1, num_future_boundary_steps: int = 1, datastore_boundary: BaseDatastore | None = None, load_single_member: bool = False, ) -> None: """ Parameters ---------- datastore : BaseDatastore Datastore providing access to state/forcing/static arrays. split : str, optional Data split (``"train"``, ``"val"``, or ``"test"``). Default ``"train"``. ar_steps : int, optional Number of autoregressive steps per training sample. Default ``3``. num_past_forcing_steps : int, optional Past forcing window length ``i`` so that ``[t-i, ..., t]`` forcings are concatenated. Default ``1``. num_future_forcing_steps : int, optional Future forcing window length ``j`` so that ``[t, ..., t+j]`` forcings are available. Default ``1``. num_past_boundary_steps : int, optional Past window length for boundary forcing, in boundary time steps. Default ``1``. num_future_boundary_steps : int, optional Future window length for boundary forcing. Default ``1``. datastore_boundary : BaseDatastore, optional Datastore providing boundary forcing. If ``None``, the boundary tensor is empty. load_single_member : bool, optional If ``False`` and the datastore returns an ensemble of state realisations, treat each state ensemble member as an independent sample. If ``True``, only ensemble member 0 is used. Default ``False``. Raises ------ ValueError If the datastore does not provide state data, if the configured ``ar_steps`` and forcing windows leave zero samples in ``split``, or if the state/forcing dimension order does not match the datastore's expected dimension order. NotImplementedError If ``datastore_boundary`` has ensemble forcing. """ super().__init__() self.split = split self.ar_steps = ar_steps self.datastore = datastore self.datastore_boundary = datastore_boundary self.num_past_forcing_steps = num_past_forcing_steps self.num_future_forcing_steps = num_future_forcing_steps self.num_past_boundary_steps = num_past_boundary_steps self.num_future_boundary_steps = num_future_boundary_steps self.load_single_member = load_single_member da_state = self.datastore.get_dataarray( category="state", split=self.split ) if da_state is None: raise ValueError( "The datastore must provide state data for the WeatherDataset." ) self.da_state = da_state self.da_forcing = self.datastore.get_dataarray( category="forcing", split=self.split ) self.da_boundary_forcing = None self._forecast_step_boundary = None if datastore_boundary is not None: if datastore_boundary.has_ensemble_forcing: raise NotImplementedError( "Boundary datastores with ensemble forcing are not " "supported yet." ) self.da_boundary_forcing = datastore_boundary.get_dataarray( category="forcing", split=self.split ) if self.da_boundary_forcing is not None: assert datastore_boundary is not None if datastore_boundary.is_forecast: self._forecast_step_boundary = get_time_step( self.da_boundary_forcing.elapsed_forecast_duration.values ) crop_dim, crop_slice = get_time_crop_slice( self.da_state, self.da_boundary_forcing, da_requested_is_forecast=self.datastore.is_forecast, da_available_is_forecast=datastore_boundary.is_forecast, num_past_steps=self.num_past_boundary_steps, num_future_steps=self.num_future_boundary_steps, requested_max_lead=self._max_state_lead_used(), ) self.da_state = apply_time_crop(self.da_state, crop_dim, crop_slice) # Interior forcing shares the state's time axis and `__len__` # counts samples on both by position, so crop them together. if self.da_forcing is not None: self.da_forcing = apply_time_crop( self.da_forcing, crop_dim, crop_slice ) if datastore_boundary.is_forecast: self._check_boundary_analysis_times() self._check_boundary_forecast_horizon() if self.datastore.is_ensemble and self.load_single_member: warnings.warn( "only using first ensemble member, so dataset size is " "effectively reduced by the number of ensemble members " f"({self.da_state.ensemble_member.size})", UserWarning, stacklevel=2, ) # check that with the provided data-arrays and ar_steps that we have a # non-zero amount of samples if self.__len__() <= 0 and self.da_state is not None: remedies = ( "the number of autoregressive steps (`ar_steps`) and/or the " "forcing window size (`num_past_forcing_steps` and " "`num_future_forcing_steps`)" ) if self.datastore_boundary is not None: remedies += ( ", or the boundary window (`num_past_boundary_steps` and " "`num_future_boundary_steps`), which determines how much " "of the interior is cropped to stay within the boundary " "coverage" ) raise ValueError( "The provided datastore only provides " f"{len(self.da_state.time)} total time steps, which is too few " "to create a single sample for the WeatherDataset " f"configuration used in the `{split}` split. You could try " f"reducing {remedies}." ) # Check the dimensions and their ordering parts = dict(state=self.da_state) if self.da_forcing is not None: parts["forcing"] = self.da_forcing for part, da in parts.items(): if da is not None: expected_dim_order = self.datastore.expected_dim_order( category=part ) if da.dims != expected_dim_order: raise ValueError( f"The dimension order of the `{part}` data ({da.dims}) " f"does not match the expected dimension order " f"({expected_dim_order}). Maybe you forgot to " "transpose the data in `BaseDatastore.get_dataarray`?" ) def __len__(self) -> int: """ Return the number of autoregressive training samples available. Returns ------- int Number of (init, target) pairs derivable from the datastore. """ assert self.da_state is not None if self.datastore.is_forecast: # for now we simply create a single sample for each analysis time # and then take the first (2 + ar_steps) forecast times. # If the datastore returns an ensemble of state realisations and # `load_single_member=False`, each ensemble member is exposed as an # independent sample by scaling the base dataset length below. # Check that there are enough forecast steps available to create # samples. The required minimum is the larger of 2 (for the two # initial states) and num_past_forcing_steps, plus ar_steps. n_forecast_steps = self.da_state.elapsed_forecast_duration.size required_state_steps = ( max(2, self.num_past_forcing_steps) + self.ar_steps ) if n_forecast_steps < required_state_steps: raise ValueError( "The number of forecast steps available " f"({n_forecast_steps}) is less than the required " f"{required_state_steps} (max(2, " f"num_past_forcing_steps={self.num_past_forcing_steps})" f" + ar_steps={self.ar_steps}) for creating a sample " "with initial and target states." ) if self.da_forcing is not None: # When forcing is present, the forecast horizon must also # cover num_future_forcing_steps beyond the last target step. n_forcing_forecast_steps = ( self.da_forcing.elapsed_forecast_duration.size ) required_forcing_steps = ( required_state_steps + self.num_future_forcing_steps ) if n_forcing_forecast_steps < required_forcing_steps: raise ValueError( "The number of forcing forecast steps available " f"({n_forcing_forecast_steps}) is less than the " f"required {required_forcing_steps} " f"(max(2, num_past_forcing_steps=" f"{self.num_past_forcing_steps}) + ar_steps=" f"{self.ar_steps} + num_future_forcing_steps=" f"{self.num_future_forcing_steps}) for " "constructing forcing windows." ) base_len = self.da_state.analysis_time.size else: # Number of valid sample start indices in a contiguous time # series. With T total time steps and a per-sample state window # of W = max(2, num_past_forcing_steps) + ar_steps, valid start # indices are [0 .. T - W], i.e. (T - W + 1) samples in total. # _slice_state_time never uses num_future_forcing_steps, so it # must not shrink the state-only count; only the forcing window # (when forcing is present) additionally needs those steps, to # match _window_forcing_in_time. state_window = max(2, self.num_past_forcing_steps) + self.ar_steps n_state_samples = len(self.da_state.time) - state_window + 1 if self.da_forcing is not None: forcing_window = state_window + self.num_future_forcing_steps n_forcing_samples = ( len(self.da_forcing.time) - forcing_window + 1 ) base_len = max(0, min(n_state_samples, n_forcing_samples)) else: base_len = max(0, n_state_samples) if self.datastore.is_ensemble and not self.load_single_member: return base_len * self.da_state.ensemble_member.size return base_len def _sample_window_times(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """Init, first-target and last-target time of every sample. Returns ------- tuple of np.ndarray Three equal-length arrays of datetime64, one entry per sample (before any ensemble-member expansion). Empty when the split is too short to yield a sample. """ assert self.da_state is not None init_steps = self.INIT_STEPS offset = max(0, self.num_past_forcing_steps - init_steps) n_total = init_steps + self.ar_steps if self.datastore.is_forecast: leads = self.da_state.elapsed_forecast_duration.values if len(leads) < offset + n_total: return (np.array([]), np.array([]), np.array([])) launches = self.da_state.analysis_time.values return ( launches + leads[offset + init_steps - 1], launches + leads[offset + init_steps], launches + leads[offset + n_total - 1], ) times = self.da_state.time.values # Count samples exactly as `__len__` does state_window = offset + n_total n_samples = len(times) - state_window + 1 if self.da_forcing is not None: n_samples = min( n_samples, len(self.da_forcing.time) - state_window - self.num_future_forcing_steps + 1, ) if n_samples <= 0: return (np.array([]), np.array([]), np.array([])) first = offset + init_steps - 1 return ( times[first : first + n_samples], times[first + 1 : first + 1 + n_samples], times[first + self.ar_steps : first + self.ar_steps + n_samples], ) def _check_boundary_analysis_times(self) -> None: """Check the boundary `analysis_time` is unique and sorted, as the `pad` launch lookup requires. Raises ------ ValueError If `analysis_time` has duplicates or is unsorted. """ assert self.da_boundary_forcing is not None index = self.da_boundary_forcing.get_index("analysis_time") if not index.is_unique: duplicates = index[index.duplicated()].unique().tolist() raise ValueError( "The boundary datastore's `analysis_time` must be unique, " f"but {len(duplicates)} value(s) repeat, e.g. " f"{duplicates[:3]}. A boundary datastore that repeats each " "launch per ensemble member has to be de-duplicated first." ) if not index.is_monotonic_increasing: raise ValueError( "The boundary datastore's `analysis_time` must be sorted in " "increasing order." ) def _check_boundary_forecast_horizon(self) -> None: """Check every sample's window fits inside one boundary launch. Done up front, since cropping the interior cannot fix a boundary forecast horizon that is too short. Raises ------ ValueError If no launch is early enough for a sample, or if the boundary forecast horizon cannot span a sample's window. """ assert self.da_boundary_forcing is not None init_times, first_targets, last_targets = self._sample_window_times() if len(init_times) == 0: return leads = self.da_boundary_forcing.elapsed_forecast_duration.values lead_step = get_time_step(leads) analysis_index = self.da_boundary_forcing.analysis_time.get_index( "analysis_time" ) launch_bounds = np.array( [ _latest_usable_launch( init_time, first_target, leads[0], lead_step, self.num_past_boundary_steps, ) for init_time, first_target in zip(init_times, first_targets) ] ) launch_idx = analysis_index.get_indexer(launch_bounds, method="pad") if (launch_idx < 0).any(): earliest = init_times[launch_idx < 0].min() raise ValueError( "No boundary forecast is launched early enough for the model " f"init time {earliest} with " f"{self.num_past_boundary_steps} past window steps. The " "boundary datastore must start earlier, or use a smaller " "`num_past_boundary_steps`." ) launches = analysis_index.values[launch_idx] last_lead = np.floor( (last_targets - launches - leads[0]) / lead_step ).astype(int) needed = int(last_lead.max()) + self.num_future_boundary_steps if needed > len(leads) - 1: required = leads[0] + needed * lead_step raise ValueError( "The boundary forecast horizon is too short: each sample is " "windowed from a single boundary launch, which here needs " f"lead time out to {_format_timedelta(required)} to cover " f"{self.ar_steps} autoregressive steps with a " f"({self.num_past_boundary_steps}, " f"{self.num_future_boundary_steps}) window, but the boundary " f"forecasts only run out to {_format_timedelta(leads[-1])}. " "Reduce `ar_steps` or the boundary window, or use boundary " "forecasts with a longer horizon." ) def _max_state_lead_used(self) -> np.timedelta64 | None: """Largest state lead time read per sample, for forecast interiors, which is as far as the boundary has to cover. Returns ------- np.timedelta64 or None Lead time of the last state step read, or ``None`` for an analysis interior. """ if not self.datastore.is_forecast: return None assert self.da_state is not None leads = self.da_state.elapsed_forecast_duration.values offset = max(0, self.num_past_forcing_steps - self.INIT_STEPS) last_idx = offset + self.INIT_STEPS + self.ar_steps - 1 return leads[min(last_idx, len(leads) - 1)] def _slice_state_time( self, da_state: xr.DataArray, idx: int, n_steps: int ) -> xr.DataArray: """Slice ``da_state`` by integer ``idx`` into one training sample. For analysis data the sample's ``time`` is contiguous; for forecast data we pick a single ``analysis_time`` and walk its lead times. The leading offset accounts for ``num_past_forcing_steps`` so the forcing window of the very first sample is in-bounds. Parameters ---------- da_state : xr.DataArray The state dataarray to slice from. idx : int The sample index (already ensemble-member-adjusted). n_steps : int Number of autoregressive target steps to include, beyond ``INIT_STEPS`` initial states. Returns ------- da_sliced : xr.DataArray Sliced state with a single ``time`` dimension covering ``INIT_STEPS + n_steps`` consecutive state times. """ init_steps = self.INIT_STEPS n_total = init_steps + n_steps offset = max(0, self.num_past_forcing_steps - init_steps) if self.datastore.is_forecast: da_sliced = da_state.isel( analysis_time=idx, elapsed_forecast_duration=slice(offset, offset + n_total), ) da_sliced["time"] = ( da_sliced.analysis_time + da_sliced.elapsed_forecast_duration ) da_sliced = da_sliced.swap_dims( {"elapsed_forecast_duration": "time"} ) else: start_idx = idx + offset da_sliced = da_state.isel( time=slice(start_idx, start_idx + n_total) ) return da_sliced def _window_same_forecast_by_idx( self, da_forcing: xr.DataArray, idx: int, state_times: xr.DataArray, num_past_steps: int, num_future_steps: int, ) -> xr.DataArray: """Window forcing from the same forecast datastore as state. Indexes ``analysis_time`` by position, like the state slice, and walks the lead times along with the state. Parameters ---------- da_forcing : xr.DataArray Forcing dataarray from the same forecast datastore as state. idx : int The ``analysis_time`` index of the sample's launch. state_times : xr.DataArray The ``time`` coordinate of the already-sliced state sample. num_past_steps : int Number of past forcing steps to include in each window. num_future_steps : int Number of future forcing steps to include in each window. Returns ------- xr.DataArray Concatenated windows with dims ``('time', 'grid_index', 'window', 'forcing_feature')``. """ init_steps = self.INIT_STEPS offset = max(0, self.num_past_forcing_steps - init_steps) + init_steps da_list = [] for step in range(self.ar_steps): start_lead = offset + step - num_past_steps end_lead = offset + step + num_future_steps + 1 target_time = state_times[init_steps + step].values da_sliced = da_forcing.isel( analysis_time=idx, elapsed_forecast_duration=slice(start_lead, end_lead), ).rename({"elapsed_forecast_duration": "window"}) da_sliced = da_sliced.assign_coords( window=np.arange(-num_past_steps, num_future_steps + 1) ) da_sliced = da_sliced.expand_dims(dim={"time": [target_time]}) da_list.append(da_sliced) return xr.concat(da_list, dim="time") def _window_forcing_in_time( self, da_forcing: xr.DataArray, state_times: xr.DataArray, num_past_steps: int, num_future_steps: int, forecast_step: np.timedelta64 | None, ) -> xr.DataArray: """Window forcing/boundary in time, aligned to interior state times. For each target step the latest forcing time at or before the state time is found (``pad``) and a window of ``num_past_steps + num_future_steps + 1`` entries is taken around it. Forecast data is windowed along the lead times of a single launch per sample, see :func:`_latest_usable_launch`. Parameters ---------- da_forcing : xr.DataArray Forcing or boundary dataarray to window, either analysis (``time`` dimension) or forecast (``analysis_time`` and ``elapsed_forecast_duration`` dimensions). state_times : xr.DataArray The ``time`` coordinate of the already-sliced state sample. num_past_steps : int Number of past steps to include in each window. num_future_steps : int Number of future steps to include in each window. forecast_step : np.timedelta64 or None Spacing between ``da_forcing`` lead times, required when ``da_forcing`` is in forecast mode. Returns ------- xr.DataArray Concatenated windows with dims ``('time', 'grid_index', 'window', 'forcing_feature')``. """ init_steps = self.INIT_STEPS da_list = [] if "analysis_time" in da_forcing.dims: assert forecast_step is not None model_init_time = cast( "np.datetime64", state_times[init_steps - 1].values ) first_target_time = cast( "np.datetime64", state_times[init_steps].values ) analysis_index = da_forcing.analysis_time.get_index("analysis_time") forcing_at_idx = analysis_index.get_indexer( [ _latest_usable_launch( model_init_time, first_target_time, da_forcing.elapsed_forecast_duration.values[0], forecast_step, num_past_steps, ) ], method="pad", )[0] if forcing_at_idx < 0: raise ValueError( "No boundary/forcing analysis time is early enough for " f"the model init time ({model_init_time}) with " f"{num_past_steps} past window steps." ) forcing_at = da_forcing.analysis_time[forcing_at_idx] # `elapsed_forecast_duration` need not start at zero, so the # window index is measured from the first lead, not from launch. lead_offset = da_forcing.elapsed_forecast_duration.values[0] def lead_index(valid_time: np.datetime64) -> int: """Position of the latest lead at or before ``valid_time``. Parameters ---------- valid_time : np.datetime64 The time to look up. Returns ------- int Index along ``elapsed_forecast_duration``. """ return int( np.floor( (valid_time - forcing_at.values - lead_offset) / forecast_step ) ) for step_idx in range(len(state_times) - init_steps): target_time = cast( "np.datetime64", state_times[init_steps + step_idx].values ) lead = lead_index(target_time) window_start = lead - num_past_steps window_end = lead + num_future_steps + 1 _check_window_bounds( window_start, window_end, da_forcing.sizes["elapsed_forecast_duration"], target_time, num_past_steps, num_future_steps, "elapsed_forecast_duration", ) da_sliced = da_forcing.isel( analysis_time=int(forcing_at_idx), elapsed_forecast_duration=slice( int(window_start), int(window_end) ), ).rename({"elapsed_forecast_duration": "window"}) da_sliced = da_sliced.assign_coords( window=np.arange(-num_past_steps, num_future_steps + 1) ) da_sliced = da_sliced.expand_dims(dim={"time": [target_time]}) da_list.append(da_sliced) else: forcing_time_index = da_forcing.time.get_index("time") for step_idx in range(init_steps, len(state_times)): state_time = cast("np.datetime64", state_times[step_idx].values) forcing_time_idx = forcing_time_index.get_indexer( [state_time], method="pad" )[0] if forcing_time_idx < 0: raise ValueError( f"No boundary/forcing time at or before {state_time}." ) window_start = forcing_time_idx - num_past_steps window_end = forcing_time_idx + num_future_steps + 1 _check_window_bounds( window_start, window_end, da_forcing.sizes["time"], state_time, num_past_steps, num_future_steps, "time", ) da_window = da_forcing.isel( time=slice(int(window_start), int(window_end)) ).rename({"time": "window"}) da_window = da_window.assign_coords( window=np.arange(-num_past_steps, num_future_steps + 1) ) da_window = da_window.expand_dims(dim={"time": [state_time]}) da_list.append(da_window) return xr.concat(da_list, dim="time") def _empty_windowed_dataarray( self, grid_index: xr.DataArray, target_times: xr.DataArray ) -> xr.DataArray: """Build an empty windowed forcing/boundary dataarray. Used when no forcing or boundary is configured, so that the sample always has the same five entries. Parameters ---------- grid_index : xr.DataArray The ``grid_index`` coordinate to use (interior or boundary grid). target_times : xr.DataArray The ``time`` coordinate spanning the autoregressive target steps. Returns ------- xr.DataArray Empty array with dims ``("time", "grid_index", "forcing_feature")`` and a zero-length feature dimension. """ return xr.DataArray( data=np.empty((self.ar_steps, grid_index.size, 0)), dims=("time", "grid_index", "forcing_feature"), coords={ "time": target_times, "grid_index": grid_index, "forcing_feature": [], }, ) def _build_item_dataarrays( self, idx: int ) -> tuple[ xr.DataArray, xr.DataArray, xr.DataArray, xr.DataArray, xr.DataArray ]: """ Create the dataarrays for the initial states, target states, forcing and boundary data for the sample at index `idx`. Parameters ---------- idx : int The index of the sample to create the dataarrays for. Returns ------- da_init_states : xr.DataArray The dataarray for the initial states. da_target_states : xr.DataArray The dataarray for the target states. da_forcing_windowed : xr.DataArray The dataarray for the forcing data, windowed for the sample. da_boundary_windowed : xr.DataArray The dataarray for the boundary forcing data, windowed for the sample. da_target_times : xr.DataArray The dataarray for the target times. """ # Handle indexing over state ensemble members. If forcing data also # has an ensemble dimension, we select the same member below. sample_idx = idx i_ensemble = 0 assert self.da_state is not None if self.datastore.is_ensemble: n_ensemble_members = self.da_state.ensemble_member.size if not self.load_single_member: sample_idx, i_ensemble = divmod(idx, n_ensemble_members) da_state = self.da_state.isel(ensemble_member=i_ensemble) else: da_state = self.da_state if self.da_forcing is not None: if self.datastore.has_ensemble_forcing: da_forcing = self.da_forcing.isel(ensemble_member=i_ensemble) else: da_forcing = self.da_forcing else: da_forcing = None da_state = self._slice_state_time( da_state=da_state, idx=sample_idx, n_steps=self.ar_steps ) state_times = da_state["time"] if da_forcing is not None: if self.datastore.is_forecast: da_forcing_windowed = self._window_same_forecast_by_idx( da_forcing=da_forcing, idx=sample_idx, state_times=state_times, num_past_steps=self.num_past_forcing_steps, num_future_steps=self.num_future_forcing_steps, ) else: da_forcing_windowed = self._window_forcing_in_time( da_forcing=da_forcing, state_times=state_times, num_past_steps=self.num_past_forcing_steps, num_future_steps=self.num_future_forcing_steps, forecast_step=None, ) if self.da_boundary_forcing is not None: da_boundary_windowed = self._window_forcing_in_time( da_forcing=self.da_boundary_forcing, state_times=state_times, num_past_steps=self.num_past_boundary_steps, num_future_steps=self.num_future_boundary_steps, forecast_step=self._forecast_step_boundary, ) else: da_boundary_windowed = None # load the data into memory da_state.load() if da_forcing is not None: da_forcing_windowed.load() if da_boundary_windowed is not None: da_boundary_windowed.load() da_init_states = da_state.isel(time=slice(0, 2)) da_target_states = da_state.isel(time=slice(2, None)) da_target_times = da_target_states.time if da_forcing is not None: # stack the `forcing_feature` and `window_sample` dimensions into a # single `forcing_feature` dimension da_forcing_windowed = da_forcing_windowed.stack( forcing_feature_windowed=("forcing_feature", "window") ) else: da_forcing_windowed = self._empty_windowed_dataarray( da_state.grid_index, da_target_times ) if da_boundary_windowed is not None: da_boundary_windowed = da_boundary_windowed.stack( forcing_feature_windowed=("forcing_feature", "window") ) else: # Feature dim is empty, so the interior grid_index will do. da_boundary_windowed = self._empty_windowed_dataarray( da_state.grid_index, da_target_times ) return ( da_init_states, da_target_states, da_forcing_windowed, da_boundary_windowed, da_target_times, ) def __getitem__( # ty: ignore[invalid-method-override] self, idx: int ) -> tuple[ torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor ]: """ Return a single training sample, which consists of the initial states, target states, forcing, boundary and batch times. The returned data is unstandardized; normalization is applied on-device in `ForecasterModule.on_after_batch_transfer`. Parameters ---------- idx : int The index of the sample to return, this will refer to the time of the initial state. Negative indices follow Python sequence convention. Out-of-range indices raise ``IndexError``. Returns ------- init_states : torch.Tensor Initial states, shape ``(2, num_grid_nodes, num_state_vars)``. target_states : torch.Tensor Target states, shape ``(pred_steps, num_grid_nodes, num_state_vars)``. forcing : torch.Tensor Windowed forcing, shape ``(pred_steps, num_grid_nodes, num_windowed_forcing_vars)`` where ``num_windowed_forcing_vars = num_forcing_vars`` ``* (num_past_forcing_steps + num_future_forcing_steps + 1)``. boundary : torch.Tensor Windowed boundary forcing, shape ``(pred_steps, num_boundary_grid_nodes,`` ``num_windowed_boundary_vars)``. target_times : torch.Tensor Times of the target steps, shape ``(pred_steps,)``. """ n_samples = len(self) if idx < 0: idx += n_samples if not 0 <= idx < n_samples: raise IndexError( f"index {idx} out of range for WeatherDataset of length " f"{n_samples}" ) ( da_init_states, da_target_states, da_forcing_windowed, da_boundary_windowed, da_target_times, ) = self._build_item_dataarrays(idx=idx) tensor_dtype = torch.float32 init_states = torch.tensor(da_init_states.values, dtype=tensor_dtype) target_states = torch.tensor( da_target_states.values, dtype=tensor_dtype ) target_times = torch.tensor( da_target_times.astype("datetime64[ns]").astype("int64").values, dtype=torch.int64, ) forcing = torch.tensor(da_forcing_windowed.values, dtype=tensor_dtype) boundary = torch.tensor(da_boundary_windowed.values, dtype=tensor_dtype) # init_states: (2, num_grid_nodes, num_state_vars) # target_states: (pred_steps, num_grid_nodes, num_state_vars) # forcing: (pred_steps, num_grid_nodes, num_windowed_forcing_vars) # boundary: (pred_steps, num_boundary_grid_nodes, # num_windowed_boundary_vars) # target_times: (pred_steps,) return init_states, target_states, forcing, boundary, target_times def __iter__( self, ) -> Iterator[ tuple[ torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor ] ]: """ Convenience method to iterate over the dataset. This isn't used by pytorch DataLoader which itself implements an iterator that uses Dataset.__getitem__ and Dataset.__len__. Yields ------ tuple of torch.Tensor Same 5-tuple as :meth:`__getitem__`. """ for i in range(len(self)): yield self[i]
[docs] def create_dataarray_from_tensor( self, tensor: torch.Tensor, time: datetime.datetime | list[datetime.datetime] | np.ndarray, category: str, ) -> xr.DataArray: """ Construct a xarray.DataArray from a `pytorch.Tensor` with coordinates for `grid_index`, `time` and `{category}_feature` matching the shape and number of times provided and add the x/y coordinates from the datastore. The number if times provided is expected to match the shape of the tensor. For a 2D tensor, the dimensions are assumed to be (grid_index, {category}_feature) and only a single time should be provided. For a 3D tensor, the dimensions are assumed to be (time, grid_index, {category}_feature) and a list of times should be provided. Parameters ---------- tensor : torch.Tensor The tensor to construct the DataArray from, this assumed to have the same dimension ordering as returned by the __getitem__ method (i.e. time, grid_index, {category}_feature). The tensor will be copied to the CPU before constructing the DataArray. time : datetime.datetime or list[datetime.datetime] The time or times of the tensor. category : str The category of the tensor, either "state", "forcing" or "static". Returns ------- da : xr.DataArray The constructed DataArray. """ def _is_listlike(obj: object) -> bool: """Return ``True`` for list/tuple/ndarray-like containers.""" return hasattr(obj, "__iter__") and not isinstance(obj, str) add_time_as_dim = False if len(tensor.shape) == 2: dims = ["grid_index", f"{category}_feature"] if _is_listlike(time): raise ValueError( "Expected a single time for a 2D tensor with assumed " "dimensions (grid_index, {category}_feature), but got " f"{len(time)} times" # ty: ignore[invalid-argument-type] ) elif len(tensor.shape) == 3: add_time_as_dim = True dims = ["time", "grid_index", f"{category}_feature"] if not _is_listlike(time): raise ValueError( "Expected a list of times for a 3D tensor with assumed " "dimensions (time, grid_index, {category}_feature), but " "got a single time" ) else: raise ValueError( "Expected tensor to have 2 or 3 dimensions, but got " f"{len(tensor.shape)}" ) da_datastore_state = getattr(self, f"da_{category}") da_grid_index = da_datastore_state.grid_index da_state_feature = da_datastore_state.state_feature coords = { f"{category}_feature": da_state_feature, "grid_index": da_grid_index, } if add_time_as_dim: coords["time"] = time da = xr.DataArray( tensor.cpu().numpy(), dims=dims, coords=coords, ) for grid_coord in ["x", "y"]: if ( grid_coord in da_datastore_state.coords and grid_coord not in da.coords ): da.coords[grid_coord] = da_datastore_state[grid_coord] if not add_time_as_dim: da.coords["time"] = time return da
[docs] class WeatherDataModule(pl.LightningDataModule): """DataModule for weather data.""" def __init__( self, datastore: BaseDatastore, ar_steps_train: int = 3, ar_steps_eval: int = 25, num_past_forcing_steps: int = 1, num_future_forcing_steps: int = 1, num_past_boundary_steps: int = 1, num_future_boundary_steps: int = 1, datastore_boundary: BaseDatastore | None = None, load_single_member: bool = False, batch_size: int = 4, num_workers: int = 16, eval_split: str = "test", ) -> None: """ Parameters ---------- datastore : BaseDatastore Datastore used for all splits. ar_steps_train : int, optional Number of autoregressive steps for training batches. Default ``3``. ar_steps_eval : int, optional Number of autoregressive steps for validation/test batches. Default ``25``. num_past_forcing_steps : int, optional Number of past forcing steps to include. Default ``1``. num_future_forcing_steps : int, optional Number of future forcing steps to include. Default ``1``. num_past_boundary_steps : int, optional Number of past boundary forcing steps to include. Default ``1``. num_future_boundary_steps : int, optional Number of future boundary forcing steps to include. Default ``1``. datastore_boundary : BaseDatastore, optional Separate datastore providing boundary forcing, used for all splits. If ``None``, the boundary tensor is empty. load_single_member : bool, optional If ``True``, load only a single ensemble member per sample. Default ``False``. batch_size : int, optional Mini-batch size for dataloaders. Default ``4``. num_workers : int, optional Number of background workers per dataloader. Default ``16``. eval_split : str, optional Dataset split to use for ``test_dataloader``. Default ``"test"``. """ super().__init__() self._datastore = datastore self._datastore_boundary = datastore_boundary self.num_past_forcing_steps = num_past_forcing_steps self.num_future_forcing_steps = num_future_forcing_steps self.num_past_boundary_steps = num_past_boundary_steps self.num_future_boundary_steps = num_future_boundary_steps self.ar_steps_train = ar_steps_train self.ar_steps_eval = ar_steps_eval self.load_single_member = load_single_member self.batch_size = batch_size self.num_workers: int = num_workers self.train_dataset: WeatherDataset | None = None self.val_dataset: WeatherDataset | None = None self.test_dataset: WeatherDataset | None = None self.multiprocessing_context: str | None = None self.eval_split = eval_split if num_workers > 0: # default to spawn for now, as the default on linux "fork" hangs # when using dask (which the npyfilesmeps datastore uses) self.multiprocessing_context = "spawn"
[docs] def setup(self, stage: str | None = None) -> None: """ Instantiate datasets for the requested trainer stage. Parameters ---------- stage : str or None, optional Trainer stage identifier (``"fit"``/``"test"``/``None``). When ``None``, both the training split and the validation/test evaluation splits are prepared. """ shared_kwargs: dict[str, Any] = { "num_past_forcing_steps": self.num_past_forcing_steps, "num_future_forcing_steps": self.num_future_forcing_steps, "num_past_boundary_steps": self.num_past_boundary_steps, "num_future_boundary_steps": self.num_future_boundary_steps, "datastore_boundary": self._datastore_boundary, "load_single_member": self.load_single_member, } if stage == "fit" or stage is None: self.train_dataset = WeatherDataset( datastore=self._datastore, split="train", ar_steps=self.ar_steps_train, **shared_kwargs, ) self.val_dataset = WeatherDataset( datastore=self._datastore, split="val", ar_steps=self.ar_steps_eval, **shared_kwargs, ) if stage == "test" or stage is None: self.test_dataset = WeatherDataset( datastore=self._datastore, split=self.eval_split, ar_steps=self.ar_steps_eval, **shared_kwargs, )
[docs] def train_dataloader(self) -> torch.utils.data.DataLoader: """Load train dataset. Returns ------- torch.utils.data.DataLoader Dataloader over ``self.train_dataset``, shuffled. """ assert self.train_dataset is not None return torch.utils.data.DataLoader( self.train_dataset, batch_size=self.batch_size, num_workers=self.num_workers, shuffle=True, multiprocessing_context=self.multiprocessing_context, persistent_workers=self.num_workers > 0, pin_memory=torch.cuda.is_available(), )
[docs] def val_dataloader(self) -> torch.utils.data.DataLoader: """Load validation dataset. Returns ------- torch.utils.data.DataLoader Dataloader over ``self.val_dataset``, not shuffled. """ assert self.val_dataset is not None return torch.utils.data.DataLoader( self.val_dataset, batch_size=self.batch_size, num_workers=self.num_workers, shuffle=False, multiprocessing_context=self.multiprocessing_context, persistent_workers=self.num_workers > 0, pin_memory=torch.cuda.is_available(), )
[docs] def test_dataloader(self) -> torch.utils.data.DataLoader: """Load test dataset. Returns ------- torch.utils.data.DataLoader Dataloader over ``self.test_dataset``, not shuffled. """ assert self.test_dataset is not None return torch.utils.data.DataLoader( self.test_dataset, batch_size=self.batch_size, num_workers=self.num_workers, shuffle=False, multiprocessing_context=self.multiprocessing_context, persistent_workers=self.num_workers > 0, pin_memory=torch.cuda.is_available(), )