Skip to content

Data Utilities

samudra.utils.data

Masks(prognostic, boundary) dataclass

Read-only mask metadata used to expose the ocean and mask land.

Tensor contents are shared between canonical views for efficiency and must be treated as immutable by callers.

CanonicalReadRequest(time_indices, channels) dataclass

A storage-independent request for canonical ocean-data planes.

The shape of time_indices defines the leading dimensions of the returned planes. Keeping this core request to NumPy makes it usable by Python and native readers without importing xarray concepts into the boundary.

ChannelStatistics(mean, std) dataclass

Normalization statistics aligned one-for-one with canonical channels.

CanonicalReader

Bases: Protocol

Narrow storage seam implemented by xarray now and native readers later.

CanonicalSource(name, _reader, masks, data_layout) dataclass

A structurally immutable, read-capable view of canonical ocean data.

Physical xarray layout is private to the reader. In particular, callers see the same ordered channels for flat and compact OM4 stores. Channel selection and time slicing return new views and never mutate the source. Tensor-valued masks are shared, read-only metadata; mutating their contents is unsupported.

reader property

Return the storage reader so backends can decorate its read behavior.

with_reader(reader)

Return an equivalent source backed by a replacement reader.

Source code in src/samudra/utils/data.py
def with_reader(self, reader: CanonicalReader) -> Self:
    """Return an equivalent source backed by a replacement reader."""
    if reader.channels != self.channels:
        raise ValueError(
            "Replacement reader channels must match the canonical source: "
            f"expected {self.channels}, got {reader.channels}"
        )
    return dataclasses.replace(self, _reader=reader)

from_canonical_datasets(name, data, means, stds, masks, data_layout) classmethod

Construct from datasets that are already in canonical channel form.

Raw OM4 callers should use :meth:from_datasets. This factory remains useful for focused in-memory tests and named preprocessing stages.

Source code in src/samudra/utils/data.py
@classmethod
def from_canonical_datasets(
    cls,
    name: str,
    data: xr.Dataset,
    means: xr.Dataset,
    stds: xr.Dataset,
    masks: Masks,
    data_layout: DataLayout,
) -> Self:
    """Construct from datasets that are already in canonical channel form.

    Raw OM4 callers should use :meth:`from_datasets`. This factory remains
    useful for focused in-memory tests and named preprocessing stages.
    """
    channels = tuple(str(name) for name in means.data_vars)
    if any("lev" in dataset.dims for dataset in (data, means, stds)):
        raise ValueError("Canonical datasets cannot expose a 'lev' dimension")
    if set(channels) - set(data.data_vars) or set(channels) - set(stds.data_vars):
        raise ValueError("Canonical data, means, and stds have different channels")
    return cls(
        name=name,
        _reader=_XarrayCanonicalReader(
            data,
            means[list(channels)],
            stds[list(channels)],
            channels,
        ),
        masks=masks,
        data_layout=data_layout,
    )

slice_time(time)

Slice the data source to only include the specified time slice.

Source code in src/samudra/utils/data.py
def slice_time(self, time: "TimeConfig") -> Self:
    """Slice the data source to only include the specified time slice."""
    data_time_min = self.time.values.min()
    data_time_max = self.time.values.max()
    time_start = time.time_slice.start
    time_end = time.time_slice.stop
    if time_start > data_time_max or time_end < data_time_min:
        raise ValueError(
            f"Time slice {time} is entirely outside the range of the data "
            f"{str(data_time_min)[:10]} to {str(data_time_max)[:10]}"
        )

    if time_start < data_time_min or time_end > data_time_max:
        logger.warning(
            f"Time slice {time} is partially outside the range of the data "
            f"{str(data_time_min)[:10]} to {str(data_time_max)[:10]}"
        )

    return dataclasses.replace(
        self,
        name=f"{time=}[{self.name}]",
        _reader=self._reader.slice_time(time),
    )

read(time_indices, channels)

