Source code for esmvalcore.preprocessor._supplementary_vars

"""Preprocessor functions for ancillary variables and cell measures."""

from __future__ import annotations

import logging
from typing import TYPE_CHECKING, Literal, TypedDict

import iris.coords

if TYPE_CHECKING:
    from collections.abc import Callable, Iterable

    from iris.cube import Cube

logger = logging.getLogger(__name__)


class RequiredSupplementaries(TypedDict):
    variables: list[str]
    required: Literal["require_at_least_one", "prefer_at_least_one"]


PREPROCESSOR_SUPPLEMENTARIES: dict[str, RequiredSupplementaries] = {}


def register_supplementaries(
    variables: list[str],
    required: Literal["require_at_least_one", "prefer_at_least_one"],
) -> Callable:
    """Register supplementary variables required for a preprocessor function.

    Parameters
    ----------
    variables:
        List of variable names.
    required:
        How strong the requirement is. Can be 'require_at_least_one' if at
        least one variable must be available or 'prefer_at_least_one' if it is
        preferred that at least one variable is available, but not strictly
        necessary.
    """
    valid = ("require_at_least_one", "prefer_at_least_one")
    if required not in valid:
        msg = f"`required` should be one of {valid}"
        raise NotImplementedError(msg)
    supplementaries = {
        "variables": variables,
        "required": required,
    }

    def wrapper(func):
        PREPROCESSOR_SUPPLEMENTARIES[func.__name__] = supplementaries
        return func

    return wrapper


def add_cell_measure(
    cube: Cube,
    cell_measure_cube: Cube | iris.coords.CellMeasure,
    measure: Literal["area", "volume"],
) -> None:
    """Add cell measure to cube (in-place).

    Note
    ----
    This assumes that the cell measure spans the rightmost dimensions of the
    cube.

    Parameters
    ----------
    cube:
        Iris cube with input data.
    cell_measure_cube:
        Iris cube or cell measure coord with cell measure data.
    measure:
        Name of the measure, can be 'area' or 'volume'.

    Returns
    -------
    iris.cube.Cube
        Cube with added cell measure

    Raises
    ------
    ValueError
        If measure name is not 'area' or 'volume'.
    """
    if measure not in ["area", "volume"]:
        msg = f"measure name must be 'area' or 'volume', got {measure} instead"
        raise ValueError(msg)
    coord_dims = tuple(
        range(cube.ndim - len(cell_measure_cube.shape), cube.ndim),
    )
    cell_measure_data = cell_measure_cube.core_data()
    if cell_measure_cube.has_lazy_data():
        cube_chunks = tuple(cube.lazy_data().chunks[d] for d in coord_dims)
        cell_measure_data = cell_measure_data.rechunk(cube_chunks)
    cell_measure = iris.coords.CellMeasure(
        cell_measure_data,
        standard_name=cell_measure_cube.standard_name,
        units=cell_measure_cube.units,
        measure=measure,
        var_name=cell_measure_cube.var_name,
        attributes=cell_measure_cube.attributes,
    )
    cube.add_cell_measure(cell_measure, coord_dims)
    logger.debug(
        "Added %s as cell measure in cube of %s.",
        cell_measure_cube.var_name,
        cube.var_name,
    )


def find_matching_coord_dims(
    coord_to_match: iris.coords.DimCoord,
    cube: iris.cube.Cube,
) -> tuple[int] | None:
    """Find a matching coordinate from the ancillary variable in the cube.

    Parameters
    ----------
    coord_to_match: iris.coords.DimCoord
        Coordinate from an ancillary cube to match.
    cube: iris.cube.Cube
        Iris cube with variable data.

    Returns
    -------
    cube_dims: tuple or None
        Tuple containing the matched cube coordinate dimension for the
        coordinate from the ancillary cube in the data cube. If no match
        is found, None is returned.
    """
    cube_dims = None
    for cube_coord in cube.coords():
        if (
            (
                cube_coord.var_name == coord_to_match.var_name
                or (
                    coord_to_match.standard_name is not None
                    and cube_coord.standard_name
                    == coord_to_match.standard_name
                )
                or (
                    coord_to_match.long_name is not None
                    and cube_coord.long_name == coord_to_match.long_name
                )
            )
            and cube_coord.units == coord_to_match.units
            and cube_coord.shape == coord_to_match.shape
        ):
            cube_dims = cube.coord_dims(cube_coord)
            msg = (
                f"Found a matching coordinate for {coord_to_match.var_name}"
                f" with coordinate {cube_coord.var_name}"
                f" in the cube of variable '{cube.var_name}'."
            )
            logger.debug(msg)
            break
    return cube_dims