Read canonical channels at integer time indices.

Source code in src/samudra/utils/data.py
def read(self, time_indices: np.ndarray, channels: Sequence[str]) -> np.ndarray:
    """Read canonical channels at integer time indices."""
    return self._reader.read(CanonicalReadRequest(time_indices, tuple(channels)))

to_xarray_dataset()

Return the backing xarray dataset when the reader supports it.

Source code in src/samudra/utils/data.py
def to_xarray_dataset(self) -> xr.Dataset:
    """Return the backing xarray dataset when the reader supports it."""
    if not isinstance(self._reader, _XarrayCanonicalReader):
        raise TypeError("This canonical dataset is not backed by xarray")
    return self._reader.data

from_datasets(data, means, stds, *, data_layout, prognostic_var_names, boundary_var_names, name='CanonicalSource') classmethod

Build a canonical reader from already-canonicalized xarray datasets.

Source code in src/samudra/utils/data.py
@classmethod
def from_datasets(
    cls,
    data: xr.Dataset,
    means: xr.Dataset,
    stds: xr.Dataset,
    *,
    data_layout: DataLayout,
    prognostic_var_names: PrognosticVarNames,
    boundary_var_names: BoundaryVarNames,
    name: str = "CanonicalSource",
) -> Self:
    """Build a canonical reader from already-canonicalized xarray datasets."""
    channels = tuple(dict.fromkeys((*prognostic_var_names, *boundary_var_names)))
    for dataset in (data, means, stds):
        level_channels = [
            name
            for name in channels
            if name in dataset and "lev" in dataset[name].dims
        ]
        if level_channels:
            raise ValueError(
                "Canonical channels cannot expose a 'lev' dimension: "
                f"{level_channels}"
            )
    masks = extract_wet_mask(
        data,
        prognostic_var_names,
        boundary_var_names,
    )

    missing_data = set(channels).difference(data.data_vars)
    missing_means = set(channels).difference(means.data_vars)
    missing_stds = set(channels).difference(stds.data_vars)
    if missing_data or missing_means or missing_stds:
        raise ValueError(
            "Canonical channels are missing: "
            f"data={sorted(missing_data)}, means={sorted(missing_means)}, "
            f"stds={sorted(missing_stds)}"
        )

    return cls(
        name=name,
        _reader=_XarrayCanonicalReader(
            data=data,
            means=means[list(channels)],
            stds=stds[list(channels)],
            channels=channels,
        ),
        masks=masks,
        data_layout=data_layout,
    )

BatchPreprocessor(source, prognostic_var_names, boundary_var_names, *, normalize_before_mask=True, masked_fill_value=0.0)

Prepare canonical host tensors for models and restore physical values.

Source code in src/samudra/utils/data.py
def __init__(
    self,
    source: CanonicalSource,
    prognostic_var_names: Sequence[str],
    boundary_var_names: Sequence[str],
    *,
    normalize_before_mask: bool = True,
    masked_fill_value: float = 0.0,
) -> None:
    """Prepare canonical host tensors for models and restore physical values."""
    self.prognostic_mask = source.masks.prognostic
    self.boundary_mask = source.masks.boundary
    self.normalize_before_mask = normalize_before_mask
    self.masked_fill_value = masked_fill_value

    # Pre-compute arrays for faster tensor normalization. Canonicalization has
    # already aligned every scalar statistic to one ordered logical channel.
    prognostic_statistics = source.statistics(prognostic_var_names)
    boundary_statistics = source.statistics(boundary_var_names)
    self._prognostic_mean_np = prognostic_statistics.mean
    self._prognostic_std_np = prognostic_statistics.std
    self._boundary_mean_np = boundary_statistics.mean
    self._boundary_std_np = boundary_statistics.std

normalize_tensor_prognostic(data, fill_nan=True, fill_value=0.0)

Normalize a prognostic tensor without masking or flattening.

Source code in src/samudra/utils/data.py
def normalize_tensor_prognostic(
    self, data: torch.Tensor, fill_nan=True, fill_value=0.0
) -> torch.Tensor:
    """Normalize a prognostic tensor without masking or flattening."""
    return self._normalize_tensor(
        data,
        self._prognostic_mean_np,
        self._prognostic_std_np,
        fill_nan=fill_nan,
        fill_value=fill_value,
    )

unnormalize_tensor_prognostic(data, fill_value=float('nan'))

Unnormalize prognostic tensor and apply fill value to land cells.

Source code in src/samudra/utils/data.py
def unnormalize_tensor_prognostic(
    self, data: torch.Tensor, fill_value=float("nan")
) -> torch.Tensor:
    """Unnormalize prognostic tensor and apply fill value to land cells."""
    tensor_mean = torch.from_numpy(self._prognostic_mean_np).to(
        data.device, data.dtype
    )
    tensor_std = torch.from_numpy(self._prognostic_std_np).to(
        data.device, data.dtype
    )

    expand_var_dim = [1] * data.ndim
    expand_var_dim[-3] = -1
    assert data.shape[-3] == self._prognostic_mean_np.shape[0]
    tensor_mean = tensor_mean.reshape(expand_var_dim)
    tensor_std = tensor_std.reshape(expand_var_dim)

    unnorm = data * tensor_std + tensor_mean
    unnorm = torch.where(
        self.prognostic_mask.to(data.device) == 0, fill_value, unnorm
    )
    unnorm = unnorm.to(data.dtype)
    return unnorm

unnormalize_tensor_boundary(data, fill_value=float('nan'))

Unnormalize boundary tensor.

Source code in src/samudra/utils/data.py
def unnormalize_tensor_boundary(
    self, data: torch.Tensor, fill_value=float("nan")
) -> torch.Tensor:
    """Unnormalize boundary tensor."""
    tensor_mean = torch.from_numpy(self._boundary_mean_np).to(
        data.device, data.dtype
    )
    tensor_std = torch.from_numpy(self._boundary_std_np).to(data.device, data.dtype)

    expand_var_dim = [1] * data.ndim
    expand_var_dim[-3] = -1
    assert data.shape[-3] == self._boundary_mean_np.shape[0]
    tensor_mean = tensor_mean.reshape(expand_var_dim)
    tensor_std = tensor_std.reshape(expand_var_dim)

    unnorm = data * tensor_std + tensor_mean
    unnorm = torch.where(
        self.boundary_mask.to(data.device) == 0, fill_value, unnorm
    )
    unnorm = unnorm.to(data.dtype)
    return unnorm

LoadStats(load_time_seconds) dataclass

Captures stats about loading a single ModelBatch object.

accumulated(stats) classmethod

Accumulate the stats across multiple LoadStats objects in a batch.

Source code in src/samudra/utils/data.py
@classmethod
def accumulated(cls, stats: list["LoadStats"]) -> "LoadStats":
    """Accumulate the stats across multiple LoadStats objects in a batch."""
    return cls(sum(s.load_time_seconds for s in stats))

extract_wet_mask(data, prognostic_var_names, boundary_var_names)

A mask for where the oceans are. Water is wet.

Source code in src/samudra/utils/data.py
def extract_wet_mask(
    data: xr.Dataset,
    prognostic_var_names: PrognosticVarNames,
    boundary_var_names: BoundaryVarNames,
) -> Masks:
    """A mask for where the oceans are. Water is wet."""
    data_ = flatten_masks(data)
    wet_inp_np = np.stack(
        [_mask_array_for_data_var(data_, var_name) for var_name in prognostic_var_names]
    )
    boundary_mask_vars = [
        _mask_var_for_data_var(data_, var_name) for var_name in boundary_var_names
    ]
    boundary_masks = [
        _mask_array_for_data_var(data_, var_name) for var_name in boundary_var_names
    ]
    wet_surface_mask_np = (
        np.stack(boundary_masks)
        if len(set(boundary_mask_vars)) > 1
        else boundary_masks[0]
    )

    wet_inp = torch.from_numpy(wet_inp_np)
    wet_surface = torch.from_numpy(wet_surface_mask_np)
    return Masks(wet_inp.bool(), wet_surface.bool())