def get_data_dims(
    cube: Cube,
    ancillary: Cube | iris.coords.AncillaryVariable,
) -> list[None | int]:
    """Get matching data dimensions between cube and ancillary variable.

    Parameters
    ----------
    cube: iris.cube.Cube
        Iris cube with input data.
    ancillary: Cube or iris.coords.AncillaryVariable
        Iris cube or AncillaryVariable with ancillary data.

    Returns
    -------
    data_dims: list
        List with as many entries as the ancillary variable dimensions.
        The i-th entry corresponds to the i-th ancillary variable
        dimension match with the cube's dimensions. If there is no match
        between the ancillary variable and the cube for a dimension, then entry
        defaults to None.
    """
    # Match the coordinates of the ancillary cube to coordinates and
    # dimensions in the input cube before adding the ancillary variable.
    data_dims: list[None | int] = []
    if isinstance(ancillary, iris.coords.AncillaryVariable):
        start_dim = cube.ndim - len(ancillary.shape)
        data_dims = list(range(start_dim, cube.ndim))
    else:
        data_dims = [None] * ancillary.ndim
        for coord in ancillary.coords():
            try:
                cube_dims = cube.coord_dims(coord)
            except iris.exceptions.CoordinateNotFoundError:
                cube_dims = find_matching_coord_dims(coord, cube)
            if cube_dims is not None:
                for ancillary_dim, cube_dim in zip(
                    ancillary.coord_dims(coord),
                    cube_dims,
                    strict=True,
                ):
                    data_dims[ancillary_dim] = cube_dim
        if None in data_dims:
            none_dims = ", ".join(
                str(i) for i, d in enumerate(data_dims) if d is None
            )
            msg = (
                f"Failed to add\n{ancillary}\nas ancillary var "
                f"to the cube\n{cube}\n"
                f"Mismatch between ancillary cube and variable cube coordinate"
                f" {none_dims}"
            )
            logger.error(msg)
            raise iris.exceptions.CoordinateNotFoundError(msg)
    return data_dims


def add_ancillary_variable(
    cube: Cube,
    ancillary_cube: Cube | iris.coords.AncillaryVariable,
) -> None:
    """Add ancillary variable to cube (in-place).

    Parameters
    ----------
    cube:
        Iris cube with input data.
    ancillary_cube:
        Iris cube or AncillaryVariable with ancillary data.

    Returns
    -------
    iris.cube.Cube
        Cube with added ancillary variables
    """
    try:
        ancillary_var = iris.coords.AncillaryVariable(
            ancillary_cube.core_data(),
            standard_name=ancillary_cube.standard_name,
            units=ancillary_cube.units,
            var_name=ancillary_cube.var_name,
            attributes=ancillary_cube.attributes,
            long_name=ancillary_cube.long_name,
        )
    except AttributeError as err:
        msg = (
            f"Failed to add\n{ancillary_cube}\nas ancillary var to the cube\n{cube}\n"
            "ancillary_cube should be either an iris.cube.Cube or an "
            "iris.coords.AncillaryVariable object."
        )
        raise ValueError(msg) from err
    data_dims = get_data_dims(cube, ancillary_cube)
    if ancillary_cube.has_lazy_data():
        cube_chunks = tuple(cube.lazy_data().chunks[d] for d in data_dims)
        ancillary_var.data = ancillary_cube.lazy_data().rechunk(cube_chunks)
    cube.add_ancillary_variable(ancillary_var, data_dims)
    logger.debug(
        "Added %s as ancillary variable in cube of %s.",
        ancillary_cube.var_name,
        cube.var_name,
    )


[docs] def add_supplementary_variables( cube: Cube, supplementary_cubes: Iterable[Cube], ) -> Cube: """Add ancillary variables and/or cell measures to cube (in-place). Parameters ---------- cube: Cube to add to. supplementary_cubes: Iterable of cubes containing the supplementary variables. Returns ------- iris.cube.Cube Cube with added ancillary variables and/or cell measures. """ measure_names: dict[str, Literal["area", "volume"]] = { "areacella": "area", "areacello": "area", "volcello": "volume", } for supplementary_cube in supplementary_cubes: if supplementary_cube.var_name in measure_names: measure_name = measure_names[supplementary_cube.var_name] add_cell_measure(cube, supplementary_cube, measure_name) else: add_ancillary_variable(cube, supplementary_cube) return cube
[docs] def remove_supplementary_variables(cube: Cube) -> Cube: """Remove supplementary variables from cube (in-place). Strip cell measures or ancillary variables from the cube. Parameters ---------- cube: Iris cube with data and cell measures or ancillary variables. Returns ------- iris.cube.Cube Cube without cell measures or ancillary variables. """ if cube.cell_measures(): for measure in cube.cell_measures(): cube.remove_cell_measure(measure) if cube.ancillary_variables(): for variable in cube.ancillary_variables(): cube.remove_ancillary_variable(variable) return cube