flatten_masks(data)

Adds level-wise mask variables from the stacked wet mask.

Source code in src/samudra/utils/data.py
def flatten_masks(
    data: xr.Dataset,
) -> xr.Dataset:
    """Adds level-wise mask variables from the stacked wet mask."""
    data_ = data.copy()
    if f"{_CANONICAL_MASK_PREFIX}0" not in data_.variables:
        assert _STACKED_WET_MASK_VAR in data_.variables, (
            "Wet mask cannot be constructed without "
            "either the wetmask variable or the level-wise masks"
        )

        wet_mask = data_[_STACKED_WET_MASK_VAR]
        for i in range(wet_mask.sizes["lev"]):
            data_[f"{_CANONICAL_MASK_PREFIX}{i}"] = wet_mask.isel(lev=i)

        data_ = data_.drop_vars(_STACKED_WET_MASK_VAR)

    return data_

unflatten_masks(data, num_levels)

Adds a stacked wet mask xarray.DataArray from level-wise mask variables.

Source code in src/samudra/utils/data.py
def unflatten_masks(
    data: xr.Dataset,
    num_levels: int,
) -> xr.Dataset:
    """Adds a stacked wet mask `xarray.DataArray` from level-wise mask variables."""
    data_ = data.copy()
    mask_vars = [f"{_CANONICAL_MASK_PREFIX}{i}" for i in range(num_levels)]
    if _STACKED_WET_MASK_VAR not in data_.variables:
        assert mask_vars[0] in data_.variables, "Wet mask must have masks as data vars!"

        wetmask = data_[mask_vars].to_array(dim="lev", name=_STACKED_WET_MASK_VAR)

        lev = data_.coords.get("lev", np.arange(len(mask_vars)))
        data_[_STACKED_WET_MASK_VAR] = wetmask.assign_coords(lev=lev)
        data_ = data_.drop_vars(mask_vars)

    return data_

spherical_area(data)

Compute real grid cell areas on a spherical Earth.

Uses the spherical geometry formula: A = R² × Δλ × (sin(φ₂) - sin(φ₁))

where: - R is Earth's radius (6371 km) - Δλ is the longitude spacing in radians - φ₁, φ₂ are the latitude bounds of the cell in radians

Parameters:

Name Type Description Default
data Dataset

Dataset containing lat/lon coordinates

required

Returns:

Type Description
Grid

Grid cell areas in m²

Source code in src/samudra/utils/data.py
def spherical_area(data: xr.Dataset) -> Grid:
    """
    Compute real grid cell areas on a spherical Earth.

    Uses the spherical geometry formula:
    A = R² × Δλ × (sin(φ₂) - sin(φ₁))

    where:
    - R is Earth's radius (6371 km)
    - Δλ is the longitude spacing in radians
    - φ₁, φ₂ are the latitude bounds of the cell in radians

    Args:
        data: Dataset containing lat/lon coordinates

    Returns:
        Grid cell areas in m²
    """
    R = 6371000  # Earth radius in meters

    lats = data.lat.to_numpy()
    lons = data.lon.to_numpy()

    # Compute grid spacing (assuming uniform spacing)
    dlat = np.abs(np.diff(lats).mean())
    dlon = np.abs(np.diff(lons).mean())

    # Convert to radians
    dlat_rad = np.deg2rad(dlat)
    dlon_rad = np.deg2rad(dlon)
    lats_rad = np.deg2rad(lats)

    # Compute cell areas: A = R² × dlon × (sin(lat + dlat/2) - sin(lat - dlat/2))
    areas = np.zeros((len(lats), len(lons)))
    for i, lat_rad in enumerate(lats_rad):
        lat_north = lat_rad + dlat_rad / 2
        lat_south = lat_rad - dlat_rad / 2
        area = R**2 * dlon_rad * (np.sin(lat_north) - np.sin(lat_south))
        areas[i, :] = area

    return torch.from_numpy(areas)

get_inference_steps(data_source, input_steps, output_steps)

Get the number of inference/rollout steps for the given time configuration.

Parameters:

Name Type Description Default
data_source CanonicalSource

The data source sliced to the inference time range

required
input_steps int

Raw timesteps consumed by each model call.

required
output_steps int

Future raw timesteps emitted by each model call.

required

Returns:

Name Type Description
num_steps

Total number of rolled-out inferences which fit into the time range

Source code in src/samudra/utils/data.py
def get_inference_steps(
    data_source: CanonicalSource,
    input_steps: int,
    output_steps: int,
):
    """
    Get the number of inference/rollout steps for the given time configuration.

    Args:
        data_source: The data source sliced to the inference time range
        input_steps: Raw timesteps consumed by each model call.
        output_steps: Future raw timesteps emitted by each model call.

    Returns:
        num_steps: Total number of rolled-out inferences which fit into the time range
    """
    available_targets = max(data_source.time.size - input_steps, 0)
    available_targets -= available_targets % output_steps
    # Aggregators record the initial input history followed by forecast targets.
    return input_steps + available_targets

get_anomalies_vars(var_names)

Get the variables that need to be computed for anomalies.

Source code in src/samudra/utils/data.py
def get_anomalies_vars(var_names: BoundaryVarNames) -> tuple[str, ...]:
    """Get the variables that need to be computed for anomalies."""
    return tuple([var for var in var_names if var.endswith("_anomalies")])

compute_anomalies(data, means, stds, anomalies_vars)

Compute anomalies for the given variables.

Source code in src/samudra/utils/data.py
def compute_anomalies(
    data: xr.Dataset,
    means: xr.Dataset,
    stds: xr.Dataset,
    anomalies_vars: tuple[str, ...],
) -> tuple[xr.Dataset, xr.Dataset, xr.Dataset]:
    """Compute anomalies for the given variables."""
    for var in anomalies_vars:
        base_var = var.replace("_anomalies", "")
        if var not in data.variables and base_var in data.variables:
            logger.info(f"Computing anomalies for {base_var}")
            climatology = (
                data[base_var].groupby("time.dayofyear").mean("time").compute()
            )
            # Remove the seasonal cycle (climatology) from the detrended data
            day_of_year = data[base_var]["time"].dt.dayofyear
            data[var] = (
                data[base_var] - climatology.sel(dayofyear=day_of_year)
            ).compute()
            data = data.drop_vars(["dayofyear"])
            means[var] = data[var].mean().compute()
            stds[var] = data[var].std().compute()
    return data, means, stds

with_level_index_vars(data, depth_levels)

Ensure variable names use a depth level index, not depth level value.

Source code in src/samudra/utils/data.py
def with_level_index_vars(
    data: xr.Dataset,
    depth_levels: Sequence[float],
) -> xr.Dataset:
    """
    Ensure variable names use a depth level index, not depth level value.
    """
    data_copy = data.copy()

    for var in data.variables:
        # OM4 data format has variables in the form: var_lev_{depthlevel}
        # ex. so_lev_1040_0. We need to convert into var_{depthlevelidx}
        var_str = str(var)
        if "_lev_" in var_str:
            var_split = var_str.split("_lev_")
            var = var_split[0]
            lev_in_depth = float(var_split[1].replace("_", "."))
            lev_in_depth_idx = depth_levels.index(lev_in_depth)
            data_copy = data_copy.rename({var_str: f"{var}_{lev_in_depth_idx!s}"})

    return data_copy

with_depth_value_vars(data, data_layout)

Inverse of with_level_index_vars: name 3D variables by depth value.

Renames the depth-resolved prognostic variables (<var>_<level_index>) back to the OM4 <var>_lev_<depth> form (e.g. thetao_0 -> thetao_lev_2_5). Which variables to rename is read directly off data_layout.prognostic_var_names rather than inferred from the data, so per-level masks (mask_<i>) and level-free prognostics (e.g. zos) are never mistaken for depth-resolved variables by name alone.

Source code in src/samudra/utils/data.py
def with_depth_value_vars(
    data: xr.Dataset,
    data_layout: DataLayout,
) -> xr.Dataset:
    """Inverse of `with_level_index_vars`: name 3D variables by depth value.

    Renames the depth-resolved prognostic variables (``<var>_<level_index>``)
    back to the OM4 ``<var>_lev_<depth>`` form (e.g. ``thetao_0`` ->
    ``thetao_lev_2_5``). Which variables to rename is read directly off
    ``data_layout.prognostic_var_names`` rather than inferred from the data, so
    per-level masks (``mask_<i>``) and level-free prognostics (e.g. ``zos``) are
    never mistaken for depth-resolved variables by name alone.
    """
    renames = {}
    for var_name in data_layout.prognostic_var_names:
        base, _, idx = var_name.rpartition("_")
        if not (base and idx.isdigit()):
            continue  # level-free prognostic variable, e.g. zos
        depth = data_layout.depth_levels[int(idx)]
        depth_str = str(depth).replace(".", "_")
        if var_name in data.variables:
            renames[var_name] = f"{base}_lev_{depth_str}"

    return data.rename(renames)

with_lat_lon_coords(data)

Standardize dataset coordinates; prefer "lat"/"lon" over "y"/"x".

Source code in src/samudra/utils/data.py
def with_lat_lon_coords(data: xr.Dataset) -> xr.Dataset:
    """Standardize dataset coordinates; prefer "lat"/"lon" over "y"/"x"."""
    data_copy = data.copy()
    if "lat" not in data_copy.dims:
        # Preserve the 2-D geographic coords under a non-colliding name, then
        # rename the x/y dims to the 1-D lat/lon we standardize on.
        preserve = {n: f"{n}_2d" for n in ("lat", "lon") if n in data_copy.coords}
        data_copy = data_copy.rename(preserve).rename({"x": "lon", "y": "lat"})

    return data_copy

stack_levels(data, data_layout)

Reassemble a flattened OM4 dataset into analysis-ready, depth-stacked form.

Inverts the preprocessing flattening so downstream analysis does not have to: per-level prognostic channels (thetao_0 ...) become thetao(lev, ...) and the per-level mask_i become a single stacked wetmask. Grid coordinates are preserved. This is the dataset-level counterpart to the eval writer's reassembly, for putting ground-truth inputs on the same footing as predictions.

Implemented as the inverse of with_level_index_vars followed by the existing compact_dataset (stacks the _lev_ form) and unflatten_masks.

Source code in src/samudra/utils/data.py
def stack_levels(
    data: xr.Dataset,
    data_layout: DataLayout,
) -> xr.Dataset:
    """Reassemble a flattened OM4 dataset into analysis-ready, depth-stacked form.

    Inverts the preprocessing flattening so downstream analysis does not have to:
    per-level prognostic channels (``thetao_0`` ...) become ``thetao(lev, ...)``
    and the per-level ``mask_i`` become a single stacked ``wetmask``. Grid
    coordinates are preserved. This is the dataset-level counterpart to the eval
    writer's reassembly, for putting ground-truth inputs on the same footing as
    predictions.

    Implemented as the inverse of `with_level_index_vars` followed by the existing
    `compact_dataset` (stacks the ``_lev_`` form) and `unflatten_masks`.
    """
    data = with_depth_value_vars(data, data_layout)
    data = compact_dataset(data)
    if "mask_0" in data.variables:
        data = unflatten_masks(data, num_levels=len(data_layout.depth_levels))
    return data