import logging
from collections import namedtuple
from functools import reduce
from operator import mul as operator_mul

try:
    from matplotlib.path import Path
except ImportError:
    pass

import cfdm
import numpy as np
from numpy import array as numpy_array
from numpy import array_equal as numpy_array_equal
from numpy import asanyarray as numpy_asanyarray
from numpy import can_cast as numpy_can_cast
from numpy import delete as numpy_delete
from numpy import diff as numpy_diff
from numpy import empty as numpy_empty
from numpy import finfo as numpy_finfo
from numpy import full as numpy_full
from numpy import ndarray as numpy_ndarray
from numpy import prod as numpy_prod
from numpy import reshape as numpy_reshape
from numpy import shape as numpy_shape
from numpy import size as numpy_size
from numpy import squeeze as numpy_squeeze
from numpy import tile as numpy_tile
from numpy import unique as numpy_unique
from numpy import where as numpy_where
from numpy.ma import is_masked as numpy_ma_is_masked
from numpy.ma import isMA as numpy_ma_isMA
from numpy.ma import where as numpy_ma_where

from . import (
    AuxiliaryCoordinate,
    Bounds,
    CellMethod,
    Constructs,
    Count,
    DimensionCoordinate,
    Domain,
    DomainAncillary,
    DomainAxis,
    FieldList,
    Flags,
    Index,
    List,
    mixin,
)
from .constants import masked as cf_masked
from .data import (
    Data,
    GatheredArray,
    RaggedContiguousArray,
    RaggedIndexedArray,
    RaggedIndexedContiguousArray,
)
from .decorators import (
    _deprecated_kwarg_check,
    _inplace_enabled,
    _inplace_enabled_define_and_cleanup,
    _manage_log_level_via_verbosity,
)
from .formula_terms import FormulaTerms
from .functions import (
    _DEPRECATION_ERROR,
    _DEPRECATION_ERROR_ARG,
    _DEPRECATION_ERROR_KWARG_VALUE,
    _DEPRECATION_ERROR_KWARGS,
    _DEPRECATION_ERROR_METHOD,
    DeprecationError,
    _section,
    chunksize,
    parse_indices,
)
from .functions import relaxed_identities as cf_relaxed_identities
from .query import Query, eq, ge, gt, le, lt
from .regrid import (
    RegridOperator,
    conservative_regridding_methods,
    create_Field,
    create_Grid,
    create_Regrid,
    get_cartesian_coords,
    grids_have_same_coords,
    grids_have_same_masks,
    regrid_check_bounds,
    regrid_check_method,
    regrid_check_use_src_mask,
    regrid_compute_field_mass,
    regrid_copy_coordinate_references,
    regrid_create_operator,
    regrid_fill_fields,
    regrid_get_axis_indices,
    regrid_get_coord_order,
    regrid_get_destination_mask,
    regrid_get_latlon,
    regrid_get_operator_method,
    regrid_get_regridded_data,
    regrid_get_reordered_sections,
    regrid_get_section_shape,
    regrid_initialize,
    regrid_update_coordinate_references,
    regrid_update_coordinates,
    regrid_use_bounds,
    run_Regrid,
)
from .subspacefield import SubspaceField
from .timeduration import TimeDuration
from .units import Units

logger = logging.getLogger(__name__)

# --------------------------------------------------------------------
# Commonly used units
# --------------------------------------------------------------------
# _units_degrees = Units("degrees")
_units_radians = Units("radians")
_units_metres = Units("m")
_units_1 = Units("1")

# --------------------------------------------------------------------
# Map each allowed input collapse method name to its corresponding
# Data method. Input collapse methods not in this sictionary are
# assumed to have a corresponding Data method with the same name.
# --------------------------------------------------------------------
_collapse_methods = {
    **{
        name: name
        for name in [
            "mean",  # results in 'mean': 'mean' entry, etc.
            "mean_absolute_value",
            "mean_of_upper_decile",
            "max",
            "maximum_absolute_value",
            "min",
            "max",
            "minimum_absolute_value",
            "mid_range",
            "range",
            "median",
            "sd",
            "sum",
            "sum_of_squares",
            "integral",
            "root_mean_square",
            "var",
            "sample_size",
            "sum_of_weights",
            "sum_of_weights2",
        ]
    },
    **{  # non-identical mapped names:
        "avg": "mean",
        "average": "mean",
        "maximum": "max",
        "minimum": "min",
        "standard_deviation": "sd",
        "variance": "var",
    },
}

# --------------------------------------------------------------------
# Map each allowed input collapse method name to its corresponding CF
# cell method.
# --------------------------------------------------------------------
_collapse_cell_methods = {
    **{
        name: name
        for name in [
            "point",
            "mean",
            "mean_absolute_value",
            "mean_of_upper_decile",
            "maximum",
            "maximum_absolute_value",
            "minimum",
            "minimum_absolute_value",
            "mid_range",
            "range",
            "median",
            "standard_deviation",
            "sum",
            "root_mean_square",
            "sum_of_squares",
            "variance",
        ]
    },
    **{  # non-identical mapped names:
        "avg": "mean",
        "average": "mean",
        "max": "maximum",
        "min": "minimum",
        "sd": "standard_deviation",
        "integral": "sum",
        "var": "variance",
        "sample_size": "point",
        "sum_of_weights": "sum",
        "sum_of_weights2": "sum",
    },
}

# --------------------------------------------------------------------
# These Data methods may be weighted
# --------------------------------------------------------------------
_collapse_weighted_methods = set(
    (
        "mean",
        "mean_absolute_value",
        "mean_of_upper_decile",
        "avg",
        "average",
        "sd",
        "standard_deviation",
        "var",
        "variance",
        # 'sum_of_weights',
        # 'sum_of_weights2',
        "integral",
        "root_mean_square",
    )
)

# --------------------------------------------------------------------
# These Data methods may specify a number of degrees of freedom
# --------------------------------------------------------------------
_collapse_ddof_methods = set(
    (
        "sd",
        "var",
    )
)

_earth_radius = Data(6371229.0, "m")

_relational_methods = (
    "__eq__",
    "__ne__",
    "__lt__",
    "__le__",
    "__gt__",
    "__ge__",
)

_xxx = namedtuple(
    "data_dimension", ["size", "axis", "key", "coord", "coord_type", "scalar"]
)

# _empty_set = set()


class Field(mixin.FieldDomain, mixin.PropertiesData, cfdm.Field):
    """A field construct of the CF data model.

    The field construct is central to the CF data model, and includes
    all the other constructs. A field corresponds to a CF-netCDF data
    variable with all of its metadata. All CF-netCDF elements are
    mapped to a field construct or some element of the CF field
    construct. The field construct contains all the data and metadata
    which can be extracted from the file using the CF conventions.

    The field construct consists of a data array and the definition of
    its domain (that describes the locations of each cell of the data
    array), field ancillary constructs containing metadata defined
    over the same domain, and cell method constructs to describe how
    the cell values represent the variation of the physical quantity
    within the cells of the domain. The domain is defined collectively
    by the following constructs of the CF data model: domain axis,
    dimension coordinate, auxiliary coordinate, cell measure,
    coordinate reference and domain ancillary constructs.

    The field construct also has optional properties to describe
    aspects of the data that are independent of the domain. These
    correspond to some netCDF attributes of variables (e.g. units,
    long_name and standard_name), and some netCDF global file
    attributes (e.g. history and institution).

    **NetCDF interface**

    {{netCDF variable}}

    {{netCDF global attributes}}

    {{netCDF group attributes}}

    {{netCDF geometry group}}

    Some components exist within multiple constructs, but when written
    to a netCDF dataset the netCDF names associated with such
    components will be arbitrarily taken from one of them. The netCDF
    variable, dimension and sample dimension names and group
    structures for such components may be set or removed consistently
    across all such components with the `nc_del_component_variable`,
    `nc_set_component_variable`, `nc_set_component_variable_groups`,
    `nc_clear_component_variable_groups`,
    `nc_del_component_dimension`, `nc_set_component_dimension`,
    `nc_set_component_dimension_groups`,
    `nc_clear_component_dimension_groups`,
    `nc_del_component_sample_dimension`,
    `nc_set_component_sample_dimension`,
    `nc_set_component_sample_dimension_groups`,
    `nc_clear_component_sample_dimension_groups` methods.

    CF-compliance issues for field constructs read from a netCDF
    dataset may be accessed with the `dataset_compliance` method.

    """

    def __new__(cls, *args, **kwargs):
        """Store component classes."""
        instance = super().__new__(cls)
        instance._AuxiliaryCoordinate = AuxiliaryCoordinate
        instance._Bounds = Bounds
        instance._Constructs = Constructs
        instance._Domain = Domain
        instance._DomainAncillary = DomainAncillary
        instance._DomainAxis = DomainAxis
        #        instance._Data = Data
        instance._RaggedContiguousArray = RaggedContiguousArray
        instance._RaggedIndexedArray = RaggedIndexedArray
        instance._RaggedIndexedContiguousArray = RaggedIndexedContiguousArray
        instance._GatheredArray = GatheredArray
        instance._Count = Count
        instance._Index = Index
        instance._List = List
        return instance

    _special_properties = mixin.PropertiesData._special_properties
    _special_properties += ("flag_values", "flag_masks", "flag_meanings")

    def __init__(
        self, properties=None, source=None, copy=True, _use_data=True
    ):
        """**Initialization**

        :Parameters:

            properties: `dict`, optional
                Set descriptive properties. The dictionary keys are
                property names, with corresponding values. Ignored if the
                *source* parameter is set.

                *Parameter example:*
                  ``properties={'standard_name': 'air_temperature'}``

                Properties may also be set after initialisation with the
                `set_properties` and `set_property` methods.

            source: optional
                Initialize the properties, data and metadata constructs
                from those of *source*.

            copy: `bool`, optional
                If False then do not deep copy input parameters prior to
                initialization. By default arguments are deep copied.

        """
        super().__init__(
            properties=properties,
            source=source,
            copy=copy,
            _use_data=_use_data,
        )

        if source:
            flags = getattr(source, "Flags", None)
            if flags is not None:
                self.Flags = flags.copy()

    def __getitem__(self, indices):
        """Return a subspace of the field construct defined by indices.

        f.__getitem__(indices) <==> f[indices]

        Subspacing by indexing uses rules that are very similar to the
        numpy indexing rules, the only differences being:

        * An integer index i specified for a dimension reduces the size of
          this dimension to unity, taking just the i-th element, but keeps
          the dimension itself, so that the rank of the array is not
          reduced.

        * When two or more dimensions’ indices are sequences of integers
          then these indices work independently along each dimension
          (similar to the way vector subscripts work in Fortran). This is
          the same indexing behaviour as on a Variable object of the
          netCDF4 package.

        * For a dimension that is cyclic, a range of indices specified by
          a `slice` that spans the edges of the data (such as ``-2:3`` or
          ``3:-2:-1``) is assumed to "wrap" around, rather then producing
          a null result.

        .. seealso:: `indices`, `squeeze`, `subspace`, `where`

        **Examples**

        >>> f.shape
        (12, 73, 96)
        >>> f[0].shape
        (1, 73, 96)
        >>> f[3, slice(10, 0, -2), 95:93:-1].shape
        (1, 5, 2)

        >>> f.shape
        (12, 73, 96)
        >>> f[:, [0, 72], [5, 4, 3]].shape
        (12, 2, 3)

        >>> f.shape
        (12, 73, 96)
        >>> f[...].shape
        (12, 73, 96)
        >>> f[slice(0, 12), :, 10:0:-2].shape
        (12, 73, 5)
        >>> f[[True, True, False, True, True, False, False, True, True, True,
        ...    True, True]].shape
        (9, 64, 128)
        >>> f[..., :6, 9:1:-2, [1, 3, 4]].shape
        (6, 4, 3)

        """
        logger.debug(
            self.__class__.__name__ + ".__getitem__"
        )  # pragma: no cover
        logger.debug(f"    input indices = {indices}")  # pragma: no cover

        if indices is Ellipsis:
            return self.copy()

        data = self.data
        shape = data.shape

        # Parse the index
        if not isinstance(indices, tuple):
            indices = (indices,)

        if isinstance(indices[0], str) and indices[0] == "mask":
            auxiliary_mask = indices[:2]
            indices2 = indices[2:]
        else:
            auxiliary_mask = None
            indices2 = indices

        indices, roll = parse_indices(shape, indices2, cyclic=True)

        if roll:
            new = self
            axes = data._axes
            cyclic_axes = data._cyclic
            for iaxis, shift in roll.items():
                axis = axes[iaxis]
                if axis not in cyclic_axes:
                    _ = self.get_data_axes()[iaxis]
                    raise IndexError(
                        "Can't take a cyclic slice from non-cyclic "
                        f"{self.constructs.domain_axis_identity(_)!r} axis"
                    )

                logger.debug(
                    f"    roll, iaxis, shift = {roll} {iaxis} {shift}"
                )  # pragma: no cover

                new = new.roll(iaxis, shift)
        else:
            new = self.copy()

        # ------------------------------------------------------------
        # Subspace the field construct's data
        # ------------------------------------------------------------
        if auxiliary_mask:
            auxiliary_mask = list(auxiliary_mask)
            findices = auxiliary_mask + indices
        else:
            findices = indices

        logger.debug(f"    shape    = {shape}")  # pragma: no cover
        logger.debug(f"    indices  = {indices}")  # pragma: no cover
        logger.debug(f"    indices2 = {indices2}")  # pragma: no cover
        logger.debug(f"    findices = {findices}")  # pragma: no cover

        new_data = new.data[tuple(findices)]

        # Set sizes of domain axes
        data_axes = new.get_data_axes()
        domain_axes = new.domain_axes(todict=True)
        for axis, size in zip(data_axes, new_data.shape):
            domain_axes[axis].set_size(size)

        # ------------------------------------------------------------
        # Subspace constructs with data
        # ------------------------------------------------------------
        if data_axes:
            construct_data_axes = new.constructs.data_axes()

            for key, construct in new.constructs.filter_by_axis(
                *data_axes, axis_mode="or", todict=True
            ).items():
                construct_axes = construct_data_axes[key]
                dice = []
                needs_slicing = False
                for axis in construct_axes:
                    if axis in data_axes:
                        needs_slicing = True
                        dice.append(indices[data_axes.index(axis)])
                    else:
                        dice.append(slice(None))

                # Generally we do not apply an auxiliary mask to the
                # metadata items, but for DSGs we do.
                if auxiliary_mask and new.DSG:
                    item_mask = []
                    for mask in auxiliary_mask[1]:
                        iaxes = [
                            data_axes.index(axis)
                            for axis in construct_axes
                            if axis in data_axes
                        ]
                        for i, (axis, size) in enumerate(
                            zip(data_axes, mask.shape)
                        ):
                            if axis not in construct_axes:
                                if size > 1:
                                    iaxes = None
                                    break

                                mask = mask.squeeze(i)

                        if iaxes is None:
                            item_mask = None
                            break
                        else:
                            mask1 = mask.transpose(iaxes)
                            for i, axis in enumerate(construct_axes):
                                if axis not in data_axes:
                                    mask1.inset_dimension(i)

                            item_mask.append(mask1)

                    if item_mask:
                        needs_slicing = True
                        dice = [auxiliary_mask[0], item_mask] + dice

                # Replace existing construct with its subspace
                if needs_slicing:
                    new.set_construct(
                        construct[tuple(dice)],
                        key=key,
                        axes=construct_axes,
                        copy=False,
                    )

        new.set_data(new_data, axes=data_axes, copy=False)

        return new

    def __repr__(self):
        """Called by the `repr` built-in function.

        x.__repr__() <==> repr(x)

        """
        return super().__repr__().replace("<", "<CF ", 1)

    def __setitem__(self, indices, value):
        """Called to implement assignment to x[indices]=value.

        x.__setitem__(indices, value) <==> x[indices]=value

        .. versionadded:: 2.0

        """
        if isinstance(value, self.__class__):
            value = self._conform_for_assignment(value)

        try:
            data = value.get_data(None, _fill_value=False)
        except AttributeError:
            pass
        else:
            if data is None:
                raise ValueError(
                    f"Can't assign to a {self.__class__.__name__} from a "
                    f"{value.__class__.__name__} with no data"
                )

            value = data

        data = self.get_data(_fill_value=False)
        data[indices] = value

    #    @property
    #    def _cyclic(self):
    #        """Storage for axis cyclicity.
    #
    #        Do not change the value in-place.
    #
    #        """
    #        return self._custom.get("_cyclic", _empty_set)
    #
    #    @_cyclic.setter
    #    def _cyclic(self, value):
    #        """value must be a set.
    #
    #        Do not change the value in-place.
    #
    #        """
    #        self._custom["_cyclic"] = value
    #
    #    @_cyclic.deleter
    #    def _cyclic(self):
    #        self._custom["_cyclic"] = _empty_set

    def analyse_items(self, relaxed_identities=None):
        """Analyse a domain.

        :Returns:

            `dict`
                A description of the domain.

        **Examples**

        >>> print(f)
        Axes           : time(3) = [1979-05-01 12:00:00, ..., 1979-05-03 12:00:00] gregorian
                       : air_pressure(5) = [850.000061035, ..., 50.0000038147] hPa
                       : grid_longitude(106) = [-20.5400109887, ..., 25.6599887609] degrees
                       : grid_latitude(110) = [23.3200002313, ..., -24.6399995089] degrees
        Aux coords     : latitude(grid_latitude(110), grid_longitude(106)) = [[67.1246607722, ..., 22.8886948065]] degrees_N
                       : longitude(grid_latitude(110), grid_longitude(106)) = [[-45.98136251, ..., 35.2925499052]] degrees_E
        Coord refs     : <CF CoordinateReference: rotated_latitude_longitude>

        >>> f.analyse_items()
        {
         'dim_coords': {'dim0': <CF Dim ....>,
         'aux_coords': {'N-d': {'aux0': <CF AuxiliaryCoordinate: latitude(110, 106) degrees_N>,
                                'aux1': <CF AuxiliaryCoordinate: longitude(110, 106) degrees_E>},
                        'dim0': {'1-d': {},
                                 'N-d': {},},
                        'dim1': {'1-d': {},
                                 'N-d': {},},
                        'dim2': {'1-d': {},
                                 'N-d': {'aux0': <CF AuxiliaryCoordinate: latitude(110, 106) degrees_N>,
                                         'aux1': <CF AuxiliaryCoordinate: longitude(110, 106) degrees_E>},},
                        'dim3': {'1-d': {},
                                 'N-d': {'aux0': <CF AuxiliaryCoordinate: latitude(110, 106) degrees_N>,
                                         'aux1': <CF AuxiliaryCoordinate: longitude(110, 106) degrees_E>},},},
         'axis_to_coord': {'dim0': <CF DimensionCoordinate: time(3) gregorian>,
                           'dim1': <CF DimensionCoordinate: air_pressure(5) hPa>,
                           'dim2': <CF DimensionCoordinate: grid_latitude(110) degrees>,
                           'dim3': <CF DimensionCoordinate: grid_longitude(106) degrees>},
         'axis_to_id': {'dim0': 'time',
                        'dim1': 'air_pressure',
                        'dim2': 'grid_latitude',
                        'dim3': 'grid_longitude'},
         'cell_measures': {'N-d': {},
                           'dim0': {'1-d': {},
                                    'N-d': {},},
                           'dim1': {'1-d': {},
                                    'N-d': {},},
                           'dim2': {'1-d': {},
                                    'N-d': {},},
                           'dim3': {'1-d': {},
                                    'N-d': {},},
            },
         'id_to_aux': {},
         'id_to_axis': {'air_pressure': 'dim1',
                        'grid_latitude': 'dim2',
                        'grid_longitude': 'dim3',
                        'time': 'dim0'},
         'id_to_coord': {'air_pressure': <CF DimensionCoordinate: air_pressure(5) hPa>,
                         'grid_latitude': <CF DimensionCoordinate: grid_latitude(110) degrees>,
                         'grid_longitude': <CF DimensionCoordinate: grid_longitude(106) degrees>,
                         'time': <CF DimensionCoordinate: time(3) gregorian>},
         'id_to_key': {'air_pressure': 'dim1',
                       'grid_latitude': 'dim2',
                       'grid_longitude': 'dim3',
                       'time': 'dim0'},
         'undefined_axes': [],
         'warnings': [],
        }

        """
        # ------------------------------------------------------------
        # Map each axis identity to its identifier, if such a mapping
        # exists.
        #
        # For example:
        # >>> id_to_axis
        # {'time': 'dim0', 'height': dim1'}
        # ------------------------------------------------------------
        id_to_axis = {}

        # ------------------------------------------------------------
        # For each dimension that is identified by a 1-d auxiliary
        # coordinate, map its dimension's its identifier.
        #
        # For example:
        # >>> id_to_aux
        # {'region': 'aux0'}
        # ------------------------------------------------------------
        id_to_aux = {}

        # ------------------------------------------------------------
        # The keys of the coordinate items which provide axis
        # identities
        #
        # For example:
        # >>> id_to_key
        # {'region': 'aux0'}
        # ------------------------------------------------------------
        #        id_to_key = {}

        axis_to_id = {}

        # ------------------------------------------------------------
        # Map each dimension's identity to the coordinate which
        # provides that identity.
        #
        # For example:
        # >>> id_to_coord
        # {'time': <CF Coordinate: time(12)>}
        # ------------------------------------------------------------
        id_to_coord = {}

        axis_to_coord = {}

        # ------------------------------------------------------------
        # List the dimensions which are undefined, in that no unique
        # identity can be assigned to them.
        #
        # For example:
        # >>> undefined_axes
        # ['dim2']
        # ------------------------------------------------------------
        undefined_axes = []

        # ------------------------------------------------------------
        #
        # ------------------------------------------------------------
        warnings = []
        id_to_dim = {}
        axis_to_aux = {}
        axis_to_dim = {}

        if relaxed_identities is None:
            relaxed_identities = cf_relaxed_identities()

        #        dimension_coordinates = self.dimension_coordinates(view=True)
        #        auxiliary_coordinates = self.auxiliary_coordinates(view=True)

        for axis in self.domain_axes(todict=True):

            #            dims = self.constructs.chain(
            #                "filter_by_type",
            #                ("dimension_coordinate",), "filter_by_axis", (axis,)
            #                mode="and", todict=True
            #            )
            key, dim = self.dimension_coordinate(
                item=True, default=(None, None), filter_by_axis=(axis,)
            )

            if dim is not None:
                # This axis of the domain has a dimension coordinate
                identity = dim.identity(strict=True, default=None)
                if identity is None:
                    # Dimension coordinate has no identity, but it may
                    # have a recognised axis.
                    for ctype in ("T", "X", "Y", "Z"):
                        if getattr(dim, ctype, False):
                            identity = ctype
                            break

                if identity is None and relaxed_identities:
                    identity = dim.identity(relaxed=True, default=None)

                if identity:
                    if identity in id_to_axis:
                        warnings.append("Field has multiple {identity!r} axes")

                    axis_to_id[axis] = identity
                    id_to_axis[identity] = axis
                    axis_to_coord[axis] = key
                    id_to_coord[identity] = key
                    axis_to_dim[axis] = key
                    id_to_dim[identity] = key
                    continue

            else:
                key, aux = self.auxiliary_coordinate(
                    filter_by_axis=(axis,),
                    axis_mode="and",  # TODO check this "and"
                    item=True,
                    default=(None, None),
                )
                if aux is not None:
                    # This axis of the domain does not have a
                    # dimension coordinate but it does have exactly
                    # one 1-d auxiliary coordinate, so that will do.
                    identity = aux.identity(strict=True, default=None)

                    if identity is None and relaxed_identities:
                        identity = aux.identity(relaxed=True, default=None)

                    if identity and aux.has_data():
                        if identity in id_to_axis:
                            warnings.append(
                                f"Field has multiple {identity!r} axes"
                            )

                        axis_to_id[axis] = identity
                        id_to_axis[identity] = axis
                        axis_to_coord[axis] = key
                        id_to_coord[identity] = key
                        axis_to_aux[axis] = key
                        id_to_aux[identity] = key
                        continue

            # Still here? Then this axis is undefined
            undefined_axes.append(axis)

        return {
            "axis_to_id": axis_to_id,
            "id_to_axis": id_to_axis,
            "axis_to_coord": axis_to_coord,
            "axis_to_dim": axis_to_dim,
            "axis_to_aux": axis_to_aux,
            "id_to_coord": id_to_coord,
            "id_to_dim": id_to_dim,
            "id_to_aux": id_to_aux,
            "undefined_axes": undefined_axes,
            "warnings": warnings,
        }

    def _is_broadcastable(self, shape):
        """Checks the field's data array is broadcastable to a shape.

        :Parameters:

            shape: sequence of `int`

        :Returns:

            `bool`

        """
        shape0 = getattr(self, "shape", None)
        if shape is None:
            return False

        shape1 = shape

        if tuple(shape1) == tuple(shape0):
            # Same shape
            return True

        ndim0 = len(shape0)
        ndim1 = len(shape1)
        if not ndim0 or not ndim1:
            # Either or both is scalar
            return True

        for setN in set(shape0), set(shape1):
            if setN == {1}:
                return True

        if ndim1 > ndim0:
            return False

        for n, m in zip(shape1[::-1], shape0[::-1]):
            if n != m and n != 1:
                return False

        return True

    def _axis_positions(self, axes, parse=True, return_axes=False):
        """Convert the given axes to their positions in the data.

        Any domain axes that are not spanned by the data are ignored.

        If there is no data then an empty list is returned.

        .. versionadded:: 3.9.0

        :Parameters:
            axes: (sequence of) `str` or `int`
                The axes to be converted. TODO domain axis selection

            parse: `bool`, optional

                If False then do not parse the *axes*. Parsing should
                always occur unless the given *axes* are the output of
                a previous call to `parse_axes`. By default *axes* is
                parsed by `_parse_axes`.

            return_axes: `bool`, optional

                If True then also return the domain axis identifiers
                corresponding to the positions.

        :Returns:

            `list` [, `list`]
                The domain axis identifiers. If *return_axes* is True
                then also return the corresponding domain axis
                identifiers.

        """
        data_axes = self.get_data_axes(default=None)
        if data_axes is None:
            return []

        if parse:
            axes = self._parse_axes(axes)

        axes = [a for a in axes if a in data_axes]
        positions = [data_axes.index(a) for a in axes]

        if return_axes:
            return positions, axes

        return positions

    def _binary_operation_old(self, other, method):
        """Implement binary arithmetic and comparison operations on the
        master data array with metadata-aware broadcasting.

        It is intended to be called by the binary arithmetic and
        comparison methods, such as `__sub__`, `__imul__`, `__rdiv__`,
        `__lt__`, etc.

        :Parameters:

            other: `Field` or `Query` or any object that broadcasts to the field construct's data

            method: `str`
                The binary arithmetic or comparison method name (such as
                ``'__idiv__'`` or ``'__ge__'``).

        :Returns:

            `Field`
                The new field, or the same field if the operation was an
                in place augmented arithmetic assignment.

        **Examples**

        >>> h = f._binary_operation(g, '__add__')
        >>> h = f._binary_operation(g, '__ge__')
        >>> f._binary_operation(g, '__isub__')
        >>> f._binary_operation(g, '__rdiv__')

        """

        if isinstance(other, Query):
            # --------------------------------------------------------
            # Combine the field with a Query object
            # --------------------------------------------------------
            return NotImplemented

        if not isinstance(other, self.__class__):
            # --------------------------------------------------------
            # Combine the field with anything other than a Query
            # object or another field construct
            # --------------------------------------------------------
            if numpy_size(other) == 1:
                # ----------------------------------------------------
                # No changes to the field metadata constructs are
                # required so can use the metadata-unaware parent
                # method
                # ----------------------------------------------------
                other = Data(other)
                if other.ndim > 0:
                    other.squeeze(inplace=True)

                return super()._binary_operation(other, method)

            if self._is_broadcastable(numpy_shape(other)):
                return super()._binary_operation(other, method)

            raise ValueError(
                f"Can't combine {self.__class__.__name__!r} with "
                f"{other.__class__.__name__!r} due to incompatible data "
                f"shapes: {self.shape}, {numpy_shape(other)}"
            )

        # ============================================================
        # Still here? Then combine the field with another field
        # ============================================================

        units = self.Units
        sn = self.get_property("standard_name", None)
        ln = self.get_property("long_name", None)

        other_sn = other.get_property("standard_name", None)
        other_ln = other.get_property("long_name", None)

        # ------------------------------------------------------------
        # Analyse each domain
        # ------------------------------------------------------------
        relaxed_identities = cf_relaxed_identities()
        s = self.analyse_items(relaxed_identities=relaxed_identities)
        v = other.analyse_items(relaxed_identities=relaxed_identities)

        logger.debug(s)  # pragma: no cover
        logger.debug()  # pragma: no cover
        logger.debug(v)  # pragma: no cover

        if s["warnings"] or v["warnings"]:
            raise ValueError(
                f"Can't combine fields: {s['warnings'] or v['warnings']}"
            )

        # Check that at most one field has undefined axes
        if s["undefined_axes"] and v["undefined_axes"]:
            raise ValueError(
                "Can't combine fields: Both fields have not-strictly-defined "
                "axes: {!r}, {!r}. Consider setting "
                "cf.relaxed_identities(True)".format(
                    tuple(
                        self.constructs.domain_axis_identity(a)
                        for a in s["undefined_axes"]
                    ),
                    tuple(
                        other.constructs.domain_axis_identity(a)
                        for a in v["undefined_axes"]
                    ),
                )
            )

        # Find the axis names which are present in both fields
        matching_ids = set(s["id_to_axis"]).intersection(v["id_to_axis"])
        logger.debug(
            f"s['id_to_axis'] = {s['id_to_axis']}"
        )  # pragma: no cover
        logger.debug(
            f"v['id_to_axis'] = {v['id_to_axis']}"
        )  # pragma: no cover
        logger.debug(f"matching_ids    = {matching_ids}")  # pragma: no cover

        # Check that any matching axes defined by an auxiliary
        # coordinate are done so in both fields.
        for identity in set(s["id_to_aux"]).symmetric_difference(
            v["id_to_aux"]
        ):
            if identity in matching_ids:
                raise ValueError(
                    f"Can't combine fields: {identity!r} axis defined by "
                    "auxiliary in only 1 field"
                )  # TODO ~WRONG

        #  ------------------------------------------------------------
        # For matching dimension coordinates check that they have
        # consistent coordinate references and that one of the following is
        # true:
        #
        # 1) They have equal size > 1 and their data arrays are
        #    equivalent
        #
        # 2) They have unequal sizes and one of them has size 1
        #
        # 3) They have equal size = 1. In this case, if the data
        #    arrays are not equivalent then the axis will be omitted
        #    from the result field.
        # -------------------------------------------------------------

        # List of size 1 axes to be completely removed from the result
        # field. Such an axis's size 1 defining coordinates have
        # unequivalent data arrays.
        #
        # For example:
        # >>> remove_size1_axes0
        # ['dim2']
        remove_size1_axes0 = []

        # List of matching axes with equivalent defining dimension
        # coordinate data arrays.
        #
        # Note that we don't need to include matching axes with
        # equivalent defining *auxiliary* coordinate data arrays.
        #
        # For example:
        # >>>
        # [('dim2', 'dim0')]
        matching_axes_with_equivalent_data = {}

        # For each field, list those of its matching axes which need
        # to be broadcast against the other field. I.e. those axes
        # which are size 1 but size > 1 in the other field.
        #
        # For example:
        # >>> s['size1_broadcast_axes']
        # ['dim1']
        s["size1_broadcast_axes"] = []
        v["size1_broadcast_axes"] = []

        # DO SOMETING WITH v['size1_broadcast_axes'] to be symmetrical with regards
        # coord refs!!!!!

        # Map axes in field1 to axes in field0 and vice versa
        #
        # For example:
        # >>> axis1_to_axis0
        # {'dim1': 'dim0', 'dim2': 'dim1', 'dim0': 'dim2'}
        # >>> axis0_to_axis1
        # {'dim0': 'dim1', 'dim1': 'dim2', 'dim2': 'dim0'}
        axis1_to_axis0 = {}
        axis0_to_axis1 = {}

        remove_items = set()

        for identity in matching_ids:
            axis0 = s["id_to_axis"][identity]
            axis1 = v["id_to_axis"][identity]

            axis1_to_axis0[axis1] = axis0
            axis0_to_axis1[axis0] = axis1

            key0 = s["id_to_coord"][identity]
            key1 = v["id_to_coord"][identity]

            coord0 = self.constructs[key0]
            coord1 = other.constructs[key1]

            # Check the sizes of the defining coordinates
            size0 = coord0.size
            size1 = coord1.size
            if size0 != size1:
                # Defining coordinates have different sizes
                if size0 == 1:
                    # Broadcast
                    s["size1_broadcast_axes"].append(axis0)
                elif size1 == 1:
                    # Broadcast
                    v["size1_broadcast_axes"].append(axis1)
                else:
                    # Can't broadcast
                    raise ValueError(
                        "Can't combine fields: Can't broadcast "
                        f"{identity!r} axes with sizes {size0} and {size1}"
                    )

                # Move on to the next identity if the defining
                # coordinates have different sizes
                continue

            # Still here? Then the defining coordinates have the same
            # size.

            # Check that equally sized defining coordinate data arrays
            # are compatible
            if coord0._equivalent_data(coord1):
                # The defining coordinates have equivalent data
                # arrays

                # If the defining coordinates are attached to
                # coordinate references then check that those
                # coordinate references are equivalent

                # For each field, find the coordinate references which
                # contain the defining coordinate.
                # refs0 = [ref for ref in self.coordinate_references.values()
                #          if key0 in ref.coordinates()]
                refs0 = [
                    key
                    for key, ref in self.coordinate_references.items()
                    if key0 in ref.coordinates()
                ]
                refs1 = [
                    key
                    for key, ref in other.coordinate_references.items()
                    if key1 in ref.coordinates()
                ]

                nrefs = len(refs0)
                if nrefs > 1 or nrefs != len(refs1):
                    # The defining coordinate are associated with
                    # different numbers of coordinate references
                    equivalent_refs = False
                elif not nrefs:
                    # Neither defining coordinate is associated with a
                    # coordinate reference
                    equivalent_refs = True
                else:
                    # Each defining coordinate is associated with
                    # exactly one coordinate reference
                    equivalent_refs = self._equivalent_coordinate_references(
                        other, key0=refs0[0], key1=refs1[0], s=s, t=v
                    )

                if not equivalent_refs:
                    # The defining coordinates have non-equivalent
                    # coordinate references
                    if coord0.size == 1:
                        # The defining coordinates have non-equivalent
                        # coordinate references but both defining
                        # coordinates are of size 1 => flag this axis
                        # to be omitted from the result field.
                        # remove_size1_axes0.append(axis0)            dch
                        if refs0:
                            key0 = refs0[0]
                            ref0 = self.coordinate_references[key0]
                            remove_items.add(refs0[0])
                            remove_items.update(
                                ref0.coordinate_conversion.domain_ancillaries().values()
                            )
                    else:
                        # The defining coordinates have non-equivalent
                        # coordinate references and they are of size >
                        # 1
                        raise ValueError(
                            "Can't combine fields: Incompatible coordinate "
                            f"references for {identity!r} coordinates"
                        )

                elif identity not in s["id_to_aux"]:
                    # The defining coordinates are both dimension
                    # coordinates, have equivalent data arrays and
                    # have equivalent coordinate references.
                    matching_axes_with_equivalent_data[axis0] = axis1
                else:
                    # The defining coordinates are both auxiliary
                    # coordinates, have equivalent data arrays and
                    # have equivalent coordinate references.
                    pass

            else:
                if coord0.size > 1:
                    # The defining coordinates have non-equivalent
                    # data arrays and are both of size > 1
                    raise ValueError(
                        f"Can't combine fields: Incompatible {identity!r} "
                        f"coordinate values: {coord0.data}, {coord1.data}"
                    )
                else:
                    # The defining coordinates have non-equivalent
                    # data arrays and are both size 1 => this axis to
                    # be omitted from the result field
                    remove_size1_axes0.append(axis0)

        logger.debug(
            f"1: s['size1_broadcast_axes'] = {s['size1_broadcast_axes']}"
        )  # pragma: no cover
        logger.debug(
            f"1: v['size1_broadcast_axes'] = {v['size1_broadcast_axes']}"
        )  # pragma: no cover
        logger.debug(
            f"1: remove_size1_axes0 = {remove_size1_axes0}"
        )  # pragma: no cover

        matching_axis1_to_axis0 = axis1_to_axis0.copy()
        matching_axis0_to_axis1 = axis0_to_axis1.copy()

        logger.debug(
            f"1: axis1_to_axis0 = {axis1_to_axis0}"
        )  # pragma: no cover
        logger.debug(
            f"1: axis0_to_axis1 = {axis0_to_axis1}"
        )  # pragma: no cover

        # ------------------------------------------------------------
        # Still here? Then the two fields are combinable!
        # ------------------------------------------------------------

        # ------------------------------------------------------------
        # 2.1 Create copies of the two fields, unless it is an in
        #     place combination, in which case we don't want to copy
        #     self)
        # ------------------------------------------------------------
        field1 = other.copy()

        inplace = method[2] == "i"
        if not inplace:
            field0 = self.copy()
        else:
            field0 = self

        s["new_size1_axes"] = []

        # ------------------------------------------------------------
        # Permute the axes of the data array of field0 so that:
        #
        # * All of the matching axes are the inner (fastest varying)
        #   axes
        #
        # * All of the undefined axes are the outer (slowest varying)
        #   axes
        #
        # * All of the defined but unmatched axes are in the middle
        # ------------------------------------------------------------
        data_axes0 = field0.get_data_axes()
        axes_unD = []  # Undefined axes
        axes_unM = []  # Defined but unmatched axes
        axes0_M = []  # Defined and matched axes
        for axis0 in data_axes0:
            if axis0 in axis0_to_axis1:
                # Matching axis
                axes0_M.append(axis0)
            elif axis0 in s["undefined_axes"]:
                # Undefined axis
                axes_unD.append(axis0)
            else:
                # Defined but unmatched axis
                axes_unM.append(axis0)

        logger.debug(
            "2: axes_unD, axes_unM, axes0_M = "
            f"{axes_unD} {axes_unM} {axes0_M}"
        )  # pragma: no cover

        field0.transpose(axes_unD + axes_unM + axes0_M, inplace=True)

        end_of_undefined0 = len(axes_unD)
        start_of_unmatched0 = end_of_undefined0
        start_of_matched0 = start_of_unmatched0 + len(axes_unM)
        logger.debug(
            f"2: end_of_undefined0   = {end_of_undefined0}"
        )  # pragma: no cover
        logger.debug(
            f"2: start_of_unmatched0 = {start_of_unmatched0}"
        )  # pragma: no cover
        logger.debug(
            f"2: start_of_matched0   = {start_of_matched0}"
        )  # pragma: no cover

        # ------------------------------------------------------------
        # Permute the axes of the data array of field1 so that:
        #
        # * All of the matching axes are the inner (fastest varying)
        #   axes and in corresponding positions to data0
        #
        # * All of the undefined axes are the outer (slowest varying)
        #   axes
        #
        # * All of the defined but unmatched axes are in the middle
        # ------------------------------------------------------------
        data_axes1 = field1.get_data_axes()
        axes_unD = []
        axes_unM = []
        axes1_M = [axis0_to_axis1[axis0] for axis0 in axes0_M]
        for axis1 in data_axes1:
            if axis1 in axes1_M:
                pass
            elif axis1 in axis1_to_axis0:
                # Matching axis
                axes_unM.append(axis1)
            elif axis1 in v["undefined_axes"]:
                # Undefined axis
                axes_unD.append(axis1)
            else:
                # Defined but unmatched axis
                axes_unM.append(axis1)

        logger.debug(
            "2: axes_unD , axes_unM , axes0_M = "
            f"{axes_unD} {axes_unM} {axes0_M}"
        )  # pragma: no cover

        field1.transpose(axes_unD + axes_unM + axes1_M, inplace=True)

        start_of_unmatched1 = len(axes_unD)
        start_of_matched1 = start_of_unmatched1 + len(axes_unM)
        undefined_indices1 = slice(None, start_of_unmatched1)
        unmatched_indices1 = slice(start_of_unmatched1, start_of_matched1)
        logger.debug(
            f"2: start_of_unmatched1 = {start_of_unmatched1}"
        )  # pragma: no cover
        logger.debug(
            f"2: start_of_matched1   = {start_of_matched1}"
        )  # pragma: no cover
        logger.debug(
            f"2: undefined_indices1  = {undefined_indices1}"
        )  # pragma: no cover
        logger.debug(
            f"2: unmatched_indices1  = {unmatched_indices1}"
        )  # pragma: no cover

        # ------------------------------------------------------------
        # Make sure that each pair of matching axes run in the same
        # direction
        #
        # Note that the axis0_to_axis1 dictionary currently only maps
        # matching axes
        # ------------------------------------------------------------
        logger.debug(
            "2: axis0_to_axis1 = {}", axis0_to_axis1
        )  # pragma: no cover

        for axis0, axis1 in axis0_to_axis1.items():
            if field1.direction(axis1) != field0.direction(axis0):
                field1.flip(axis1, inplace=True)

        # ------------------------------------------------------------
        # 2f. Insert size 1 axes into the data array of field0 to
        #     correspond to defined but unmatched axes in field1
        #
        # For example, if   field0.data is      1 3         T Y X
        #              and  field1.data is          4 1 P Z   Y X
        #              then field0.data becomes 1 3     1 1 T Y X
        # ------------------------------------------------------------
        unmatched_axes1 = data_axes1[unmatched_indices1]
        logger.debug(
            "2: unmatched_axes1=", unmatched_axes1
        )  # pragma: no cover

        if unmatched_axes1:
            for axis1 in unmatched_axes1:
                new_axis = field0.set_construct(field0._DomainAxis(1))
                field0.insert_dimension(
                    new_axis, end_of_undefined0, inplace=True
                )
                logger.debug(
                    f"2: axis1, field0.shape = {axis1} {field0.data.shape}"
                )  # pragma: no cover

                axis0 = (
                    set(field0.get_data_axes()).difference(data_axes0).pop()
                )

                axis1_to_axis0[axis1] = axis0
                axis0_to_axis1[axis0] = axis1
                s["new_size1_axes"].append(axis0)

                start_of_unmatched0 += 1
                start_of_matched0 += 1

                data_axes0 = field0.get_data_axes()

        # ------------------------------------------------------------
        # Insert size 1 axes into the data array of field1 to
        # correspond to defined but unmatched axes in field0
        #
        # For example, if   field0.data is      1 3     1 1 T Y X
        #              and  field1.data is          4 1 P Z   Y X
        #              then field1.data becomes     4 1 P Z 1 Y X
        # ------------------------------------------------------------
        unmatched_axes0 = data_axes0[start_of_unmatched0:start_of_matched0]
        logger.debug(
            f"2: unmatched_axes0 = {unmatched_axes0}"
        )  # pragma: no cover

        if unmatched_axes0:
            for axis0 in unmatched_axes0:
                new_axis = field1.set_construct(field1._DomainAxis(1))
                field1.insert_dimension(
                    new_axis, start_of_matched1, inplace=True
                )
                logger.debug(
                    f"2: axis0, field1.shape = {axis0} {field1.shape}"
                )  # pragma: no cover

                axis1 = (
                    set(field1.get_data_axes()).difference(data_axes1).pop()
                )

                axis0_to_axis1[axis0] = axis1
                axis1_to_axis0[axis1] = axis0

                start_of_unmatched1 += 1

                data_axes1 = field1.get_data_axes()

        # ------------------------------------------------------------
        # Insert size 1 axes into the data array of field0 to
        # correspond to undefined axes (of any size) in field1
        #
        # For example, if   field0.data is      1 3     1 1 T Y X
        #              and  field1.data is          4 1 P Z 1 Y X
        #              then field0.data becomes 1 3 1 1 1 1 T Y X
        # ------------------------------------------------------------
        axes1 = data_axes1[undefined_indices1]
        if axes1:
            for axis1 in axes1:
                new_axis = field0.set_construct(field0._DomainAxis(1))
                field0.insert_dimension(
                    new_axis, end_of_undefined0, inplace=True
                )

                axis0 = (
                    set(field0.get_data_axes()).difference(data_axes0).pop()
                )

                axis0_to_axis1[axis0] = axis1
                axis1_to_axis0[axis1] = axis0
                s["new_size1_axes"].append(axis0)

                data_axes0 = field0.get_data_axes()

        logger.debug(
            f"2: axis0_to_axis1 = {axis0_to_axis1}"
        )  # pragma: no cover
        logger.debug(
            f"2: axis1_to_axis0 = {axis1_to_axis0}"
        )  # pragma: no cover
        logger.debug(
            f"2: s['new_size1_axes']  = {s['new_size1_axes']}"
        )  # pragma: no cover

        # ============================================================
        # 3. Combine the data objects
        #
        # Note that, by now, field0.ndim >= field1.ndim.
        # ============================================================
        new_data0 = field0.data._binary_operation(field1.data, method)
        #        new_data0 = super(Field, field0)._binary_operation(
        #                        field1, method).data

        logger.debug(
            f"3: new_data0.shape = {new_data0.shape}"
        )  # pragma: no cover
        logger.debug(
            f"3: field0.shape = {field0.data.shape}"
        )  # pragma: no cover

        # ============================================================
        # 4. Adjust the domain of field0 to accommodate its new data
        # ============================================================
        # Field 1 dimension coordinate to be inserted into field 0
        insert_dim = {}
        # Field 1 auxiliary coordinate to be inserted into field 0
        insert_aux = {}
        # Field 1 domain ancillaries to be inserted into field 0
        insert_domain_anc = {}
        # Field 1 coordinate references to be inserted into field 0
        insert_ref = set()

        # ------------------------------------------------------------
        # 4a. Remove selected size 1 axes
        # ------------------------------------------------------------
        logger.debug(
            f"4: field0.constructs.keys() = {sorted(field0.constructs.keys())}"
        )  # pragma: no cover
        logger.debug(
            f"4: field1.constructs.keys() = {sorted(field1.constructs.keys())}"
        )  # pragma: no cover

        # AND HEREIN LIES THE PROBLEM            TODO
        for size1_axis in remove_size1_axes0:
            field0.del_construct(size1_axis)

        # ------------------------------------------------------------
        # 4b. If broadcasting has grown any size 1 axes in field0
        #     then replace their size 1 coordinates with the
        #     corresponding size > 1 coordinates from field1.
        # ------------------------------------------------------------
        refs0 = dict(field0.coordinate_references)
        refs1 = dict(field1.coordinate_references)

        field1_dimension_coordinates = field1.dimension_coordinates(
            todict=True
        )
        #        field1_auxiliary_coordinates = field1.auxiliary_coordinates(todict=True)
        field1_coordinate_references = field1.coordinate_references(
            todict=True
        )

        field1_domain_ancillaries = field1.domain_ancillaries(todict=True)
        field1_domain_axes = field1.domain_axes(todict=True)

        #        field0_auxiliary_coordinates = field0.auxiliary_coordinates(todict=True)
        #        field0_domain_ancillaries = field0_domain_ancillaries(todict=True)

        #        c = field0.constructs.filter_by_type(
        #            "auxiliary_coordinate", "domain_ancillary",
        #        )

        for axis0 in s["size1_broadcast_axes"] + s["new_size1_axes"]:
            axis1 = axis0_to_axis1[axis0]

            field0.set_construct(field1_domain_axes[axis1], key=axis0)

            # Copy field1 1-d coordinates for this axis to field0
            #            if axis1 in field1.Items.d:
            if axis1 in field1_dimension_coordinates:
                insert_dim[axis1] = [axis0]

            for key1 in field1.auxiliary_coordinates(
                filter_by_axis=(axis1,), axis_mode="exact", todict=True
            ):
                insert_aux[key1] = [axis0]

            # Copy field1 coordinate references which span this axis
            # to field0, along with all of their domain ancillaries
            # (even if those domain ancillaries do not span this
            # axis).
            for key1, ref1 in refs1.items():
                if axis1 not in field1.coordinate_reference_domain_axes(key1):
                    continue

            # Remove all field0 auxiliary coordinates and domain
            # ancillaries which span this axis
            remove_items.update(
                field0.constructs.filter(
                    filter_by_type=(
                        "auxiliary_coordinate",
                        "domain_ancillary",
                    ),
                    filter_by_axis=(axis0,),
                    # TODO check if we need an axis_mode="or" or "subset" here
                    todict=True,
                )
            )

            # Remove all field0 coordinate references which span this
            # axis, and their domain ancillaries (even if those domain
            # ancillaries do not span this axis).
            for key0 in tuple(refs0):
                if axis0 in field0.coordinate_reference_domain_axes(key0):
                    ref0 = refs0.pop(key0)
                    remove_items.add(key0)
                    remove_items.update(
                        field0.domain_ancillaries(
                            *tuple(
                                ref0.coordinate_conversion.domain_ancillaries().values()
                            ),
                            todict=True,
                        )
                    )

        # ------------------------------------------------------------
        # Consolidate auxiliary coordinates for matching axes
        #
        # A field0 auxiliary coordinate is retained if:
        #
        # 1) it is the defining coordinate for its axis
        #
        # 2) there is a corresponding field1 auxiliary coordinate
        #    spanning the same axes which has the same identity and
        #    equivalent data array
        #
        # 3) there is a corresponding field1 auxiliary coordinate
        #    spanning the same axes which has the same identity and a
        #    size-1 data array.
        # -------------------------------------------------------------
        field1_auxiliary_coordinates = field1.auxiliary_coordinates(
            todict=True
        )
        auxs1 = field1_auxiliary_coordinates.copy()
        #        auxs1 = dict(field1_auxiliary_coordinates.items())
        logger.debug(f"5: remove_items = {remove_items}")  # pragma: no cover

        for key0, aux0 in field0.auxiliary_coordinates(todict=True).items():
            if key0 in remove_items:
                # Field0 auxiliary coordinate has already marked for
                # removal
                continue

            if key0 in s["id_to_aux"].values():
                # Field0 auxiliary coordinate has already been checked
                continue

            if aux0.identity() is None:
                # Auxiliary coordinate has no identity
                remove_items.add(key0)
                continue

            axes0 = field0.get_data_axes(key0)
            if not set(axes0).issubset(matching_axis0_to_axis1):
                # Auxiliary coordinate spans at least on non-matching
                # axis
                remove_items.add(key0)
                continue

            found_equivalent_auxiliary_coordinates = False
            for key1, aux1 in tuple(auxs1.items()):
                if key1 in v["id_to_aux"].values():
                    # Field1 auxiliary coordinate has already been checked
                    del auxs1[key1]
                    continue

                if aux1.identity() is None:
                    # Field1 auxiliary coordinate has no identity
                    del auxs1[key1]
                    continue

                axes1 = field1.get_data_axes(key0)
                if not set(axes1).issubset(matching_axis1_to_axis0):
                    # Field 1 auxiliary coordinate spans at least one
                    # non-matching axis
                    del auxs1[key1]
                    continue

                if field1.constructs[key1].size == 1:
                    # Field1 auxiliary coordinate has size-1 data array
                    found_equivalent_auxiliary_coordinates = True
                    del auxs1[key1]
                    break

                if field0._equivalent_construct_data(
                    field1, key0=key0, key1=key1, s=s, t=v
                ):
                    # Field0 auxiliary coordinate has equivalent data
                    # to a field1 auxiliary coordinate
                    found_equivalent_auxiliary_coordinates = True
                    del auxs1[key1]
                    break

            if not found_equivalent_auxiliary_coordinates:
                remove_items.add(key0)

        # ------------------------------------------------------------
        # Copy field1 auxiliary coordinates which do not span any
        # matching axes to field0
        # ------------------------------------------------------------
        field1_data_axes = field1.constructs.data_axes()
        for key1 in field1_auxiliary_coordinates:
            if key1 in insert_aux:
                continue

            axes1 = field1_data_axes[key1]
            if set(axes1).isdisjoint(matching_axis1_to_axis0):
                insert_aux[key1] = [axis1_to_axis0[axis1] for axis1 in axes1]

        # ------------------------------------------------------------
        # Insert field1 items into field0
        # ------------------------------------------------------------

        # Map field1 items keys to field0 item keys
        key1_to_key0 = {}

        logger.debug(
            f"5: insert_dim               = {insert_dim}"
        )  # pragma: no cover
        logger.debug(
            f"5: insert_aux               = {insert_aux}"
        )  # pragma: no cover
        logger.debug(
            f"5: insert_domain_anc        = {insert_domain_anc}"
        )  # pragma: no cover
        logger.debug(
            f"5: insert_ref               = {insert_ref}"
        )  # pragma: no cover
        logger.debug(
            f"5: field0.constructs.keys() = {sorted(field0.constructs.keys())}"
        )  # pragma: no cover
        logger.debug(
            f"5: field1.constructs.keys() = {sorted(field1.constructs.keys())}"
        )  # pragma: no cover

        for key1, axes0 in insert_dim.items():
            try:
                key0 = field0.set_construct(
                    field1_dimension_coordinates[key1], axes=axes0
                )
            except ValueError:
                # There was some sort of problem with the insertion, so
                # just ignore this item.
                pass
            else:
                key1_to_key0[key1] = key0

            logger.debug(
                "axes0, key1, field1.constructs[key1] = "
                f"{axes0}, {key1}, {field1.constructs[key1]!r}"
            )  # pragma: no cover

        for key1, axes0 in insert_aux.items():
            try:
                key0 = field0.set_construct(
                    field1_auxiliary_coordinates[key1], axes=axes0
                )
            except ValueError:
                # There was some sort of problem with the insertion, so
                # just ignore this item.
                pass
            else:
                key1_to_key0[key1] = key0

            logger.debug(
                "axes0, key1, field1.constructs[key1] = "
                f"{axes0}, {key1}, {field1.constructs[key1]!r}"
            )  # pragma: no cover

        #        field1_domain_ancillaries = field1.domain_ancillaries(todict=True)

        for key1, axes0 in insert_domain_anc.items():
            try:
                key0 = field0.set_construct(
                    field1_domain_ancillaries[key1], axes=axes0
                )
            except ValueError as error:
                # There was some sort of problem with the insertion, so
                # just ignore this item.
                logger.debug(
                    f"Domain ancillary insertion problem: {error}"
                )  # pragma: no cover
            else:
                key1_to_key0[key1] = key0

            logger.debug(
                "domain ancillary axes0, key1, field1.constructs[key1] ="
                f" {axes0}, {key1}, {field1.constructs[key1]!r}"
            )  # pragma: no cover

        # ------------------------------------------------------------
        # Remove field0 which are no longer required
        # ------------------------------------------------------------
        if remove_items:
            logger.debug(sorted(field0.constructs.keys()))  # pragma: no cover
            logger.debug(
                f"Removing {sorted(remove_items)!r} from field0"
            )  # pragma: no cover

            for key in remove_items:
                field0.del_construct(key, default=None)

        # ------------------------------------------------------------
        # Copy coordinate references from field1 to field0 (do this
        # after removing any coordinates and domain ancillaries)
        # ------------------------------------------------------------
        for key1 in insert_ref:
            ref1 = field1_coordinate_references[key1]
            logger.debug(
                f"Copying {ref1!r} from field1 to field0"
            )  # pragma: no cover

            identity_map = dict(
                field1.constructs.filter_by_type(
                    "dimension_coordinate",
                    "axuiliary_coordinate",
                    "domain_ancillary",
                    todict=True,
                )
            )
            for key1, item1 in identity_map.copy().items():
                identity_map[key1] = key1_to_key0.get(key1, item1.identity())

            new_ref0 = ref1.change_identifiers(identity_map, strict=True)

            field0.set_construct(new_ref0, copy=False)

        field0.set_data(new_data0, set_axes=False, copy=False)

        # ------------------------------------------------------------
        # Remove misleading identities
        # ------------------------------------------------------------
        # Warning: This code is replicated in PropertiesData
        if sn != other_sn:
            if sn is not None and other_sn is not None:
                field0.del_property("standard_name", None)
                field0.del_property("long_name", None)
            elif other_sn is not None:
                field0.set_property("standard_name", other_sn, copy=False)
                if other_ln is None:
                    field0.del_property("long_name", None)
                else:
                    field0.set_property("long_name", other_ln, copy=False)
        elif ln is None and other_ln is not None:
            field0.set_property("long_name", other_ln, copy=False)

        # Warning: This code is replicated in PropertiesData
        new_units = field0.Units
        if (
            method in _relational_methods
            or not units.equivalent(new_units)
            and not (units.isreftime and new_units.isreftime)
        ):
            field0.del_property("standard_name", None)
            field0.del_property("long_name", None)

        if method in _relational_methods:
            field0.override_units(Units(), inplace=True)

        return field0

    def _binary_operation(self, other, method):
        """Implement binary arithmetic and comparison operations on the
        master data array with metadata-aware broadcasting.

        It is intended to be called by the binary arithmetic and
        comparison methods, such as `__sub__`, `__imul__`, `__rdiv__`,
        `__lt__`, etc.

        :Parameters:

            other: `Field` or `Query` or any object that broadcasts to the field construct's data

            method: `str`
                The binary arithmetic or comparison method name (such as
                ``'__idiv__'`` or ``'__ge__'``).

        :Returns:

            `Field`
                The new field, or the same field if the operation was an
                in place augmented arithmetic assignment.

        **Examples**

        >>> h = f._binary_operation(g, '__add__')
        >>> h = f._binary_operation(g, '__ge__')
        >>> f._binary_operation(g, '__isub__')
        >>> f._binary_operation(g, '__rdiv__')

        """

        if isinstance(other, Query):
            # --------------------------------------------------------
            # Combine the field with a Query object
            # --------------------------------------------------------
            return NotImplemented

        if not isinstance(other, self.__class__):
            # --------------------------------------------------------
            # Combine the field with anything other than a Query
            # object or another field construct
            # --------------------------------------------------------
            if numpy_size(other) == 1:
                # ----------------------------------------------------
                # No changes to the field metadata constructs are
                # required so can use the metadata-unaware parent
                # method
                # ----------------------------------------------------
                if other is None:
                    other = numpy_array(None, dtype=object)

                other = Data(other)
                if other.ndim > 0:
                    other.squeeze(inplace=True)

                return super()._binary_operation(other, method)

            if self._is_broadcastable(numpy_shape(other)):
                return super()._binary_operation(other, method)

            raise ValueError(
                f"Can't combine {self.__class__.__name__!r} with "
                f"{other.__class__.__name__!r} due to incompatible data "
                f"shapes: {self.shape}, {numpy_shape(other)})"
            )

        # ============================================================
        # Still here? Then combine the field with another field
        # ============================================================
        relaxed_identities = cf_relaxed_identities()

        units = self.Units
        sn = self.get_property("standard_name", None)
        ln = self.get_property("long_name", None)

        other_sn = other.get_property("standard_name", None)
        other_ln = other.get_property("long_name", None)

        field1 = other.copy()

        inplace = method[2] == "i"
        if not inplace:
            field0 = self.copy()
        else:
            field0 = self

        # Analyse the two fields' data array dimensions
        out0 = {}
        out1 = {}
        for i, (f, out) in enumerate(zip((field0, field1), (out0, out1))):
            data_axes = f.get_data_axes()
            for axis in f.domain_axes(todict=True):
                identity = None
                key = None
                coord = None
                coord_type = None

                key, coord = f.dimension_coordinate(
                    item=True,
                    default=(None, None),
                    filter_by_axis=(axis,),
                )
                if coord is not None:
                    # This axis of the domain has a dimension
                    # coordinate
                    identity = coord.identity(strict=True, default=None)
                    if identity is None:
                        # Dimension coordinate has no identity, but it
                        # may have a recognised axis.
                        for ctype in ("T", "X", "Y", "Z"):
                            if getattr(coord, ctype, False):
                                identity = ctype
                                break

                    if identity is None and relaxed_identities:
                        identity = coord.identity(relaxed=True, default=None)
                else:
                    key, coord = f.auxiliary_coordinate(
                        item=True,
                        default=(None, None),
                        filter_by_axis=(axis,),
                        axis_mode="exact",
                    )
                    if coord is not None:
                        # This axis of the domain does not have a
                        # dimension coordinate but it does have
                        # exactly one 1-d auxiliary coordinate, so
                        # that will do.
                        identity = coord.identity(strict=True, default=None)

                        if identity is None and relaxed_identities:
                            identity = coord.identity(
                                relaxed=True, default=None
                            )

                if identity is None:
                    identity = i
                else:
                    coord_type = coord.construct_type

                out[identity] = _xxx(
                    size=f.domain_axis(axis).get_size(),
                    axis=axis,
                    key=key,
                    coord=coord,
                    coord_type=coord_type,
                    scalar=(axis not in data_axes),
                )

        for identity, y in tuple(out1.items()):
            asdas = True
            if y.scalar and identity in out0 and isinstance(identity, str):
                a = out0[identity]
                if a.size > 1:
                    field1.insert_dimension(y.axis, position=0, inplace=True)
                    asdas = False

            if y.scalar and asdas:
                del out1[identity]

        for identity, a in tuple(out0.items()):
            asdas = True
            if a.scalar and identity in out1 and isinstance(identity, str):
                y = out1[identity]
                if y.size > 1:
                    field0.insert_dimension(a.axis, position=0, inplace=True)
                    asdas = False

            if a.scalar and asdas:
                del out0[identity]

        squeeze1 = []
        insert0 = []

        # List of axes that will have been added to field0 as new
        # trailing dimensions. E.g. ['domainaxis1']
        axes_added_from_field1 = []

        # Dictionary of size > 1 axes from field1 which will replace
        # matching size 1 axes in field0. E.g. {'domainaxis1':
        #     data_dimension(
        #         size=8,
        #         axis='domainaxis1',
        #         key='dimensioncoordinate1',
        #         coord=<CF DimensionCoordinate: longitude(8) degrees_east>,
        #         coord_type='dimension_coordinate',
        #         scalar=False
        #     )
        # }
        axes_to_replace_from_field1 = {}

        # List of field1 coordinate reference constructs which will be
        # added to field0
        refs_to_add_from_field1 = []

        # Check that the two fields are combinable
        for i, (identity, y) in enumerate(tuple(out1.items())):

            if isinstance(identity, int):
                if y.size == 1:
                    del out1[identity]
                    squeeze1.append(i)
                else:
                    insert0.append(y.axis)
            elif identity not in out0:
                insert0.append(y.axis)

        # Make sure that both data arrays have the same number of
        # dimensions
        if squeeze1:
            field1.squeeze(squeeze1, inplace=True)

        for axis1 in insert0:
            new_axis0 = field0.set_construct(self._DomainAxis(1))
            field0.insert_dimension(
                new_axis0, position=field0.ndim, inplace=True
            )
            axes_added_from_field1.append(axis1)

        while field1.ndim < field0.ndim:
            new_axis = field1.set_construct(self._DomainAxis(1))
            field1.insert_dimension(new_axis, position=0, inplace=True)

        while field0.ndim < field1.ndim:
            new_axis = field0.set_construct(self._DomainAxis(1))
            field0.insert_dimension(
                new_axis, position=field0.ndim, inplace=True
            )

        # Make sure that the dimensions in data1 are in the same order
        # as the dimensions in data0
        for identity, y in out1.items():
            logger.info(f"{identity} {y}")
            if isinstance(identity, int) or identity not in out0:
                field1.swapaxes(
                    field1.get_data_axes().index(y.axis), -1, inplace=True
                )
            else:
                # This identity is also in out0
                a = out0[identity]
                logger.info(f"{identity} {y.axis} {a.axis}")
                logger.info(
                    f"{a} {field0.get_data_axes()} {field1.get_data_axes()} "
                    f"{field1.get_data_axes().index(y.axis)} "
                    f"{field0.get_data_axes().index(a.axis)}"
                )

                field1.swapaxes(
                    field1.get_data_axes().index(y.axis),
                    field0.get_data_axes().index(a.axis),
                    inplace=True,
                )

        axis_map = {
            axis1: axis0
            for axis1, axis0 in zip(
                field1.get_data_axes(), field0.get_data_axes()
            )
        }

        logger.info(f"\naxis_map= {axis_map}\n")

        # ------------------------------------------------------------
        # Check that the two fields have compatible metadata
        # ------------------------------------------------------------
        for i, (identity, y) in enumerate(tuple(out1.items())):
            if isinstance(identity, int) or identity not in out0:
                continue

            a = out0[identity]

            if y.size == 1:
                continue

            if y.size > 1 and a.size == 1:
                axes_to_replace_from_field1[y.axis] = y
                continue

            if y.size != a.size:
                raise ValueError(
                    f"Can't broadcast size {y.size} {identity!r} axis to size "
                    f"{a.size} {identity!r} axis"
                )

            # Ensure matching axis directions
            if y.coord.direction() != a.coord.direction():
                other.flip(y.axis, inplace=True)

            # Check for matching coordinate values
            if not y.coord._equivalent_data(a.coord):
                raise ValueError(
                    f"Can't combine size {y.size} {identity!r} axes with "
                    f"non-matching coordinate values"
                )

            # Check coord refs
            refs1 = field1.get_coordinate_reference(construct=y.key, key=True)
            refs0 = field0.get_coordinate_reference(construct=a.key, key=True)

            n_refs = len(refs1)
            n0_refs = len(refs0)

            if n_refs != n0_refs:
                raise ValueError(
                    f"Can't combine {self.__class__.__name__!r} with "
                    f"{other.__class__.__name__!r} because the coordinate "
                    f"references have different lengths: {n_refs} and "
                    f"{n0_refs}."
                )

            n_equivalent_refs = 0
            for ref1 in refs1:
                for ref0 in refs0[:]:
                    if field1._equivalent_coordinate_references(
                        field0, key0=ref1, key1=ref0, axis_map=axis_map
                    ):
                        n_equivalent_refs += 1
                        refs0.remove(ref0)
                        break

            if n_equivalent_refs != n_refs:
                raise ValueError(
                    f"Can't combine {self.__class__.__name__!r} with "
                    f"{other.__class__.__name__!r} because the fields have "
                    "incompatible coordinate references."
                )

        # Change the domain axis sizes in field0 so that they match
        # the broadcasted result data
        for identity, y in out1.items():
            if identity in out0 and isinstance(identity, str):
                a = out0[identity]
                if y.size > 1 and a.size == 1:
                    for key0, c in field0.constructs.filter_by_axis(
                        a.axis, axis_mode="or", todict=True
                    ).items():
                        removed_refs0 = field0.del_coordinate_reference(
                            construct=key0, default=None
                        )
                        if removed_refs0 and c.construct_type in (
                            "dimension_coordinate",
                            "auxiliary_coordinate",
                        ):
                            for ref in removed_refs0:
                                for key0 in ref.coordinates():
                                    field0.del_construct(key0, default=None)

                        field0.del_construct(key0, default=None)

                    field0.domain_axis(a.axis).set_size(y.size)
            elif y.size > 1:
                axis0 = axis_map[y.axis]
                field0.domain_axis(axis0).set_size(y.size)

        # ------------------------------------------------------------
        # Operate on the data
        # ------------------------------------------------------------
        new_data = field0.data._binary_operation(field1.data, method)

        field0.set_data(new_data, set_axes=False, copy=False)

        logger.info(f"\naxes_added_from_field1= {axes_added_from_field1}\n")
        logger.info(
            f"axes_to_replace_from_field1= {axes_to_replace_from_field1}"
        )

        already_copied = {}

        # ------------------------------------------------------------
        # Copy over coordinate and cell meausure constructs from
        # field1
        # ------------------------------------------------------------
        #       if axes_added_from_field1:
        #           constructs = field1.constructs.filter_by_type(
        #               'dimension_coordinate', 'auxiliary_coordinate',
        #               'cell_measure')
        # #            constructs = constructs.filter_by_axis(
        #                  'subset', *axes_added_from_field1)
        #
        #            for key1, c in constructs.items():
        #                axes = [axis_map[axis1] for axis1 in
        #                        field1.get_data_axes(key1)]
        #                key0 = field0.set_construct(c, axes=axes, copy=False)
        #                already_copied[key1] = key0

        #        for axis1, y in axes_to_replace_from_field1.items():
        #            axis0 = axis_map[axis1]
        new_axes = set(axes_added_from_field1).union(
            axes_to_replace_from_field1
        )
        logger.info(f"\nnew_axes ={new_axes}")

        if new_axes:
            constructs = field1.constructs.filter(
                filter_by_type=(
                    "dimension_coordinate",
                    "auxiliary_coordinate",
                    "cell_measure",
                ),
                filter_by_axis=new_axes,
                axis_mode="subset",
                todict=True,
            )
            #            constructs = field1.constructs.filter_by_type(
            #                "dimension_coordinate",
            #                "auxiliary_coordinate",
            #                "cell_measure",
            #                view=True,
            #            )
            #            constructs = constructs.filter_by_axis(
            #                *new_axes, mode="subset", view=True
            #            )
            for key, c in constructs.items():
                c_axes = field1.get_data_axes(key)
                axes = [axis_map[axis1] for axis1 in c_axes]
                key0 = field0.set_construct(c, axes=axes, copy=False)
                already_copied[key] = key0

        # ------------------------------------------------------------
        # Copy over coordinate reference constructs from field1,
        # including their domain ancillary constructs.
        # ------------------------------------------------------------
        for key, ref in field1.coordinate_references(todict=True).items():
            axes = field1._coordinate_reference_axes(key)
            if axes.issubset(new_axes):
                refs_to_add_from_field1.append(ref)
            elif axes.intersection(axes_to_replace_from_field1):
                refs_to_add_from_field1.append(ref)

        logger.info("\nrefs_to_add_from_field1={refs_to_add_from_field1}")

        for ref in refs_to_add_from_field1:
            # Copy coordinates
            coords = []
            for key1 in ref.coordinates():
                if key1 not in already_copied:
                    c = field1.constructs.get(key1, None)
                    if c is None:
                        already_copied[key1] = None
                    else:
                        axes = [
                            axis_map[axis]
                            for axis in field1.get_data_axes(key1)
                        ]
                        key0 = field0.set_construct(c, axes=axes, copy=False)
                        already_copied[key1] = key0

                key0 = already_copied[key1]
                if key0 is not None:
                    coords.append(key0)

            ref.clear_coordinates()
            ref.set_coordinates(coords)

            # Copy domain ancillaries to field0
            for (
                term,
                key1,
            ) in ref.coordinate_conversion.domain_ancillaries().items():
                if key1 not in already_copied:
                    c = field1.constructs.get(key1, None)
                    if c is None:
                        already_copied[key1] = None
                    else:
                        axes = [
                            axis_map[axis]
                            for axis in field1.get_data_axes(key1)
                        ]
                        key0 = field0.set_construct(c, axes=axes, copy=False)
                        already_copied[key1] = key0

                key0 = already_copied[key1]
                ref.coordinate_conversion.set_domain_ancillary(term, key0)

            # Copy coordinate reference to field0
            field0.set_construct(ref, copy=False)

        # ------------------------------------------------------------
        # Remove misleading identities
        # ------------------------------------------------------------
        # Warning: This block of code is replicated in PropertiesData
        if sn != other_sn:
            if sn is not None and other_sn is not None:
                field0.del_property("standard_name", None)
                field0.del_property("long_name", None)
            elif other_sn is not None:
                field0.set_property("standard_name", other_sn, copy=False)
                if other_ln is None:
                    field0.del_property("long_name", None)
                else:
                    field0.set_property("long_name", other_ln, copy=False)
        elif ln is None and other_ln is not None:
            field0.set_property("long_name", other_ln, copy=False)

        # Warning: This block of code is replicated in PropertiesData
        new_units = field0.Units
        if (
            method in _relational_methods
            or not units.equivalent(new_units)
            and not (units.isreftime and new_units.isreftime)
        ):
            field0.del_property("standard_name", None)
            field0.del_property("long_name", None)

        if method in _relational_methods:
            field0.override_units(Units(), inplace=True)

        # ------------------------------------------------------------
        # Return the result field
        # ------------------------------------------------------------
        return field0

    def _conform_cell_methods(self):
        """Changes the axes of the field's cell methods so they conform.

        :Returns:

            `None`

        """
        axis_map = {}

        for cm in self.cell_methods(todict=True).values():
            for axis in cm.get_axes(()):
                if axis in axis_map:
                    continue

                if axis == "area":
                    axis_map[axis] = axis
                    continue

                axis_map[axis] = self.domain_axis(axis, key=True, default=axis)

            cm.change_axes(axis_map, inplace=True)

    def _conform_for_assignment(self, other, check_coordinates=False):
        """Conform *other* so that it is ready for metadata-unaware
        assignment broadcasting across *self*.

        Note that *other* is not changed.

        :Parameters:

            other: `Field`
                The field to conform.

        :Returns:

            `Field`
                The conformed version of *other*.

        **Examples**

        >>> h = f._conform_for_assignment(g)

        """
        # Analyse each domain
        s = self.analyse_items()
        v = other.analyse_items()

        if s["warnings"] or v["warnings"]:
            raise ValueError(
                f"Can't setitem: {s['warnings'] or v['warnings']}"
            )

        # Find the set of matching axes
        matching_ids = set(s["id_to_axis"]).intersection(v["id_to_axis"])
        if not matching_ids:
            raise ValueError("Can't assign: No matching axes")

        # ------------------------------------------------------------
        # Check that any matching axes defined by auxiliary
        # coordinates are done so in both fields.
        # ------------------------------------------------------------
        for identity in matching_ids:
            if (identity in s["id_to_aux"]) + (
                identity in v["id_to_aux"]
            ) == 1:
                raise ValueError(
                    f"Can't assign: {identity!r} axis defined by auxiliary in "
                    "only 1 field"
                )

        copied = False

        # ------------------------------------------------------------
        # Check that 1) all undefined axes in other have size 1 and 2)
        # that all of other's unmatched but defined axes have size 1
        # and squeeze any such axes out of its data array.
        #
        # For example, if   self.data is        P T     Z Y   X   A
        #              and  other.data is     1     B C   Y 1 X T
        #              then other.data becomes            Y   X T
        # ------------------------------------------------------------
        squeeze_axes1 = []
        other_domain_axes = other.domain_axes(todict=True)

        for axis1 in v["undefined_axes"]:
            axis_size = other_domain_axes[axis1].get_size()
            if axis_size != 1:
                raise ValueError(
                    "Can't assign: Can't broadcast undefined axis with "
                    f"size {axis_size}"
                )

            squeeze_axes1.append(axis1)

        for identity in set(v["id_to_axis"]).difference(matching_ids):
            axis1 = v["id_to_axis"][identity]
            axis_size = other_domain_axes[axis1].get_size()
            if axis_size != 1:
                raise ValueError(
                    "Can't assign: Can't broadcast size "
                    f"{axis_size} {identity!r} axis"
                )

            squeeze_axes1.append(axis1)

        if squeeze_axes1:
            if not copied:
                other = other.copy()
                copied = True

            other.squeeze(squeeze_axes1, inplace=True)

        # ------------------------------------------------------------
        # Permute the axes of other.data so that they are in the same
        # order as their matching counterparts in self.data
        #
        # For example, if   self.data is       P T Z Y X   A
        #              and  other.data is            Y X T
        #              then other.data becomes   T   Y X
        # ------------------------------------------------------------
        data_axes0 = self.get_data_axes()
        data_axes1 = other.get_data_axes()

        transpose_axes1 = []
        for axis0 in data_axes0:
            identity = s["axis_to_id"][axis0]
            if identity in matching_ids:
                axis1 = v["id_to_axis"][identity]
                if axis1 in data_axes1:
                    transpose_axes1.append(axis1)

        if transpose_axes1 != data_axes1:
            if not copied:
                other = other.copy()
                copied = True

            other.transpose(transpose_axes1, inplace=True)

        # ------------------------------------------------------------
        # Insert size 1 axes into other.data to match axes in
        # self.data which other.data doesn't have.
        #
        # For example, if   self.data is       P T Z Y X A
        #              and  other.data is        T   Y X
        #              then other.data becomes 1 T 1 Y X 1
        # ------------------------------------------------------------
        expand_positions1 = []
        for i, axis0 in enumerate(data_axes0):
            identity = s["axis_to_id"][axis0]
            if identity in matching_ids:
                axis1 = v["id_to_axis"][identity]
                if axis1 not in data_axes1:
                    expand_positions1.append(i)
            else:
                expand_positions1.append(i)

        if expand_positions1:
            if not copied:
                other = other.copy()
                copied = True

            for i in expand_positions1:
                new_axis = other.set_construct(other._DomainAxis(1))
                other.insert_dimension(new_axis, position=i, inplace=True)

        # ----------------------------------------------------------------
        # Make sure that each pair of matching axes has the same
        # direction
        # ----------------------------------------------------------------
        flip_axes1 = []
        for identity in matching_ids:
            axis1 = v["id_to_axis"][identity]
            axis0 = s["id_to_axis"][identity]
            if other.direction(axis1) != self.direction(axis0):
                flip_axes1.append(axis1)

        if flip_axes1:
            if not copied:
                other = other.copy()
                copied = True

            other.flip(flip_axes1, inplace=True)

        # Find the axis names which are present in both fields
        if not check_coordinates:
            return other

        # Still here?
        matching_ids = set(s["id_to_axis"]).intersection(v["id_to_axis"])

        for identity in matching_ids:
            key0 = s["id_to_coord"][identity]
            key1 = v["id_to_coord"][identity]

            coord0 = self.constructs[key0]
            coord1 = other.constructs[key1]

            # Check the sizes of the defining coordinates
            size0 = coord0.size
            size1 = coord1.size
            if size0 != size1:
                if size0 == 1 or size1 == 1:
                    continue

                raise ValueError(
                    f"Can't broadcast {identity!r} axes with sizes {size0} "
                    f"and {size1}"
                )

            # Check that equally sized defining coordinate data arrays
            # are compatible
            if not coord0._equivalent_data(coord1):
                raise ValueError(
                    f"Matching {identity!r} coordinate constructs have "
                    "different data"
                )

            # If the defining coordinates are attached to
            # coordinate references then check that those
            # coordinate references are equivalent

            # For each field, find the coordinate references which
            # contain the defining coordinate.
            refs0 = [
                key
                for key, ref in self.coordinate_references(todict=True).items()
                if key0 in ref.coordinates()
            ]
            refs1 = [
                key
                for key, ref in other.coordinate_references(
                    todict=True
                ).items()
                if key1 in ref.coordinates()
            ]

            nrefs = len(refs0)
            error_msg = (
                f"Can't combine {self.__class__.__name__!r} with "
                f"{other.__class__.__name__!r} because the defining "
                "coordinates are attached to incompatible coordinate "
                "references."
            )
            if nrefs > 1 or nrefs != len(refs1):
                raise ValueError(error_msg)

            if nrefs and not self._equivalent_coordinate_references(
                other, key0=refs0[0], key1=refs1[0], s=s, t=v
            ):
                raise ValueError(error_msg)

        return other

    def _conform_for_data_broadcasting(self, other):
        """Conforms the field with another, ready for data broadcasting.

        Note that the other field, *other*, is not changed in-place.

        :Parameters:

            other: `Field`
                The field to conform.

        :Returns:

            `Field`
                The conformed version of *other*.

        **Examples**

        >>> h = f._conform_for_data_broadcasting(g)

        """

        other = self._conform_for_assignment(other, check_coordinates=True)

        # Remove leading size one dimensions
        ndiff = other.ndim - self.ndim
        if ndiff > 0 and set(other.shape[:ndiff]) == set((1,)):
            for i in range(ndiff):
                other = other.squeeze(0)

        return other

    @_manage_log_level_via_verbosity
    def _equivalent_construct_data(
        self,
        field1,
        key0=None,
        key1=None,
        s=None,
        t=None,
        atol=None,
        rtol=None,
        verbose=None,
        axis_map=None,
    ):
        """True if the field has equivalent construct data to another.

        Two real numbers ``x`` and ``y`` are considered equal if
        ``|x-y|<=atol+rtol|y|``, where ``atol`` (the tolerance on absolute
        differences) and ``rtol`` (the tolerance on relative differences)
        are positive, typically very small numbers. See the *atol* and
        *rtol* parameters.

        :Parameters:

            key0: `str`

            key1: `str`

            field1: `Field`

            s: `dict`, optional

            t: `dict`, optional

            atol: `float`, optional
                The tolerance on absolute differences between real
                numbers. The default value is set by the `atol` function.

            rtol: `float`, optional
                The tolerance on relative differences between real
                numbers. The default value is set by the `rtol` function.

            {{verbose: `int` or `str` or `None`, optional}}

        """
        item0 = self.constructs[key0]
        item1 = field1.constructs[key1]

        if item0.has_data() != item1.has_data():
            logger.info(
                f"{self.__class__.__name__}: Only one item has data"
            )  # pragma: no cover
            return False

        if not item0.has_data():
            # Neither field has a data array
            return True

        if item0.size != item1.size:
            logger.info(
                f"{self.__class__.__name__}: Different metadata construct "
                f"data array size: {item0.size} != {item1.size}"
            )  # pragma: no cover
            return False

        if item0.ndim != item1.ndim:
            logger.info(
                f"{self.__class__.__name__}: Different data array ranks "
                f"({item0.ndim}, {item1.ndim})"
            )  # pragma: no cover
            return False

        axes0 = self.get_data_axes(key0, default=())
        axes1 = field1.get_data_axes(key1, default=())

        if s is None:
            s = self.analyse_items()
        if t is None:
            t = field1.analyse_items()

        transpose_axes = []
        if axis_map is None:
            for axis0 in axes0:
                axis1 = t["id_to_axis"].get(s["axis_to_id"][axis0], None)
                if axis1 is None:
                    # TODO: improve message here (make user friendly):
                    logger.info(
                        "t['id_to_axis'] does not have a key "
                        f"s['axis_to_id'][axis0] for {self.__class__.__name__}"
                    )  # pragma: no cover
                    return False

                transpose_axes.append(axes1.index(axis1))
        else:
            for axis0 in axes0:
                axis1 = axis_map.get(axis0)
                if axis1 is None:
                    # TODO: improve message here (make user friendly):
                    logger.info(
                        f"axis_map[axis0] is None for {self.__class__.__name__}"
                    )  # pragma: no cover
                    return False

                transpose_axes.append(axes1.index(axis1))

        copy1 = True

        if transpose_axes != list(range(item1.ndim)):
            if copy1:
                item1 = item1.copy()
                copy1 = False

            item1.transpose(transpose_axes, inplace=True)

        if item0.shape != item1.shape:
            # add traceback TODO
            return False

        flip_axes = [
            i
            for i, (axis1, axis0) in enumerate(zip(axes1, axes0))
            if field1.direction(axis1) != self.direction(axis0)
        ]

        if flip_axes:
            if copy1:
                item1 = item1.copy()
                copy1 = False

            item1.flip(flip_axes, inplace=True)

        if not item0._equivalent_data(
            item1, rtol=rtol, atol=atol, verbose=verbose
        ):
            # add traceback TODO
            return False

        return True

    # ----------------------------------------------------------------
    # Worker functions for weights
    # ----------------------------------------------------------------
    def _weights_area_XY(
        self,
        comp,
        weights_axes,
        auto=False,
        measure=False,
        radius=None,
        methods=False,
    ):
        """Calculate area weights from X and Y dimension coordinate
        constructs.

        :Parameters:

            measure: `bool`
                If True then make sure that the weights represent true
                cell sizes.

            methods: `bool`, optional
                If True then add a description of the method used to
                create the weights to the *comp* dictionary, as opposed to
                the actual weights.

        :Returns:

            `bool` or `None`

        """
        xkey, xcoord = self.dimension_coordinate(
            "X", item=True, default=(None, None)
        )
        ykey, ycoord = self.dimension_coordinate(
            "Y", item=True, default=(None, None)
        )

        if xcoord is None or ycoord is None:
            if auto:
                return

            raise ValueError(
                "No unique 'X' and 'Y' dimension coordinate constructs for "
                "calculating area weights"
            )

        if xcoord.Units.equivalent(
            Units("radians")
        ) and ycoord.Units.equivalent(Units("radians")):
            pass
        elif xcoord.Units.equivalent(
            Units("metres")
        ) and ycoord.Units.equivalent(Units("metres")):
            radius = None
        else:
            if auto:
                return

            raise ValueError(
                "Insufficient coordinate constructs for calculating "
                "area weights"
            )

        xaxis = self.get_data_axes(xkey)[0]
        yaxis = self.get_data_axes(ykey)[0]

        for axis in (xaxis, yaxis):
            if axis in weights_axes:
                if auto:
                    return

                raise ValueError(
                    "Multiple weights specifications for "
                    f"{self.constructs.domain_axis_identity(axis)!r} axis"
                )

        if measure and radius is not None:
            radius = self.radius(default=radius)

        if measure or xcoord.size > 1:
            if not xcoord.has_bounds():
                if auto:
                    return

                raise ValueError(
                    "Can't create area weights: No bounds for "
                    f"{xcoord.identity()!r} axis"
                )

            if methods:
                comp[(xaxis,)] = "linear " + xcoord.identity()
            else:
                cells = xcoord.cellsize
                if xcoord.Units.equivalent(Units("radians")):
                    cells.Units = _units_radians
                    if measure:
                        cells *= radius
                        cells.override_units(radius.Units, inplace=True)
                else:
                    cells.Units = Units("metres")

                comp[(xaxis,)] = cells

            weights_axes.add(xaxis)

        if measure or ycoord.size > 1:
            if not ycoord.has_bounds():
                if auto:
                    return

                raise ValueError(
                    "Can't create area weights: No bounds for "
                    f"{ycoord.identity()!r} axis"
                )

            if ycoord.Units.equivalent(Units("radians")):
                ycoord = ycoord.clip(-90, 90, units=Units("degrees"))
                ycoord.sin(inplace=True)

                if methods:
                    comp[(yaxis,)] = "linear sine " + ycoord.identity()
                else:
                    cells = ycoord.cellsize
                    if measure:
                        cells *= radius

                    comp[(yaxis,)] = cells
            else:
                if methods:
                    comp[(yaxis,)] = "linear " + ycoord.identity()
                else:
                    cells = ycoord.cellsize
                    comp[(yaxis,)] = cells

            weights_axes.add(yaxis)

        return True

    def _weights_data(
        self,
        w,
        comp,
        weights_axes,
        axes=None,
        data=False,
        components=False,
        methods=False,
    ):
        """Creates weights for the field construct's data array.

        :Parameters:

            methods: `bool`, optional
                If True then add a description of the method used to
                create the weights to the *comp* dictionary, as
                opposed to the actual weights.

        """
        # --------------------------------------------------------
        # Data weights
        # --------------------------------------------------------
        field_data_axes = self.get_data_axes()

        if axes is not None:
            if isinstance(axes, (str, int)):
                axes = (axes,)

            if len(axes) != w.ndim:
                raise ValueError(
                    "'axes' parameter must provide an axis identifier "
                    f"for each weights data dimension. Got {axes!r} for "
                    f"{w.ndim} dimension(s)."
                )

            iaxes = [
                field_data_axes.index(self.domain_axis(axis, key=True))
                for axis in axes
            ]

            if data:
                for i in range(self.ndim):
                    if i not in iaxes:
                        w = w.insert_dimension(position=i)
                        iaxes.insert(i, i)

                w = w.transpose(iaxes)

                if w.ndim > 0:
                    while w.shape[0] == 1:
                        w = w.squeeze(0)

        else:
            iaxes = list(range(self.ndim - w.ndim, self.ndim))

        if not (components or methods):
            if not self._is_broadcastable(w.shape):
                raise ValueError(
                    f"The 'Data' weights (shape {w.shape}) are not "
                    "broadcastable to the field construct's data "
                    f"(shape {self.shape})."
                )

            axes0 = field_data_axes[self.ndim - w.ndim :]
        else:
            axes0 = [field_data_axes[i] for i in iaxes]

        for axis0 in axes0:
            if axis0 in weights_axes:
                raise ValueError(
                    "Multiple weights specified for "
                    f"{self.constructs.domain_axis_identity(axis0)!r} axis"
                )

        if methods:
            comp[tuple(axes0)] = "custom data"
        else:
            comp[tuple(axes0)] = w

        weights_axes.update(axes0)

        return True

    def _weights_field(self, fields, comp, weights_axes, methods=False):
        """Creates a weights field."""
        s = self.analyse_items()

        domain_axes = self.domain_axes(todict=True)
        #        domain_axes_size_1 = self.domain_axes(filter_by_size=(1,), todict=True)

        for w in fields:
            t = w.analyse_items()
            # TODO CHECK this with org

            if t["undefined_axes"]:
                #                if set(
                #                    t.domain_axes.filter_by_size(gt(1), view=True)
                #                ).intersection(t["undefined_axes"]):
                w_domain_axes_1 = w.domain_axes(
                    filter_by_size=(1,), todict=True
                )
                if set(w_domain_axes_1).intersection(t["undefined_axes"]):
                    raise ValueError(
                        f"Weights field {w} has at least one undefined "
                        f"domain axes: {w_domain_axes_1}."
                    )

            w = w.squeeze()

            w_domain_axes = w.domain_axes(todict=True)

            axis1_to_axis0 = {}

            coordinate_references = self.coordinate_references(todict=True)
            w_coordinate_references = w.coordinate_references(todict=True)

            for axis1 in w.get_data_axes():
                identity = t["axis_to_id"].get(axis1, None)
                if identity is None:
                    raise ValueError(
                        "Weights field has unmatched, size > 1 "
                        f"{w.constructs.domain_axis_identity(axis1)!r} axis"
                    )

                axis0 = s["id_to_axis"].get(identity, None)
                if axis0 is None:
                    raise ValueError(
                        f"Weights field has unmatched, size > 1 {identity!r} "
                        "axis"
                    )

                w_axis_size = w_domain_axes[axis1].get_size()
                self_axis_size = domain_axes[axis0].get_size()

                if w_axis_size != self_axis_size:
                    raise ValueError(
                        f"Weights field has incorrectly sized {identity!r} "
                        f"axis ({w_axis_size} != {self_axis_size})"
                    )

                axis1_to_axis0[axis1] = axis0

                # Check that the defining coordinate data arrays are
                # compatible
                key0 = s["axis_to_coord"][axis0]
                key1 = t["axis_to_coord"][axis1]

                if not self._equivalent_construct_data(
                    w, key0=key0, key1=key1, s=s, t=t
                ):
                    raise ValueError(
                        f"Weights field has incompatible {identity!r} "
                        "coordinates"
                    )

                # Still here? Then the defining coordinates have
                # equivalent data arrays

                # If the defining coordinates are attached to
                # coordinate references then check that those
                # coordinate references are equivalent
                refs0 = [
                    key
                    for key, ref in coordinate_references.items()
                    if key0 in ref.coordinates()
                ]
                refs1 = [
                    key
                    for key, ref in w_coordinate_references.items()
                    if key1 in ref.coordinates()
                ]

                nrefs = len(refs0)
                if nrefs > 1 or nrefs != len(refs1):
                    # The defining coordinate are associated with
                    # different numbers of coordinate references
                    equivalent_refs = False
                elif not nrefs:
                    # Neither defining coordinate is associated with a
                    # coordinate reference
                    equivalent_refs = True
                else:
                    # Each defining coordinate is associated with
                    # exactly one coordinate reference
                    equivalent_refs = self._equivalent_coordinate_references(
                        w, key0=refs0[0], key1=refs1[0], s=s, t=t
                    )

                if not equivalent_refs:
                    raise ValueError(
                        "Input weights field has an incompatible "
                        "coordinate reference"
                    )

            axes0 = tuple(
                [axis1_to_axis0[axis1] for axis1 in w.get_data_axes()]
            )

            for axis0 in axes0:
                if axis0 in weights_axes:
                    raise ValueError(
                        "Multiple weights specified for "
                        f"{self.constructs.domain_axis_identity(axis0)!r} "
                        "axis"
                    )

            comp[tuple(axes0)] = w.data

            weights_axes.update(axes0)

        return True

    def _weights_field_scalar(self, methods=False):
        """Return a scalar field of weights with long_name ``'weight'``.

        :Returns:

            `Field`

        """
        data = Data(1.0, "1")

        f = type(self)()
        f.set_data(data, copy=False)
        f.long_name = "weight"
        f.comment = f"Weights for {self!r}"

        return f

    def _weights_geometry_area(
        self,
        domain_axis,
        comp,
        weights_axes,
        auto=False,
        measure=False,
        radius=None,
        great_circle=False,
        return_areas=False,
        methods=False,
    ):
        """Creates area weights for polygon geometry cells.

        .. versionadded:: 3.2.0

        :Parameters:

            domain_axis : `str` or `None`

            measure: `bool`
                If True then make sure that the weights represent true
                cell sizes.

            methods: `bool`, optional
                If True then add a description of the method used to
                create the weights to the *comp* dictionary, as opposed to
                the actual weights.

        :Returns:

            `bool` or `Data`

        """
        axis, aux_X, aux_Y, aux_Z = self._weights_yyy(
            domain_axis, "polygon", methods=methods, auto=auto
        )

        if axis is None:
            if auto:
                return False

            if domain_axis is None:
                raise ValueError("No polygon geometries")

            raise ValueError(
                "No polygon geometries for "
                f"{self.constructs.domain_axis_identity(domain_axis)!r} axis"
            )

        if axis in weights_axes:
            if auto:
                return False

            raise ValueError(
                "Multiple weights specifications for "
                f"{self.constructs.domain_axis_identity(axis)!r} axis"
            )

        # Check for interior rings
        interior_ring_X = aux_X.get_interior_ring(None)
        interior_ring_Y = aux_Y.get_interior_ring(None)
        if interior_ring_X is None and interior_ring_Y is None:
            interior_ring = None
        elif interior_ring_X is None:
            raise ValueError(
                "Can't find weights: X coordinates have missing "
                "interior ring variable"
            )
        elif interior_ring_Y is None:
            raise ValueError(
                "Can't find weights: Y coordinates have missing "
                "interior ring variable"
            )
        elif not interior_ring_X.data.equals(interior_ring_Y.data):
            raise ValueError(
                "Can't find weights: Interior ring variables for X and Y "
                "coordinates have different data values"
            )
        else:
            interior_ring = interior_ring_X.data
            if interior_ring.shape != aux_X.bounds.shape[:-1]:
                raise ValueError(
                    "Can't find weights: Interior ring variables have "
                    f"incorrect shape. Got {interior_ring.shape}, expected "
                    f"{aux_X.bounds.shape[:-1]}"
                )

        x = aux_X.bounds.data
        y = aux_Y.bounds.data

        if x.Units.equivalent(_units_metres) and y.Units.equivalent(
            _units_metres
        ):
            # ----------------------------------------------------
            # Plane polygons defined by straight lines.
            #
            # Use the shoelace formula:
            # https://en.wikipedia.org/wiki/Shoelace_formula
            #
            # Do this in preference to weights based on spherical
            # polygons, which require the great circle assumption.
            # ----------------------------------------------------
            spherical = False

            if methods:
                comp[(axis,)] = "area plane polygon geometry"
                return True

            y.Units = x.Units

            all_areas = (x[..., :-1] * y[..., 1:]).sum(-1, squeeze=True) - (
                x[..., 1:] * y[..., :-1]
            ).sum(-1, squeeze=True)

            for i, (parts_x, parts_y) in enumerate(zip(x, y)):
                for j, (nodes_x, nodes_y) in enumerate(zip(parts_x, parts_y)):
                    nodes_x = nodes_x.compressed()
                    nodes_y = nodes_y.compressed()

                    if (nodes_x.size and nodes_x[0] != nodes_x[-1]) or (
                        nodes_y.size and nodes_y[0] != nodes_y[-1]
                    ):
                        # First and last nodes of this polygon
                        # part are different => need to account
                        # for the "last" edge of the polygon that
                        # joins the first and last points.
                        all_areas[i, j] += x[-1] * y[0] - x[0] * y[-1]

            all_areas = all_areas.abs() * 0.5

        elif x.Units.equivalent(_units_radians) and y.Units.equivalent(
            _units_radians
        ):
            # ----------------------------------------------------
            # Spherical polygons defined by great circles
            #
            # The area of such a spherical polygon is given by the
            # sum of the interior angles minus (N-2)pi, where N is
            # the number of sides (Todhunter,
            # https://en.wikipedia.org/wiki/Spherical_trigonometry#Spherical_polygons):
            #
            # Area of N-sided polygon on the unit sphere =
            #     \left(\sum _{n=1}^{N}A_{n}\right) - (N - 2)\pi
            #
            # where A_{n} denotes the n-th interior angle.
            # ----------------------------------------------------
            spherical = True

            if not great_circle:
                raise ValueError(
                    "Must set great_circle=True to allow the derivation of "
                    "area weights from spherical polygons composed from "
                    "great circle segments."
                )

            if methods:
                comp[(axis,)] = "area spherical polygon geometry"
                return True

            x.Units = _units_radians
            y.Units = _units_radians

            interior_angle = self._weights_interior_angle(x, y)

            # Find the number of edges of each polygon (note that
            # this number may be one too few, but we'll adjust for
            # that later).
            N = interior_angle.sample_size(-1, squeeze=True)

            all_areas = interior_angle.sum(-1, squeeze=True) - (N - 2) * np.pi

            for i, (parts_x, parts_y) in enumerate(zip(x, y)):
                for j, (nodes_x, nodes_y) in enumerate(zip(parts_x, parts_y)):
                    nodes_x = nodes_x.compressed()
                    nodes_y = nodes_y.compressed()

                    if (nodes_x.size and nodes_x[0] != nodes_x[-1]) or (
                        nodes_y.size and nodes_y[0] != nodes_y[-1]
                    ):
                        # First and last nodes of this polygon
                        # part are different => need to account
                        # for the "last" edge of the polygon that
                        # joins the first and last points.
                        interior_angle = self._weights_interior_angle(
                            nodes_x[[0, -1]], nodes_y[[0, -1]]
                        )

                        all_areas[i, j] += interior_angle + np.pi

            area_min = all_areas.min()
            if area_min < 0:
                raise ValueError(
                    "A spherical polygon geometry part has negative area"
                )
        else:
            return False

        # Change the sign of areas for polygons that are interior
        # rings
        if interior_ring is not None:
            all_areas.where(interior_ring, -all_areas, inplace=True)

        # Sum the areas of each part to get the total area of each
        # cell
        areas = all_areas.sum(-1, squeeze=True)

        if measure and spherical and aux_Z is not None:
            # Multiply by radius squared, accounting for any Z
            # coordinates, to get the actual area
            z = aux_Z.get_data(None, _fill_value=False)
            if z is None:
                r = radius
            else:
                if not z.Units.equivalent(_units_metres):
                    raise ValueError(
                        "Z coordinates must have units equivalent to "
                        f"metres for area calculations. Got {z.Units!r}"
                    )

                positive = aux_Z.get_property("positive", None)
                if positive is None:
                    raise ValueError(
                        "Value of Z coordinate 'positive' property is not "
                        "defined"
                    )

                if positive.lower() == "up":
                    r = radius + z
                elif positive.lower() == "down":
                    r = radius - z
                else:
                    raise ValueError(
                        "Bad value of Z coordinate 'positive' "
                        f"property: {positive!r}."
                    )

            areas *= r**2

        if return_areas:
            return areas

        comp[(axis,)] = areas

        weights_axes.add(axis)

        return True

    def _weights_geometry_line(
        self,
        domain_axis,
        comp,
        weights_axes,
        auto=False,
        measure=False,
        radius=None,
        great_circle=False,
        methods=False,
    ):
        """Creates line-length weights for line geometries.

        .. versionadded:: 3.2.0

        :Parameters:

            measure: `bool`
                If True then make sure that the weights represent true
                cell sizes.

            methods: `bool`, optional
                If True then add a description of the method used to
                create the weights to the *comp* dictionary, as opposed to
                the actual weights.

        """
        axis, aux_X, aux_Y, aux_Z = self._weights_yyy(
            domain_axis, "line", methods=methods, auto=auto
        )

        if axis is None:
            if auto:
                return False

            if domain_axis is None:
                raise ValueError("No line geometries")

            raise ValueError(
                "No line geometries for "
                f"{self.constructs.domain_axis_identity(domain_axis)!r} axis"
            )

        if axis in weights_axes:
            if auto:
                return False

            raise ValueError(
                "Multiple weights specifications for "
                f"{self.constructs.domain_axis_identity(axis)!r} axis"
            )

        x = aux_X.bounds.data
        y = aux_Y.bounds.data

        if x.Units.equivalent(_units_metres) and y.Units.equivalent(
            _units_metres
        ):
            # ----------------------------------------------------
            # Plane lines.
            #
            # Each line segment is the simple cartesian distance
            # between two adjacent nodes.
            # ----------------------------------------------------
            if methods:
                comp[(axis,)] = "linear plane line geometry"
                return True

            y.Units = x.Units

            delta_x = x.diff(axis=-1)
            delta_y = y.diff(axis=-1)

            all_lengths = (delta_x**2 + delta_y**2) ** 0.5
            all_lengths = all_lengths.sum(-1, squeeze=True)

        elif x.Units.equivalent(_units_radians) and y.Units.equivalent(
            _units_radians
        ):
            # ----------------------------------------------------
            # Spherical lines.
            #
            # Each line segment is a great circle arc between two
            # adjacent nodes.
            #
            # The length of the great circle arc is the the
            # interior angle multiplied by the radius. The
            # interior angle is calculated with a special case of
            # the Vincenty formula:
            # https://en.wikipedia.org/wiki/Great-circle_distance
            # ----------------------------------------------------
            if not great_circle:
                raise ValueError(
                    "Must set great_circle=True to allow the derivation "
                    "of line-length weights from great circle segments."
                )

            if methods:
                comp[(axis,)] = "linear spherical line geometry"
                return True

            x.Units = _units_radians
            y.Units = _units_radians

            interior_angle = self._weights_interior_angle(x, y)
            if interior_angle.min() < 0:
                raise ValueError(
                    "A spherical line geometry segment has "
                    f"negative length: {interior_angle.min() * radius!r}"
                )

            all_lengths = interior_angle.sum(-1, squeeze=True)

            if measure:
                all_lengths *= radius
        else:
            return False

        # Sum the lengths of each part to get the total length of
        # each cell
        lengths = all_lengths.sum(-1, squeeze=True)

        comp[(axis,)] = lengths

        weights_axes.add(axis)

        return True

    def _weights_geometry_volume(
        self,
        comp,
        weights_axes,
        auto=False,
        measure=False,
        radius=None,
        great_circle=False,
        methods=False,
    ):
        """Creates volume weights for polygon geometry cells.

        .. versionadded:: 3.2.0

        :Parameters:

            measure: `bool`
                If True then make sure that the weights represent true
                cell sizes.

            methods: `bool`, optional
                If True then add a description of the method used to
                create the weights to the *comp* dictionary, as opposed to
                the actual weights.

        """
        axis, aux_X, aux_Y, aux_Z = self._weights_yyy(
            "polygon", methods=methods, auto=auto
        )

        if axis is None and auto:
            return False

        if axis in weights_axes:
            if auto:
                return False

            raise ValueError(
                "Multiple weights specifications for "
                f"{self.constructs.domain_axis_identity(axis)!r} axis"
            )

        x = aux_X.bounds.data
        y = aux_Y.bounds.data
        z = aux_Z.bounds.data

        if not z.Units.equivalent(_units_metres):
            if auto:
                return False

            raise ValueError(
                "Z coordinate bounds must have units equivalent to "
                f"metres for volume calculations. Got {z.Units!r}."
            )

        if not methods:
            # Initialise cell volumes as the cell areas
            volumes = self._weights_geometry_area(
                comp,
                weights_axes,
                auto=auto,
                measure=measure,
                radius=radius,
                great_circle=great_circle,
                methods=False,
                return_areas=True,
            )

            if measure:
                delta_z = abs(z[..., 1] - z[..., 0])
                delta_z.squeeze(axis=-1, inplace=True)

        if x.Units.equivalent(_units_metres) and y.Units.equivalent(
            _units_metres
        ):
            # ----------------------------------------------------
            # Plane polygons defined by straight lines.
            #
            # Do this in preference to weights based on spherical
            # polygons, which require the great circle assumption.
            # ----------------------------------------------------
            if methods:
                comp[(axis,)] = "volume plane polygon geometry"
                return True

            if measure:
                volumes *= delta_z

        elif x.Units.equivalent(_units_radians) and y.Units.equivalent(
            _units_radians
        ):
            # ----------------------------------------------------
            # Spherical polygons defined by great circles
            #
            # The area of such a spherical polygon is given by the
            # sum of the interior angles minus (N-2)pi, where N is
            # the number of sides (Todhunter):
            #
            # https://en.wikipedia.org/wiki/Spherical_trigonometry#Area_and_spherical_excess
            #
            # The interior angle of a side is calculated with a
            # special case of the Vincenty formula:
            # https://en.wikipedia.org/wiki/Great-circle_distance
            # ----------------------------------------------------
            if not great_circle:
                raise ValueError(
                    "Must set great_circle=True to allow the derivation "
                    "of volume weights from spherical polygons composed "
                    "from great circle segments."
                )

            if methods:
                comp[(axis,)] = "volume spherical polygon geometry"
                return True

            if measure:
                r = radius

                # actual_volume =
                #    [actual_area/(4*pi*r**2)]
                #    * (4/3)*pi*[(r+delta_z)**3 - r**3)]
                volumes *= (
                    delta_z**3 / (3 * r**2) + delta_z**2 / r + delta_z
                )
        else:
            raise ValueError(
                "X and Y coordinate bounds must both have units "
                "equivalent to either metres, for plane polygon, or radians, "
                "for spherical polygon, volume calculations. Got "
                f"{x.Units!r} and {y.Units!r}."
            )

        comp[(axis,)] = volumes

        weights_axes.add(axis)

        return True

    def _weights_interior_angle(self, data_lambda, data_phi):
        """Find the interior angle between each adjacent pair of
        geometry nodes defined on a sphere.

        The interior angle of two points on the sphere is calculated with
        a special case of the Vincenty formula
        (https://en.wikipedia.org/wiki/Great-circle_distance):

        \Delta \sigma =\arctan {
            \frac {\sqrt {\left(\cos \phi _{2}\sin(\Delta \lambda )\right)^{2} +
                   \left(\cos \phi _{1}\sin \phi _{2} -
                         \sin \phi _{1}\cos \phi _{2}\cos(\Delta \lambda )\right)^{2} } }
                  {\sin \phi _{1}\sin \phi _{2} +
                   \cos \phi _{1}\cos \phi _{2}\cos(\Delta \lambda )}
                  }

        :Parameters:

            data_lambda: `Data`
                Longitudes. Must have units of radians, which is not
                checked.

            data_phi: `Data`
                Latitudes. Must have units of radians, which is not
                checked.

        :Returns:

            `Data`
                The interior angles in units of radians.

        """
        delta_lambda = data_lambda.diff(axis=-1)

        cos_phi = data_phi.cos()
        sin_phi = data_phi.sin()

        cos_phi_1 = cos_phi[..., :-1]
        cos_phi_2 = cos_phi[..., 1:]

        sin_phi_1 = sin_phi[..., :-1]
        sin_phi_2 = sin_phi[..., 1:]

        cos_delta_lambda = delta_lambda.cos()
        sin_delta_lambda = delta_lambda.sin()

        numerator = (
            (cos_phi_2 * sin_delta_lambda) ** 2
            + (
                cos_phi_1 * sin_phi_2
                - sin_phi_1 * cos_phi_2 * cos_delta_lambda
            )
            ** 2
        ) ** 0.5

        denominator = (
            sin_phi_1 * sin_phi_2 + cos_phi_1 * cos_phi_2 * cos_delta_lambda
        )

        # TODO RuntimeWarning: overflow encountered in true_divide comes from
        # numerator/denominator with missing values

        interior_angle = (numerator / denominator).arctan()

        interior_angle.override_units(_units_1, inplace=True)

        return interior_angle

    def _weights_linear(
        self,
        axis,
        comp,
        weights_axes,
        auto=False,
        measure=False,
        methods=False,
    ):
        """1-d linear weights from dimension coordinate constructs.

        :Parameters:

            axis: `str`

            comp: `dict`

            weights_axes: `set`

            auto: `bool`

            measure: `bool`
                If True then make sure that the weights represent true
                cell sizes.

            methods: `bool`, optional
                If True then add a description of the method used to
                create the weights to the *comp* dictionary, as opposed to
                the actual weights.

        :Returns:

            `bool`

        """
        da_key = self.domain_axis(axis, key=True, default=None)
        if da_key is None:
            if auto:
                return False

            raise ValueError(
                "Can't create weights: Can't find domain axis "
                f"matching {axis!r}"
            )

        dim = self.dimension_coordinate(filter_by_axis=(da_key,), default=None)
        if dim is None:
            if auto:
                return False

            raise ValueError(
                f"Can't create linear weights for {axis!r} axis: Can't find "
                "dimension coordinate construct."
            )

        if not measure and dim.size == 1:
            return False

        if da_key in weights_axes:
            if auto:
                return False

            raise ValueError(
                f"Can't create linear weights for {axis!r} axis: Multiple "
                "axis specifications"
            )

        if not dim.has_bounds():
            # Dimension coordinate has no bounds
            if auto:
                return False

            raise ValueError(
                f"Can't create linear weights for {axis!r} axis: No bounds"
            )
        else:
            # Bounds exist
            if methods:
                comp[
                    (da_key,)
                ] = "linear " + self.constructs.domain_axis_identity(da_key)
            else:
                comp[(da_key,)] = dim.cellsize

        weights_axes.add(da_key)

        return True

    def _weights_measure(
        self, measure, comp, weights_axes, methods=False, auto=False
    ):
        """Cell measure weights.

        :Parameters:

            methods: `bool`, optional
                If True then add a description of the method used to
                create the weights to the *comp* dictionary, as opposed to
                the actual weights.

        :Returns:

            `bool`

        """
        m = self.cell_measures(filter_by_measure=(measure,), todict=True)
        len_m = len(m)

        if not len_m:
            if measure == "area":
                return False

            if auto:
                return

            raise ValueError(
                f"Can't find weights: No {measure!r} cell measure"
            )

        elif len_m > 1:
            if auto:
                return False

            raise ValueError(
                f"Can't find weights: Multiple {measure!r} cell measures"
            )

        key, clm = m.popitem()

        clm_axes0 = self.get_data_axes(key)

        clm_axes = tuple(
            [axis for axis, n in zip(clm_axes0, clm.data.shape) if n > 1]
        )

        for axis in clm_axes:
            if axis in weights_axes:
                if auto:
                    return False

                raise ValueError(
                    "Multiple weights specifications for "
                    f"{self.constructs.domain_axis_identity(axis)!r} axis"
                )

        clm = clm.get_data(_fill_value=False).copy()
        if clm_axes != clm_axes0:
            iaxes = [clm_axes0.index(axis) for axis in clm_axes]
            clm.squeeze(iaxes, inplace=True)

        if methods:
            comp[tuple(clm_axes)] = measure + " cell measure"
        else:
            comp[tuple(clm_axes)] = clm

        weights_axes.update(clm_axes)

        return True

    def _weights_scale(self, w, scale):
        """Scale the weights so that they are <= scale.

        :Parameters:

            w: `Data

            scale: number

        :Returns:

            `Data`

        """
        scale = Data.asdata(scale).datum()
        if scale <= 0:
            raise ValueError(
                "'scale' parameter must be a positive number. " f"Got {scale}"
            )

        wmax = w.maximum()
        factor = wmax / scale
        factor.dtype = float
        if numpy_can_cast(factor.dtype, w.dtype):
            w /= factor
        else:
            w = w / factor

        return w

    def _weights_yyy(
        self, domain_axis, geometry_type, methods=False, auto=False
    ):
        """Checks whether weights can be created for given coordinates.

        .. versionadded:: 3.2.0

        :Parameters:

            domain_axis : `str` or `None`

            geometry_type: `str`
                Either ``'polygon'`` or ``'line'``.

            auto: `bool`

        :Returns:

            `tuple`

        """
        aux_X = None
        aux_Y = None
        aux_Z = None
        x_axis = None
        y_axis = None
        z_axis = None

        auxiliary_coordinates_1d = self.auxiliary_coordinates(
            filter_by_naxes=(1,), todict=True
        )

        for key, aux in auxiliary_coordinates_1d.items():
            if aux.get_geometry(None) != geometry_type:
                continue

            if aux.X:
                aux_X = aux.copy()
                x_axis = self.get_data_axes(key)[0]
                if domain_axis is not None and x_axis != domain_axis:
                    aux_X = None
                    continue
            elif aux.Y:
                aux_Y = aux.copy()
                y_axis = self.get_data_axes(key)[0]
                if domain_axis is not None and y_axis != domain_axis:
                    aux_Y = None
                    continue
            elif aux.Z:
                aux_Z = aux.copy()
                z_axis = self.get_data_axes(key)[0]
                if domain_axis is not None and z_axis != domain_axis:
                    aux_Z = None
                    continue

        if aux_X is None or aux_Y is None:
            if auto:
                return (None,) * 4

            raise ValueError(
                "Can't create weights: Need both X and Y nodes to "
                f"calculate {geometry_type} geometry weights"
            )

        if x_axis != y_axis:
            if auto:
                return (None,) * 4

            raise ValueError(
                "Can't create weights: X and Y nodes span different "
                "domain axes"
            )

        axis = x_axis

        if aux_X.get_bounds(None) is None or aux_Y.get_bounds(None) is None:
            # Not both X and Y coordinates have bounds
            if auto:
                return (None,) * 4

            raise ValueError("Not both X and Y coordinates have bounds")

        if aux_X.bounds.shape != aux_Y.bounds.shape:
            if auto:
                return (None,) * 4

            raise ValueError(
                "Can't find weights: X and Y geometry coordinate bounds "
                "must have the same shape. "
                f"Got {aux_X.bounds.shape} and {aux_Y.bounds.shape}"
            )

        if not methods:
            if aux_X.bounds.data.fits_in_one_chunk_in_memory(
                aux_X.bounds.dtype.itemsize
            ):
                aux_X.bounds.varray
            if aux_X.bounds.data.fits_in_one_chunk_in_memory(
                aux_Y.bounds.dtype.itemsize
            ):
                aux_X.bounds.varray

        if aux_Z is None:
            for key, aux in auxiliary_coordinates_1d.items():
                if aux.Z:
                    aux_Z = aux.copy()
                    z_axis = self.get_data_axes(key)[0]

        # Check Z coordinates
        if aux_Z is not None:
            if z_axis != x_axis:
                if auto:
                    return (None,) * 4

                raise ValueError(
                    "Z coordinates span different domain axis to X and Y "
                    "geometry coordinates"
                )

        return axis, aux_X, aux_Y, aux_Z

    # ----------------------------------------------------------------
    # End of worker functions for weights
    # ----------------------------------------------------------------

    # ----------------------------------------------------------------
    # Attributes
    # ----------------------------------------------------------------
    @property
    def DSG(self):
        """True if the field contains a collection of discrete sampling
        geometries.

        .. versionadded:: 2.0

        .. seealso:: `featureType`

        **Examples**

        >>> f.featureType
        'timeSeries'
        >>> f.DSG
        True

        >>> f.get_property('featureType', 'NOT SET')
        NOT SET
        >>> f.DSG
        False

        """
        return self.has_property("featureType")

    @property
    def Flags(self):
        """A `Flags` object containing self-describing CF flag values.

        Stores the `flag_values`, `flag_meanings` and `flag_masks` CF
        properties in an internally consistent manner.

        **Examples**

        >>> f.Flags
        <CF Flags: flag_values=[0 1 2], flag_masks=[0 2 2], flag_meanings=['low' 'medium' 'high']>

        """
        try:
            return self._custom["Flags"]
        except KeyError:
            raise AttributeError(
                f"{self.__class__.__name__!r} object has no attribute 'Flags'"
            )

    @Flags.setter
    def Flags(self, value):
        self._custom["Flags"] = value

    @Flags.deleter
    def Flags(self):
        try:
            return self._custom.pop("Flags")
        except KeyError:
            raise AttributeError(
                f"{self.__class__.__name__!r} object has no attribute 'Flags'"
            )

    @property
    def varray(self):
        """A numpy array view of the data array.

        Changing the elements of the returned view changes the data array.

        .. seealso:: `array`, `data`, `datetime_array`

        **Examples**

        >>> f.data
        <CF Data(5): [0, ... 4] kg m-1 s-2>
        >>> a = f.array
        >>> type(a)
        <type 'numpy.ndarray'>
        >>> print(a)
        [0 1 2 3 4]
        >>> a[0] = 999
        >>> print(a)
        [999 1 2 3 4]
        >>> print(f.array)
        [999 1 2 3 4]
        >>> f.data
        <CF Data(5): [999, ... 4] kg m-1 s-2>

        """
        self.uncompress(inplace=True)
        return super().varray

    # ----------------------------------------------------------------
    # CF properties
    # ----------------------------------------------------------------
    @property
    def flag_values(self):
        """The flag_values CF property.

        Provides a list of the flag values. Use in conjunction with
        `flag_meanings`. See http://cfconventions.org/latest.html for
        details.

        Stored as a 1-d numpy array but may be set as any array-like
        object.

        **Examples**

        >>> f.flag_values = ['a', 'b', 'c']
        >>> f.flag_values
        array(['a', 'b', 'c'], dtype='|S1')
        >>> f.flag_values = numpy.arange(4)
        >>> f.flag_values
        array([1, 2, 3, 4])
        >>> del f.flag_values

        >>> f.set_property('flag_values', 1)
        >>> f.get_property('flag_values')
        array([1])
        >>> f.del_property('flag_values')

        """
        try:
            return self.Flags.flag_values
        except AttributeError:
            raise AttributeError(
                f"{self.__class__.__name__!r} doesn't have CF property "
                "'flag_values'"
            )

    @flag_values.setter
    def flag_values(self, value):
        try:
            flags = self.Flags
        except AttributeError:
            self.Flags = Flags(flag_values=value)
        else:
            flags.flag_values = value

    @flag_values.deleter
    def flag_values(self):
        try:
            del self.Flags.flag_values
        except AttributeError:
            raise AttributeError(
                f"{self.__class__.__name__!r} doesn't have CF property "
                "'flag_values'"
            )
        else:
            if not self.Flags:
                del self.Flags

    @property
    def flag_masks(self):
        """The flag_masks CF property.

        Provides a list of bit fields expressing Boolean or enumerated
        flags. See http://cfconventions.org/latest.html for details.

        Stored as a 1-d numpy array but may be set as array-like object.

        **Examples**

        >>> f.flag_masks = numpy.array([1, 2, 4], dtype='int8')
        >>> f.flag_masks
        array([1, 2, 4], dtype=int8)
        >>> f.flag_masks = (1, 2, 4, 8)
        >>> f.flag_masks
        array([1, 2, 4, 8], dtype=int8)
        >>> del f.flag_masks

        >>> f.set_property('flag_masks', 1)
        >>> f.get_property('flag_masks')
        array([1])
        >>> f.del_property('flag_masks')

        """
        try:
            return self.Flags.flag_masks
        except AttributeError:
            raise AttributeError(
                f"{self.__class__.__name__!r} doesn't have CF property "
                "'flag_masks'"
            )

    @flag_masks.setter
    def flag_masks(self, value):
        try:
            flags = self.Flags
        except AttributeError:
            self.Flags = Flags(flag_masks=value)
        else:
            flags.flag_masks = value

    @flag_masks.deleter
    def flag_masks(self):
        try:
            del self.Flags.flag_masks
        except AttributeError:
            raise AttributeError(
                f"{self.__class__.__name__!r} doesn't have CF property "
                "'flag_masks'"
            )
        else:
            if not self.Flags:
                del self.Flags

    @property
    def flag_meanings(self):
        """The flag_meanings CF property.

        Use in conjunction with `flag_values` to provide descriptive words
        or phrases for each flag value. If multi-word phrases are used to
        describe the flag values, then the words within a phrase should be
        connected with underscores. See
        http://cfconventions.org/latest.html for details.

        Stored as a 1-d numpy string array but may be set as a space
        delimited string or any array-like object.

        **Examples**

        >>> f.flag_meanings = 'low medium      high'
        >>> f.flag_meanings
        array(['low', 'medium', 'high'],
              dtype='|S6')
        >>> del flag_meanings

        >>> f.flag_meanings = ['left', 'right']
        >>> f.flag_meanings
        array(['left', 'right'],
              dtype='|S5')

        >>> f.flag_meanings = 'ok'
        >>> f.flag_meanings
        array(['ok'],
              dtype='|S2')

        >>> f.set_property('flag_meanings', numpy.array(['a', 'b']))
        >>> f.get_property('flag_meanings')
        array(['a', 'b'],
              dtype='|S1')
        >>> f.del_property('flag_meanings')

        """
        try:
            return " ".join(self.Flags.flag_meanings)
        except AttributeError:
            raise AttributeError(
                f"{self.__class__.__name__!r} doesn't have CF property "
                "'flag_meanings'"
            )

    @flag_meanings.setter
    def flag_meanings(self, value):
        try:  # TODO deal with space-delimited strings
            flags = self.Flags
        except AttributeError:
            self.Flags = Flags(flag_meanings=value)
        else:
            flags.flag_meanings = value

    @flag_meanings.deleter
    def flag_meanings(self):
        try:
            del self.Flags.flag_meanings
        except AttributeError:
            raise AttributeError(
                f"{self.__class__.__name__!r} doesn't have CF property "
                "'flag_meanings'"
            )
        else:
            if not self.Flags:
                del self.Flags

    @property
    def Conventions(self):
        """The Conventions CF property.

        The name of the conventions followed by the field. See
        http://cfconventions.org/latest.html for details.

        **Examples**

        >>> f.Conventions = 'CF-1.6'
        >>> f.Conventions
        'CF-1.6'
        >>> del f.Conventions

        >>> f.set_property('Conventions', 'CF-1.6')
        >>> f.get_property('Conventions')
        'CF-1.6'
        >>> f.del_property('Conventions')

        """
        return self.get_property("Conventions")

    @Conventions.setter
    def Conventions(self, value):
        self.set_property("Conventions", value, copy=False)

    @Conventions.deleter
    def Conventions(self):
        self.del_property("Conventions")

    @property
    def featureType(self):
        """The featureType CF property.

        The type of discrete sampling geometry, such as ``point`` or
        ``timeSeriesProfile``. See http://cfconventions.org/latest.html
        for details.

        .. versionadded:: 2.0

        **Examples**

        >>> f.featureType = 'trajectoryProfile'
        >>> f.featureType
        'trajectoryProfile'
        >>> del f.featureType

        >>> f.set_property('featureType', 'profile')
        >>> f.get_property('featureType')
        'profile'
        >>> f.del_property('featureType')

        """
        return self.get_property("featureType", default=AttributeError())

    @featureType.setter
    def featureType(self, value):
        self.set_property("featureType", value, copy=False)

    @featureType.deleter
    def featureType(self):
        self.del_property("featureType")

    @property
    def institution(self):
        """The institution CF property.

        Specifies where the original data was produced. See
        http://cfconventions.org/latest.html for details.

        **Examples**

        >>> f.institution = 'University of Reading'
        >>> f.institution
        'University of Reading'
        >>> del f.institution

        >>> f.set_property('institution', 'University of Reading')
        >>> f.get_property('institution')
        'University of Reading'
        >>> f.del_property('institution')

        """
        return self.get_property("institution")

    @institution.setter
    def institution(self, value):
        self.set_property("institution", value, copy=False)

    @institution.deleter
    def institution(self):
        self.del_property("institution")

    @property
    def references(self):
        """The references CF property.

        Published or web-based references that describe the data or
        methods used to produce it. See
        http://cfconventions.org/latest.html for details.

        **Examples**

        >>> f.references = 'some references'
        >>> f.references
        'some references'
        >>> del f.references

        >>> f.set_property('references', 'some references')
        >>> f.get_property('references')
        'some references'
        >>> f.del_property('references')

        """
        return self.get_property("references")

    @references.setter
    def references(self, value):
        self.set_property("references", value, copy=False)

    @references.deleter
    def references(self):
        self.del_property("references")

    @property
    def standard_error_multiplier(self):
        """The standard_error_multiplier CF property.

        If a data variable with a `standard_name` modifier of
        ``'standard_error'`` has this attribute, it indicates that the
        values are the stated multiple of one standard error. See
        http://cfconventions.org/latest.html for details.

        **Examples**

        >>> f.standard_error_multiplier = 2.0
        >>> f.standard_error_multiplier
        2.0
        >>> del f.standard_error_multiplier

        >>> f.set_property('standard_error_multiplier', 2.0)
        >>> f.get_property('standard_error_multiplier')
        2.0
        >>> f.del_property('standard_error_multiplier')

        """
        return self.get_property("standard_error_multiplier")

    @standard_error_multiplier.setter
    def standard_error_multiplier(self, value):
        self.set_property("standard_error_multiplier", value)

    @standard_error_multiplier.deleter
    def standard_error_multiplier(self):
        self.del_property("standard_error_multiplier")

    @property
    def source(self):
        """The source CF property.

        The method of production of the original data. If it was
        model-generated, `source` should name the model and its version,
        as specifically as could be useful. If it is observational,
        `source` should characterize it (for example, ``'surface
        observation'`` or ``'radiosonde'``). See
        http://cfconventions.org/latest.html for details.

        **Examples**

        >>> f.source = 'radiosonde'
        >>> f.source
        'radiosonde'
        >>> del f.source

        >>> f.set_property('source', 'surface observation')
        >>> f.get_property('source')
        'surface observation'
        >>> f.del_property('source')

        """
        return self.get_property("source")

    @source.setter
    def source(self, value):
        self.set_property("source", value, copy=False)

    @source.deleter
    def source(self):
        self.del_property("source")

    @property
    def title(self):
        """The title CF property.

        A short description of the file contents from which this field was
        read, or is to be written to. See
        http://cfconventions.org/latest.html for details.

        **Examples**

        >>> f.title = 'model data'
        >>> f.title
        'model data'
        >>> del f.title

        >>> f.set_property('title', 'model data')
        >>> f.get_property('title')
        'model data'
        >>> f.del_property('title')

        """
        return self.get_property("title")

    @title.setter
    def title(self, value):
        self.set_property("title", value, copy=False)

    @title.deleter
    def title(self):
        self.del_property("title")

    # ----------------------------------------------------------------
    # Methods
    # ----------------------------------------------------------------
    def cell_area(
        self,
        radius="earth",
        great_circle=False,
        set=False,
        insert=False,
        force=False,
    ):
        """Return a field containing horizontal cell areas.

        .. versionadded:: 1.0

        .. seealso:: `bin`, `collapse`, `radius`, `weights`

        :Parameters:

            radius: optional
                Specify the radius used for calculating the areas of
                cells defined in spherical polar coordinates. The
                radius is that which would be returned by this call of
                the field construct's `~cf.Field.radius` method:
                ``f.radius(radius)``. See `cf.Field.radius` for
                details.

                By default *radius* is ``'earth'`` which means that if
                and only if the radius can not found from the datums
                of any coordinate reference constructs, then the
                default radius taken as 6371229 metres.

            great_circle: `bool`, optional
                If True then allow, if required, the derivation of i)
                area weights from polygon geometry cells by assuming
                that each cell part is a spherical polygon composed of
                great circle segments; and ii) and the derivation of
                line-length weights from line geometry cells by
                assuming that each line part is composed of great
                circle segments.

                .. versionadded:: 3.2.0

            insert: deprecated at version 3.0.0

            force: deprecated at version 3.0.0

        :Returns:

            `Field`
                A field construct containing the horizontal cell
                areas.

        **Examples**

        >>> a = f.cell_area()
        >>> a = f.cell_area(radius=cf.Data(3389.5, 'km'))
        >>> a = f.cell_area(insert=True)

        """
        if insert:
            _DEPRECATION_ERROR_KWARGS(
                self, "cell_area", {"insert": insert}, version="3.0.0"
            )  # pragma: no cover

        if force:
            _DEPRECATION_ERROR_KWARGS(
                self, "cell_area", {"force": force}, version="3.0.0"
            )  # pragma: no cover

        w = self.weights(
            "area",
            radius=radius,
            measure=True,
            scale=None,
            great_circle=great_circle,
        )

        w.set_property("standard_name", "cell_area", copy=False)

        return w

    def radius(self, default=None):
        """Return the radius of a latitude-longitude plane defined in
        spherical polar coordinates.

        The radius is taken from the datums of any coordinate
        reference constructs, but if and only if this is not possible
        then a default value may be used instead.

        .. versionadded:: 3.0.2

        .. seealso:: `bin`, `cell_area`, `collapse`, `weights`

        :Parameters:

            default: optional
                The radius is taken from the datums of any coordinate
                reference constructs, but if and only if this is not
                possible then the value set by the *default* parameter
                is used. May be set to any numeric scalar object,
                including `numpy` and `Data` objects. The units of the
                radius are assumed to be metres, unless specified by a
                `Data` object. If the special value ``'earth'`` is
                given then the default radius taken as 6371229
                metres. If *default* is `None` an exception will be
                raised if no unique datum can be found in the
                coordinate reference constructs.

                *Parameter example:*
                  Five equivalent ways to set a default radius of
                  6371200 metres: ``6371200``,
                  ``numpy.array(6371200)``, ``cf.Data(6371200)``,
                  ``cf.Data(6371200, 'm')``, ``cf.Data(6371.2,
                  'km')``.

        :Returns:

            `Data`
                The radius of the sphere, in units of metres.

        **Examples**

        >>> f.radius()
        <CF Data(): 6371178.98 m>

        >>> g.radius()
        ValueError: No radius found in coordinate reference constructs and no default provided
        >>> g.radius('earth')
        <CF Data(): 6371229.0 m>
        >>> g.radius(1234)
        <CF Data(): 1234.0 m>

        """
        radii = []
        for cr in self.coordinate_references(todict=True).values():
            r = cr.datum.get_parameter("earth_radius", None)
            if r is not None:
                r = Data.asdata(r)
                if not r.Units:
                    r.override_units("m", inplace=True)

                if r.size != 1:
                    radii.append(r)
                    continue

                got = False
                for _ in radii:
                    if r == _:
                        got = True
                        break

                if not got:
                    radii.append(r)

        if len(radii) > 1:
            raise ValueError(
                "Multiple radii found from coordinate reference "
                f"constructs: {radii!r}"
            )

        if not radii:
            if default is None:
                raise ValueError(
                    "No radius found from coordinate reference constructs "
                    "and no default provided"
                )

            if isinstance(default, str):
                if default != "earth":
                    raise ValueError(
                        "The default radius must be numeric, 'earth', "
                        "or None"
                    )

                return _earth_radius.copy()

            r = Data.asdata(default).squeeze()
        else:
            r = Data.asdata(radii[0]).squeeze()

        if r.size != 1:
            raise ValueError(f"Multiple radii: {r!r}")

        r.Units = Units("m")
        r.dtype = float
        return r

    def laplacian_xy(
        self,
        x_wrap=None,
        one_sided_at_boundary=False,
        radius=None,
    ):
        r"""Calculate the Laplacian in X-Y coordinates.

        The horizontal Laplacian of a scalar function is calculated
        from a field that has dimension coordinates of X and Y, in
        either Cartesian (e.g. plane projection) or spherical polar
        coordinate systems.

        The horizontal Laplacian in Cartesian coordinates is given by:

        .. math:: \nabla^2 f(x, y) = \frac{\partial^2 f}{\partial x^2}
                                     +
                                     \frac{\partial^2 f}{\partial y^2}

        The horizontal Laplacian in spherical polar coordinates is
        given by:

        .. math:: \nabla^2 f(\theta, \phi) =
                    \frac{1}{r^2 \sin\theta}
                    \frac{\partial}{\partial \theta}
                    \left(
                    \sin\theta
                    \frac{\partial f}{\partial \theta}
                    \right)
                    +
                    \frac{1}{r^2 \sin^2\theta}
                    \frac{\partial^2 f}{\partial \phi^2}

        where *r* is radial distance to the origin, :math:`\theta` is
        the polar angle with respect to polar axis, and :math:`\phi`
        is the azimuthal angle.

        The Laplacian is calculated using centred finite differences
        apart from at the boundaries (see the *x_wrap* and
        *one_sided_at_boundary* parameters). If missing values are
        present then missing values will be returned at all points
        where a centred finite difference could not be calculated.

        .. versionadded:: 3.12.0

        .. seealso:: `derivative`, `grad_xy`, `iscyclic`, `cf.div_xy`

        :Parameters:

            x_wrap: `bool`, optional
                Whether the X axis is cyclic or not. By default
                *x_wrap* is set to the result of this call to the
                field construct's `iscyclic` method:
                ``f.iscyclic('X')``. If the X axis is cyclic then
                centred differences at one boundary will always use
                values from the other boundary, regardless of the
                setting of *one_sided_at_boundary*.

                The cyclicity of the Y axis is always set to the
                result of ``f.iscyclic('Y')``.

            one_sided_at_boundary: `bool`, optional
                If True then one-sided finite differences are
                calculated at the non-cyclic boundaries. By default
                missing values are set at non-cyclic boundaries.

            {{radius: optional}}

        :Returns:

            `Field` or `None`
                The horizontal Laplacian of the scalar field, or
                `None` if the operation was in-place.

        >>> f = cf.example_field(0)
        >>> print(f)
        Field: specific_humidity (ncvar%q)
        ----------------------------------
        Data            : specific_humidity(latitude(5), longitude(8)) 1
        Cell methods    : area: mean
        Dimension coords: latitude(5) = [-75.0, ..., 75.0] degrees_north
                        : longitude(8) = [22.5, ..., 337.5] degrees_east
                        : time(1) = [2019-01-01 00:00:00]
        >>> f[...] = 0.1
        >>> print(f.array)
        [[0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1]
         [0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1]
         [0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1]
         [0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1]
         [0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1]]
        >>> lp = f.laplacian_xy(radius='earth')
        >>> lp
        <CF Field: long_name=X-Y Laplacian of specific_humidity(latitude(5), longitude(8)) m-2.rad-2>
        >>> print(lp.array)
        [[-- -- -- -- -- -- -- --]
         [-- -- -- -- -- -- -- --]
         [0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0]
         [-- -- -- -- -- -- -- --]
         [-- -- -- -- -- -- -- --]]
        >>> lp = f.laplacian_xy(radius='earth', one_sided_at_boundary=True)
        >>> print(lp.array)
        [[0. 0. 0. 0. 0. 0. 0. 0.]
         [0. 0. 0. 0. 0. 0. 0. 0.]
         [0. 0. 0. 0. 0. 0. 0. 0.]
         [0. 0. 0. 0. 0. 0. 0. 0.]
         [0. 0. 0. 0. 0. 0. 0. 0.]]

        """
        f = self.copy()
        identity = f.identity()

        x_key, x_coord = f.dimension_coordinate(
            "X", item=True, default=(None, None)
        )
        y_key, y_coord = f.dimension_coordinate(
            "Y", item=True, default=(None, None)
        )

        if x_coord is None:
            raise ValueError("Field has no unique 'X' dimension coordinate")

        if y_coord is None:
            raise ValueError("Field has no unique 'Y' dimension coordinate")

        if x_wrap is None:
            x_wrap = f.iscyclic(x_key)

        x_units = x_coord.Units
        y_units = y_coord.Units

        # Check for spherical polar coordinates
        latlon = (x_units.islongitude and y_units.islatitude) or (
            x_units.units == "degrees" and y_units.units == "degrees"
        )

        if latlon:
            # --------------------------------------------------------
            # Spherical polar coordinates
            # --------------------------------------------------------
            # Convert latitude and longitude units to radians, so that
            # the units of the result are nice.
            x_coord.Units = _units_radians
            y_coord.Units = _units_radians

            # Get theta as a field that will broadcast to f, and
            # adjust its values so that theta=0 is at the north pole.
            theta = np.pi / 2 - f.convert(y_key, full_domain=True)

            sin_theta = theta.sin()

            r = f.radius(default=radius)
            r2_sin_theta = sin_theta * r**2

            d2f_dphi2 = f.derivative(
                x_key,
                wrap=x_wrap,
                one_sided_at_boundary=one_sided_at_boundary,
            ).derivative(
                x_key,
                wrap=x_wrap,
                one_sided_at_boundary=one_sided_at_boundary,
            )

            term1 = d2f_dphi2 / (r2_sin_theta * sin_theta)

            df_dtheta = f.derivative(
                y_key, wrap=None, one_sided_at_boundary=one_sided_at_boundary
            )

            term2 = (df_dtheta * sin_theta).derivative(
                y_key, wrap=None, one_sided_at_boundary=one_sided_at_boundary
            ) / r2_sin_theta

            f = term1 + term2

            # Reset latitude and longitude coordinate units
            f.dimension_coordinate("X").Units = x_units
            f.dimension_coordinate("Y").Units = y_units
        else:
            # --------------------------------------------------------
            # Cartesian coordinates
            # --------------------------------------------------------
            d2f_dx2 = f.derivative(
                x_key,
                wrap=x_wrap,
                one_sided_at_boundary=one_sided_at_boundary,
            ).derivative(
                x_key,
                wrap=x_wrap,
                one_sided_at_boundary=one_sided_at_boundary,
            )

            d2f_dy2 = f.derivative(
                y_key,
                wrap=None,
                one_sided_at_boundary=one_sided_at_boundary,
            ).derivative(
                y_key,
                wrap=None,
                one_sided_at_boundary=one_sided_at_boundary,
            )

            f = d2f_dx2 + d2f_dy2

        # Set the standard name and long name
        f.set_property("long_name", f"Horizontal Laplacian of {identity}")
        f.del_property("standard_name", None)

        return f

    def map_axes(self, other):
        """Map the axis identifiers of the field to their equivalent
        axis identifiers of another.

        :Parameters:

            other: `Field`

        :Returns:

            `dict`
                A dictionary whose keys are the axis identifiers of the
                field with corresponding values of axis identifiers of the
                of other field.

        **Examples**

        >>> f.map_axes(g)
        {'dim0': 'dim1',
         'dim1': 'dim0',
         'dim2': 'dim2'}

        """
        s = self.analyse_items()
        t = other.analyse_items()
        id_to_axis1 = t["id_to_axis"]

        out = {}
        for axis, identity in s["axis_to_id"].items():
            if identity in id_to_axis1:
                out[axis] = id_to_axis1[identity]

        return out

    def close(self):
        """Close all files referenced by the field construct.

        Note that a closed file will be automatically reopened if its
        contents are subsequently required.

        :Returns:

            `None`

        **Examples**

        >>> f.close()

        """
        super().close()

        for construct in self.constructs.filter_by_data(todict=True).values():
            construct.close()

    def iscyclic(self, *identity, **filter_kwargs):
        """Returns True if the specified axis is cyclic.

        .. versionadded:: 1.0

        .. seealso:: `axis`, `cyclic`, `period`, `domain_axis`

        :Parameters:

            identity, filter_kwargs: optional
                Select the unique domain axis construct returned by
                ``f.domain_axis(*identity, **filter_kwargs)``. See
                `domain_axis` for details.

        :Returns:

            `bool`
                True if the selected axis is cyclic, otherwise False.

        **Examples**

        >>> f.iscyclic('X')
        True
        >>> f.iscyclic('latitude')
        False

        >>> x = f.iscyclic('long_name=Latitude')
        >>> x = f.iscyclic('dimensioncoordinate1')
        >>> x = f.iscyclic('domainaxis2')
        >>> x = f.iscyclic('key%domainaxis2')
        >>> x = f.iscyclic('ncdim%y')
        >>> x = f.iscyclic(2)

        """
        axis = self.domain_axis(
            *identity, key=True, default=None, **filter_kwargs
        )
        if axis is None:
            raise ValueError("Can't identify unique domain axis")

        return axis in self.cyclic()

    @classmethod
    def concatenate(cls, fields, axis=0, _preserve=True):
        """Join a sequence of fields together.

        This is different to `cf.aggregate` because it does not account
        for all metadata. For example, it assumes that the axis order is
        the same in each field.

        .. versionadded:: 1.0

        .. seealso:: `cf.aggregate`, `Data.concatenate`

        :Parameters:

            fields: `FieldList`
                The sequence of fields to concatenate.

            axis: `int`, optional
                The axis along which the arrays will be joined. The
                default is 0. Note that scalar arrays are treated as if
                they were one dimensional.

        :Returns:

            `Field`
                The field generated from the concatenation of input fields.

        """
        if isinstance(fields, cls):
            return fields.copy()

        field0 = fields[0]
        out = field0.copy()

        if len(fields) == 1:
            return out

        new_data = Data.concatenate(
            [f.get_data(_fill_value=False) for f in fields],
            axis=axis,
            _preserve=_preserve,
        )

        # Change the domain axis size
        dim = out.get_data_axes()[axis]
        out.set_construct(DomainAxis(size=new_data.shape[axis]), key=dim)

        # Insert the concatenated data
        out.set_data(new_data, set_axes=False, copy=False)

        # ------------------------------------------------------------
        # Concatenate constructs with data
        # ------------------------------------------------------------
        for key, construct in field0.constructs.filter_by_data(
            todict=True
        ).items():
            construct_axes = field0.get_data_axes(key)

            if dim not in construct_axes:
                # This construct does not span the concatenating axis
                # in the first field
                continue

            constructs = [construct]
            for f in fields[1:]:
                c = f.constructs.get(key)
                if c is None:
                    # This field does not have this construct
                    constructs = None
                    break

                constructs.append(c)

            if not constructs:
                # Not every field has this construct, so remove it
                # from the output field.
                out.del_construct(key)
                continue

            # Still here? Then try concatenating the constructs from
            # each field.
            try:
                construct = construct.concatenate(
                    constructs,
                    axis=construct_axes.index(dim),
                    _preserve=_preserve,
                )
            except ValueError:
                # Couldn't concatenate this construct, so remove it from
                # the output field.
                out.del_construct(key)
            else:
                # Successfully concatenated this construct, so insert
                # it into the output field.
                out.set_construct(
                    construct, key=key, axes=construct_axes, copy=False
                )

        return out

    def weights(
        self,
        weights=True,
        scale=None,
        measure=False,
        components=False,
        methods=False,
        radius="earth",
        data=False,
        great_circle=False,
        axes=None,
        **kwargs,
    ):
        """Return weights for the data array values.

        The weights are those used during a statistical collapse of the
        data. For example when computing a area weight average.

        Weights for any combination of axes may be returned.

        Weights are either derived from the field construct's metadata
        (such as coordinate cell sizes) or provided explicitly in the form
        of other `Field` constructs. In any case, the outer product of
        these weights components is returned in a field which is
        broadcastable to the original field (see the *components* parameter
        for returning the components individually).

        By default null, equal weights are returned.

        .. versionadded:: 1.0

        .. seealso:: `bin`, `cell_area`, `collapse`, `moving_window`,
                     `radius`

        :Parameters:

            weights: *optional*
                Specify the weights to be created. There are three
                distinct methods:

                * **Type 1** will create weights for all axes of size
                  greater than 1, raising an exception if this is not
                  possible (this is the default).;

                * **Type 2** will always succeed in creating weights for
                  all axes of the field, even if some of those weights are
                  null.

                * **Type 3** allows particular types of weights to be
                  defined for particular axes, and an exception will be
                  raised if it is not possible to the create weights.

            ..

                **Type 1** and **Type 2** come at the expense of not
                always being able to control exactly how the weights are
                created (although which methods were used can be inspected
                with use of the *methods* parameter).

                * **Type 1**: *weights* may be:

                  ==========  ============================================
                  *weights*   Description
                  ==========  ============================================
                  `True`      This is the default. Weights are created for
                              all axes (or a subset of them, see the
                              *axes* parameter). Set the *methods*
                              parameter to find out how the weights were
                              actually created.

                              The weights components are created for axes
                              of the field by one or more of the following
                              methods, in order of preference,

                                1. Volume cell measures
                                2. Area cell measures
                                3. Area calculated from (grid) latitude
                                   and (grid) longitude dimension
                                   coordinate constructs with bounds
                                4. Cell sizes of dimension coordinate
                                   constructs with bounds
                                5. Equal weights

                              and the outer product of these weights
                              components is returned in a field constructs
                              which is broadcastable to the original field
                              construct (see the *components* parameter).
                  ==========  ============================================

                * **Type 2**: *weights* may be one of:

                  ==========  ============================================
                  *weights*   Description
                  ==========  ============================================
                  `None`      Equal weights for all axes.

                  `False`     Equal weights for all axes.

                  `Data`      Explicit weights in a `Data` object that
                              must be broadcastable to the field
                              construct's data, unless the *axes*
                              parameter is also set.

                  `dict`      Explicit weights in a dictionary of the form
                              that is returned from a call to the
                              `weights` method with ``component=True``
                  ==========  ============================================

                * **Type 3**: *weights* may be one, or a sequence, of:

                  ============  ==========================================
                  *weights*     Description
                  ============  ==========================================
                  ``'area'``    Cell area weights from the field
                                construct's area cell measure construct
                                or, if one doesn't exist, from (grid)
                                latitude and (grid) longitude dimension
                                coordinate constructs. Set the *methods*
                                parameter to find out how the weights were
                                actually created.

                  ``'volume'``  Cell volume weights from the field
                                construct's volume cell measure construct.

                  `str`         Weights from the cell sizes of the
                                dimension coordinate construct with this
                                identity.

                  `Field`       Explicit weights from the data of another
                                field construct, which must be
                                broadcastable to this field construct.
                  ============  ==========================================

                If *weights* is a sequence of any combination of the
                above then the returned field contains the outer product
                of the weights defined by each element of the
                sequence. The ordering of the sequence is irrelevant.

                *Parameter example:*
                  To create to 2-dimensional weights based on cell
                  areas: ``f.weights('area')``. To create to
                  3-dimensional weights based on cell areas and linear
                  height: ``f.weights(['area', 'Z'])``.

            scale: number, optional
                If set to a positive number then scale the weights so that
                they are less than or equal to that number. If weights
                components have been requested (see the *components*
                parameter) then each component is scaled independently of
                the others.

                *Parameter example:*
                  To scale all weights so that they lie between 0 and 1:
                  ``scale=1``.

            measure: `bool`, optional
                Create weights that are cell measures, i.e. which
                describe actual cell sizes (e.g. cell areas) with
                appropriate units (e.g. metres squared).

                Cell measures can be created for any combination of
                axes. For example, cell measures for a time axis are
                the time span for each cell with canonical units of
                seconds; cell measures for the combination of four
                axes representing time and three dimensional space
                could have canonical units of metres cubed seconds.

                .. note:: Specifying cell volume weights via
                          ``weights=['X', 'Y', 'Z']`` or
                          ``weights=['area', 'Z']`` (or other
                          equivalents) will produce **an incorrect
                          result if the vertical dimension coordinates
                          do not define the actual height or depth
                          thickness of every cell in the domain**. In
                          this case, ``weights='volume'`` should be
                          used instead, which requires the field
                          construct to have a "volume" cell measure
                          construct.

                          If ``weights=True`` then care also needs to
                          be taken, as a "volume" cell measure
                          construct will be used if present, otherwise
                          the cell volumes will be calculated using
                          the size of the vertical coordinate cells.

            radius: optional
                Specify the radius used for calculating the areas of
                cells defined in spherical polar coordinates. The
                radius is that which would be returned by this call of
                the field construct's `~cf.Field.radius` method:
                ``f.radius(radius)``. See the `cf.Field.radius` for
                details.

                By default *radius* is ``'earth'`` which means that if
                and only if the radius can not found from the datums
                of any coordinate reference constructs, then the
                default radius taken as 6371229 metres.

            components: `bool`, optional
                If True then a dictionary of orthogonal weights components
                is returned instead of a field. Each key is a tuple of
                integers representing axis positions in the field
                construct's data, with corresponding values of weights in
                `Data` objects. The axes of weights match the axes of the
                field construct's data array in the order given by their
                dictionary keys.

            methods: `bool`, optional
                If True, then return a dictionary describing methods used
                to create the weights.

            data: `bool`, optional
                If True then return the weights in a `Data` instance that
                is broadcastable to the original data.

                .. versionadded:: 3.1.0

            great_circle: `bool`, optional
                If True then allow, if required, the derivation of i) area
                weights from polygon geometry cells by assuming that each
                cell part is a spherical polygon composed of great circle
                segments; and ii) and the derivation of line-length
                weights from line geometry cells by assuming that each
                line part is composed of great circle segments.

                .. versionadded:: 3.2.0

            axes: (sequence of) `int` or `str`, optional
                Modify the behaviour when *weights* is `True` or a `Data`
                instance. Ignored for any other value the *weights*
                parameter.

                If *weights* is `True` then weights are created only for
                the specified axes (as opposed to all
                axes). I.e. ``weight=True, axes=axes`` is identical to
                ``weights=axes``.

                If *weights* is a `Data` instance then the specified axes
                identify each dimension of the given weights. If the
                weights do not broadcast to the field construct's data
                then setting the *axes* parameter is required so that the
                broadcasting can be inferred, otherwise setting the *axes*
                is not required.

                *Parameter example:*
                  ``axes='T'``

                *Parameter example:*
                  ``axes=['longitude']``

                *Parameter example:*
                  ``axes=[3, 1]``

                .. versionadded:: 3.3.0

            kwargs: deprecated at version 3.0.0.

        :Returns:

            `Field` or `Data` or `dict`
                The weights field; or if *data* is True, weights data in
                broadcastable form; or if *components* is True, orthogonal
                weights in a dictionary.

        **Examples**

        >>> f
        <CF Field: air_temperature(time(12), latitude(145), longitude(192)) K>
        >>> f.weights()
        <CF Field: long_name:weight(time(12), latitude(145), longitude(192)) 86400 s.rad>
        >>> f.weights(scale=1.0)
        <CF Field: long_name:weight(time(12), latitude(145), longitude(192)) 1>
        >>> f.weights(components=True)
        {(0,): <CF Data(12): [30.0, ..., 31.0] d>,
         (1,): <CF Data(145): [5.94949998503e-05, ..., 5.94949998503e-05]>,
         (2,): <CF Data(192): [0.0327249234749, ..., 0.0327249234749] radians>}
        >>> f.weights(components=True, scale=1.0)
        {(0,): <CF Data(12): [0.967741935483871, ..., 1.0] 1>,
         (1,): <CF Data(145): [0.00272710399807, ..., 0.00272710399807]>,
         (2,): <CF Data(192): [1.0, ..., 1.0]>}
        >>> f.weights(components=True, scale=2.0)
        {(0,): <CF Data(12): [1.935483870967742, ..., 2.0] 1>,
         (1,): <CF Data(145): [0.00545420799614, ..., 0.00545420799614]>,
         (2,): <CF Data(192): [2.0, ..., 2.0]>}
        >>> f.weights(methods=True)
        {(0,): 'linear time',
         (1,): 'linear sine latitude',
         (2,): 'linear longitude'}

        """
        if isinstance(weights, str) and weights == "auto":
            _DEPRECATION_ERROR_KWARG_VALUE(
                self,
                "weights",
                "weights",
                "auto",
                message="Use value True instead.",
                version="3.0.7",
            )  # pragma: no cover

        if kwargs:
            _DEPRECATION_ERROR_KWARGS(
                self, "weights", kwargs
            )  # pragma: no cover

        if measure and scale is not None:
            raise ValueError("Can't set measure=True and scale")

        if components and data:
            raise ValueError("Can't set components=True and data=True")

        if weights is None or weights is False:
            # --------------------------------------------------------
            # All equal weights
            # --------------------------------------------------------
            if components or methods:
                # Return an empty components dictionary
                return {}

            if data:
                # Return an scalar Data instance
                return Data(1.0, "1")

            # Return a field containing a single weight of 1
            return self._weights_field_scalar()

        # Still here?
        if methods:
            components = True

        comp = {}
        field_data_axes = self.get_data_axes()

        # All axes which have weights
        weights_axes = set()

        if radius is not None:
            radius = self.radius(default=radius)

        if weights is True and axes is not None:
            # --------------------------------------------------------
            # Restrict weights to the specified axes
            # --------------------------------------------------------
            weights = axes

        if weights is True:
            # --------------------------------------------------------
            # Auto-detect all weights
            # --------------------------------------------------------
            # Volume weights
            if self._weights_measure(
                "volume", comp, weights_axes, methods=methods, auto=True
            ):
                # Found volume weights from cell measures
                pass

            elif self._weights_measure(
                "area", comp, weights_axes, methods=methods, auto=True
            ):
                # Found area weights from cell measures
                pass
            elif self._weights_area_XY(
                comp,
                weights_axes,
                measure=measure,
                radius=radius,
                methods=methods,
                auto=True,
            ):
                # Found area weights from X and Y dimension
                # coordinates
                pass

            domain_axes = self.domain_axes(todict=True)

            for da_key in domain_axes:
                if self._weights_geometry_area(
                    da_key,
                    comp,
                    weights_axes,
                    measure=measure,
                    radius=radius,
                    great_circle=great_circle,
                    methods=methods,
                    auto=True,
                ):
                    # Found area weights from polygon geometries
                    pass
                elif self._weights_geometry_line(
                    da_key,
                    comp,
                    weights_axes,
                    measure=measure,
                    radius=radius,
                    great_circle=great_circle,
                    methods=methods,
                    auto=True,
                ):
                    # Found linear weights from line geometries
                    pass
                elif self._weights_linear(
                    da_key,
                    comp,
                    weights_axes,
                    measure=measure,
                    methods=methods,
                    auto=True,
                ):
                    # Found linear weights from dimension coordinates
                    pass

            weights_axes = []
            for key in comp:
                weights_axes.extend(key)

            size_N_axes = []
            for key, domain_axis in domain_axes.items():
                if domain_axis.get_size(0) > 1:
                    size_N_axes.append(key)

            missing_axes = set(size_N_axes).difference(weights_axes)
            if missing_axes:
                missing_axes_id = self.constructs.domain_axis_identity(
                    missing_axes.pop()
                )
                raise ValueError(
                    f"Can't find weights for {missing_axes_id!r} axis."
                )

        elif isinstance(weights, dict):
            # --------------------------------------------------------
            # Dictionary
            # --------------------------------------------------------
            for key, value in weights.items():
                key = [self.domain_axis(i, key=True) for i in key]
                for k in key:
                    if k not in field_data_axes:
                        raise ValueError(
                            f"Can't find weights: {k!r} domain axis does "
                            "not correspond to one of the data axes."
                        )

                multiple_weights = weights_axes.intersection(key)
                if multiple_weights:
                    multiple_weights_id = self.constructs.domain_axis_identity(
                        multiple_weights.pop()
                    )
                    raise ValueError(
                        f"Can't find weights: Multiple specifications for "
                        f"{multiple_weights_id!r} domain axis"
                    )

                weights_axes.update(key)

                if methods:
                    comp[tuple(key)] = "custom data"
                else:
                    comp[tuple(key)] = value.copy()

        elif isinstance(weights, self.__class__):
            # --------------------------------------------------------
            # Field
            # --------------------------------------------------------
            self._weights_field([weights], comp, weights_axes)

        elif isinstance(weights, Data):
            # --------------------------------------------------------
            # Data
            # --------------------------------------------------------
            self._weights_data(
                weights,
                comp,
                weights_axes,
                axes=axes,
                data=data,
                components=components,
                methods=methods,
            )
        else:
            # --------------------------------------------------------
            # String or sequence
            # --------------------------------------------------------
            fields = []
            axes = []
            cell_measures = []

            if isinstance(weights, str):
                if weights in ("area", "volume"):
                    cell_measures = (weights,)
                else:
                    axes.append(weights)
            else:
                # In rare edge cases, e.g. if a user sets:
                #     weights=f[0].cell_area
                # when they mean weights=f[0].cell_area(), we reach this
                # code but weights is not iterable. So check it is first:
                try:
                    weights = iter(weights)
                except TypeError:
                    raise TypeError(
                        f"Invalid type of 'weights' parameter: {weights}"
                    )

                for w in tuple(weights):
                    if isinstance(w, self.__class__):
                        fields.append(w)
                    elif isinstance(w, Data):
                        raise ValueError(
                            f"Invalid weight {w} in sequence of weights."
                        )
                    elif w in ("area", "volume"):
                        cell_measures.append(w)
                    else:
                        axes.append(w)

            # Field weights
            self._weights_field(fields, comp, weights_axes)

            # Volume weights
            if "volume" in cell_measures:
                self._weights_measure(
                    "volume", comp, weights_axes, methods=methods, auto=False
                )

            # Area weights
            if "area" in cell_measures:
                if self._weights_measure(
                    "area", comp, weights_axes, methods=methods, auto=True
                ):
                    # Found area weights from cell measures
                    pass
                elif self._weights_area_XY(
                    comp,
                    weights_axes,
                    measure=measure,
                    radius=radius,
                    methods=methods,
                    auto=True,
                ):
                    # Found area weights from X and Y dimension
                    # coordinates
                    pass
                else:
                    # Found area weights from polygon geometries
                    self._weights_geometry_area(
                        None,
                        comp,
                        weights_axes,
                        measure=measure,
                        radius=radius,
                        great_circle=great_circle,
                        methods=methods,
                        auto=False,
                    )

            for axis in axes:
                da_key = self.domain_axis(axis, key=True, default=None)
                if da_key is None:
                    raise ValueError(
                        "Can't create weights: can't find axis matching "
                        f"{axis!r}"
                    )

                if self._weights_geometry_area(
                    da_key,
                    comp,
                    weights_axes,
                    measure=measure,
                    radius=radius,
                    great_circle=great_circle,
                    methods=methods,
                    auto=True,
                ):
                    # Found area weights from polygon geometries
                    pass
                elif self._weights_geometry_line(
                    da_key,
                    comp,
                    weights_axes,
                    measure=measure,
                    radius=radius,
                    great_circle=great_circle,
                    methods=methods,
                    auto=True,
                ):
                    # Found linear weights from line geometries
                    pass
                else:
                    self._weights_linear(
                        da_key,
                        comp,
                        weights_axes,
                        measure=measure,
                        methods=methods,
                        auto=False,
                    )

            # Check for area weights specified by X and Y axes
            # separately and replace them with area weights
            xaxis = self.domain_axis("X", key=True, default=None)
            yaxis = self.domain_axis("Y", key=True, default=None)
            if xaxis != yaxis and (xaxis,) in comp and (yaxis,) in comp:
                del comp[(xaxis,)]
                del comp[(yaxis,)]
                weights_axes.discard(xaxis)
                weights_axes.discard(yaxis)
                if not self._weights_measure(
                    "area", comp, weights_axes, methods=methods
                ):
                    self._weights_area_XY(
                        comp,
                        weights_axes,
                        measure=measure,
                        radius=radius,
                        methods=methods,
                    )

        if not methods:
            if scale is not None:
                # --------------------------------------------------------
                # Scale the weights so that they are <= scale
                # --------------------------------------------------------
                for key, w in comp.items():
                    comp[key] = self._weights_scale(w, scale)

            for w in comp.values():
                mn = w.minimum()
                if mn <= 0:
                    raise ValueError(
                        "All weights must be positive. "
                        f"Got a weight of {mn}"
                    )

        if components or methods:
            # --------------------------------------------------------
            # Return a dictionary of component weights, which may be
            # empty.
            # --------------------------------------------------------
            components = {}
            for key, v in comp.items():
                key = [field_data_axes.index(axis) for axis in key]
                if not key:
                    continue

                components[tuple(key)] = v

            return components

        # Still here?
        if not comp:
            # --------------------------------------------------------
            # No component weights have been defined so return an
            # equal weights field
            # --------------------------------------------------------
            f = self._weights_field_scalar()
            if data:
                return f.data

            return f

        # ------------------------------------------------------------
        # Still here? Return a weights field which is the outer
        # product of the component weights
        # ------------------------------------------------------------
        pp = sorted(comp.items())
        waxes, wdata = pp.pop(0)
        while pp:
            a, y = pp.pop(0)
            wdata.outerproduct(y, inplace=True)
            waxes += a

        if scale is not None:
            # --------------------------------------------------------
            # Scale the weights so that they are <= scale
            # --------------------------------------------------------
            wdata = self._weights_scale(wdata, scale)

        # ------------------------------------------------------------
        # Reorder the data so that its dimensions are in the same
        # relative order as self
        # ------------------------------------------------------------
        transpose = [
            waxes.index(axis) for axis in self.get_data_axes() if axis in waxes
        ]
        wdata = wdata.transpose(transpose)
        waxes = [waxes[i] for i in transpose]

        # Set cyclicity
        for axis in self.get_data_axes():
            if axis in waxes and self.iscyclic(axis):
                wdata.cyclic(waxes.index(axis), iscyclic=True)

        if data:
            # Insert missing size one dimensions for broadcasting
            for i, axis in enumerate(self.get_data_axes()):
                if axis not in waxes:
                    waxes.insert(i, axis)
                    wdata.insert_dimension(i, inplace=True)

            return wdata

        field = self.copy()
        field.nc_del_variable(None)
        field.del_data()
        field.del_data_axes()

        not_needed_axes = set(field.domain_axes(todict=True)).difference(
            weights_axes
        )

        for key in self.cell_methods(todict=True).copy():
            field.del_construct(key)

        for key in self.field_ancillaries(todict=True).copy():
            field.del_construct(key)

        for key in field.coordinate_references(todict=True).copy():
            if field.coordinate_reference_domain_axes(key).intersection(
                not_needed_axes
            ):
                field.del_coordinate_reference(key)

        for key in field.constructs.filter_by_axis(
            *not_needed_axes, axis_mode="or", todict=True
        ):
            field.del_construct(key)

        for key in not_needed_axes:
            field.del_construct(key)

        field.set_data(wdata, axes=waxes, copy=False)
        field.clear_properties()
        field.long_name = "weights"

        return field

    @_inplace_enabled(default=False)
    def digitize(
        self,
        bins,
        upper=False,
        open_ends=False,
        closed_ends=None,
        return_bins=False,
        inplace=False,
    ):
        """Return the indices of the bins to which each value belongs.

        Values (including masked values) that do not belong to any bin
        result in masked values in the output field construct of indices.

        Bins defined by percentiles are easily created with the
        `percentile` method

        *Example*:
          Find the indices for bins defined by the 10th, 50th and 90th
          percentiles:

          >>> bins = f.percentile([0, 10, 50, 90, 100], squeeze=True)
          >>> i = f.digitize(bins, closed_ends=True)

        The output field construct is given a ``long_name`` property, and
        some or all of the following properties that define the bins:

        =====================  ===========================================
        Property               Description
        =====================  ===========================================
        ``bin_count``          An integer giving the number of bins

        ``bin_bounds``         A 1-d vector giving the bin bounds. The
                               first two numbers describe the lower and
                               upper boundaries of the first bin, the
                               second two numbers describe the lower and
                               upper boundaries of the second bin, and so
                               on. The presence of left-unbounded and
                               right-unbounded bins (see the *bins* and
                               *open_ends* parameters) is deduced from the
                               ``bin_count`` property. If the
                               ``bin_bounds`` vector has 2N elements then
                               the ``bin_count`` property will be N+2 if
                               there are left-unbounded and
                               right-unbounded bins, or N if no such bins
                               are present.

        ``bin_interval_type``  A string that specifies the nature of the
                               bin boundaries, i.e. if they are closed or
                               open. For example, if the lower boundary is
                               closed and the upper boundary is open
                               (which is the case when the *upper*
                               parameter is False) then
                               ``bin_interval_type`` will have the value
                               ``'lower: closed upper: open'``.

        ``bin_units``          A string giving the units of the bin
                               boundary values (e.g. ``'Kelvin'``). If the
                               *bins* parameter is a `Data` object with
                               units then these are used to set this
                               property, otherwise the field construct's
                               units are used.

        ``bin_calendar``       A string giving the calendar of reference
                               date-time units for the bin boundary values
                               (e.g. ``'noleap'``). If the units are not
                               reference date-time units this property
                               will be omitted. If the calendar is the CF
                               default calendar, then this property may be
                               omitted. If the *bins* parameter is a
                               `Data` object with a calendar then this is
                               used to set this property, otherwise the
                               field construct's calendar is used.

        ``bin_standard_name``  A string giving the standard name of the
                               bin boundaries
                               (e.g. ``'air_temperature'``). If there is
                               no standard name then this property will be
                               omitted.

        ``bin_long_name``      A string giving the long name of the bin
                               boundaries (e.g. ``'Air Temperature'``). If
                               there is no long name, or the
                               ``bin_standard_name`` is present, then this
                               property will be omitted.
        =====================  ===========================================

        Of these properties, the ``bin_count`` and ``bin_bounds`` are
        guaranteed to be output, with the others being dependent on the
        available metadata.

        .. versionadded:: 3.0.2

        .. seealso:: `bin`, `histogram`, `percentile`

        :Parameters:

            bins: array_like
                The bin boundaries. One of:

                * An integer

                  Create this many equally sized, contiguous bins spanning
                  the range of the data. I.e. the smallest bin boundary is
                  the minimum of the data and the largest bin boundary is
                  the maximum of the data. In order to guarantee that each
                  data value lies inside a bin, the *closed_ends*
                  parameter is assumed to be True.

                * A 1-d array

                  When sorted into a monotonically increasing sequence,
                  each boundary, with the exception of the two end
                  boundaries, counts as the upper boundary of one bin and
                  the lower boundary of next. If the *open_ends* parameter
                  is True then the lowest lower bin boundary also defines
                  a left-unbounded (i.e. not bounded below) bin, and the
                  largest upper bin boundary also defines a
                  right-unbounded (i.e. not bounded above) bin.

                * A 2-d array

                  The second dimension, that must have size 2, contains
                  the lower and upper boundaries of each bin. The bins to
                  not have to be contiguous, but must not overlap. If the
                  *open_ends* parameter is True then the lowest lower bin
                  boundary also defines a left-unbounded (i.e. not bounded
                  below) bin, and the largest upper bin boundary also
                  defines a right-unbounded (i.e. not bounded above) bin.

            upper: `bool`, optional
                If True then each bin includes its upper bound but not its
                lower bound. By default the opposite is applied, i.e. each
                bin includes its lower bound but not its upper bound.

            open_ends: `bool`, optional
                If True then create left-unbounded (i.e. not bounded
                below) and right-unbounded (i.e. not bounded above) bins
                from the lowest lower bin boundary and largest upper bin
                boundary respectively. By default these bins are not
                created

            closed_ends: `bool`, optional
                If True then extend the most extreme open boundary by a
                small amount so that its bin includes values that are
                equal to the unadjusted boundary value. This is done by
                multiplying it by ``1.0 - epsilon`` or ``1.0 + epsilon``,
                whichever extends the boundary in the appropriate
                direction, where ``epsilon`` is the smallest positive
                64-bit float such that ``1.0 + epsilson != 1.0``. I.e. if
                *upper* is False then the largest upper bin boundary is
                made slightly larger and if *upper* is True then the
                lowest lower bin boundary is made slightly lower.

                By default *closed_ends* is assumed to be True if *bins*
                is a scalar and False otherwise.

            return_bins: `bool`, optional
                If True then also return the bins in their 2-d form.

            {{inplace: `bool`, optional}}

        :Returns:

            `Field` or `None`, [`Data`]
                The field construct containing indices of the bins to
                which each value belongs, or `None` if the operation was
                in-place.

                If *return_bins* is True then also return the bins in
                their 2-d form.

        **Examples**

        >>> f = cf.example_field(0)
        >>> f
        <CF Field: specific_humidity(latitude(5), longitude(8)) 0.001 1>
        >>> f.properties()
        {'Conventions': 'CF-1.7',
         'standard_name': 'specific_humidity',
         'units': '0.001 1'}
        >>> print(f.array)
        [[  7.  34.   3.  14.  18.  37.  24.  29.]
         [ 23.  36.  45.  62.  46.  73.   6.  66.]
         [110. 131. 124. 146.  87. 103.  57.  11.]
         [ 29.  59.  39.  70.  58.  72.   9.  17.]
         [  6.  36.  19.  35.  18.  37.  34.  13.]]
        >>> g = f.digitize([0, 50, 100, 150])
        >>> g
        <CF Field: long_name=Bin index to which each 'specific_humidity' value belongs(latitude(5), longitude(8))>
        >>> print(g.array)
        [[0 0 0 0 0 0 0 0]
         [0 0 0 1 0 1 0 1]
         [2 2 2 2 1 2 1 0]
         [0 1 0 1 1 1 0 0]
         [0 0 0 0 0 0 0 0]]
        >>> g.properties()
        {'Conventions': 'CF-1.7',
         'long_name': "Bin index to which each 'specific_humidity' value belongs",
         'bin_bounds': array([  0,  50,  50, 100, 100, 150]),
         'bin_count': 3,
         'bin_interval_type': 'lower: closed upper: open',
         'bin_standard_name': 'specific_humidity',
         'bin_units': '0.001 1'}

        >>> g = f.digitize([[10, 20], [40, 60], [100, 140]])
        >>> print(g.array)
        [[-- -- --  0  0 -- -- --]
         [-- --  1 --  1 -- -- --]
         [ 2  2  2 -- --  2  1  0]
         [--  1 -- --  1 -- --  0]
         [-- --  0 --  0 -- --  0]]
        >>> g.properties()
        {'Conventions': 'CF-1.7',
         'long_name': "Bin index to which each 'specific_humidity' value belongs",
         'bin_bounds': array([ 10,  20,  40,  60, 100, 140]),
         'bin_count': 3,
         'bin_interval_type': 'lower: closed upper: open',
         'bin_standard_name': 'specific_humidity',
         'bin_units': '0.001 1'}

        >>> g = f.digitize([[10, 20], [40, 60], [100, 140]], open_ends=True)
        >>> print(g.array)
        [[ 0 --  0  1  1 -- -- --]
         [-- --  2 --  2 --  0 --]
         [ 3  3  3 -- --  3  2  1]
         [--  2 -- --  2 --  0  1]
         [ 0 --  1 --  1 -- --  1]]
        >>> g.properties()
        {'Conventions': 'CF-1.7',
         'long_name': "Bin index to which each 'specific_humidity' value belongs",
         'bin_bounds': array([ 10,  20,  40,  60, 100, 140]),
         'bin_count': 5,
         'bin_interval_type': 'lower: closed upper: open',
         'bin_standard_name': 'specific_humidity',
         'bin_units': '0.001 1'}

        >>> g = f.digitize([2, 6, 45, 100], upper=True)
        >>> g
        <CF Field: long_name=Bin index to which each 'specific_humidity' value belongs(latitude(5), longitude(8))>
        >>> print(g.array)
        [[ 1  1  0  1  1  1  1  1]
         [ 1  1  1  2  2  2  0  2]
         [-- -- -- --  2 --  2  1]
         [ 1  2  1  2  2  2  1  1]
         [ 0  1  1  1  1  1  1  1]]
        >>> g.properties()
        {'Conventions': 'CF-1.7',
         'long_name': "Bin index to which each 'specific_humidity' value belongs",
         'bin_bounds': array([  2,   6,   6,  45,  45, 100]),
         'bin_count': 3,
         'bin_interval_type': 'lower: open upper: closed',
         'bin_standard_name': 'specific_humidity',
         'bin_units': '0.001 1'}

        >>> g, bins = f.digitize(10, return_bins=True)
        >>> bins
        <CF Data(10, 2): [[3.0, ..., 146.00000000000003]] 0.001 1>
        >>> g, bins = f.digitize(10, upper=True, return_bins=True)
        <CF Data(10, 2): [[2.999999999999999, ..., 146.0]] 0.001 1>
        >>> print(g.array)
        [[0 2 0 0 1 2 1 1]
         [1 2 2 4 3 4 0 4]
         [7 8 8 9 5 6 3 0]
         [1 3 2 4 3 4 0 0]
         [0 2 1 2 1 2 2 0]]

        >>> f[1, [2, 5]] = cf.masked
        >>> print(f.array)
        [[  7.  34.   3.  14.  18.  37.  24.  29.]
         [ 23.  36.   --  62.  46.   --   6.  66.]
         [110. 131. 124. 146.  87. 103.  57.  11.]
         [ 29.  59.  39.  70.  58.  72.   9.  17.]
         [  6.  36.  19.  35.  18.  37.  34.  13.]]
        >>> g = f.digitize(10)
        >>> print(g.array)
        [[ 0  2  0  0  1  2  1  1]
         [ 1  2 --  4  3 --  0  4]
         [ 7  8  8  9  5  6  3  0]
         [ 1  3  2  4  3  4  0  0]
         [ 0  2  1  2  1  2  2  0]]
        >>> g.properties()
        {'Conventions': 'CF-1.7',
         'long_name': "Bin index to which each 'specific_humidity' value belongs",
         'bin_bounds': array([  3. ,  17.3,  17.3,  31.6,  31.6,  45.9,  45.9,  60.2,
                60.2,  74.5,  74.5,  88.8,  88.8, 103.1, 103.1, 117.4, 117.4, 131.7,
                131.7, 146. ]),
         'bin_count': 10,
         'bin_interval_type': 'lower: closed upper: open',
         'bin_standard_name': 'specific_humidity',
         'bin_units': '0.001 1'}

        """
        f = _inplace_enabled_define_and_cleanup(self)

        new_data, bins = self.data.digitize(
            bins,
            upper=upper,
            open_ends=open_ends,
            closed_ends=closed_ends,
            return_bins=True,
        )
        units = new_data.Units

        f.set_data(new_data, set_axes=False, copy=False)
        f.override_units(units, inplace=True)

        # ------------------------------------------------------------
        # Set properties
        # ------------------------------------------------------------
        f.set_property(
            "long_name",
            f"Bin index to which each {self.identity()!r} value belongs",
            copy=False,
        )

        f.set_property("bin_bounds", bins.array.flatten(), copy=False)

        bin_count = bins.shape[0]
        if open_ends:
            bin_count += 2

        f.set_property("bin_count", bin_count, copy=False)

        if upper:
            bin_interval_type = "lower: open upper: closed"
        else:
            bin_interval_type = "lower: closed upper: open"

        f.set_property("bin_interval_type", bin_interval_type, copy=False)

        standard_name = f.del_property("standard_name", None)
        if standard_name is not None:
            f.set_property("bin_standard_name", standard_name, copy=False)
        else:
            long_name = f.del_property("long_name", None)
            if long_name is not None:
                f.set_property("bin_long_name", long_name, copy=False)

        bin_units = bins.Units
        units = getattr(bin_units, "units", None)
        if units is not None:
            f.set_property("bin_units", units, copy=False)

        calendar = getattr(bin_units, "calendar", None)
        if calendar is not None:
            f.set_property("bin_calendar", calendar, copy=False)

        if return_bins:
            return f, bins

        return f

    @_manage_log_level_via_verbosity
    def bin(
        self,
        method,
        digitized,
        weights=None,
        measure=False,
        scale=None,
        mtol=1,
        ddof=1,
        radius="earth",
        great_circle=False,
        return_indices=False,
        verbose=None,
    ):
        """Collapse the data values that lie in N-dimensional bins.

        The data values of the field construct are binned according to how
        they correspond to the N-dimensional histogram bins of another set
        of variables (see `cf.histogram` for details), and each bin of
        values is collapsed with one of the collapse methods allowed by
        the *method* parameter.

        The number of dimensions of the output binned data is equal to the
        number of field constructs provided by the *digitized*
        argument. Each such field construct defines a sequence of bins and
        provides indices to the bins that each value of another field
        construct belongs. There is no upper limit to the number of
        dimensions of the output binned data.

        The output bins are defined by the exterior product of the
        one-dimensional bins of each digitized field construct. For
        example, if only one digitized field construct is provided then
        the output bins simply comprise its one-dimensional bins; if there
        are two digitized field constructs then the output bins comprise
        the two-dimensional matrix formed by all possible combinations of
        the two sets of one-dimensional bins; etc.

        An output value for a bin is formed by collapsing (using the
        method given by the *method* parameter) the elements of the data
        for which the corresponding locations in the digitized field
        constructs, taken together, index that bin. Note that it may be
        the case that not all output bins are indexed by the digitized
        field constructs, and for these bins missing data is returned.

        The returned field construct will have a domain axis construct for
        each dimension of the output bins, with a corresponding dimension
        coordinate construct that defines the bin boundaries.

        .. versionadded:: 3.0.2

        .. seealso:: `collapse`, `digitize`, `weights`, `cf.histogram`

        :Parameters:

            method: `str`
                The collapse method used to combine values that map to
                each cell of the output field construct. The following
                methods are available (see
                https://ncas-cms.github.io/cf-python/analysis.html#collapse-methods
                for precise definitions):

                ============================  ============================  ========
                *method*                      Description                   Weighted
                ============================  ============================  ========
                ``'maximum'``                 The maximum of the values.    Never

                ``'minimum'``                 The minimum of the values.    Never

                ``'maximum_absolute_value'``  The maximum of the absolute   Never
                                              values.

                ``'minimum_absolute_value'``  The minimum of the absolute   Never
                                              values.

                ``'mid_range'``               The average of the maximum    Never
                                              and the minimum of the
                                              values.

                ``'range'``                   The absolute difference       Never
                                              between the maximum and the
                                              minimum of the values.

                ``'median'``                  The median of the values.     Never

                ``'sum'``                     The sum of the values.        Never

                ``'sum_of_squares'``          The sum of the squares of     Never
                                              values.

                ``'sample_size'``             The sample size, i.e. the     Never
                                              number of non-missing
                                              values.

                ``'sum_of_weights'``          The sum of weights, as        Never
                                              would be used for other
                                              calculations.

                ``'sum_of_weights2'``         The sum of squares of         Never
                                              weights, as would be used
                                              for other calculations.

                ``'mean'``                    The weighted or unweighted    May be
                                              mean of the values.

                ``'mean_absolute_value'``     The mean of the absolute      May be
                                              values.

                ``'mean_of_upper_decile'``    The mean of the upper group   May be
                                              of data values defined by
                                              the upper tenth of their
                                              distribution.

                ``'variance'``                The weighted or unweighted    May be
                                              variance of the values, with
                                              a given number of degrees of
                                              freedom.

                ``'standard_deviation'``      The square root of the        May be
                                              weighted or unweighted
                                              variance.

                ``'root_mean_square'``        The square root of the        May be
                                              weighted or unweighted mean
                                              of the squares of the
                                              values.

                ``'integral'``                The integral of values.       Always
                ============================  ============================  ========

                * Collapse methods that are "Never" weighted ignore the
                  *weights* parameter, even if it is set.

                * Collapse methods that "May be" weighted will only be
                  weighted if the *weights* parameter is set.

                * Collapse methods that are "Always" weighted require the
                  *weights* parameter to be set.

            digitized: (sequence of) `Field`
                One or more field constructs that contain digitized data
                with corresponding metadata, as would be output by
                `cf.Field.digitize`. Each field construct contains indices
                to the one-dimensional bins to which each value of an
                original field construct belongs; and there must be
                ``bin_count`` and ``bin_bounds`` properties as defined by
                the `digitize` method (and any of the extra properties
                defined by that method are also recommended).

                The bins defined by the ``bin_count`` and ``bin_bounds``
                properties are used to create a dimension coordinate
                construct for the output field construct.

                Each digitized field construct must be transformable so
                that it is broadcastable to the input field construct's
                data. This is done by using the metadata constructs of the
                to create a mapping of physically compatible dimensions
                between the fields, and then manipulating the dimensions
                of the digitized field construct's data to ensure that
                broadcasting can occur.

            weights: optional
                Specify the weights for the collapse calculations. The
                weights are those that would be returned by this call of
                the field construct's `~cf.Field.weights` method:
                ``f.weights(weights, measure=measure, scale=scale,
                radius=radius, great_circle=great_circle,
                components=True)``. See the *measure, *scale*, *radius*
                and *great_circle* parameters and `cf.Field.weights` for
                details.

                .. note:: By default *weights* is `None`, resulting in
                          **unweighted calculations**.

                .. note:: Setting *weights* to `True` is generally a good
                          way to ensure that all collapses are
                          appropriately weighted according to the field
                          construct's metadata. In this case, if it is not
                          possible to create weights for any axis then an
                          exception will be raised.

                          However, care needs to be taken if *weights* is
                          `True` when cell volume weights are desired. The
                          volume weights will be taken from a "volume"
                          cell measure construct if one exists, otherwise
                          the cell volumes will be calculated as being
                          proportional to the sizes of one-dimensional
                          vertical coordinate cells. In the latter case
                          **if the vertical dimension coordinates do not
                          define the actual height or depth thickness of
                          every cell in the domain then the weights will
                          be incorrect**.

                If *weights* is the boolean `True` then weights are
                calculated for all of the domain axis constructs.

                *Parameter example:*
                  To specify weights based on the field construct's
                  metadata for all axes use ``weights=True``.

                *Parameter example:*
                  To specify weights based on cell areas, leaving all
                  other axes unweighted, use ``weights='area'``.

                *Parameter example:*
                  To specify weights based on cell areas and linearly in
                  time, leaving all other axes unweighted, you could set
                  ``weights=('area', 'T')``.

            measure: `bool`, optional
                Create weights, as defined by the *weights* parameter,
                which are cell measures, i.e. which describe actual cell
                sizes (e.g. cell areas) with appropriate units
                (e.g. metres squared). By default the weights are scaled
                to lie between 0 and 1 and have arbitrary units (see the
                *scale* parameter).

                Cell measures can be created for any combination of
                axes. For example, cell measures for a time axis are the
                time span for each cell with canonical units of seconds;
                cell measures for the combination of four axes
                representing time and three dimensional space could have
                canonical units of metres cubed seconds.

                When collapsing with the ``'integral'`` method, *measure*
                must be True, and the units of the weights are
                incorporated into the units of the returned field
                construct.

                .. note:: Specifying cell volume weights via
                          ``weights=['X', 'Y', 'Z']`` or
                          ``weights=['area', 'Z']`` (or other equivalents)
                          will produce **an incorrect result if the
                          vertical dimension coordinates do not define the
                          actual height or depth thickness of every cell
                          in the domain**. In this case,
                          ``weights='volume'`` should be used instead,
                          which requires the field construct to have a
                          "volume" cell measure construct.

                          If ``weights=True`` then care also needs to be
                          taken, as a "volume" cell measure construct will
                          be used if present, otherwise the cell volumes
                          will be calculated using the size of the
                          vertical coordinate cells.

            scale: number, optional
                If set to a positive number then scale the weights, as
                defined by the *weights* parameter, so that they are less
                than or equal to that number. By default the weights are
                scaled to lie between 0 and 1 (i.e.  *scale* is 1).

                *Parameter example:*
                  To scale all weights so that they lie between 0 and 0.5:
                  ``scale=0.5``.

            mtol: number, optional
                Set the fraction of input data elements which is allowed
                to contain missing data when contributing to an individual
                output data element. Where this fraction exceeds *mtol*,
                missing data is returned. The default is 1, meaning that a
                missing datum in the output array occurs when its
                contributing input array elements are all missing data. A
                value of 0 means that a missing datum in the output array
                occurs whenever any of its contributing input array
                elements are missing data. Any intermediate value is
                permitted.

                *Parameter example:*
                  To ensure that an output array element is a missing
                  datum if more than 25% of its input array elements are
                  missing data: ``mtol=0.25``.

            ddof: number, optional
                The delta degrees of freedom in the calculation of a
                standard deviation or variance. The number of degrees of
                freedom used in the calculation is (N-*ddof*) where N
                represents the number of non-missing elements contributing
                to the calculation. By default *ddof* is 1, meaning the
                standard deviation and variance of the population is
                estimated according to the usual formula with (N-1) in the
                denominator to avoid the bias caused by the use of the
                sample mean (Bessel's correction).

            radius: optional
                Specify the radius used for calculating the areas of
                cells defined in spherical polar coordinates. The
                radius is that which would be returned by this call of
                the field construct's `~cf.Field.radius` method:
                ``f.radius(radius)``. See the `cf.Field.radius` for
                details.

                By default *radius* is ``'earth'`` which means that if
                and only if the radius can not found from the datums
                of any coordinate reference constructs, then the
                default radius taken as 6371229 metres.

            great_circle: `bool`, optional
                If True then allow, if required, the derivation of i) area
                weights from polygon geometry cells by assuming that each
                cell part is a spherical polygon composed of great circle
                segments; and ii) and the derivation of line-length
                weights from line geometry cells by assuming that each
                line part is composed of great circle segments.

                .. versionadded:: 3.2.0

            {{verbose: `int` or `str` or `None`, optional}}

        :Returns:

            `Field`
                The field construct containing the binned values.

        **Examples**

        Find the range of values that lie in each bin:

        >>> print(q)
        Field: specific_humidity (ncvar%q)
        ----------------------------------
        Data            : specific_humidity(latitude(5), longitude(8)) 0.001 1
        Cell methods    : area: mean
        Dimension coords: latitude(5) = [-75.0, ..., 75.0] degrees_north
                        : longitude(8) = [22.5, ..., 337.5] degrees_east
                        : time(1) = [2019-01-01 00:00:00]
        >>> print(q.array)
        [[  7.  34.   3.  14.  18.  37.  24.  29.]
         [ 23.  36.  45.  62.  46.  73.   6.  66.]
         [110. 131. 124. 146.  87. 103.  57.  11.]
         [ 29.  59.  39.  70.  58.  72.   9.  17.]
         [  6.  36.  19.  35.  18.  37.  34.  13.]]
        >>> indices = q.digitize(10)
        >>> b = q.bin('range', digitized=indices)
        >>> print(b)
        Field: specific_humidity
        ------------------------
        Data            : specific_humidity(specific_humidity(10)) 0.001 1
        Cell methods    : latitude: longitude: range
        Dimension coords: specific_humidity(10) = [10.15, ..., 138.85000000000002] 0.001 1
        >>> print(b.array)
        [14. 11. 11. 13. 11.  0.  0.  0.  7.  0.]

        Find various metrics describing how
        ``tendency_of_sea_water_potential_temperature_expressed_as_heat_content``
        data varies with ``sea_water_potential_temperature`` and
        ``sea_water_salinity``:

        >>> t
        Field: sea_water_potential_temperature (ncvar%sea_water_potential_temperature)
        ------------------------------------------------------------------------------
        Data            : sea_water_potential_temperature(time(1), depth(1), latitude(5), longitude(8)) K
        Cell methods    : area: mean time(1): mean
        Dimension coords: time(1) = [2290-06-01 00:00:00] 360_day
                        : depth(1) = [3961.89990234375] m
                        : latitude(5) = [-1.875, ..., 3.125] degrees_north
                        : longitude(8) = [75.0, ..., 83.75] degrees_east
        Auxiliary coords: model_level_number(depth(1)) = [18]
        >>> s
        Field: sea_water_salinity (ncvar%sea_water_salinity)
        ----------------------------------------------------
        Data            : sea_water_salinity(time(1), depth(1), latitude(5), longitude(8)) psu
        Cell methods    : area: mean time(1): mean
        Dimension coords: time(1) = [2290-06-01 00:00:00] 360_day
                        : depth(1) = [3961.89990234375] m
                        : latitude(5) = [-1.875, ..., 3.125] degrees_north
                        : longitude(8) = [75.0, ..., 83.75] degrees_east
        Auxiliary coords: model_level_number(depth(1)) = [18]
        >>> x
        Field: tendency_of_sea_water_potential_temperature_expressed_as_heat_content (ncvar%tend)
        -----------------------------------------------------------------------------------------
        Data            : tendency_of_sea_water_potential_temperature_expressed_as_heat_content(time(1), depth(1), latitude(5), longitude(8)) W m-2
        Cell methods    : area: mean time(1): mean
        Dimension coords: time(1) = [2290-06-01 00:00:00] 360_day
                        : depth(1) = [3961.89990234375] m
                        : latitude(5) = [-1.875, ..., 3.125] degrees_north
                        : longitude(8) = [75.0, ..., 83.75] degrees_east
        Auxiliary coords: model_level_number(depth(1)) = [18]
        >>> print(x.array)
        [[[[-209.72  340.86   94.75  154.21   38.54 -262.75  158.22  154.58]
           [ 311.67  245.91 -168.16   47.61 -219.66 -270.33  226.1    52.0 ]
           [     -- -112.34  271.67  189.22    9.92  232.39  221.17  206.0 ]
           [     --      --  -92.31 -285.57  161.55  195.89 -258.29    8.35]
           [     --      --   -7.82 -299.79  342.32 -169.38  254.5   -75.4 ]]]]

        >>> t_indices = t.digitize(6)
        >>> s_indices = s.digitize(4)

        >>> n = x.bin('sample_size', [t_indices, s_indices])
        >>> print(n)
        Field: number_of_observations
        -----------------------------
        Data            : number_of_observations(sea_water_salinity(4), sea_water_potential_temperature(6)) 1
        Cell methods    : latitude: longitude: point
        Dimension coords: sea_water_salinity(4) = [6.3054151982069016, ..., 39.09366758167744] psu
                        : sea_water_potential_temperature(6) = [278.1569468180338, ..., 303.18466695149743] K
        >>> print(n.array)
        [[ 1  2 2  2 --  2]
         [ 2  1 3  3  3  2]
         [-- -- 3 --  1 --]
         [ 1 -- 1  3  2  1]]

        >>> m = x.bin('mean', [t_indices, s_indices], weights=['X', 'Y', 'Z', 'T'])
        >>> print(m)
        Field: tendency_of_sea_water_potential_temperature_expressed_as_heat_content
        ----------------------------------------------------------------------------
        Data            : tendency_of_sea_water_potential_temperature_expressed_as_heat_content(sea_water_salinity(4), sea_water_potential_temperature(6)) W m-2
        Cell methods    : latitude: longitude: mean
        Dimension coords: sea_water_salinity(4) = [6.3054151982069016, ..., 39.09366758167744] psu
                        : sea_water_potential_temperature(6) = [278.1569468180338, ..., 303.18466695149743] K
        >>> print(m.array)
        [[ 189.22 131.36    6.75 -41.61     --  100.04]
         [-116.73 232.38   -4.82 180.47 134.25 -189.55]
         [     --     --  180.69     --  47.61      --]
         [158.22      -- -262.75  64.12 -51.83 -219.66]]

        >>> i = x.bin(
        ...         'integral', [t_indices, s_indices],
        ...         weights=['X', 'Y', 'Z', 'T'], measure=True
        ...     )
        >>> print(i)
        Field: long_name=integral of tendency_of_sea_water_potential_temperature_expressed_as_heat_content
        --------------------------------------------------------------------------------------------------
        Data            : long_name=integral of tendency_of_sea_water_potential_temperature_expressed_as_heat_content(sea_water_salinity(4), sea_water_potential_temperature(6)) 86400 m3.kg.s-2
        Cell methods    : latitude: longitude: sum
        Dimension coords: sea_water_salinity(4) = [6.3054151982069016, ..., 39.09366758167744] psu
                        : sea_water_potential_temperature(6) = [278.1569468180338, ..., 303.18466695149743] K
        >>> print(i.array)
        [[ 3655558758400.0 5070927691776.0   260864491520.0 -1605439586304.0               --  3863717609472.0]
         [-4509735059456.0 4489564127232.0  -280126521344.0 10454746267648.0  7777254113280.0 -7317268463616.0]
         [              --              -- 10470463373312.0               --   919782031360.0               --]
         [ 3055211773952.0              -- -5073676009472.0  3715958833152.0 -2000787079168.0 -4243632160768.0]]

        >>> w = x.bin('sum_of_weights', [t_indices, s_indices], weights=['X', 'Y', 'Z', 'T'], measure=True)
        Field: long_name=sum_of_weights of tendency_of_sea_water_potential_temperature_expressed_as_heat_content
        --------------------------------------------------------------------------------------------------------
        Data            : long_name=sum_of_weights of tendency_of_sea_water_potential_temperature_expressed_as_heat_content(sea_water_salinity(4), sea_water_potential_temperature(6)) 86400 m3.s
        Cell methods    : latitude: longitude: sum
        Dimension coords: sea_water_salinity(4) = [7.789749830961227, ..., 36.9842486679554] psu
                        : sea_water_potential_temperature(6) = [274.50717671712243, ..., 302.0188242594401] K
        >>> print(w.array)
        [[19319093248.0 38601412608.0 38628990976.0 38583025664.0            --  38619795456.0]
         [38628990976.0 19319093248.0 57957281792.0 57929699328.0 57929695232.0  38601412608.0]
         [         --              -- 57948086272.0            -- 19319093248.0             --]
         [19309897728.0            -- 19309897728.0 57948086272.0 38601412608.0  19319093248.0]]

        Demonstrate that the integral divided by the sum of the cell
        measures is equal to the mean:

        >>> print(i/w)
        Field:
        -------
        Data            : (sea_water_salinity(4), sea_water_potential_temperature(6)) kg.s-3
        Cell methods    : latitude: longitude: sum
        Dimension coords: sea_water_salinity(4) = [7.789749830961227, ..., 36.9842486679554] psu
                        : sea_water_potential_temperature(6) = [274.50717671712243, ..., 302.0188242594401] K
        >>> (i/w == m).all()
        True

        """
        logger.info(f"    Method: {method}")  # pragma: no cover

        if method == "integral":
            if weights is None:
                raise ValueError(
                    "Must specify weights for 'integral' calculations."
                )

            if not measure:
                raise ValueError(
                    "Must set measure=True for 'integral' calculations."
                )

            if scale is not None:
                raise ValueError(
                    "Can't set scale for 'integral' calculations."
                )

        axes = []
        bin_indices = []
        shape = []
        dims = []
        names = []

        # Initialize the output binned field
        out = type(self)(properties=self.properties())

        # Sort out its identity
        if method == "sample_size":
            out.standard_name = "number_of_observations"
        elif method in (
            "integral",
            "sum_of_squares",
            "sum_of_weights",
            "sum_of_weights2",
        ):
            out.del_property("standard_name", None)

        long_name = self.get_property("long_name", None)
        if long_name is None:
            out.long_name = (
                method + " of " + self.get_property("standard_name", "")
            )
        else:
            out.long_name = method + " of " + long_name

        # ------------------------------------------------------------
        # Create domain axes and dimension coordinates for the output
        # binned field
        # ------------------------------------------------------------
        if isinstance(digitized, self.__class__):
            digitized = (digitized,)

        for f in digitized[::-1]:
            f = self._conform_for_data_broadcasting(f)

            if not self._is_broadcastable(f.shape):
                raise ValueError(
                    f"Conformed digitized field {f!r} construct must have "
                    f"shape broadcastable to {self.shape}."
                )

            bin_bounds = f.get_property("bin_bounds", None)
            bin_count = f.get_property("bin_count", None)
            bin_interval_type = f.get_property("bin_interval_type", None)
            bin_units = f.get_property("bin_units", None)
            bin_calendar = f.get_property("bin_calendar", None)
            bin_standard_name = f.get_property("bin_standard_name", None)
            bin_long_name = f.get_property("bin_long_name", None)

            if bin_count is None:
                raise ValueError(
                    f"Digitized field construct {f!r} must have a 'bin_count' "
                    "property."
                )

            if bin_bounds is None:
                raise ValueError(
                    f"Digitized field construct {f!r} must have a "
                    "'bin_bounds' property."
                )

            if bin_count != len(bin_bounds) / 2:
                raise ValueError(
                    f"Digitized field construct {f!r} bin_count must equal "
                    f"len(bin_bounds)/2. Got bin_count={bin_count}, "
                    f"len(bin_bounds)/2={len(bin_bounds) / 2}"
                )

            # Create dimension coordinate for bins
            dim = DimensionCoordinate()
            if bin_standard_name is not None:
                dim.standard_name = bin_standard_name
            elif bin_long_name is not None:
                dim.long_name = bin_long_name

            if bin_interval_type is not None:
                dim.set_property(
                    "bin_interval_type", bin_interval_type, copy=False
                )

            # Create units for the bins
            units = Units(bin_units, bin_calendar)

            data = Data(
                0.5 * (bin_bounds[1::2] + bin_bounds[0::2]), units=units
            )
            dim.set_data(data=data, copy=False)

            bounds_data = Data(
                numpy_reshape(bin_bounds, (bin_count, 2)), units=units
            )
            dim.set_bounds(self._Bounds(data=bounds_data))

            logger.info(
                f"                    bins     : {dim.identity()} {bounds_data!r}"  # DCH
            )  # pragma: no cover

            # Set domain axis and dimension coordinate for bins
            axis = out.set_construct(self._DomainAxis(dim.size))
            out.set_construct(dim, axes=[axis], copy=False)

            axes.append(axis)
            bin_indices.append(f.data)
            shape.append(dim.size)
            dims.append(dim)
            names.append(dim.identity())

        # ------------------------------------------------------------
        # Initialize the ouput data as a totally masked array
        # ------------------------------------------------------------
        if method == "sample_size":
            dtype = int
        else:
            dtype = self.dtype

        data = Data.masked_all(shape=tuple(shape), dtype=dtype, units=None)
        out.set_data(data, axes=axes, copy=False)
        out.hardmask = False

        c = self.copy()

        # ------------------------------------------------------------
        # Parse the weights
        # ------------------------------------------------------------
        if weights is not None:
            if not measure and scale is None:
                scale = 1.0

            weights = self.weights(
                weights,
                scale=scale,
                measure=measure,
                radius=radius,
                great_circle=great_circle,
                components=True,
            )

        # ------------------------------------------------------------
        # Find the unique multi-dimensional bin indices (TODO: can I
        # LAMA this?)
        # ------------------------------------------------------------
        y = numpy_empty((len(bin_indices), bin_indices[0].size), dtype=int)
        for i, f in enumerate(bin_indices):
            y[i, :] = f.array.flatten()

        unique_indices = numpy_unique(y, axis=1)
        del f
        del y

        # DCH
        logger.info(f"    Weights: {weights}")  # pragma: no cover
        logger.info(
            f"    Number of indexed ({', '.join(names)}) bins: "
            f"{unique_indices.shape[1]}"
        )  # pragma: no cover
        logger.info(
            f"    ({', '.join(names)}) bin indices:"  # DCH
        )  # pragma: no cover

        # Loop round unique collections of bin indices
        for i in zip(*unique_indices):
            logger.info(f"{' '.join(str(i))}")

            b = bin_indices[0] == i[0]
            for a, n in zip(bin_indices[1:], i[1:]):
                b &= a == n

            b.filled(False, inplace=True)

            c.set_data(
                self.data.where(b, None, cf_masked), set_axes=False, copy=False
            )

            result = c.collapse(
                method=method, weights=weights, measure=measure
            ).data
            out.data[i] = result.datum()

        # Set correct units (note: takes them from the last processed
        # "result" variable in the above loop)
        out.override_units(result.Units, inplace=True)
        out.hardmask = True

        # ------------------------------------------------------------
        # Create a cell method (if possible)
        # ------------------------------------------------------------
        standard_names = []
        domain_axes = self.domain_axes(filter_by_size=(ge(2),), todict=True)

        for da_key in domain_axes:
            dim = self.dimension_coordinate(
                filter_by_axis=(da_key,), default=None
            )
            if dim is None:
                continue

            standard_name = dim.get_property("standard_name", None)
            if standard_name is None:
                continue

            standard_names.append(standard_name)

        if len(standard_names) == len(domain_axes):
            cell_method = CellMethod(
                axes=sorted(standard_names),
                method=_collapse_cell_methods[method],
            )
            out.set_construct(cell_method, copy=False)

        return out

    def has_construct(self, *identity, **filter_kwargs):
        """Whether a metadata construct exists.

        .. versionadded:: 3.4.0

        .. seealso:: `construct`, `del_construct`, `get_construct`,
                     `set_construct`

        :Parameters:

            identity, filter_kwargs: optional
                Select the unique construct returned by
                ``f.construct(*identity, **filter_kwargs)``. See
                `construct` for details.

        :Returns:

            `bool`
                `True` if the construct exists, otherwise `False`.

        **Examples**

        >>> f = cf.example_field(0)
        >>> print(f)
        Field: specific_humidity (ncvar%q)
        ----------------------------------
        Data            : specific_humidity(latitude(5), longitude(8)) 1
        Cell methods    : area: mean
        Dimension coords: latitude(5) = [-75.0, ..., 75.0] degrees_north
                        : longitude(8) = [22.5, ..., 337.5] degrees_east
                        : time(1) = [2019-01-01 00:00:00]

        >>> f.has_construct('T')
        True
        >>> f.has_construct('longitude')
        True
        >>> f.has_construct('Z')
        False

        """
        return (
            self.construct(*identity, default=None, **filter_kwargs)
            is not None
        )

    def histogram(self, digitized):
        """Return a multi-dimensional histogram of the data.

        **This has moved to** `cf.histogram`

        """
        raise RuntimeError("Use cf.histogram instead.")

    @_deprecated_kwarg_check("i")
    @_manage_log_level_via_verbosity
    def collapse(
        self,
        method,
        axes=None,
        squeeze=False,
        mtol=1,
        weights=None,
        ddof=1,
        a=None,
        inplace=False,
        group=None,
        regroup=False,
        within_days=None,
        within_years=None,
        over_days=None,
        over_years=None,
        coordinate=None,
        group_by=None,
        group_span=None,
        group_contiguous=1,
        measure=False,
        scale=None,
        radius="earth",
        great_circle=False,
        verbose=None,
        _create_zero_size_cell_bounds=False,
        _update_cell_methods=True,
        i=False,
        _debug=False,
        **kwargs,
    ):
        """Collapse axes of the field.

        Collapsing one or more dimensions reduces their size and replaces
        the data along those axes with representative statistical
        values. The result is a new field construct with consistent
        metadata for the collapsed values.

        By default all axes with size greater than 1 are collapsed
        completely (i.e. to size 1) with a given collapse method.

        *Example:*
          Find the minimum of the entire data:

          >>> b = a.collapse('minimum')

        The collapse can also be applied to any subset of the field
        construct's dimensions. In this case, the domain axis and
        coordinate constructs for the non-collapsed dimensions remain the
        same. This is implemented either with the axes keyword, or with a
        CF-netCDF cell methods-like syntax for describing both the
        collapse dimensions and the collapse method in a single
        string. The latter syntax uses construct identities instead of
        netCDF dimension names to identify the collapse axes.

        Statistics may be created to represent variation over one
        dimension or a combination of dimensions.

        *Example:*
           Two equivalent techniques for creating a field construct of
           temporal maxima at each horizontal location:

           >>> b = a.collapse('maximum', axes='T')
           >>> b = a.collapse('T: maximum')

        *Example:*
          Find the horizontal maximum, with two equivalent techniques.

          >>> b = a.collapse('maximum', axes=['X', 'Y'])
          >>> b = a.collapse('X: Y: maximum')

        Variation over horizontal area may also be specified by the
        special identity 'area'. This may be used for any horizontal
        coordinate reference system.

        *Example:*
          Find the horizontal maximum using the special identity 'area':

          >>> b = a.collapse('area: maximum')


        **Collapse methods**

        The following collapse methods are available (see
        https://ncas-cms.github.io/cf-python/analysis.html#collapse-methods
        for precise definitions):

        ============================  ============================
        Method                        Description
        ============================  ============================
        ``'maximum'``                 The maximum of the values.

        ``'minimum'``                 The minimum of the values.

        ``'maximum_absolute_value'``  The maximum of the absolute
                                      values.

        ``'minimum_absolute_value'``  The minimum of the absolute
                                      values.

        ``'mid_range'``               The average of the maximum
                                      and the minimum of the
                                      values.

        ``'median'``                  The median of the values.

        ``'range'``                   The absolute difference
                                      between the maximum and the
                                      minimum of the values.

        ``'sum'``                     The sum of the values.

        ``'sum_of_squares'``          The sum of the squares of
                                      values.

        ``'sample_size'``             The sample size, i.e. the
                                      number of non-missing
                                      values.

        ``'sum_of_weights'``          The sum of weights, as
                                      would be used for other
                                      calculations.

        ``'sum_of_weights2'``         The sum of squares of
                                      weights, as would be used
                                      for other calculations.

        ``'mean'``                    The weighted or unweighted
                                      mean of the values.

        ``'mean_absolute_value'``     The mean of the absolute
                                      values.

        ``'mean_of_upper_decile'``    The mean of the upper group
                                      of data values defined by
                                      the upper tenth of their
                                      distribution.

        ``'variance'``                The weighted or unweighted
                                      variance of the values, with
                                      a given number of degrees of
                                      freedom.

        ``'standard_deviation'``      The weighted or unweighted
                                      standard deviation of the
                                      values, with a given number
                                      of degrees of freedom.

        ``'root_mean_square'``        The square root of the
                                      weighted or unweighted mean
                                      of the squares of the
                                      values.

        ``'integral'``                The integral of values.
        ============================  ============================


        **Data type and missing data**

        In all collapses, missing data array elements are accounted for in
        the calculation.

        Any collapse method that involves a calculation (such as
        calculating a mean), as opposed to just selecting a value (such as
        finding a maximum), will return a field containing double
        precision floating point numbers. If this is not desired then the
        data type can be reset after the collapse with the `dtype`
        attribute of the field construct.


        **Collapse weights**

        The calculations of means, standard deviations and variances are,
        by default, **not weighted**. For weights to be incorporated in
        the collapse, the axes to be weighted must be identified with the
        *weights* keyword.

        Weights are either derived from the field construct's metadata
        (such as cell sizes), or may be provided explicitly in the form of
        other field constructs containing data of weights values. In
        either case, the weights actually used are those derived by the
        `weights` method of the field construct with the same weights
        keyword value. Collapsed axes that are not identified by the
        *weights* keyword are unweighted during the collapse operation.

        *Example:*
          Create a weighted time average:

          >>> b = a.collapse('T: mean', weights=True)

        *Example:*
          Calculate the mean over the time and latitude axes, with
          weights only applied to the latitude axis:

          >>> b = a.collapse('T: Y: mean', weights='Y')

        *Example*
          Alternative syntax for specifying area weights:

          >>> b = a.collapse('area: mean', weights=True)

        An alternative technique for specifying weights is to set the
        *weights* keyword to the output of a call to the `weights` method.

        *Example*
          Alternative syntax for specifying weights:

          >>> b = a.collapse('area: mean', weights=a.weights('area'))

        **Multiple collapses**

        Multiple collapses normally require multiple calls to `collapse`:
        one on the original field construct and then one on each interim
        field construct.

        *Example:*
          Calculate the temporal maximum of the weighted areal means
          using two independent calls:

          >>> b = a.collapse('area: mean', weights=True).collapse('T: maximum')

        If preferred, multiple collapses may be carried out in a single
        call by using the CF-netCDF cell methods-like syntax (note that
        the colon (:) is only used after the construct identity that
        specifies each axis, and a space delimits the separate collapses).

        *Example:*
          Calculate the temporal maximum of the weighted areal means in
          a single call, using the cf-netCDF cell methods-like syntax:

          >>> b =a.collapse('area: mean T: maximum', weights=True)


        **Grouped collapses**

        A grouped collapse is one for which as axis is not collapsed
        completely to size 1. Instead the collapse axis is partitioned
        into non-overlapping groups and each group is collapsed to size
        1. The resulting axis will generally have more than one
        element. For example, creating 12 annual means from a timeseries
        of 120 months would be a grouped collapse.

        Selected statistics for overlapping groups can be calculated with
        the `moving_window` method.

        The *group* keyword defines the size of the groups. Groups can be
        defined in a variety of ways, including with `Query`,
        `TimeDuration` and `Data` instances.

        An element of the collapse axis can not be a member of more than
        one group, and may be a member of no groups. Elements that are not
        selected by the *group* keyword are excluded from the result.

        *Example:*
          Create annual maxima from a time series, defining a year to
          start on 1st December.

          >>> b = a.collapse('T: maximum', group=cf.Y(month=12))

        *Example:*
          Find the maximum of each group of 6 elements along an axis.

          >>> b = a.collapse('T: maximum', group=6)

        *Example:*
          Create December, January, February maxima from a time series.

          >>> b = a.collapse('T: maximum', group=cf.djf())

        *Example:*
          Create maxima for each 3-month season of a timeseries (DJF, MAM,
          JJA, SON).

          >>> b = a.collapse('T: maximum', group=cf.seasons())

        *Example:*
          Calculate zonal means for the western and eastern hemispheres.

          >>> b = a.collapse('X: mean', group=cf.Data(180, 'degrees'))

        Groups can be further described with the *group_span* parameter
        (to include groups whose actual span is not equal to a given
        value) and the *group_contiguous* parameter (to include
        non-contiguous groups, or any contiguous group containing
        overlapping cells).


        **Climatological statistics**

        Climatological statistics may be derived from corresponding
        portions of the annual cycle in a set of years (e.g. the average
        January temperatures in the climatology of 1961-1990, where the
        values are derived by averaging the 30 Januarys from the separate
        years); or from corresponding portions of the diurnal cycle in a
        set of days (e.g. the average temperatures for each hour in the
        day for May 1997). A diurnal climatology may also be combined with
        a multiannual climatology (e.g. the minimum temperature for each
        hour of the average day in May from a 1961-1990 climatology).

        Calculation requires two or three collapses, depending on the
        quantity being created, all of which are grouped collapses. Each
        collapse method needs to indicate its climatological nature with
        one of the following qualifiers,

        ================  =======================
        Method qualifier  Associated keyword
        ================  =======================
        ``within years``  *within_years*
        ``within days``   *within_days*
        ``over years``    *over_years* (optional)
        ``over days``     *over_days* (optional)
        ================  =======================

        and the associated keyword specifies how the method is to be
        applied.

        *Example*
          Calculate the multiannual average of the seasonal means:

          >>> b = a.collapse('T: mean within years T: mean over years',
          ...                within_years=cf.seasons(), weights=True)

        *Example:*
          Calculate the multiannual variance of the seasonal
          minima. Note that the units of the result have been changed
          from 'K' to 'K2':

          >>> b = a.collapse('T: minimum within years T: variance over years',
          ...                within_years=cf.seasons(), weights=True)

        When collapsing over years, it is assumed by default that each
        portion of the annual cycle is collapsed over all years that are
        present. This is the case in the above two examples. It is
        possible, however, to restrict the years to be included, or group
        them into chunks, with the *over_years* keyword.

        *Example:*
          Calculate the multiannual average of the seasonal means in 5
          year chunks:

          >>> b = a.collapse(
          ...     'T: mean within years T: mean over years', weights=True,
          ...     within_years=cf.seasons(), over_years=cf.Y(5)
          ... )

        *Example:*
          Calculate the multiannual average of the seasonal means,
          restricting the years from 1963 to 1968:

          >>> b = a.collapse(
          ...     'T: mean within years T: mean over years', weights=True,
          ...     within_years=cf.seasons(),
          ...     over_years=cf.year(cf.wi(1963, 1968))
          ... )

        Similarly for collapses over days, it is assumed by default that
        each portion of the diurnal cycle is collapsed over all days that
        are present, But it is possible to restrict the days to be
        included, or group them into chunks, with the *over_days* keyword.

        The calculation can be done with multiple collapse calls, which
        can be useful if the interim stages are needed independently, but
        be aware that the interim field constructs will have
        non-CF-compliant cell method constructs.

        *Example:*
          Calculate the multiannual maximum of the seasonal standard
          deviations with two separate collapse calls:

          >>> b = a.collapse('T: standard_deviation within years',
          ...                within_years=cf.seasons(), weights=True)


        .. versionadded:: 1.0

        .. seealso:: `bin`, `cell_area`, `convolution_filter`,
                     `moving_window`, `radius`, `weights`

        :Parameters:

            method: `str`
                Define the collapse method. All of the axes specified by
                the *axes* parameter are collapsed simultaneously by this
                method. The method is given by one of the following
                strings (see
                https://ncas-cms.github.io/cf-python/analysis.html#collapse-methods
                for precise definitions):

                ============================  ============================  ========
                *method*                      Description                   Weighted
                ============================  ============================  ========
                ``'maximum'``                 The maximum of the values.    Never

                ``'minimum'``                 The minimum of the values.    Never

                ``'maximum_absolute_value'``  The maximum of the absolute   Never
                                              values.

                ``'minimum_absolute_value'``  The minimum of the absolute   Never
                                              values.

                ``'mid_range'``               The average of the maximum    Never
                                              and the minimum of the
                                              values.

                ``'median'``                  The median of the values.     Never

                ``'range'``                   The absolute difference       Never
                                              between the maximum and the
                                              minimum of the values.

                ``'sum'``                     The sum of the values.        Never

                ``'sum_of_squares'``          The sum of the squares of     Never
                                              values.

                ``'sample_size'``             The sample size, i.e. the     Never
                                              number of non-missing
                                              values.

                ``'sum_of_weights'``          The sum of weights, as        Never
                                              would be used for other
                                              calculations.

                ``'sum_of_weights2'``         The sum of squares of         Never
                                              weights, as would be used
                                              for other calculations.

                ``'mean'``                    The weighted or unweighted    May be
                                              mean of the values.

                ``'mean_absolute_value'``     The mean of the absolute      May be
                                              values.

                ``'mean_of_upper_decile'``    The mean of the upper group   May be
                                              of data values defined by
                                              the upper tenth of their
                                              distribution.

                ``'variance'``                The weighted or unweighted    May be
                                              variance of the values, with
                                              a given number of degrees of
                                              freedom.

                ``'standard_deviation'``      The weighted or unweighted    May be
                                              standard deviation of the
                                              values, with a given number
                                              of degrees of freedom.

                ``'root_mean_square'``        The square root of the        May be
                                              weighted or unweighted mean
                                              of the squares of the
                                              values.

                ``'integral'``                The integral of values.       Always
                ============================  ============================  ========

                * Collapse methods that are "Never" weighted ignore the
                  *weights* parameter, even if it is set.

                * Collapse methods that "May be" weighted will only be
                  weighted if the *weights* parameter is set.

                * Collapse methods that are "Always" weighted require the
                  *weights* parameter to be set.

                An alternative form of providing the collapse method is to
                provide a CF cell methods-like string. In this case an
                ordered sequence of collapses may be defined and both the
                collapse methods and their axes are provided. The axes are
                interpreted as for the *axes* parameter, which must not
                also be set. For example:

                >>> g = f.collapse(
                ...     'time: max (interval 1 hr) X: Y: mean dim3: sd')

                is equivalent to:

                >>> g = f.collapse('max', axes='time')
                >>> g = g.collapse('mean', axes=['X', 'Y'])
                >>> g = g.collapse('sd', axes='dim3')

                Climatological collapses are carried out if a *method*
                string contains any of the modifiers ``'within days'``,
                ``'within years'``, ``'over days'`` or ``'over
                years'``. For example, to collapse a time axis into
                multiannual means of calendar monthly minima:

                >>> g = f.collapse(
                ...     'time: minimum within years T: mean over years',
                ...     within_years=cf.M()
                ... )

                which is equivalent to:

                >>> g = f.collapse(
                ...     'time: minimum within years', within_years=cf.M())
                >>> g = g.collapse('mean over years', axes='T')

            axes: (sequence of) `str`, optional
                The axes to be collapsed, defined by those which would be
                selected by passing each given axis description to a call
                of the field construct's `domain_axis` method. For
                example, for a value of ``'X'``, the domain axis construct
                returned by ``f.domain_axis('X')`` is selected. If a
                selected axis has size 1 then it is ignored. By default
                all axes with size greater than 1 are collapsed.

                *Parameter example:*
                  ``axes='X'``

                *Parameter example:*
                  ``axes=['X']``

                *Parameter example:*
                  ``axes=['X', 'Y']``

                *Parameter example:*
                  ``axes=['Z', 'time']``

                If the *axes* parameter has the special value ``'area'``
                then it is assumed that the X and Y axes are intended.

                *Parameter example:*
                  ``axes='area'`` is equivalent to ``axes=['X', 'Y']``.

                *Parameter example:*
                  ``axes=['area', Z']`` is equivalent to ``axes=['X', 'Y',
                  'Z']``.

            weights: optional
                Specify the weights for the collapse axes. The weights
                are, in general, those that would be returned by this call
                of the field construct's `weights` method:
                ``f.weights(weights, axes=axes, measure=measure,
                scale=scale, radius=radius, great_circle=great_circle,
                components=True)``. See the *axes*, *measure*, *scale*,
                *radius* and *great_circle* parameters and
                `cf.Field.weights` for details.

                .. note:: By default *weights* is `None`, resulting in
                          **unweighted calculations**.

                If the alternative form of providing the collapse method
                and axes combined as a CF cell methods-like string via the
                *method* parameter has been used, then the *axes*
                parameter is ignored and the axes are derived from the
                *method* parameter. For example, if *method* is ``'T:
                area: minimum'`` then this defines axes of ``['T',
                'area']``. If *method* specifies multiple collapses,
                e.g. ``'T: minimum area: mean'`` then this implies axes of
                ``'T'`` for the first collapse, and axes of ``'area'`` for
                the second collapse.

                .. note:: Setting *weights* to `True` is generally a good
                          way to ensure that all collapses are
                          appropriately weighted according to the field
                          construct's metadata. In this case, if it is not
                          possible to create weights for any axis then an
                          exception will be raised.

                          However, care needs to be taken if *weights* is
                          `True` when cell volume weights are desired. The
                          volume weights will be taken from a "volume"
                          cell measure construct if one exists, otherwise
                          the cell volumes will be calculated as being
                          proportional to the sizes of one-dimensional
                          vertical coordinate cells. In the latter case
                          **if the vertical dimension coordinates do not
                          define the actual height or depth thickness of
                          every cell in the domain then the weights will
                          be incorrect**.

                *Parameter example:*
                  To specify weights based on the field construct's
                  metadata for all collapse axes use ``weights=True``.

                *Parameter example:*
                  To specify weights based on cell areas use
                  ``weights='area'``.

                *Parameter example:*
                  To specify weights based on cell areas and linearly in
                  time you could set ``weights=('area', 'T')``.

            measure: `bool`, optional
                Create weights which are cell measures, i.e. which
                describe actual cell sizes (e.g. cell area) with
                appropriate units (e.g. metres squared). By default the
                weights are normalized and have arbitrary units.

                Cell measures can be created for any combination of
                axes. For example, cell measures for a time axis are the
                time span for each cell with canonical units of seconds;
                cell measures for the combination of four axes
                representing time and three dimensional space could have
                canonical units of metres cubed seconds.

                When collapsing with the ``'integral'`` method, *measure*
                must be True, and the units of the weights are
                incorporated into the units of the returned field
                construct.

                .. note:: Specifying cell volume weights via
                          ``weights=['X', 'Y', 'Z']`` or
                          ``weights=['area', 'Z']`` (or other equivalents)
                          will produce **an incorrect result if the
                          vertical dimension coordinates do not define the
                          actual height or depth thickness of every cell
                          in the domain**. In this case,
                          ``weights='volume'`` should be used instead,
                          which requires the field construct to have a
                          "volume" cell measure construct.

                          If ``weights=True`` then care also needs to be
                          taken, as a "volume" cell measure construct will
                          be used if present, otherwise the cell volumes
                          will be calculated using the size of the
                          vertical coordinate cells.

                .. versionadded:: 3.0.2

            scale: number, optional
                If set to a positive number then scale the weights so that
                they are less than or equal to that number. By default the
                weights are scaled to lie between 0 and 1 (i.e.  *scale*
                is 1).

                *Parameter example:*
                  To scale all weights so that they lie between 0 and 0.5:
                  ``scale=0.5``.

                .. versionadded:: 3.0.2

            radius: optional
                Specify the radius used for calculating the areas of
                cells defined in spherical polar coordinates. The
                radius is that which would be returned by this call of
                the field construct's `~cf.Field.radius` method:
                ``f.radius(radius)``. See the `cf.Field.radius` for
                details.

                By default *radius* is ``'earth'`` which means that if
                and only if the radius can not found from the datums
                of any coordinate reference constructs, then the
                default radius taken as 6371229 metres.

                .. versionadded:: 3.0.2

            great_circle: `bool`, optional
                If True then allow, if required, the derivation of i) area
                weights from polygon geometry cells by assuming that each
                cell part is a spherical polygon composed of great circle
                segments; and ii) and the derivation of line-length
                weights from line geometry cells by assuming that each
                line part is composed of great circle segments.

                .. versionadded:: 3.2.0

            squeeze: `bool`, optional
                If True then size 1 collapsed axes are removed from the
                output data array. By default the axes which are collapsed
                are retained in the result's data array.

            mtol: number, optional
                Set the fraction of input data elements which is allowed
                to contain missing data when contributing to an individual
                output data element. Where this fraction exceeds *mtol*,
                missing data is returned. The default is 1, meaning that a
                missing datum in the output array occurs when its
                contributing input array elements are all missing data. A
                value of 0 means that a missing datum in the output array
                occurs whenever any of its contributing input array
                elements are missing data. Any intermediate value is
                permitted.

                *Parameter example:*
                  To ensure that an output array element is a missing
                  datum if more than 25% of its input array elements are
                  missing data: ``mtol=0.25``.

            ddof: number, optional
                The delta degrees of freedom in the calculation of a
                standard deviation or variance. The number of degrees of
                freedom used in the calculation is (N-*ddof*) where N
                represents the number of non-missing elements. By default
                *ddof* is 1, meaning the standard deviation and variance
                of the population is estimated according to the usual
                formula with (N-1) in the denominator to avoid the bias
                caused by the use of the sample mean (Bessel's
                correction).

            coordinate: optional
                Specify how the cell coordinate values for collapsed axes
                are placed. This has no effect on the cell bounds for the
                collapsed axes, which always represent the extrema of the
                input coordinates.

                The *coordinate* parameter may be one of:

                ===============  =========================================
                *coordinate*     Description
                ===============  =========================================
                `None`           This is the default.

                                 If the collapse is a climatological time
                                 collapse over years or over days then
                                 assume a value of ``'min'``, otherwise
                                 assume value of ``'mid_range'``.

                ``'mid_range'``  An output coordinate is the mean of
                                 first and last input coordinate bounds
                                 (or the first and last coordinates if
                                 there are no bounds). This is the
                                 default.

                ``'minimum'``    An output coordinate is the minimum of
                                 the input coordinates.

                ``'maximum'``    An output coordinate is the maximum of
                                 the input coordinates.
                ===============  =========================================

                *Parameter example:*
                  ``coordinate='minimum'``

            group: optional
                A grouped collapse is one for which an axis is not
                collapsed completely to size 1. Instead, the collapse axis
                is partitioned into non-overlapping groups and each group
                is collapsed to size 1, independently of the other
                groups. The results of the collapses are concatenated so
                that the output axis has a size equal to the number of
                groups.

                An element of the collapse axis can not be a member of
                more than one group, and may be a member of no
                groups. Elements that are not selected by the *group*
                parameter are excluded from the result.

                The *group* parameter defines how the axis elements are
                partitioned into groups, and may be one of:

                ===============  =========================================
                *group*          Description
                ===============  =========================================
                `Data`           Define groups by coordinate values that
                                 span the given range. The first group
                                 starts at the first coordinate bound of
                                 the first axis element (or its coordinate
                                 if there are no bounds) and spans the
                                 defined group size. Each subsequent
                                 group immediately follows the preceding
                                 one. By default each group contains the
                                 consecutive run of elements whose
                                 coordinate values lie within the group
                                 limits (see the *group_by* parameter).

                                 * By default each element will be in
                                   exactly one group (see the *group_by*,
                                   *group_span* and *group_contiguous*
                                   parameters).

                                 * By default groups may contain different
                                   numbers of elements.

                                 * If no units are specified then the
                                   units of the coordinates are assumed.

                `TimeDuration`   Define groups by a time interval spanned
                                 by the coordinates. The first group
                                 starts at or before the first coordinate
                                 bound of the first axis element (or its
                                 coordinate if there are no bounds) and
                                 spans the defined group size. Each
                                 subsequent group immediately follows the
                                 preceding one. By default each group
                                 contains the consecutive run of elements
                                 whose coordinate values lie within the
                                 group limits (see the *group_by*
                                 parameter).

                                 * By default each element will be in
                                   exactly one group (see the *group_by*,
                                   *group_span* and *group_contiguous*
                                   parameters).

                                 * By default groups may contain different
                                   numbers of elements.

                                 * The start of the first group may be
                                   before the first first axis element,
                                   depending on the offset defined by the
                                   time duration. For example, if
                                   ``group=cf.Y(month=12)`` then the first
                                   group will start on the closest 1st
                                   December to the first axis element.

                `Query`          Define groups from elements whose
                                 coordinates satisfy the query
                                 condition. Multiple groups are created:
                                 one for each maximally consecutive run
                                 within the selected elements.

                                 If a sequence of `Query` is provided then
                                 groups are defined for each query.

                                 * If a coordinate does not satisfy any of
                                   the query conditions then its element
                                   will not be in a group.

                                 * By default groups may contain different
                                   numbers of elements.

                                 * If no units are specified then the
                                   units of the coordinates are assumed.

                                 * If an element is selected by two or
                                   more queries then the latest one in the
                                   sequence defines which group it will be
                                   in.

                `int`            Define groups that contain the given
                                 number of elements. The first group
                                 starts with the first axis element and
                                 spans the defined number of consecutive
                                 elements. Each subsequent group
                                 immediately follows the preceding one.

                                 * By default each group has the defined
                                   number of elements, apart from the last
                                   group which may contain fewer elements
                                   (see the *group_span* parameter).

                `numpy.ndarray`  Define groups by selecting elements that
                                 map to the same value in the `numpy`
                                 array. The array must contain integers
                                 and have the same length as the axis to
                                 be collapsed and its sequence of values
                                 correspond to the axis elements. Each
                                 group contains the elements which
                                 correspond to a common non-negative
                                 integer value in the numpy array. Upon
                                 output, the collapsed axis is arranged in
                                 order of increasing group number. See the
                                 *regroup* parameter, which allows the
                                 creation of such a `numpy.array` for a
                                 given grouped collapse.

                                 * The groups do not have to be in runs of
                                   consecutive elements; they may be
                                   scattered throughout the axis.

                                 * An element which corresponds to a
                                   negative integer in the array will not
                                   be in any group.
                ===============  =========================================

                *Parameter example:*
                  To define groups of 10 kilometres: ``group=cf.Data(10,
                  'km')``.

                *Parameter example:*
                  To define groups of 5 days, starting and ending at
                  midnight on each day: ``group=cf.D(5)`` (see `cf.D`).

                *Parameter example:*
                  To define groups of 1 calendar month, starting and
                  ending at day 16 of each month: ``group=cf.M(day=16)``
                  (see `cf.M`).

                *Parameter example:*
                  To define groups of the season MAM in each year:
                  ``group=cf.mam()`` (see `cf.mam`).

                *Parameter example:*
                  To define groups of the seasons DJF and JJA in each
                  year: ``group=[cf.jja(), cf.djf()]``. To define groups
                  for seasons DJF, MAM, JJA and SON in each year:
                  ``group=cf.seasons()`` (see `cf.djf`, `cf.jja` and
                  `cf.season`).

                *Parameter example:*
                  To define groups for longitude elements less than or
                  equal to 90 degrees and greater than 90 degrees:
                  ``group=[cf.le(90, 'degrees'), cf.gt(90, 'degrees')]``
                  (see `cf.le` and `cf.gt`).

                *Parameter example:*
                  To define groups of 5 elements: ``group=5``.

                *Parameter example:*
                  For an axis of size 8, create two groups, the first
                  containing the first and last elements and the second
                  containing the 3rd, 4th and 5th elements, whilst
                  ignoring the 2nd, 6th and 7th elements:
                  ``group=numpy.array([0, -1, 4, 4, 4, -1, -2, 0])``.

            regroup: `bool`, optional
                If True then, for grouped collapses, do not collapse the
                field construct, but instead return a `numpy.array` of
                integers which identifies the groups defined by the
                *group* parameter. Each group contains the elements which
                correspond to a common non-negative integer value in the
                numpy array. Elements corresponding to negative integers
                are not in any group. The array may subsequently be used
                as the value of the *group* parameter in a separate
                collapse.

                For example:

                >>> groups = f.collapse('time: mean', group=10, regroup=True)
                >>> g = f.collapse('time: mean', group=groups)

                is equivalent to:

                >>> g = f.collapse('time: mean', group=10)

            group_by: optional
                Specify how coordinates are assigned to the groups defined
                by the *group*, *within_days* or *within_years*
                parameters. Ignored unless one of these parameters is set
                to a `Data` or `TimeDuration` object.

                The *group_by* parameter may be one of:

                ============  ============================================
                *group_by*    Description
                ============  ============================================
                `None`        This is the default.

                              If the groups are defined by the *group*
                              parameter (i.e. collapses other than
                              climatological time collapses) then assume a
                              value of ``'coords'``.

                              If the groups are defined by the
                              *within_days* or *within_years* parameter
                              (i.e. climatological time collapses) then
                              assume a value of ``'bounds'``.

                ``'coords'``  Each group contains the axis elements whose
                              coordinate values lie within the group
                              limits. Every element will be in a group.

                ``'bounds'``  Each group contains the axis elements whose
                              upper and lower coordinate bounds both lie
                              within the group limits. Some elements may
                              not be inside any group, either because the
                              group limits do not coincide with coordinate
                              bounds or because the group size is
                              sufficiently small.
                ============  ============================================

            group_span: optional
                Specify how to treat groups that may not span the desired
                range. For example, when creating 3-month means, the
                *group_span* parameter can be used to allow groups which
                only contain 1 or 2 months of data.

                By default, *group_span* is `None`. This means that only
                groups whose span equals the size specified by the
                definition of the groups are collapsed; unless the groups
                have been defined by one or more `Query` objects, in which
                case then the default behaviour is to collapse all groups,
                regardless of their size.

                In effect, the *group_span* parameter defaults to `True`
                unless the groups have been defined by one or more `Query`
                objects, in which case *group_span* defaults to `False`.

                The different behaviour when the groups have been defined
                by one or more `Query` objects is necessary because a
                `Query` object can only define the composition of a group,
                and not its size (see the parameter examples below for how
                to specify a group span in this case).

                .. note:: Prior to version 3.1.0, the default value of
                          *group_span* was effectively `False`.

                In general, the span of a group is the absolute difference
                between the lower bound of its first element and the upper
                bound of its last element. The only exception to this
                occurs if *group_span* is (by default or by explicit
                setting) an integer, in which case the span of a group is
                the number of elements in the group. See also the
                *group_contiguous* parameter for how to deal with groups
                that have gaps in their coverage.

                The *group_span* parameter is only applied to groups
                defined by the *group*, *within_days* or *within_years*
                parameters, and is otherwise ignored.

                The *group_span* parameter may be one of:

                ==============  ==========================================
                *group_span*    Description
                ==============  ==========================================
                `None`          This is the default. Apply a value of
                                `True` or `False` depending on how the
                                groups have been defined.

                `True`          Ignore groups whose span is not equal to
                                the size specified by the definition of
                                the groups. Only applicable if the groups
                                are defined by a `Data`, `TimeDuration` or
                                `int` object, and this is the default in
                                this case.

                `False`         Collapse all groups, regardless of their
                                size. This is the default if the groups
                                are defined by one to more `Query`
                                objects.

                `Data`          Ignore groups whose span is not equal to
                                the given size. If no units are specified
                                then the units of the coordinates are
                                assumed.

                `TimeDuration`  Ignore groups whose span is not equals to
                                the given time duration.

                `int`           Ignore groups that contain fewer than the
                                given number of elements
                ==============  ==========================================

                *Parameter example:*
                  To collapse into groups of 10km, ignoring any groups
                  that span less than that distance: ``group=cf.Data(10,
                  'km'), group_span=True``.

                *Parameter example:*
                  To collapse a daily timeseries into monthly groups,
                  ignoring any groups that span less than 1 calendar
                  month: monthly values: ``group=cf.M(), group_span=True``
                  (see `cf.M`).

                *Parameter example:*
                  To collapse a timeseries into seasonal groups, ignoring
                  any groups that span less than three months:
                  ``group=cf.seasons(), group_span=cf.M(3)`` (see
                  `cf.seasons` and `cf.M`).

            group_contiguous: `int`, optional
                Specify how to treat groups whose elements are not
                contiguous or have overlapping cells. For example, when
                creating a December to February means, the
                *group_contiguous* parameter can be used to allow groups
                which have no data for January.

                A group is considered to be contiguous unless it has
                coordinates with bounds that do not coincide for adjacent
                cells. The definition may be expanded to include groups
                whose coordinate bounds that overlap.

                By default *group_contiguous* is ``1``, meaning that
                non-contiguous groups, and those whose coordinate bounds
                overlap, are not collapsed

                .. note:: Prior to version 3.1.0, the default value of
                          *group_contiguous* was ``0``.

                The *group_contiguous* parameter is only applied to groups
                defined by the *group*, *within_days* or *within_years*
                parameters, and is otherwise ignored.

                The *group_contiguous* parameter may be one of:

                ===================  =====================================
                *group_contiguous*   Description
                ===================  =====================================
                ``0``                Allow non-contiguous groups, and
                                     those containing overlapping cells.

                ``1``                This is the default. Ignore
                                     non-contiguous groups, as well as
                                     contiguous groups containing
                                     overlapping cells.

                ``2``                Ignore non-contiguous groups,
                                     allowing contiguous groups containing
                                     overlapping cells.
                ===================  =====================================

                *Parameter example:*
                  To allow non-contiguous groups, and those containing
                  overlapping cells: ``group_contiguous=0``.

            within_days: optional
                Define the groups for creating CF "within days"
                climatological statistics.

                Each group contains elements whose coordinates span a time
                interval of up to one day. The results of the collapses
                are concatenated so that the output axis has a size equal
                to the number of groups.

                .. note:: For CF compliance, a "within days" collapse
                          should be followed by an "over days" collapse.

                The *within_days* parameter defines how the elements are
                partitioned into groups, and may be one of:

                ==============  ==========================================
                *within_days*   Description
                ==============  ==========================================
                `TimeDuration`  Defines the group size in terms of a time
                                interval of up to one day. The first group
                                starts at or before the first coordinate
                                bound of the first axis element (or its
                                coordinate if there are no bounds) and
                                spans the defined group size. Each
                                subsequent group immediately follows the
                                preceding one. By default each group
                                contains the consecutive run of elements
                                whose coordinate cells lie within the
                                group limits (see the *group_by*
                                parameter).

                                * Groups may contain different numbers of
                                  elements.

                                * The start of the first group may be
                                  before the first first axis element,
                                  depending on the offset defined by the
                                  time duration. For example, if
                                  ``group=cf.D(hour=12)`` then the first
                                  group will start on the closest midday
                                  to the first axis element.

                `Query`         Define groups from elements whose
                                coordinates satisfy the query
                                condition. Multiple groups are created:
                                one for each maximally consecutive run
                                within the selected elements.

                                If a sequence of `Query` is provided then
                                groups are defined for each query.

                                * Groups may contain different numbers of
                                  elements.

                                * If no units are specified then the units
                                  of the coordinates are assumed.

                                * If a coordinate does not satisfy any of
                                  the conditions then its element will not
                                  be in a group.

                                * If an element is selected by two or more
                                  queries then the latest one in the
                                  sequence defines which group it will be
                                  in.
                ==============  ==========================================

                *Parameter example:*
                  To define groups of 6 hours, starting at 00:00, 06:00,
                  12:00 and 18:00: ``within_days=cf.h(6)`` (see `cf.h`).

                *Parameter example:*
                  To define groups of 1 day, starting at 06:00:
                  ``within_days=cf.D(1, hour=6)`` (see `cf.D`).

                *Parameter example:*
                  To define groups of 00:00 to 06:00 within each day,
                  ignoring the rest of each day:
                  ``within_days=cf.hour(cf.le(6))`` (see `cf.hour` and
                  `cf.le`).

                *Parameter example:*
                  To define groups of 00:00 to 06:00 and 18:00 to 24:00
                  within each day, ignoring the rest of each day:
                  ``within_days=[cf.hour(cf.le(6)), cf.hour(cf.gt(18))]``
                  (see `cf.gt`, `cf.hour` and `cf.le`).

            within_years: optional
                Define the groups for creating CF "within years"
                climatological statistics.

                Each group contains elements whose coordinates span a time
                interval of up to one calendar year. The results of the
                collapses are concatenated so that the output axis has a
                size equal to the number of groups.

                .. note:: For CF compliance, a "within years" collapse
                          should be followed by an "over years" collapse.

                The *within_years* parameter defines how the elements are
                partitioned into groups, and may be one of:

                ==============  ==========================================
                *within_years*  Description
                ==============  ==========================================
                `TimeDuration`  Define the group size in terms of a time
                                interval of up to one calendar year. The
                                first group starts at or before the first
                                coordinate bound of the first axis element
                                (or its coordinate if there are no bounds)
                                and spans the defined group size. Each
                                subsequent group immediately follows the
                                preceding one. By default each group
                                contains the consecutive run of elements
                                whose coordinate cells lie within the
                                group limits (see the *group_by*
                                parameter).

                                * Groups may contain different numbers of
                                  elements.

                                * The start of the first group may be
                                  before the first first axis element,
                                  depending on the offset defined by the
                                  time duration. For example, if
                                  ``group=cf.Y(month=12)`` then the first
                                  group will start on the closest 1st
                                  December to the first axis element.

                 `Query`        Define groups from elements whose
                                coordinates satisfy the query
                                condition. Multiple groups are created:
                                one for each maximally consecutive run
                                within the selected elements.

                                If a sequence of `Query` is provided then
                                groups are defined for each query.

                                * The first group may start outside of the
                                  range of coordinates (the start of the
                                  first group is controlled by parameters
                                  of the `TimeDuration`).

                                * If group boundaries do not coincide with
                                  coordinate bounds then some elements may
                                  not be inside any group.

                                * If the group size is sufficiently small
                                  then some elements may not be inside any
                                  group.

                                * Groups may contain different numbers of
                                  elements.
                ==============  ==========================================

                *Parameter example:*
                  To define groups of 90 days: ``within_years=cf.D(90)``
                  (see `cf.D`).

                *Parameter example:*
                  To define groups of 3 calendar months, starting on the
                  15th of a month: ``within_years=cf.M(3, day=15)`` (see
                  `cf.M`).

                *Parameter example:*
                  To define groups for the season MAM within each year:
                  ``within_years=cf.mam()`` (see `cf.mam`).

                *Parameter example:*
                  To define groups for February and for November to
                  December within each year: ``within_years=[cf.month(2),
                  cf.month(cf.ge(11))]`` (see `cf.month` and `cf.ge`).

            over_days: optional
                Define the groups for creating CF "over days"
                climatological statistics.

                By default (or if *over_days* is `None`) each group
                contains all elements for which the time coordinate cell
                lower bounds have a common time of day but different
                dates, and for which the time coordinate cell upper bounds
                also have a common time of day but different dates. The
                collapsed dime axis will have a size equal to the number
                of groups that were found.

                For example, elements corresponding to the two time
                coordinate cells

                  | ``1999-12-31 06:00:00/1999-12-31 18:00:00``
                  | ``2000-01-01 06:00:00/2000-01-01 18:00:00``

                would be together in a group; and elements corresponding
                to the two time coordinate cells

                  | ``1999-12-31 00:00:00/2000-01-01 00:00:00``
                  | ``2000-01-01 00:00:00/2000-01-02 00:00:00``

                would also be together in a different group.

                .. note:: For CF compliance, an "over days" collapse
                          should be preceded by a "within days" collapse.

                The default groups may be split into smaller groups if the
                *over_days* parameter is one of:

                ==============  ==========================================
                *over_days*     Description
                ==============  ==========================================
                `TimeDuration`  Split each default group into smaller
                                groups which span the given time duration,
                                which must be at least one day.

                                * Groups may contain different numbers of
                                  elements.

                                * The start of the first group may be
                                  before the first first axis element,
                                  depending on the offset defined by the
                                  time duration. For example, if
                                  ``group=cf.M(day=15)`` then the first
                                  group will start on the closest 15th of
                                  a month to the first axis element.

                `Query`         Split each default group into smaller
                                groups whose coordinate cells satisfy the
                                query condition.

                                If a sequence of `Query` is provided then
                                groups are defined for each query.

                                * Groups may contain different numbers of
                                  elements.

                                * If a coordinate does not satisfy any of
                                  the conditions then its element will not
                                  be in a group.

                                * If an element is selected by two or more
                                  queries then the latest one in the
                                  sequence defines which group it will be
                                  in.
                ==============  ==========================================

                *Parameter example:*
                  To define groups for January and for June to December,
                  ignoring all other months: ``over_days=[cf.month(1),
                  cf.month(cf.wi(6, 12))]`` (see `cf.month` and `cf.wi`).

                *Parameter example:*
                  To define groups spanning 90 days:
                  ``over_days=cf.D(90)`` or ``over_days=cf.h(2160)``. (see
                  `cf.D` and `cf.h`).

                *Parameter example:*
                  To define groups that each span 3 calendar months,
                  starting and ending at 06:00 in the first day of each
                  month: ``over_days=cf.M(3, hour=6)`` (see `cf.M`).

                *Parameter example:*
                  To define groups that each span a calendar month
                  ``over_days=cf.M()`` (see `cf.M`).

                *Parameter example:*
                  To define groups for January and for June to December,
                  ignoring all other months: ``over_days=[cf.month(1),
                  cf.month(cf.wi(6, 12))]`` (see `cf.month` and `cf.wi`).

            over_years: optional
                Define the groups for creating CF "over years"
                climatological statistics.

                By default (or if *over_years* is `None`) each group
                contains all elements for which the time coordinate cell
                lower bounds have a common date of the year but different
                years, and for which the time coordinate cell upper bounds
                also have a common date of the year but different
                years. The collapsed dime axis will have a size equal to
                the number of groups that were found.

                For example, elements corresponding to the two time
                coordinate cells

                  | ``1999-12-01 00:00:00/2000-01-01 00:00:00``
                  | ``2000-12-01 00:00:00/2001-01-01 00:00:00``

                would be together in a group.

                .. note:: For CF compliance, an "over years" collapse
                          should be preceded by a "within years" or "over
                          days" collapse.

                The default groups may be split into smaller groups if the
                *over_years* parameter is one of:

                ==============  ==========================================
                *over_years*    Description
                ==============  ==========================================
                `TimeDuration`  Split each default group into smaller
                                groups which span the given time duration,
                                which must be at least one day.

                                * Groups may contain different numbers of
                                  elements.

                                * The start of the first group may be
                                  before the first first axis element,
                                  depending on the offset defined by the
                                  time duration. For example, if
                                  ``group=cf.Y(month=12)`` then the first
                                  group will start on the closest 1st
                                  December to the first axis element.

                `Query`         Split each default group into smaller
                                groups whose coordinate cells satisfy the
                                query condition.

                                If a sequence of `Query` is provided then
                                groups are defined for each query.

                                * Groups may contain different numbers of
                                  elements.

                                * If a coordinate does not satisfy any of
                                  the conditions then its element will not
                                  be in a group.

                                * If an element is selected by two or more
                                  queries then the latest one in the
                                  sequence defines which group it will be
                                  in.
                ==============  ==========================================

                *Parameter example:*
                  An element with coordinate bounds {1999-06-01 06:00:00,
                  1999-09-01 06:00:00} **matches** an element with
                  coordinate bounds {2000-06-01 06:00:00, 2000-09-01
                  06:00:00}.

                *Parameter example:*
                  An element with coordinate bounds {1999-12-01 00:00:00,
                  2000-12-01 00:00:00} **matches** an element with
                  coordinate bounds {2000-12-01 00:00:00, 2001-12-01
                  00:00:00}.

                *Parameter example:*
                  To define groups spanning 10 calendar years:
                  ``over_years=cf.Y(10)`` or ``over_years=cf.M(120)`` (see
                  `cf.M` and `cf.Y`).

                *Parameter example:*
                  To define groups spanning 5 calendar years, starting and
                  ending at 06:00 on 01 December of each year:
                  ``over_years=cf.Y(5, month=12, hour=6)`` (see `cf.Y`).

                *Parameter example:*
                  To define one group spanning 1981 to 1990 and another
                  spanning 2001 to 2005: ``over_years=[cf.year(cf.wi(1981,
                  1990), cf.year(cf.wi(2001, 2005)]`` (see `cf.year` and
                  `cf.wi`).

            {{inplace: `bool`, optional}}

            {{i: deprecated at version 3.0.0}}

            kwargs: deprecated at version 3.0.0

        :Returns:

            `Field` or `numpy.ndarray`
                 The collapsed field construct. Alternatively, if the
                 *regroup* parameter is True then a `numpy` array is
                 returned.

        **Examples**

        There are further worked examples in
        https://ncas-cms.github.io/cf-python/analysis.html#statistical-collapses

        """
        if _debug:
            _DEPRECATION_ERROR_KWARGS(
                self,
                "collapse",
                {"_debug": _debug},
                "Use keyword 'verbose' instead.",
            )  # pragma: no cover

        if kwargs:
            _DEPRECATION_ERROR_KWARGS(
                self, "collapse", kwargs
            )  # pragma: no cover

        if inplace:
            f = self
        else:
            f = self.copy()

        # Whether or not to create null bounds for null
        # collapses. I.e. if the collapse axis has size 1 and no
        # bounds, whether or not to create upper and lower bounds to
        # the coordinate value. If this occurs it's because the null
        # collapse is part of a grouped collapse and so will be
        # concatenated to other collapses for the final result: bounds
        # will be made for the grouped collapse, so all elements need
        # bounds.
        #        _create_zero_size_cell_bounds = kwargs.get(
        #            '_create_zero_size_cell_bounds', False)

        # ------------------------------------------------------------
        # Parse the methods and axes
        # ------------------------------------------------------------
        if ":" in method:
            # Convert a cell methods string (such as 'area: mean dim3:
            # dim2: max T: minimum height: variance') to a CellMethod
            # construct
            if axes is not None:
                raise ValueError(
                    "Can't collapse: Can't set 'axes' when 'method' is "
                    "CF-like cell methods string"
                )

            all_methods = []
            all_axes = []
            all_within = []
            all_over = []

            for cm in CellMethod.create(method):
                all_methods.append(cm.get_method(None))
                all_axes.append(cm.get_axes(()))
                all_within.append(cm.get_qualifier("within", None))
                all_over.append(cm.get_qualifier("over", None))
        else:
            x = method.split(" within ")
            if method == x[0]:
                within = None
                x = method.split(" over ")
                if method == x[0]:
                    over = None
                else:
                    method, over = x
            else:
                method, within = x

            if isinstance(axes, (str, int)):
                axes = (axes,)

            all_methods = (method,)
            all_within = (within,)
            all_over = (over,)
            all_axes = (axes,)

        # ------------------------------------------------------------
        # Convert axes into domain axis construct keys
        # ------------------------------------------------------------
        domain_axes = None

        input_axes = all_axes
        all_axes = []
        for axes in input_axes:
            if axes is None:
                domain_axes = self.domain_axes(
                    todict=False, cached=domain_axes
                )
                all_axes.append(list(domain_axes))
                continue

            axes2 = []
            for axis in axes:
                msg = (
                    "Must have '{}' axes for an '{}' collapse. Can't "
                    "find {{!r}} axis"
                )
                if axis == "area":
                    iterate_over = ("X", "Y")
                    msg = msg.format("', '".join(iterate_over), axis)
                elif axis == "volume":
                    iterate_over = ("X", "Y", "Z")
                    msg = msg.format("', '".join(iterate_over), axis)
                else:
                    iterate_over = (axis,)
                    msg = "Can't find the collapse axis identified by {!r}"

                for x in iterate_over:
                    a = self.domain_axis(x, key=True, default=None)
                    if a is None:
                        raise ValueError(msg.format(x))
                    axes2.append(a)

            all_axes.append(axes2)

        logger.info(
            "    all_methods, all_axes, all_within, all_over = "
            f"{all_methods} {all_axes} {all_within} {all_over}"
        )  # pragma: no cover

        if group is not None and len(all_axes) > 1:
            raise ValueError(
                "Can't use the 'group' parameter for multiple collapses"
            )

        # ------------------------------------------------------------
        #
        # ------------------------------------------------------------
        domain_axes = f.domain_axes(todict=False, cached=domain_axes)
        #        auxiliary_coordinates = f.auxiliary_coordinates(view=True)
        #        dimension_coordinates = f.dimension_coordinates(view=True)

        for method, axes, within, over, axes_in in zip(
            all_methods, all_axes, all_within, all_over, input_axes
        ):

            method2 = _collapse_methods.get(method, None)
            if method2 is None:
                raise ValueError(f"Unknown collapse method: {method!r}")

            method = method2

            #            collapse_axes_all_sizes = domain_axes.filter_by_key(*axes)
            collapse_axes_all_sizes = f.domain_axes(
                filter_by_key=axes, todict=False
            )

            logger.info(
                f"    axes                    = {axes}"
            )  # pragma: no cover
            logger.info(
                f"    method                  = {method}"
            )  # pragma: no cover
            logger.info(
                f"    collapse_axes_all_sizes = {collapse_axes_all_sizes}"
            )  # pragma: no cover

            if not collapse_axes_all_sizes:
                raise ValueError(
                    "Can't collapse: Can not identify collapse axes"
                )

            if method in (
                "sum_of_weights",
                "sum_of_weights2",
                "sample_size",
                "integral",
                "maximum_absolute_value",
                "minimum_absolute_value",
                "mean_absolute_value",
                "range",
                "root_mean_square",
                "sum_of_squares",
            ):
                collapse_axes = collapse_axes_all_sizes.todict()  # copy()
            else:
                collapse_axes = collapse_axes_all_sizes.filter_by_size(
                    gt(1), todict=True
                )

            logger.info(
                f"    collapse_axes           = {collapse_axes}"
            )  # pragma: no cover

            if not collapse_axes:
                # Do nothing if there are no collapse axes
                if _create_zero_size_cell_bounds:
                    # Create null bounds if requested
                    for axis in axes:
                        #                        dc = f.dimension_coordinates(
                        #                            filter_by_axis=(axis,), axis_mode="and", todict=Tru#e
                        #                        ).value(None)
                        dc = f.dimension_coordinate(
                            filter_by_axis=(axis,),
                            default=None,
                        )
                        if dc is not None and not dc.has_bounds():
                            dc.set_bounds(dc.create_bounds(cellsize=0))

                continue

            # Check that there are enough elements to collapse
            collapse_axes_sizes = [
                da.get_size() for da in collapse_axes.values()
            ]
            size = reduce(operator_mul, collapse_axes_sizes, 1)

            logger.info(
                f"    collapse_axes_sizes     = {collapse_axes_sizes}"
            )  # pragma: no cover

            grouped_collapse = (
                within is not None or over is not None or group is not None
            )

            # --------------------------------------------------------
            # Set the group_by parameter
            # --------------------------------------------------------
            if group_by is None:
                if within is None and over is None:
                    group_by = "coords"
                else:
                    group_by = "bounds"
            elif (
                within is not None or over is not None
            ) and group_by == "coords":
                raise ValueError(
                    "Can't collapse: group_by parameter can't be "
                    "'coords' for a climatological time collapse."
                )

            # --------------------------------------------------------
            # Set the coordinate parameter
            # --------------------------------------------------------
            if coordinate is None and over is None:
                coordinate = "mid_range"

            if grouped_collapse:
                if len(collapse_axes) > 1:
                    raise ValueError(
                        "Can't do a grouped collapse on multiple axes "
                        "simultaneously"
                    )

                # ------------------------------------------------------------
                # Grouped collapse: Calculate weights
                # ------------------------------------------------------------
                g_weights = weights
                if method not in _collapse_weighted_methods:
                    g_weights = None
                else:
                    # if isinstance(weights, (dict, self.__class__, Data)):
                    #     if measure:
                    #         raise ValueError(
                    #             "TODO")
                    #
                    #     if scale is not None:
                    #         raise ValueError(
                    #             "TODO")
                    if method == "integral":
                        if not measure:
                            raise ValueError(
                                "Must set measure=True for 'integral' "
                                "collapses."
                            )

                        if scale is not None:
                            raise ValueError(
                                "Can't set scale for 'integral' collapses."
                            )
                    elif not measure and scale is None:
                        scale = 1.0
                    elif measure and scale is not None:
                        raise ValueError(
                            "Can't scale weights which are created as cell "
                            "measures i.e. can't scale the weights if "
                            "measure parameter is set to True."
                        )

                    #                    if weights is True:
                    #                        weights = tuple(collapse_axes.keys())

                    g_weights = f.weights(
                        weights,
                        components=True,
                        axes=list(collapse_axes),  # .keys()),
                        scale=scale,
                        measure=measure,
                        radius=radius,
                        great_circle=great_circle,
                    )

                    if not g_weights:
                        g_weights = None

                #                axis = collapse_axes.key()
                axis = [a for a in collapse_axes][0]

                f = f._collapse_grouped(
                    method,
                    axis,
                    within=within,
                    over=over,
                    within_days=within_days,
                    within_years=within_years,
                    over_days=over_days,
                    over_years=over_years,
                    group=group,
                    group_span=group_span,
                    group_contiguous=group_contiguous,
                    regroup=regroup,
                    mtol=mtol,
                    ddof=ddof,
                    measure=measure,
                    weights=g_weights,
                    squeeze=squeeze,
                    coordinate=coordinate,
                    group_by=group_by,
                    axis_in=axes_in[0],
                    verbose=verbose,
                )

                if regroup:
                    # Grouped collapse: Return the numpy array
                    return f

                # ----------------------------------------------------
                # Grouped collapse: Update the cell methods
                # ----------------------------------------------------
                f._update_cell_methods(
                    method=method,
                    domain_axes=collapse_axes,
                    input_axes=axes_in,
                    within=within,
                    over=over,
                    verbose=verbose,
                )
                continue

            elif regroup:
                raise ValueError(
                    "Can't return an array of groups for a non-grouped "
                    "collapse"
                )

            data_axes = f.get_data_axes()
            iaxes = [
                data_axes.index(axis)
                for axis in collapse_axes
                if axis in data_axes
            ]

            # ------------------------------------------------------------
            # Calculate weights
            # ------------------------------------------------------------
            logger.info(
                f"    Input weights           = {weights!r}"
            )  # pragma: no cover

            if method not in _collapse_weighted_methods:
                weights = None

            d_kwargs = {}
            if weights is not None:
                # if isinstance(weights, (dict, self.__class__, Data)):
                #     if measure:
                #         raise ValueError("TODO")
                #
                #     if scale is not None:
                #         raise ValueError("TODO")

                if method == "integral":
                    if not measure:
                        raise ValueError(
                            f"Must set measure=True for {method!r} collapses"
                        )

                    if scale is not None:
                        raise ValueError(
                            "Can't set scale for 'integral' collapses."
                        )
                elif not measure and scale is None:
                    scale = 1.0
                elif measure and scale is not None:
                    raise ValueError(
                        "Can't scale weights which are created as cell "
                        "measures i.e. can't scale the weights if "
                        "measure parameter is set to True."
                    )

                d_weights = f.weights(
                    weights,
                    components=True,
                    axes=list(collapse_axes.keys()),
                    scale=scale,
                    measure=measure,
                    radius=radius,
                    great_circle=great_circle,
                )

                if d_weights:
                    d_kwargs["weights"] = d_weights

                logger.info(
                    f"    Output weights          = {d_weights!r}"
                )  # pragma: no cover

            elif method == "integral":
                raise ValueError(
                    f"Must set the 'weights' parameter for {method!r} "
                    "collapses"
                )

            if method in _collapse_ddof_methods:
                d_kwargs["ddof"] = ddof

            # ========================================================
            # Collapse the data array
            # ========================================================
            logger.info(
                "  Before collapse of data:\n"
                f"    iaxes, d_kwargs = {iaxes} {d_kwargs}\n"
                f"    f.shape = {f.shape}\n"
                f"    f.dtype = {f.dtype}\n"
            )  # pragma: no cover

            getattr(f.data, method)(
                axes=iaxes,
                squeeze=squeeze,
                mtol=mtol,
                inplace=True,
                **d_kwargs,
            )

            if squeeze:
                # ----------------------------------------------------
                # Remove the collapsed axes from the field's list of
                # data array axes
                # ----------------------------------------------------
                f.set_data_axes(
                    [axis for axis in data_axes if axis not in collapse_axes]
                )

            logger.info(
                "  After collapse of data:\n"
                f"    f.shape = {f.shape}\n"
                f"    f.dtype = {f.dtype}\n"
                f"collapse_axes = {collapse_axes}"
            )  # pragma: no cover

            # ---------------------------------------------------------
            # Update dimension coordinates, auxiliary coordinates,
            # cell measures and domain ancillaries
            # ---------------------------------------------------------
            for axis, domain_axis in collapse_axes.items():
                # Ignore axes which are already size 1
                size = domain_axis.get_size()
                if size == 1:
                    continue

                # REMOVE all cell measures and domain ancillaries
                # which span this axis
                c = f.constructs.filter(
                    filter_by_type=("cell_measure", "domain_ancillary"),
                    filter_by_axis=(axis,),
                    axis_mode="or",
                    todict=True,
                )
                for key, value in c.items():
                    logger.info(
                        f"    Removing {value.construct_type}"
                    )  # pragma: no cover

                    f.del_construct(key)

                # REMOVE all 2+ dimensional auxiliary coordinates
                # which span this axis
                #                c = auxiliary_coordinates.filter_by_naxes(gt(1), view=True)
                c = f.auxiliary_coordinates(
                    filter_by_naxes=(
                        gt(
                            1,
                        ),
                    ),
                    filter_by_axis=(axis,),
                    axis_mode="or",
                    todict=True,
                )
                for key, value in c.items():
                    logger.info(
                        f"    Removing {value.construct_type} {key!r}"
                    )  # pragma: no cover

                    f.del_construct(key)

                # REMOVE all 1 dimensional auxiliary coordinates which
                # span this axis and have different values in their
                # data array and bounds.
                #
                # KEEP, after changing their data arrays, all
                # one-dimensional auxiliary coordinates which span
                # this axis and have the same values in their data
                # array and bounds.
                c = f.auxiliary_coordinates(
                    filter_by_axis=(axis,), axis_mode="exact", todict=True
                )
                for key, aux in c.items():
                    logger.info(f"key = {key}")  # pragma: no cover

                    d = aux[0]

                    # TODODASK: remove once dask. For some reason,
                    # without this we now get LAMA related failures in
                    # Partition.nbytes ...
                    _ = aux.dtype

                    if aux.has_bounds() or (aux[:-1] != aux[1:]).any():
                        logger.info(
                            f"    Removing {aux.construct_type} {key!r}"
                        )  # pragma: no cover

                        f.del_construct(key)
                    else:
                        # Change the data array for this auxiliary
                        # coordinate
                        aux.set_data(d.data, copy=False)
                        if d.has_bounds():
                            aux.bounds.set_data(d.bounds.data, copy=False)

                # Reset the axis size
                f.domain_axes(todict=True)[axis].set_size(1)
                logger.info(
                    f"Changing axis size to 1: {axis}"
                )  # pragma: no cover

                dim = f.dimension_coordinate(
                    filter_by_axis=(axis,), default=None
                )
                if dim is None:
                    continue

                # Create a new dimension coordinate for this axis
                if dim.has_bounds():
                    bounds_data = [dim.bounds.datum(0), dim.bounds.datum(-1)]
                else:
                    bounds_data = [dim.datum(0), dim.datum(-1)]

                units = dim.Units

                if coordinate == "min":
                    coordinate = "minimum"
                    print(
                        "WARNING: coordinate='min' has been deprecated. "
                        "Use coordinate='minimum' instead."
                    )
                elif coordinate == "max":
                    coordinate = "maximum"
                    print(
                        "WARNING: coordinate='max' has been deprecated. "
                        "Use coordinate='maximum' instead."
                    )

                if coordinate == "mid_range":
                    data = Data(
                        [(bounds_data[0] + bounds_data[1]) * 0.5], units=units
                    )
                elif coordinate == "minimum":
                    data = dim.data.min()
                elif coordinate == "maximum":
                    data = dim.data.max()
                else:
                    raise ValueError(
                        "Can't collapse: Bad parameter value: "
                        f"coordinate={coordinate!r}"
                    )

                bounds = self._Bounds(data=Data([bounds_data], units=units))

                dim.set_data(data, copy=False)
                dim.set_bounds(bounds, copy=False)

            # --------------------------------------------------------
            # Update the cell methods
            # --------------------------------------------------------
            if _update_cell_methods:
                f._update_cell_methods(
                    method,
                    domain_axes=collapse_axes,
                    input_axes=axes_in,
                    within=within,
                    over=over,
                    verbose=verbose,
                )

        # ------------------------------------------------------------
        # Return the collapsed field (or the classification array)
        # ------------------------------------------------------------
        return f

    @_manage_log_level_via_verbosity
    def _collapse_grouped(
        self,
        method,
        axis,
        within=None,
        over=None,
        within_days=None,
        within_years=None,
        over_days=None,
        over_years=None,
        group=None,
        group_span=None,
        group_contiguous=False,
        mtol=None,
        ddof=None,
        regroup=None,
        coordinate=None,
        measure=False,
        weights=None,
        squeeze=None,
        group_by=None,
        axis_in=None,
        verbose=None,
    ):
        """Implements a grouped collapse on a field.

        A grouped collapse is one for which an axis is not collapsed
        completely to size 1.

        :Parameters:

            method: `str`
                See `collapse` for details.

            measure: `bool`, optional
                See `collapse` for details.

            over: `str`
                See `collapse` for details.

            within: `str`
                See `collapse` for details.

        """

        def _ddddd(
            classification,
            n,
            lower,
            upper,
            increasing,
            coord,
            group_by_coords,
            extra_condition,
        ):
            """Returns configuration for a general collapse.

            :Parameter:

                extra_condition: `Query`

            :Returns:

                `numpy.ndarray`, `int`, date-time, date-time

            """
            if group_by_coords:
                q = ge(lower) & lt(upper)
            else:
                q = ge(lower, attr="lower_bounds") & le(
                    upper, attr="upper_bounds"
                )

            if extra_condition:
                q &= extra_condition

            index = q.evaluate(coord).array
            classification[index] = n

            if increasing:
                lower = upper
            else:
                upper = lower

            n += 1

            return classification, n, lower, upper

        def _time_interval(
            classification,
            n,
            coord,
            interval,
            lower,
            upper,
            lower_limit,
            upper_limit,
            group_by,
            extra_condition=None,
        ):
            """Prepares for a collapse where the group is a
            TimeDuration.

            :Parameters:

                classification: `numpy.ndarray`

                n: `int`

                coord: `DimensionCoordinate`

                interval: `TimeDuration`

                lower: date-time object

                upper: date-time object

                lower_limit: `datetime`

                upper_limit: `datetime`

                group_by: `str`

                extra_condition: `Query`, optional

            :Returns:

                (`numpy.ndarray`, `int`)

            """
            group_by_coords = group_by == "coords"

            if coord.increasing:
                # Increasing dimension coordinate
                lower, upper = interval.bounds(lower)
                while lower <= upper_limit:
                    lower, upper = interval.interval(lower)
                    classification, n, lower, upper = _ddddd(
                        classification,
                        n,
                        lower,
                        upper,
                        True,
                        coord,
                        group_by_coords,
                        extra_condition,
                    )
            else:
                # Decreasing dimension coordinate
                lower, upper = interval.bounds(upper)
                while upper >= lower_limit:
                    lower, upper = interval.interval(upper, end=True)
                    classification, n, lower, upper = _ddddd(
                        classification,
                        n,
                        lower,
                        upper,
                        False,
                        coord,
                        group_by_coords,
                        extra_condition,
                    )

            return classification, n

        def _time_interval_over(
            classification,
            n,
            coord,
            interval,
            lower,
            upper,
            lower_limit,
            upper_limit,
            group_by,
            extra_condition=None,
        ):
            """Prepares for a collapse over some TimeDuration.

            :Parameters:

                classification: `numpy.ndarray`

                n: `int`

                coord: `DimensionCoordinate`

                interval: `TimeDuration`

                lower: date-time

                upper: date-time

                lower_limit: date-time

                upper_limit: date-time

                group_by: `str`

                extra_condition: `Query`, optional

            :Returns:

                (`numpy.ndarray`, `int`)

            """
            group_by_coords = group_by == "coords"

            if coord.increasing:
                # Increasing dimension coordinate
                # lower, upper = interval.bounds(lower)
                upper = interval.interval(upper)[1]
                while lower <= upper_limit:
                    lower, upper = interval.interval(lower)
                    classification, n, lower, upper = _ddddd(
                        classification,
                        n,
                        lower,
                        upper,
                        True,
                        coord,
                        group_by_coords,
                        extra_condition,
                    )
            else:
                # Decreasing dimension coordinate
                # lower, upper = interval.bounds(upper)
                lower = interval.interval(upper, end=True)[0]
                while upper >= lower_limit:
                    lower, upper = interval.interval(upper, end=True)
                    classification, n, lower, upper = _ddddd(
                        classification,
                        n,
                        lower,
                        upper,
                        False,
                        coord,
                        group_by_coords,
                        extra_condition,
                    )

            return classification, n

        def _data_interval(
            classification,
            n,
            coord,
            interval,
            lower,
            upper,
            lower_limit,
            upper_limit,
            group_by,
            extra_condition=None,
        ):
            """Prepares for a collapse where the group is a data
            interval.

            :Returns:

                `numpy.ndarray`, `int`

            """
            group_by_coords = group_by == "coords"

            if coord.increasing:
                # Increasing dimension coordinate
                lower = lower.squeeze()
                while lower <= upper_limit:
                    upper = lower + interval
                    classification, n, lower, upper = _ddddd(
                        classification,
                        n,
                        lower,
                        upper,
                        True,
                        coord,
                        group_by_coords,
                        extra_condition,
                    )
            else:
                # Decreasing dimension coordinate
                upper = upper.squeeze()
                while upper >= lower_limit:
                    lower = upper - interval
                    classification, n, lower, upper = _ddddd(
                        classification,
                        n,
                        lower,
                        upper,
                        False,
                        coord,
                        group_by_coords,
                        extra_condition,
                    )

            return classification, n

        def _selection(
            classification,
            n,
            coord,
            selection,
            parameter,
            extra_condition=None,
            group_span=None,
            within=False,
        ):
            """Processes a group selection.

            :Parameters:

                classification: `numpy.ndarray`

                n: `int`

                coord: `DimensionCoordinate`

                selection: sequence of `Query`

                parameter: `str`
                    The name of the `cf.Field.collapse` parameter which
                    defined *selection*. This is used in error messages.

                    *Parameter example:*
                      ``parameter='within_years'``

                extra_condition: `Query`, optional

            :Returns:

                `numpy.ndarray`, `int`

            """
            # Create an iterator for stepping through each Query in
            # the selection sequence
            try:
                iterator = iter(selection)
            except TypeError:
                raise ValueError(
                    "Can't collapse: Bad parameter value: "
                    f"{parameter}={selection!r}"
                )

            for condition in iterator:
                if not isinstance(condition, Query):
                    raise ValueError(
                        f"Can't collapse: {parameter} sequence contains a "
                        f"non-{Query.__name__} object: {condition!r}"
                    )

                if extra_condition is not None:
                    condition &= extra_condition

                boolean_index = condition.evaluate(coord).array

                classification[boolean_index] = n
                n += 1

            #                if group_span is not None:
            #                    x = numpy_where(classification==n)[0]
            #                    for i in range(1, max(1, int(float(len(x))/group_span))):
            #                        n += 1
            #                        classification[x[i*group_span:(i + 1)*group_span]] = n
            #                n += 1

            return classification, n

        def _discern_runs(classification, within=False):
            """Processes a group classification.

            :Parameters:

                classification: `numpy.ndarray`

            :Returns:

                `numpy.ndarray`

            """
            x = numpy_where(numpy_diff(classification))[0] + 1
            if not x.size:
                if classification[0] >= 0:
                    classification[:] = 0

                return classification

            if classification[0] >= 0:
                classification[0 : x[0]] = 0

            n = 1
            for i, j in zip(x[:-1], x[1:]):
                if classification[i] >= 0:
                    classification[i:j] = n
                    n += 1

            if classification[x[-1]] >= 0:
                classification[x[-1] :] = n
                n += 1

            return classification

        def _discern_runs_within(classification, coord):
            """Processes group classification for a 'within'
            collapse."""
            size = classification.size
            if size < 2:
                return classification

            n = classification.max() + 1

            start = 0
            for i, c in enumerate(classification[: size - 1]):
                if c < 0:
                    continue

                if not coord[i : i + 2].contiguous(overlap=False):
                    classification[start : i + 1] = n
                    start = i + 1
                    n += 1

            return classification

        def _tyu(coord, group_by, time_interval):
            """Returns bounding values and limits for a general
            collapse.

            :Parameters:

                coord: `DimensionCoordinate`
                    The dimension coordinate construct associated with
                    the collapse.

                group_by: `str`
                    As for the *group_by* parameter of the `collapse` method.

                time_interval: `bool`
                    If True then then return a tuple of date-time
                    objects. If False return a tuple of `Data` objects.

            :Returns:

                `tuple`
                    A tuple of 4 `Data` object or, if *time_interval* is
                    True, a tuple of 4 date-time objects.

            """
            bounds = coord.get_bounds(None)
            if bounds is not None:
                lower_bounds = coord.lower_bounds
                upper_bounds = coord.upper_bounds
                lower = lower_bounds[0]
                upper = upper_bounds[0]
                lower_limit = lower_bounds[-1]
                upper_limit = upper_bounds[-1]
            elif group_by == "coords":
                if coord.increasing:
                    lower = coord.data[0]
                    upper = coord.data[-1]
                else:
                    lower = coord.data[-1]
                    upper = coord.data[0]

                lower_limit = lower
                upper_limit = upper
            else:
                raise ValueError(
                    f"Can't collapse: {coord.identity()!r} coordinate bounds "
                    f"are required with group_by={group_by!r}"
                )

            if time_interval:
                units = coord.Units
                if units.isreftime:
                    lower = lower.datetime_array[0]
                    upper = upper.datetime_array[0]
                    lower_limit = lower_limit.datetime_array[0]
                    upper_limit = upper_limit.datetime_array[0]
                elif not units.istime:
                    raise ValueError(
                        f"Can't group by {TimeDuration.__class__.__name__} "
                        f"when coordinates have units {coord.Units!r}"
                    )

            return (lower, upper, lower_limit, upper_limit)

        def _group_weights(weights, iaxis, index):
            """Subspaces weights components.

            :Parameters:

                weights: `dict` or `None`

                iaxis: `int`

                index: `list`

            :Returns:

                `dict` or `None`

            **Examples**

            >>> print(weights)
            None
            >>> print(_group_weights(weights, 2, [2, 3, 40]))
            None
            >>> print(_group_weights(weights, 1, slice(2, 56)))
            None

            >>> weights

            >>> _group_weights(weights, 2, [2, 3, 40])

            >>> _group_weights(weights, 1, slice(2, 56))

            """
            if not isinstance(weights, dict):
                return weights

            weights = weights.copy()
            for iaxes, value in weights.items():
                if iaxis in iaxes:
                    indices = [slice(None)] * len(iaxes)
                    indices[iaxes.index(iaxis)] = index
                    weights[iaxes] = value[tuple(indices)]
                    break

            return weights

        # START OF MAIN CODE

        logger.info(
            "    Grouped collapse:"
            f"        method            = {method!r}"
            f"        axis_in           = {axis_in!r}"
            f"        axis              = {axis!r}"
            f"        over              = {over!r}"
            f"        over_days         = {over_days!r}"
            f"        over_years        = {over_years!r}"
            f"        within            = {within!r}"
            f"        within_days       = {within_days!r}"
            f"        within_years      = {within_years!r}"
            f"        regroup           = {regroup!r}"
            f"        group             = {group!r}"
            f"        group_span        = {group_span!r}"
            f"        group_contiguous  = {group_contiguous!r}"
        )  # pragma: no cover

        # Size of uncollapsed axis
        axis_size = self.domain_axes(todict=True)[axis].get_size()
        # Integer position of collapse axis
        iaxis = self.get_data_axes().index(axis)

        fl = []

        # If group, rolling window, classification, etc, do something
        # special for size one axes - either return unchanged
        # (possibly mofiying cell methods with , e.g, within_days', or
        # raising an exception for 'can't match', I suppose.

        classification = None

        if group is not None:
            if within is not None or over is not None:
                raise ValueError(
                    "Can't set 'group' parameter for a climatological "
                    "collapse"
                )

            if isinstance(group, numpy_ndarray):
                classification = numpy_squeeze(group.copy())

                if classification.dtype.kind != "i":
                    raise ValueError(
                        "Can't group by numpy array of type "
                        f"{classification.dtype.name}"
                    )
                elif classification.shape != (axis_size,):
                    raise ValueError(
                        "Can't group by numpy array with incorrect "
                        f"shape: {classification.shape}"
                    )

                # Set group to None
                group = None

        if group is not None:
            if isinstance(group, Query):
                group = (group,)

            if isinstance(group, int):
                # ----------------------------------------------------
                # E.g. group=3
                # ----------------------------------------------------
                coord = None
                classification = numpy_empty((axis_size,), int)

                start = 0
                end = group
                n = 0
                while start < axis_size:
                    classification[start:end] = n
                    start = end
                    end += group
                    n += 1

                if group_span is True or group_span is None:
                    # Use the group definition as the group span
                    group_span = group

            elif isinstance(group, TimeDuration):
                # ----------------------------------------------------
                # E.g. group=cf.M()
                # ----------------------------------------------------
                coord = self.dimension_coordinate(
                    filter_by_axis=(axis,), default=None
                )
                if coord is None:
                    raise ValueError(
                        "Dimension coordinates are required for a "
                        "grouped collapse with TimeDuration groups."
                    )

                classification = numpy_empty((axis_size,), int)
                classification.fill(-1)

                lower, upper, lower_limit, upper_limit = _tyu(
                    coord, group_by, True
                )

                classification, n = _time_interval(
                    classification,
                    0,
                    coord=coord,
                    interval=group,
                    lower=lower,
                    upper=upper,
                    lower_limit=lower_limit,
                    upper_limit=upper_limit,
                    group_by=group_by,
                )

                if group_span is True or group_span is None:
                    # Use the group definition as the group span
                    group_span = group

            elif isinstance(group, Data):
                # ----------------------------------------------------
                # Chunks of
                # ----------------------------------------------------
                coord = self.dimension_coordinate(
                    filter_by_axis=(axis,), default=None
                )
                if coord is None:
                    raise ValueError(
                        "Dimension coordinates are required for a "
                        "grouped collapse with Data groups."
                    )

                if coord.Units.isreftime:
                    raise ValueError(
                        f"Can't group a reference-time axis with {group!r}. "
                        "Use a TimeDuration instance instead."
                    )

                if group.size != 1:
                    raise ValueError(
                        f"Group must have only one element: {group!r}"
                    )

                if group.Units and not group.Units.equivalent(coord.Units):
                    raise ValueError(
                        f"Can't group by {group!r} when coordinates have "
                        f"non-equivalent units {coord.Units!r}"
                    )

                classification = numpy_empty((axis_size,), int)
                classification.fill(-1)

                group = group.squeeze()

                lower, upper, lower_limit, upper_limit = _tyu(
                    coord, group_by, False
                )

                classification, n = _data_interval(
                    classification,
                    0,
                    coord=coord,
                    interval=group,
                    lower=lower,
                    upper=upper,
                    lower_limit=lower_limit,
                    upper_limit=upper_limit,
                    group_by=group_by,
                )

                if group_span is True or group_span is None:
                    # Use the group definition as the group span
                    group_span = group

            else:
                # ----------------------------------------------------
                # E.g. group=[cf.month(4), cf.month(cf.wi(9, 11))]
                # ----------------------------------------------------
                coord = self.dimension_coordinate(
                    filter_by_axis=(axis,), default=None
                )
                if coord is None:
                    coord = self.auxiliary_coordinate(
                        filter_by_axis=(axis,), axis_mode="exact", default=None
                    )
                    if coord is None:
                        raise ValueError(
                            "Dimension and/or auxiliary coordinates are "
                            "required for a grouped collapse with a "
                            "sequence of groups."
                        )

                classification = numpy_empty((axis_size,), int)
                classification.fill(-1)

                classification, n = _selection(
                    classification,
                    0,
                    coord=coord,
                    selection=group,
                    parameter="group",
                )

                classification = _discern_runs(classification)

                if group_span is None:
                    group_span = False
                elif group_span is True:
                    raise ValueError(
                        "Can't collapse: Can't set group_span=True when "
                        f"group={group!r}"
                    )

        if classification is None:
            if over == "days":
                # ----------------------------------------------------
                # Over days
                # ----------------------------------------------------
                coord = self.dimension_coordinate(
                    filter_by_axis=(axis,), default=None
                )
                if coord is None or not coord.Units.isreftime:
                    raise ValueError(
                        "Reference-time dimension coordinates are required "
                        "for an 'over days' collapse"
                    )

                if not coord.has_bounds():
                    raise ValueError(
                        "Reference-time dimension coordinate bounds are "
                        "required for an 'over days' collapse"
                    )

                cell_methods = self.cell_methods(todict=True)
                w = [
                    cm.get_qualifier("within", None)
                    for cm in cell_methods.values()
                ]
                if "days" not in w:
                    raise ValueError(
                        "An 'over days' collapse must come after a "
                        "'within days' cell method"
                    )

                # Parse the over_days parameter
                if isinstance(over_days, Query):
                    over_days = (over_days,)
                elif isinstance(over_days, TimeDuration):
                    if over_days.Units.istime and over_days < Data(1, "day"):
                        raise ValueError(
                            f"Bad parameter value: over_days={over_days!r}"
                        )

                coordinate = "minimum"

                classification = numpy_empty((axis_size,), int)
                classification.fill(-1)

                if isinstance(over_days, TimeDuration):
                    _, _, lower_limit, upper_limit = _tyu(
                        coord, "bounds", True
                    )

                bounds = coord.bounds
                lower_bounds = coord.lower_bounds.datetime_array
                upper_bounds = coord.upper_bounds.datetime_array

                HMS0 = None

                n = 0
                for lower, upper in zip(lower_bounds, upper_bounds):
                    HMS_l = (
                        eq(lower.hour, attr="hour")
                        & eq(lower.minute, attr="minute")
                        & eq(lower.second, attr="second")
                    ).addattr("lower_bounds")
                    HMS_u = (
                        eq(upper.hour, attr="hour")
                        & eq(upper.minute, attr="minute")
                        & eq(upper.second, attr="second")
                    ).addattr("upper_bounds")
                    HMS = HMS_l & HMS_u

                    if not HMS0:
                        HMS0 = HMS
                    elif HMS.equals(HMS0):
                        # We've got repeat of the first cell, which
                        # means that we must have now classified all
                        # cells. Therefore we can stop.
                        break

                    logger.info(
                        f"          HMS  = {HMS!r}"
                    )  # pragma: no cover

                    if over_days is None:
                        # --------------------------------------------
                        # over_days=None
                        # --------------------------------------------
                        # Over all days
                        index = HMS.evaluate(coord).array
                        classification[index] = n
                        n += 1
                    elif isinstance(over_days, TimeDuration):
                        # --------------------------------------------
                        # E.g. over_days=cf.M()
                        # --------------------------------------------
                        classification, n = _time_interval_over(
                            classification,
                            n,
                            coord=coord,
                            interval=over_days,
                            lower=lower,
                            upper=upper,
                            lower_limit=lower_limit,
                            upper_limit=upper_limit,
                            group_by="bounds",
                            extra_condition=HMS,
                        )
                    else:
                        # --------------------------------------------
                        # E.g. over_days=[cf.month(cf.wi(4, 9))]
                        # --------------------------------------------
                        classification, n = _selection(
                            classification,
                            n,
                            coord=coord,
                            selection=over_days,
                            parameter="over_days",
                            extra_condition=HMS,
                        )

            elif over == "years":
                # ----------------------------------------------------
                # Over years
                # ----------------------------------------------------
                coord = self.dimension_coordinate(
                    filter_by_axis=(axis,), default=None
                )
                if coord is None or not coord.Units.isreftime:
                    raise ValueError(
                        "Reference-time dimension coordinates are required "
                        "for an 'over years' collapse"
                    )

                bounds = coord.get_bounds(None)
                if bounds is None:
                    raise ValueError(
                        "Reference-time dimension coordinate bounds are "
                        "required for an 'over years' collapse"
                    )

                cell_methods = self.cell_methods(todict=True)
                w = [
                    cm.get_qualifier("within", None)
                    for cm in cell_methods.values()
                ]
                o = [
                    cm.get_qualifier("over", None)
                    for cm in cell_methods.values()
                ]
                if "years" not in w and "days" not in o:
                    raise ValueError(
                        "An 'over years' collapse must come after a "
                        "'within years' or 'over days' cell method"
                    )

                # Parse the over_years parameter
                if isinstance(over_years, Query):
                    over_years = (over_years,)
                elif isinstance(over_years, TimeDuration):
                    if over_years.Units.iscalendartime:
                        over_years.Units = Units("calendar_years")
                        if not over_years.isint or over_years < 1:
                            raise ValueError(
                                "over_years is not a whole number of "
                                f"calendar years: {over_years!r}"
                            )
                    else:
                        raise ValueError(
                            "over_years is not a whole number of calendar "
                            f"years: {over_years!r}"
                        )

                coordinate = "minimum"

                classification = numpy_empty((axis_size,), int)
                classification.fill(-1)

                if isinstance(over_years, TimeDuration):
                    _, _, lower_limit, upper_limit = _tyu(
                        coord, "bounds", True
                    )

                lower_bounds = coord.lower_bounds.datetime_array
                upper_bounds = coord.upper_bounds.datetime_array
                mdHMS0 = None

                n = 0
                for lower, upper in zip(lower_bounds, upper_bounds):
                    mdHMS_l = (
                        eq(lower.month, attr="month")
                        & eq(lower.day, attr="day")
                        & eq(lower.hour, attr="hour")
                        & eq(lower.minute, attr="minute")
                        & eq(lower.second, attr="second")
                    ).addattr("lower_bounds")
                    mdHMS_u = (
                        eq(upper.month, attr="month")
                        & eq(upper.day, attr="day")
                        & eq(upper.hour, attr="hour")
                        & eq(upper.minute, attr="minute")
                        & eq(upper.second, attr="second")
                    ).addattr("upper_bounds")
                    mdHMS = mdHMS_l & mdHMS_u

                    if not mdHMS0:
                        # Keep a record of the first cell
                        mdHMS0 = mdHMS
                        logger.info(
                            f"        mdHMS0 = {mdHMS0!r}"
                        )  # pragma: no cover
                    elif mdHMS.equals(mdHMS0):
                        # We've got repeat of the first cell, which
                        # means that we must have now classified all
                        # cells. Therefore we can stop.
                        break

                    logger.info(
                        f"        mdHMS  = {mdHMS!r}"
                    )  # pragma: no cover

                    if over_years is None:
                        # --------------------------------------------
                        # over_years=None
                        # --------------------------------------------
                        # Over all years
                        index = mdHMS.evaluate(coord).array
                        classification[index] = n
                        n += 1
                    elif isinstance(over_years, TimeDuration):
                        # --------------------------------------------
                        # E.g. over_years=cf.Y(2)
                        # --------------------------------------------
                        classification, n = _time_interval_over(
                            classification,
                            n,
                            coord=coord,
                            interval=over_years,
                            lower=lower,
                            upper=upper,
                            lower_limit=lower_limit,
                            upper_limit=upper_limit,
                            group_by="bounds",
                            extra_condition=mdHMS,
                        )
                    else:
                        # --------------------------------------------
                        # E.g. over_years=cf.year(cf.lt(2000))
                        # --------------------------------------------
                        classification, n = _selection(
                            classification,
                            n,
                            coord=coord,
                            selection=over_years,
                            parameter="over_years",
                            extra_condition=mdHMS,
                        )

            elif within == "days":
                # ----------------------------------------------------
                # Within days
                # ----------------------------------------------------
                coord = self.dimension_coordinate(
                    filter_by_axis=(axis,), default=None
                )
                if coord is None or not coord.Units.isreftime:
                    raise ValueError(
                        "Reference-time dimension coordinates are required "
                        "for an 'over years' collapse"
                    )

                bounds = coord.get_bounds(None)
                if bounds is None:
                    raise ValueError(
                        "Reference-time dimension coordinate bounds are "
                        "required for a 'within days' collapse"
                    )

                classification = numpy_empty((axis_size,), int)
                classification.fill(-1)

                # Parse the within_days parameter
                if isinstance(within_days, Query):
                    within_days = (within_days,)
                elif isinstance(within_days, TimeDuration):
                    if (
                        within_days.Units.istime
                        and TimeDuration(24, "hours") % within_days
                    ):
                        # % Data(1, 'day'): # % within_days:
                        raise ValueError(
                            f"Can't collapse: within_days={within_days!r} "
                            "is not an exact factor of 1 day"
                        )

                if isinstance(within_days, TimeDuration):
                    # ------------------------------------------------
                    # E.g. within_days=cf.h(6)
                    # ------------------------------------------------
                    lower, upper, lower_limit, upper_limit = _tyu(
                        coord, "bounds", True
                    )

                    classification, n = _time_interval(
                        classification,
                        0,
                        coord=coord,
                        interval=within_days,
                        lower=lower,
                        upper=upper,
                        lower_limit=lower_limit,
                        upper_limit=upper_limit,
                        group_by=group_by,
                    )

                    if group_span is True or group_span is None:
                        # Use the within_days definition as the group
                        # span
                        group_span = within_days

                else:
                    # ------------------------------------------------
                    # E.g. within_days=cf.hour(cf.lt(12))
                    # ------------------------------------------------
                    classification, n = _selection(
                        classification,
                        0,
                        coord=coord,
                        selection=within_days,
                        parameter="within_days",
                    )

                    classification = _discern_runs(classification)

                    classification = _discern_runs_within(
                        classification, coord
                    )

                    if group_span is None:
                        group_span = False
                    elif group_span is True:
                        raise ValueError(
                            "Can't collapse: Can't set group_span=True when "
                            f"within_days={within_days!r}"
                        )

            elif within == "years":
                # ----------------------------------------------------
                # Within years
                # ----------------------------------------------------
                coord = self.dimension_coordinate(
                    filter_by_axis=(axis,), default=None
                )
                if coord is None or not coord.Units.isreftime:
                    raise ValueError(
                        "Can't collapse: Reference-time dimension "
                        'coordinates are required for a "within years" '
                        "collapse"
                    )

                if not coord.has_bounds():
                    raise ValueError(
                        "Can't collapse: Reference-time dimension coordinate "
                        'bounds are required for a "within years" collapse'
                    )

                classification = numpy_empty((axis_size,), int)
                classification.fill(-1)

                # Parse within_years
                if isinstance(within_years, Query):
                    within_years = (within_years,)
                elif within_years is None:
                    raise ValueError(
                        "Must set the within_years parameter for a "
                        '"within years" climatalogical time collapse'
                    )

                if isinstance(within_years, TimeDuration):
                    # ------------------------------------------------
                    # E.g. within_years=cf.M()
                    # ------------------------------------------------
                    lower, upper, lower_limit, upper_limit = _tyu(
                        coord, "bounds", True
                    )

                    classification, n = _time_interval(
                        classification,
                        0,
                        coord=coord,
                        interval=within_years,
                        lower=lower,
                        upper=upper,
                        lower_limit=lower_limit,
                        upper_limit=upper_limit,
                        group_by=group_by,
                    )

                    if group_span is True or group_span is None:
                        # Use the within_years definition as the group
                        # span
                        group_span = within_years

                else:
                    # ------------------------------------------------
                    # E.g. within_years=cf.season()
                    # ------------------------------------------------
                    classification, n = _selection(
                        classification,
                        0,
                        coord=coord,
                        selection=within_years,
                        parameter="within_years",
                        within=True,
                    )

                    classification = _discern_runs(classification, within=True)

                    classification = _discern_runs_within(
                        classification, coord
                    )

                    if group_span is None:
                        group_span = False
                    elif group_span is True:
                        raise ValueError(
                            "Can't collapse: Can't set group_span=True when "
                            f"within_years={within_years!r}"
                        )

            elif over is not None:
                raise ValueError(
                    f"Can't collapse: Bad 'over' syntax: {over!r}"
                )

            elif within is not None:
                raise ValueError(
                    f"Can't collapse: Bad 'within' syntax: {within!r}"
                )

        if classification is not None:
            # ---------------------------------------------------------
            # Collapse each group
            # ---------------------------------------------------------
            logger.info(
                f"        classification    = {classification}"
            )  # pragma: no cover

            unique = numpy_unique(classification)
            unique = unique[numpy_where(unique >= 0)[0]]
            unique.sort()

            ignore_n = -1
            for u in unique:
                index = numpy_where(classification == u)[0].tolist()

                pc = self.subspace(**{axis: index})

                # ----------------------------------------------------
                # Ignore groups that don't meet the specified criteria
                # ----------------------------------------------------
                if over is None:
                    coord = pc.coordinate(axis_in, default=None)

                    if group_span is not False:
                        if isinstance(group_span, int):
                            if (
                                pc.domain_axes(todict=True)[axis].get_size()
                                != group_span
                            ):
                                classification[index] = ignore_n
                                ignore_n -= 1
                                continue
                        else:
                            if coord is None:
                                raise ValueError(
                                    "Can't collapse: Need an unambiguous 1-d "
                                    "coordinate construct when "
                                    f"group_span={group_span!r}"
                                )

                            bounds = coord.get_bounds(None)
                            if bounds is None:
                                raise ValueError(
                                    "Can't collapse: Need unambiguous 1-d "
                                    "coordinate cell bounds when "
                                    f"group_span={group_span!r}"
                                )

                            lb = bounds[0, 0].get_data(_fill_value=False)
                            ub = bounds[-1, 1].get_data(_fill_value=False)
                            if coord.T:
                                lb = lb.datetime_array.item()
                                ub = ub.datetime_array.item()

                            if not coord.increasing:
                                lb, ub = ub, lb
                            if group_span + lb != ub:
                                # The span of this group is not the
                                # same as group_span, so don't
                                # collapse it.
                                classification[index] = ignore_n
                                ignore_n -= 1
                                continue

                    if (
                        group_contiguous
                        and coord is not None
                        and coord.has_bounds()
                        and not coord.bounds.contiguous(
                            overlap=(group_contiguous == 2)
                        )
                    ):
                        # This group is not contiguous, so don't
                        # collapse it.
                        classification[index] = ignore_n
                        ignore_n -= 1
                        continue

                if regroup:
                    continue

                # ----------------------------------------------------
                # Still here? Then collapse the group
                # ----------------------------------------------------
                w = _group_weights(weights, iaxis, index)
                logger.info(
                    f"        Collapsing group {u}:"
                )  # pragma: no cover

                fl.append(
                    pc.collapse(
                        method,
                        axis,
                        weights=w,
                        measure=measure,
                        mtol=mtol,
                        ddof=ddof,
                        coordinate=coordinate,
                        squeeze=False,
                        inplace=True,
                        _create_zero_size_cell_bounds=True,
                        _update_cell_methods=False,
                    )
                )

            if regroup:
                # return the numpy array
                return classification

        elif regroup:
            raise ValueError("There is no group classification to return.")

        # Still here?
        if not fl:
            c = "contiguous " if group_contiguous else ""
            s = f" spanning {group_span}" if group_span is not False else ""
            if within is not None:
                s = f" within {within}{s}"

            raise ValueError(
                f"Can't collapse: No {c}groups{s} were identified"
            )

        if len(fl) == 1:
            f = fl[0]
        else:
            # Hack to fix missing bounds!
            for g in fl:
                try:
                    c = g.dimension_coordinate(
                        filter_by_axis=(axis,), default=None
                    )
                    if not c.has_bounds():
                        c.set_bounds(c.create_bounds())
                except Exception:
                    pass

            # --------------------------------------------------------
            # Sort the list of collapsed fields
            # --------------------------------------------------------
            if (
                coord is not None
                and coord.construct_type == "dimension_coordinate"
            ):
                fl.sort(
                    key=lambda g: g.dimension_coordinate(
                        filter_by_axis=(axis,)
                    ).datum(0),
                    reverse=coord.decreasing,
                )

            # --------------------------------------------------------
            # Concatenate the partial collapses
            # --------------------------------------------------------
            try:
                f = self.concatenate(fl, axis=iaxis, _preserve=False)
            except ValueError as error:
                raise ValueError(f"Can't collapse: {error}")

        if squeeze and f.domain_axes(todict=True)[axis].get_size() == 1:
            # Remove a totally collapsed axis from the field's
            # data array
            f.squeeze(axis, inplace=True)

        # ------------------------------------------------------------
        # Return the collapsed field
        # ------------------------------------------------------------
        self.__dict__ = f.__dict__
        logger.info("    End of grouped collapse")  # pragma: no cover

        return self

    def _update_cell_methods(
        self,
        method=None,
        domain_axes=None,
        input_axes=None,
        within=None,
        over=None,
        verbose=None,
    ):
        """Update the cell methods.

        :Parameters:

            method: `str`

            domain_axes: `Constructs` or `dict`

            {{verbose: `int` or `str` or `None`, optional}}

        :Returns:

            `None`

        """
        original_cell_methods = self.cell_methods(todict=True)  # .ordered()
        logger.info(
            "  Update cell methods:"
            f"    Original cell methods = {original_cell_methods}"
            f"    method        = {method!r}"
            f"    within        = {within!r}"
            f"    over          = {over!r}"
        )  # pragma: no cover

        if input_axes and tuple(input_axes) == ("area",):
            axes = ("area",)
        else:
            axes = tuple(domain_axes)

        comment = None

        method = _collapse_cell_methods.get(method, method)

        cell_method = CellMethod(axes=axes, method=method)
        if within:
            cell_method.set_qualifier("within", within)
        elif over:
            cell_method.set_qualifier("over", over)

        if comment:
            cell_method.set_qualifier("comment", comment)

        if original_cell_methods:
            # There are already some cell methods
            if len(domain_axes) == 1:
                # Only one axis has been collapsed
                key, original_domain_axis = tuple(domain_axes.items())[0]

                lastcm = tuple(original_cell_methods.values())[-1]
                lastcm_method = _collapse_cell_methods.get(
                    lastcm.get_method(None), lastcm.get_method(None)
                )

                if (
                    original_domain_axis.get_size()
                    == self.domain_axes(todict=True)[key].get_size()
                ):
                    if (
                        lastcm.get_axes(None) == axes
                        and lastcm_method == method
                        and lastcm_method
                        in (
                            "mean",
                            "maximum",
                            "minimum",
                            "point",
                            "sum",
                            "median",
                            "mode",
                            "minimum_absolute_value",
                            "maximum_absolute_value",
                        )
                        and not lastcm.get_qualifier("within", None)
                        and not lastcm.get_qualifier("over", None)
                    ):
                        # It was a null collapse (i.e. the method is
                        # the same as the last one and the size of the
                        # collapsed axis hasn't changed).
                        if within:
                            lastcm.within = within
                        elif over:
                            lastcm.over = over

                        cell_method = None

        if cell_method is not None:
            self.set_construct(cell_method)

        logger.info(
            f"    Modified cell methods = {self.cell_methods()}"
        )  # pragma: no cover

    @_inplace_enabled(default=False)
    def insert_dimension(self, axis, position=0, inplace=False):
        """Insert a size 1 axis into the data array.

        .. versionadded:: 3.0.0

        .. seealso:: `domain_axis`, `flatten`, `flip`, `squeeze`,
                     `transpose`, `unsqueeze`

        :Parameters:

            axis:
                Select the domain axis to insert, generally defined by that
                which would be selected by passing the given axis description
                to a call of the field construct's `domain_axis` method. For
                example, for a value of ``'X'``, the domain axis construct
                returned by ``f.domain_axis('X')`` is selected.

                If *axis* is `None` then a new domain axis construct will
                created for the inserted dimension.

            position: `int`, optional
                Specify the position that the new axis will have in the
                data array. By default the new axis has position 0, the
                slowest varying position.

            {{inplace: `bool`, optional}}

        :Returns:

            `Field` or `None`
                The field construct with expanded data, or `None` if the
                operation was in-place.

        **Examples**

        >>> f = cf.example_field(0)
        >>> print(f)
        Field: specific_humidity (ncvar%q)
        ----------------------------------
        Data            : specific_humidity(latitude(5), longitude(8)) 1
        Cell methods    : area: mean
        Dimension coords: latitude(5) = [-75.0, ..., 75.0] degrees_north
                        : longitude(8) = [22.5, ..., 337.5] degrees_east
                        : time(1) = [2019-01-01 00:00:00]
        >>> g = f.insert_dimension('T', 0)
        >>> print(g)
        Field: specific_humidity (ncvar%q)
        ----------------------------------
        Data            : specific_humidity(time(1), latitude(5), longitude(8)) 1
        Cell methods    : area: mean
        Dimension coords: latitude(5) = [-75.0, ..., 75.0] degrees_north
                        : longitude(8) = [22.5, ..., 337.5] degrees_east
                        : time(1) = [2019-01-01 00:00:00]

        A previously non-existent size 1 axis must be created prior to
        insertion:

        >>> f.insert_dimension(None, 1, inplace=True)
        >>> print(f)
        Field: specific_humidity (ncvar%q)
        ----------------------------------
        Data            : specific_humidity(time(1), key%domainaxis3(1), latitude(5), longitude(8)) 1
        Cell methods    : area: mean
        Dimension coords: latitude(5) = [-75.0, ..., 75.0] degrees_north
                        : longitude(8) = [22.5, ..., 337.5] degrees_east
                        : time(1) = [2019-01-01 00:00:00]

        """
        f = _inplace_enabled_define_and_cleanup(self)

        if axis is None:
            axis = f.set_construct(self._DomainAxis(1))
        else:
            axis = f.domain_axis(
                axis,
                key=True,
                default=ValueError("Can't identify a unique axis to insert"),
            )

        # Expand the dims in the field construct's data array
        super(Field, f).insert_dimension(
            axis=axis, position=position, inplace=True
        )

        return f

    def indices(self, *mode, **kwargs):
        """Create indices that define a subspace of the field construct.

        The subspace is defined by identifying indices based on the
        metadata constructs.

        Metadata constructs are selected conditions are specified on their
        data. Indices for subspacing are then automatically inferred from
        where the conditions are met.

        The returned tuple of indices may be used to created a subspace by
        indexing the original field construct with them.

        Metadata constructs and the conditions on their data are defined
        by keyword parameters.

        * Any domain axes that have not been identified remain unchanged.

        * Multiple domain axes may be subspaced simultaneously, and it
          doesn't matter which order they are specified in.

        * Subspace criteria may be provided for size 1 domain axes that
          are not spanned by the field construct's data.

        * Explicit indices may also be assigned to a domain axis
          identified by a metadata construct, with either a Python `slice`
          object, or a sequence of integers or booleans.

        * For a dimension that is cyclic, a subspace defined by a slice or
          by a `Query` instance is assumed to "wrap" around the edges of
          the data.

        * Conditions may also be applied to multi-dimensional metadata
          constructs. The "compress" mode is still the default mode (see
          the positional arguments), but because the indices may not be
          acting along orthogonal dimensions, some missing data may still
          need to be inserted into the field construct's data.

        **Auxiliary masks**

        When creating an actual subspace with the indices, if the first
        element of the tuple of indices is ``'mask'`` then the extent of
        the subspace is defined only by the values of elements three and
        onwards. In this case the second element contains an "auxiliary"
        data mask that is applied to the subspace after its initial
        creation, in order to set unselected locations to missing data.

        .. seealso:: `subspace`, `where`, `__getitem__`, `__setitem__`

        :Parameters:

            mode: `str`, *optional*
                There are three modes of operation, each of which provides
                indices for a different type of subspace:

                ==============  ==========================================
                *mode*          Description
                ==============  ==========================================
                ``'compress'``  This is the default mode. Unselected
                                locations are removed to create the
                                returned subspace. Note that if a
                                multi-dimensional metadata construct is
                                being used to define the indices then some
                                missing data may still be inserted at
                                unselected locations.

                ``'envelope'``  The returned subspace is the smallest that
                                contains all of the selected
                                indices. Missing data is inserted at
                                unselected locations within the envelope.

                ``'full'``      The returned subspace has the same domain
                                as the original field construct. Missing
                                data is inserted at unselected locations.
                ==============  ==========================================

            kwargs: *optional*
                A keyword name is an identity of a metadata construct, and
                the keyword value provides a condition for inferring
                indices that apply to the dimension (or dimensions)
                spanned by the metadata construct's data. Indices are
                created that select every location for which the metadata
                construct's data satisfies the condition.

        :Returns:

            `tuple`
                The indices meeting the conditions.

        **Examples**

        >>> q = cf.example_field(0)
        >>> print(q)
        Field: specific_humidity (ncvar%q)
        ----------------------------------
        Data            : specific_humidity(latitude(5), longitude(8)) 1
        Cell methods    : area: mean
        Dimension coords: latitude(5) = [-75.0, ..., 75.0] degrees_north
                        : longitude(8) = [22.5, ..., 337.5] degrees_east
                        : time(1) = [2019-01-01 00:00:00]
        >>> indices = q.indices(X=112.5)
        >>> print(indices)
        (slice(0, 5, 1), slice(2, 3, 1))
        >>> q[indices]
        <CF Field: specific_humidity(latitude(5), longitude(1)) 1>
        >>> q.indices(X=112.5, latitude=cf.gt(-60))
        (slice(1, 5, 1), slice(2, 3, 1))
        >>> q.indices(latitude=cf.eq(-45) | cf.ge(20))
        (array([1, 3, 4]), slice(0, 8, 1))
        >>> q.indices(X=[1, 2, 4], Y=slice(None, None, -1))
        (slice(4, None, -1), array([1, 2, 4]))
        >>> q.indices(X=cf.wi(-100, 200))
        (slice(0, 5, 1), slice(-2, 4, 1))
        >>> q.indices(X=slice(-2, 4))
        (slice(0, 5, 1), slice(-2, 4, 1))
        >>> q.indices('compress', X=[1, 2, 4, 6])
        (slice(0, 5, 1), array([1, 2, 4, 6]))
        >>> q.indices(Y=[True, False, True, True, False])
        (array([0, 2, 3]), slice(0, 8, 1))
        >>> q.indices('envelope', X=[1, 2, 4, 6])
        ('mask', [<CF Data(1, 6): [[False, ..., False]]>], slice(0, 5, 1), slice(1, 7, 1))
        >>> indices = q.indices('full', X=[1, 2, 4, 6])
        ('mask', [<CF Data(1, 8): [[True, ..., True]]>], slice(0, 5, 1), slice(0, 8, 1))
        >>> print(indices)
        >>> print(q)
        <CF Field: specific_humidity(latitude(5), longitude(8)) 1>

        >>> print(a)
        Field: air_potential_temperature (ncvar%air_potential_temperature)
        ------------------------------------------------------------------
        Data            : air_potential_temperature(time(120), latitude(5), longitude(8)) K
        Cell methods    : area: mean
        Dimension coords: time(120) = [1959-12-16 12:00:00, ..., 1969-11-16 00:00:00]
                        : latitude(5) = [-75.0, ..., 75.0] degrees_north
                        : longitude(8) = [22.5, ..., 337.5] degrees_east
                        : air_pressure(1) = [850.0] hPa
        >>> a.indices(T=410.5)
        (slice(2, 3, 1), slice(0, 5, 1), slice(0, 8, 1))
        >>> a.indices(T=cf.dt('1960-04-16'))
        (slice(4, 5, 1), slice(0, 5, 1), slice(0, 8, 1))
        >>> indices = a.indices(T=cf.wi(cf.dt('1962-11-01'),
        ...                             cf.dt('1967-03-17 07:30')))
        >>> print(indices)
        (slice(35, 88, 1), slice(0, 5, 1), slice(0, 8, 1))
        >>> a[indices]
        <CF Field: air_potential_temperature(time(53), latitude(5), longitude(8)) K>

        >>> print(t)
        Field: air_temperature (ncvar%ta)
        ---------------------------------
        Data            : air_temperature(atmosphere_hybrid_height_coordinate(1), grid_latitude(10), grid_longitude(9)) K
        Cell methods    : grid_latitude(10): grid_longitude(9): mean where land (interval: 0.1 degrees) time(1): maximum
        Field ancils    : air_temperature standard_error(grid_latitude(10), grid_longitude(9)) = [[0.76, ..., 0.32]] K
        Dimension coords: atmosphere_hybrid_height_coordinate(1) = [1.5]
                        : grid_latitude(10) = [2.2, ..., -1.76] degrees
                        : grid_longitude(9) = [-4.7, ..., -1.18] degrees
                        : time(1) = [2019-01-01 00:00:00]
        Auxiliary coords: latitude(grid_latitude(10), grid_longitude(9)) = [[53.941, ..., 50.225]] degrees_N
                        : longitude(grid_longitude(9), grid_latitude(10)) = [[2.004, ..., 8.156]] degrees_E
                        : long_name=Grid latitude name(grid_latitude(10)) = [--, ..., b'kappa']
        Cell measures   : measure:area(grid_longitude(9), grid_latitude(10)) = [[2391.9657, ..., 2392.6009]] km2
        Coord references: grid_mapping_name:rotated_latitude_longitude
                        : standard_name:atmosphere_hybrid_height_coordinate
        Domain ancils   : ncvar%a(atmosphere_hybrid_height_coordinate(1)) = [10.0] m
                        : ncvar%b(atmosphere_hybrid_height_coordinate(1)) = [20.0]
                        : surface_altitude(grid_latitude(10), grid_longitude(9)) = [[0.0, ..., 270.0]] m
        >>> indices = t.indices(latitude=cf.wi(51, 53))
        >>> print(indices)
        ('mask', [<CF Data(1, 5, 9): [[[False, ..., False]]]>], slice(0, 1, 1), slice(3, 8, 1), slice(0, 9, 1))
        >>> t[indices]
        <CF Field: air_temperature(atmosphere_hybrid_height_coordinate(1), grid_latitude(5), grid_longitude(9)) K>

        """
        if "exact" in mode:
            _DEPRECATION_ERROR_ARG(
                self,
                "indices",
                "exact",
                "Keywords are now never interpreted as regular expressions.",
            )  # pragma: no cover

        if len(mode) > 1:
            raise ValueError(
                "Can't provide more than one positional argument. "
                f"Got: {', '.join(repr(x) for x in mode)}"
            )

        if not mode or "compress" in mode:
            mode = "compress"
        elif "envelope" in mode:
            mode = "envelope"
        elif "full" in mode:
            mode = "full"
        else:
            raise ValueError(f"Invalid value for 'mode' argument: {mode[0]!r}")

        data_axes = self.get_data_axes()

        # ------------------------------------------------------------
        # Get the indices for every domain axis in the domain,
        # including any auxiliary masks
        # ------------------------------------------------------------
        domain_indices = self._indices(mode, data_axes, True, **kwargs)

        # Initialise the output indices with any auxiliary masks
        auxiliary_mask = domain_indices["mask"]
        if auxiliary_mask:
            # Ensure that each auxiliary mask is broadcastable to the
            # data
            masks = []
            for axes, mask in auxiliary_mask.items():
                axes = list(axes)
                for i, axis in enumerate(data_axes):
                    if axis not in axes:
                        axes.insert(0, axis)
                        mask.insert_dimension(0, inplace=True)

                new_order = [axes.index(axis) for axis in data_axes]
                mask.transpose(new_order, inplace=True)
                masks.append(mask)

            indices = ["mask", tuple(masks)]
        else:
            indices = []

        # Add the indices that apply to the field's data dimensions
        indices.extend([domain_indices["indices"][axis] for axis in data_axes])

        return tuple(indices)

        # iiiiiiiiiiiiiiiiiiii

        if "exact" in mode:
            _DEPRECATION_ERROR_ARG(
                self,
                "indices",
                "exact",
                "Keywords are now never interpreted as regular expressions.",
            )  # pragma: no cover

        if len(mode) > 1:
            raise ValueError(
                "Can't provide more than one positional argument."
            )

        envelope = "envelope" in mode
        full = "full" in mode
        compress = "compress" in mode or not (envelope or full)

        logger.debug(
            "Field.indices:"
            f"    envelope, full, compress = {envelope} {full} {compress}"
        )  # pragma: no cover

        auxiliary_mask = []

        data_axes = self.get_data_axes()

        # Initialize indices
        indices = [slice(None)] * self.ndim

        domain_axes = self.domain_axes(todict=True)

        parsed = {}
        unique_axes = set()
        n_axes = 0
        for identity, value in kwargs.items():
            if identity in domain_axes:
                axes = (identity,)
                key = None
                construct = None
            else:
                key, construct = self.construct(
                    identity,
                    filter_by_data=True,
                    item=True,
                    default=(None, None),
                )
                if construct is None:
                    raise ValueError(
                        "Can't find indices: Ambiguous axis or axes: "
                        f"{identity!r}"
                    )

                axes = self.get_data_axes(key)

            sorted_axes = tuple(sorted(axes))
            if sorted_axes not in parsed:
                n_axes += len(sorted_axes)

            parsed.setdefault(sorted_axes, []).append(
                (axes, key, construct, value)
            )

            unique_axes.update(sorted_axes)

        if len(unique_axes) < n_axes:
            raise ValueError(
                "Can't find indices: Multiple constructs with incompatible "
                "domain axes"
            )

        for sorted_axes, axes_key_construct_value in parsed.items():
            axes, keys, constructs, points = list(
                zip(*axes_key_construct_value)
            )
            n_items = len(constructs)
            n_axes = len(sorted_axes)

            if n_items > n_axes:
                if n_axes == 1:
                    a = "axis"
                else:
                    a = "axes"

                raise ValueError(
                    f"Error: Can't specify {n_items} conditions for "
                    f"{n_axes} {a}: {points}"
                )

            create_mask = False

            item_axes = axes[0]

            logger.debug(
                f"    item_axes = {item_axes!r}" f"    keys      = {keys!r}"
            )  # pragma: no cover

            if n_axes == 1:
                # ----------------------------------------------------
                # 1-d construct
                # ----------------------------------------------------
                ind = None

                logger.debug(
                    f"    {n_items} 1-d constructs: {constructs!r}"
                )  # pragma: no cover

                axis = item_axes[0]
                item = constructs[0]
                value = points[0]

                logger.debug(
                    f"    axis      = {axis!r}" f"    value     = {value!r}"
                )  # pragma: no cover

                if isinstance(value, (list, slice, tuple, numpy_ndarray)):
                    # ------------------------------------------------
                    # 1-dimensional CASE 1: Value is already an index,
                    #                       e.g. [0], (0,3),
                    #                       slice(0,4,2),
                    #                       numpy.array([2,4,7]),
                    #                       [True, False, True]
                    # -------------------------------------------------
                    logger.debug("    1-d CASE 1: ")  # pragma: no cover

                    index = value

                    if envelope or full:
                        size = self.constructs[axis].get_size()
                        d = Data(list(range(size)))
                        ind = (d[value].array,)
                        index = slice(None)

                elif (
                    item is not None
                    and isinstance(value, Query)
                    and value.operator in ("wi", "wo")
                    and item.construct_type == "dimension_coordinate"
                    and self.iscyclic(axis)
                ):
                    # self.iscyclic(sorted_axes)):
                    # ------------------------------------------------
                    # 1-dimensional CASE 2: Axis is cyclic and
                    #                       subspace criterion is a
                    #                       'within' or 'without'
                    #                       Query instance
                    # -------------------------------------------------
                    logger.debug("    1-d CASE 2: ")  # pragma: no cover

                    if item.increasing:
                        anchor0 = value.value[0]
                        anchor1 = value.value[1]
                    else:
                        anchor0 = value.value[1]
                        anchor1 = value.value[0]

                    a = self.anchor(axis, anchor0, dry_run=True)["roll"]
                    b = self.flip(axis).anchor(axis, anchor1, dry_run=True)[
                        "roll"
                    ]

                    size = item.size
                    if abs(anchor1 - anchor0) >= item.period():
                        if value.operator == "wo":
                            set_start_stop = 0
                        else:
                            set_start_stop = -a

                        start = set_start_stop
                        stop = set_start_stop
                    elif a + b == size:
                        b = self.anchor(axis, anchor1, dry_run=True)["roll"]
                        if (b == a and value.operator == "wo") or not (
                            b == a or value.operator == "wo"
                        ):
                            set_start_stop = -a
                        else:
                            set_start_stop = 0

                        start = set_start_stop
                        stop = set_start_stop
                    else:
                        if value.operator == "wo":
                            start = b - size
                            stop = -a + size
                        else:
                            start = -a
                            stop = b - size

                    index = slice(start, stop, 1)

                    if full:
                        # index = slice(start, start+size, 1)
                        d = Data(list(range(size)))
                        d.cyclic(0)
                        ind = (d[index].array,)

                        index = slice(None)

                elif item is not None:
                    # -------------------------------------------------
                    # 1-dimensional CASE 3: All other 1-d cases
                    # -------------------------------------------------
                    logger.debug("    1-d CASE 3:")  # pragma: no cover

                    item_match = value == item

                    if not item_match.any():
                        raise ValueError(
                            f"No {identity!r} axis indices found from: {value}"
                        )

                    index = numpy_asanyarray(item_match)

                    if envelope or full:
                        if numpy_ma_isMA(index):
                            ind = numpy_ma_where(index)
                        else:
                            ind = numpy_where(index)

                        index = slice(None)

                else:
                    raise ValueError(
                        "Must specify a domain axis construct or a construct "
                        "with data for which to create indices"
                    )

                logger.debug(f"    index = {index}")  # pragma: no cover

                # Put the index into the correct place in the list of
                # indices.
                #
                # Note that we might overwrite it later if there's an
                # auxiliary mask for this axis.
                if axis in data_axes:
                    indices[data_axes.index(axis)] = index

            else:
                # -----------------------------------------------------
                # N-dimensional constructs
                # -----------------------------------------------------
                logger.debug(
                    f"    {n_items} N-d constructs: {constructs!r}"
                    f"    {len(points)} points        : {points!r}"
                    f"    field.shape     : {self.shape}"
                )  # pragma: no cover

                # Make sure that each N-d item has the same relative
                # axis order as the field's data array.
                #
                # For example, if the data array of the field is
                # ordered T Z Y X and the item is ordered Y T then the
                # item is transposed so that it is ordered T Y. For
                # example, if the field's data array is ordered Z Y X
                # and the item is ordered X Y T (T is size 1) then
                # transpose the item so that it is ordered Y X T.
                g = self.transpose(data_axes, constructs=True)

                #                g = self
                #                data_axes = .get_data_axes(default=None)
                #                for item_axes2 in axes:
                #                    if item_axes2 != data_axes:
                #                        g = self.transpose(data_axes, constructs=True)
                #                        break

                item_axes = g.get_data_axes(keys[0])

                constructs = [g.constructs[key] for key in keys]
                logger.debug(
                    f"    transposed N-d constructs: {constructs!r}"
                )  # pragma: no cover

                item_matches = [
                    (value == construct).data
                    for value, construct in zip(points, constructs)
                ]

                item_match = item_matches.pop()

                for m in item_matches:
                    item_match &= m

                item_match = item_match.array  # LAMA alert

                if numpy_ma_isMA:
                    ind = numpy_ma_where(item_match)
                else:
                    ind = numpy_where(item_match)

                logger.debug(
                    f"    item_match  = {item_match}"
                    f"    ind         = {ind}"
                )  # pragma: no cover

                bounds = [
                    item.bounds.array[ind]
                    for item in constructs
                    if item.has_bounds()
                ]

                contains = False
                if bounds:
                    points2 = []
                    for v, construct in zip(points, constructs):
                        if isinstance(v, Query):
                            if v.operator == "contains":
                                contains = True
                                v = v.value
                            elif v.operator == "eq":
                                v = v.value
                            else:
                                contains = False
                                break

                        v = Data.asdata(v)
                        if v.Units:
                            v.Units = construct.Units

                        points2.append(v.datum())

                if contains:
                    # The coordinates have bounds and the condition is
                    # a 'contains' Query object. Check each
                    # potentially matching cell for actually including
                    # the point.
                    try:
                        Path
                    except NameError:
                        raise ImportError(
                            "Must install matplotlib to create indices based "
                            f"on {constructs[0].ndim}-d constructs and a "
                            "'contains' Query object"
                        )

                    if n_items != 2:
                        raise ValueError(
                            f"Can't index for cell from {n_axes}-d coordinate "
                            "objects"
                        )

                    if len(bounds) != 1:
                        raise ValueError(
                            "Can't create indices based on coordinates that "
                            "have bounds that aren't unique."
                        )

                    # Remove grid cells if, upon closer inspection,
                    # they do actually contain the point.
                    delete = [
                        n
                        for n, vertices in enumerate(zip(*zip(*bounds)))
                        if not Path(zip(*vertices)).contains_point(points2)
                    ]

                    if delete:
                        ind = [numpy_delete(ind_1d, delete) for ind_1d in ind]

            if ind is not None:
                mask_shape = [None] * self.ndim
                masked_subspace_size = 1
                ind = numpy_array(ind)
                logger.debug(f"    ind = {ind}")  # pragma: no cover

                for i, (axis, start, stop) in enumerate(
                    zip(item_axes, ind.min(axis=1), ind.max(axis=1))
                ):
                    if axis not in data_axes:
                        continue

                    position = data_axes.index(axis)

                    if indices[position] == slice(None):
                        if compress:
                            # Create a compressed index for this axis
                            size = stop - start + 1
                            index = sorted(set(ind[i]))
                        elif envelope:
                            # Create an envelope index for this axis
                            stop += 1
                            size = stop - start
                            index = slice(start, stop)
                        elif full:
                            # Create a full index for this axis
                            start = 0
                            #                            stop = self.axis_size(axis)
                            stop = domain_axes[axis].get_size()
                            size = stop - start
                            index = slice(start, stop)
                        else:
                            raise ValueError(
                                "Must have full, envelope or compress"
                            )  # pragma: no cover

                        indices[position] = index

                    mask_shape[position] = size
                    masked_subspace_size *= size
                    ind[i] -= start

                create_mask = ind.shape[1] < masked_subspace_size
            else:
                create_mask = False

            # --------------------------------------------------------
            # Create an auxiliary mask for these axes
            # --------------------------------------------------------
            logger.debug(
                f"    create_mask = {create_mask}"
            )  # pragma: no cover

            if create_mask:
                logger.debug(
                    f"    mask_shape  = {mask_shape}"
                )  # pragma: no cover

                mask = self.data._create_auxiliary_mask_component(
                    mask_shape, ind, compress
                )
                auxiliary_mask.append(mask)
                logger.debug(
                    f"    mask_shape  = {mask_shape}"
                )  # pragma: no cover
                logger.debug(
                    f"    mask.shape  = {mask.shape}"
                )  # pragma: no cover

        indices = tuple(parse_indices(self.shape, tuple(indices)))

        if auxiliary_mask:
            indices = ("mask", auxiliary_mask) + indices

            logger.debug(f"    Final indices = {indices}")  # pragma: no cover

        # Return the tuple of indices and the auxiliary mask (which
        # may be None)
        return indices

    @_inplace_enabled(default=True)
    def set_data(
        self, data, axes=None, set_axes=True, copy=True, inplace=True
    ):
        """Set the field construct data.

        .. versionadded:: 3.0.0

        .. seealso:: `data`, `del_data`, `get_data`, `has_data`,
                     `set_construct`

        :Parameters:

            data: `Data`
                The data to be inserted.

                {{data_like}}

            axes: (sequence of) `str` or `int`, optional
                Set the domain axes constructs that are spanned by the
                data. If unset, and the *set_axes* parameter is True, then
                an attempt will be made to assign existing domain axis
                constructs to the data.

                The contents of the *axes* parameter is mapped to domain
                axis constructs by translating each element into a domain
                axis construct key via the `domain_axis` method.

                *Parameter example:*
                  ``axes='domainaxis1'``

                *Parameter example:*
                  ``axes='X'``

                *Parameter example:*
                  ``axes=['latitude']``

                *Parameter example:*
                  ``axes=['X', 'longitude']``

                *Parameter example:*
                  ``axes=[1, 0]``

            set_axes: `bool`, optional
                If False then do not set the domain axes constructs that
                are spanned by the data, even if the *axes* parameter has
                been set. By default the axes are set either according to
                the *axes* parameter, or if any domain axis constructs
                exist then an attempt will be made to assign existing
                domain axis constructs to the data.

                If the *axes* parameter is `None` and no domain axis
                constructs exist then no attempt is made to assign domain
                axes constructs to the data, regardless of the value of
                *set_axes*.

            copy: `bool`, optional
                If True then set a copy of the data. By default the data
                are copied.

            {{inplace: `bool`, optional (default True)}}

                .. versionadded:: 3.7.0

        :Returns:

            `None` or `Field`
                If the operation was in-place then `None` is returned,
                otherwise return a new `Field` instance containing the new
                data.

        **Examples**

        >>> f = cf.Field()
        >>> f.set_data([1, 2, 3])
        >>> f.has_data()
        True
        >>> f.get_data()
        <CF Data(3): [1, 2, 3]>
        >>> f.data
        <CF Data(3): [1, 2, 3]>
        >>> f.del_data()
        <CF Data(3): [1, 2, 3]>
        >>> g = f.set_data([4, 5, 6], inplace=False)
        >>> g.data
        <CF Data(3): [4, 5, 6]>
        >>> f.has_data()
        False
        >>> print(f.get_data(None))
        None
        >>> print(f.del_data(None))
        None

        """
        data = self._Data(data, copy=False)

        # Construct new field
        f = _inplace_enabled_define_and_cleanup(self)

        domain_axes = f.domain_axes(todict=True)
        if axes is None and not domain_axes:
            set_axes = False

        if not set_axes:
            if not data.Units:
                units = getattr(f, "Units", None)
                if units is not None:
                    if copy:
                        copy = False
                        data = data.override_units(units, inplace=False)
                    else:
                        data.override_units(units, inplace=True)

            super(cfdm.Field, f).set_data(
                data, axes=None, copy=copy, inplace=True
            )

            return f

        if data.isscalar:
            # --------------------------------------------------------
            # The data array is scalar
            # --------------------------------------------------------
            if axes or axes == 0:
                raise ValueError(
                    "Can't set data: Wrong number of axes for scalar data "
                    f"array: axes={axes}"
                )

            axes = []

        elif axes is not None:
            # --------------------------------------------------------
            # Axes have been set
            # --------------------------------------------------------
            if isinstance(axes, (str, int, slice)):
                axes = (axes,)

            axes = [f.domain_axis(axis, key=True) for axis in axes]

            if len(axes) != data.ndim:
                raise ValueError(
                    f"Can't set data: {len(axes)} axes provided, but "
                    f"{data.ndim} needed"
                )

            for axis, size in zip(axes, data.shape):
                axis_size = domain_axes[axis].get_size(None)
                if size != axis_size:
                    axes_shape = tuple(
                        domain_axes[axis].get_size(None) for axis in axes
                    )
                    raise ValueError(
                        f"Can't set data: Data shape {data.shape} differs "
                        f"from shape implied by axes {axes}: {axes_shape}"
                    )

        elif f.get_data_axes(default=None) is None:
            # --------------------------------------------------------
            # The data is not scalar and axes have not been set and
            # the domain does not have data axes defined
            #
            # => infer the axes
            # --------------------------------------------------------
            data_shape = data.shape
            if len(data_shape) != len(set(data_shape)):
                raise ValueError(
                    f"Can't insert data: Ambiguous data shape: {data_shape}. "
                    "Consider setting the axes parameter."
                )

            if not domain_axes:
                raise ValueError("Can't set data: No domain axes exist")

            axes = []
            for n in data_shape:
                da_key = f.domain_axis(
                    filter_by_size=(n,), key=True, default=None
                )
                if da_key is None:
                    raise ValueError(
                        "Can't insert data: Ambiguous data shape: "
                        f"{data_shape}. Consider setting the axes parameter."
                    )

                axes.append(da_key)

        else:
            # --------------------------------------------------------
            # The data is not scalar and axes have not been set, but
            # there are data axes defined on the field.
            # --------------------------------------------------------
            axes = f.get_data_axes()
            if len(axes) != data.ndim:
                raise ValueError(
                    f"Wrong number of axes for data array: {axes!r}"
                )

            for axis, size in zip(axes, data.shape):
                if domain_axes[axis].get_size(None) != size:
                    raise ValueError(
                        "Can't insert data: Incompatible size for axis "
                        f"{axis!r}: {size}"
                    )

        if not data.Units:
            units = getattr(f, "Units", None)
            if units is not None:
                if copy:
                    copy = False
                    data = data.override_units(units, inplace=False)
                else:
                    data.override_units(units, inplace=True)

        super(cfdm.Field, f).set_data(data, axes=axes, copy=copy, inplace=True)

        # Apply cyclic axes
        if axes:
            cyclic = self._cyclic
            if cyclic:
                cyclic_axes = [
                    axes.index(axis) for axis in cyclic if axis in axes
                ]
                if cyclic_axes:
                    data.cyclic(cyclic_axes, True)

        return f

    def domain_mask(self, **kwargs):
        """Return a boolean field that is True where criteria are met.

        .. versionadded:: 1.1

        .. seealso:: `indices`, `mask`, `subspace`

        :Parameters:

            kwargs: optional
                A dictionary of keyword arguments to pass to the `indices`
                method to define the criteria to meet for a element to be
                set as `True`.

        :Returns:

            `Field`
                The domain mask.

        **Examples**

        Create a domain mask which is masked at all between between -30
        and 30 degrees of latitude:

        >>> m = f.domain_mask(latitude=cf.wi(-30, 30))

        """
        mask = self.copy()

        mask.clear_properties()
        mask.nc_del_variable(None)

        for key in self.constructs.filter_by_type(
            "cell_method", "field_ancillary", todict=True
        ):
            mask.del_construct(key)

        false_everywhere = Data.zeros(self.shape, dtype=bool)

        mask.set_data(false_everywhere, axes=self.get_data_axes(), copy=False)

        mask.subspace[mask.indices(**kwargs)] = True

        mask.long_name = "domain mask"

        return mask

    @_inplace_enabled(default=False)
    @_manage_log_level_via_verbosity
    def compute_vertical_coordinates(
        self, default_to_zero=True, strict=True, inplace=False, verbose=None
    ):
        """Compute non-parametric vertical coordinates.

        When vertical coordinates are a function of horizontal location as
        well as parameters which depend on vertical location, they cannot
        be stored in a vertical dimension coordinate construct. In such
        cases a parametric vertical dimension coordinate construct is
        stored and a coordinate reference construct contains the formula
        for computing the required non-parametric vertical coordinates.

        {{formula terms links}}

        For example, multi-dimensional non-parametric parametric ocean
        altitude coordinates can be computed from one-dimensional
        parametric ocean sigma coordinates.

        Coordinate reference systems based on parametric vertical
        coordinates are identified from the coordinate reference
        constructs and, if possible, the corresponding non-parametric
        vertical coordinates are computed and stored in a new auxiliary
        coordinate construct.

        If there are no appropriate coordinate reference constructs then
        the field construct is unchanged.

        .. versionadded:: 3.8.0

        .. seealso:: `CoordinateReference`

        :Parameters:

            {{default_to_zero: `bool`, optional}}

            strict: `bool`
                If False then allow the computation to occur when

                * A domain ancillary construct has no standard name, but
                  the corresponding term has a standard name that is
                  prescribed

                * When the computed standard name can not be found by
                  inference from the standard names of the domain
                  ancillary constructs, nor from the
                  ``computed_standard_name`` parameter of the relevant
                  coordinate reference construct.

                By default an exception is raised in these cases.

                If a domain ancillary construct does have a standard name,
                but one that is inconsistent with any prescribed standard
                names, then an exception is raised regardless of the value
                of *strict*.

            {{inplace: `bool`, optional}}

            {{verbose: `int` or `str` or `None`, optional}}

        :Returns:

            `Field` or `None`
                The field construct with the new non-parametric vertical
                coordinates, or `None` if the operation was in-place.

        **Examples**

        >>> f = cf.example_field(1)
        >>> print(f)
        Field: air_temperature (ncvar%ta)
        ---------------------------------
        Data            : air_temperature(atmosphere_hybrid_height_coordinate(1), grid_latitude(10), grid_longitude(9)) K
        Cell methods    : grid_latitude(10): grid_longitude(9): mean where land (interval: 0.1 degrees) time(1): maximum
        Field ancils    : air_temperature standard_error(grid_latitude(10), grid_longitude(9)) = [[0.76, ..., 0.32]] K
        Dimension coords: atmosphere_hybrid_height_coordinate(1) = [1.5]
                        : grid_latitude(10) = [2.2, ..., -1.76] degrees
                        : grid_longitude(9) = [-4.7, ..., -1.18] degrees
                        : time(1) = [2019-01-01 00:00:00]
        Auxiliary coords: latitude(grid_latitude(10), grid_longitude(9)) = [[53.941, ..., 50.225]] degrees_N
                        : longitude(grid_longitude(9), grid_latitude(10)) = [[2.004, ..., 8.156]] degrees_E
                        : long_name=Grid latitude name(grid_latitude(10)) = [--, ..., b'kappa']
        Cell measures   : measure:area(grid_longitude(9), grid_latitude(10)) = [[2391.9657, ..., 2392.6009]] km2
        Coord references: grid_mapping_name:rotated_latitude_longitude
                        : standard_name:atmosphere_hybrid_height_coordinate
        Domain ancils   : ncvar%a(atmosphere_hybrid_height_coordinate(1)) = [10.0] m
                        : ncvar%b(atmosphere_hybrid_height_coordinate(1)) = [20.0]
                        : surface_altitude(grid_latitude(10), grid_longitude(9)) = [[0.0, ..., 270.0]] m
        >>> print(f.auxiliary_coordinate('altitude', default=None))
        None
        >>> g = f.compute_vertical_coordinates()
        >>> print(g.auxiliary_coordinates)
        Constructs:
        {'auxiliarycoordinate0': <CF AuxiliaryCoordinate: latitude(10, 9) degrees_N>,
         'auxiliarycoordinate1': <CF AuxiliaryCoordinate: longitude(9, 10) degrees_E>,
         'auxiliarycoordinate2': <CF AuxiliaryCoordinate: long_name=Grid latitude name(10) >,
         'auxiliarycoordinate3': <CF AuxiliaryCoordinate: altitude(1, 10, 9) m>}
        >>> g.auxiliary_coordinate('altitude').dump()
        Auxiliary coordinate: altitude
            long_name = 'Computed from parametric atmosphere_hybrid_height_coordinate
                         vertical coordinates'
            standard_name = 'altitude'
            units = 'm'
            Data(1, 10, 9) = [[[10.0, ..., 5410.0]]] m
            Bounds:units = 'm'
            Bounds:Data(1, 10, 9, 2) = [[[[5.0, ..., 5415.0]]]] m

        """
        f = _inplace_enabled_define_and_cleanup(self)

        for cr in f.coordinate_references(todict=True).values():
            # --------------------------------------------------------
            # Compute the non-parametric vertical coordinates, if
            # possible.
            # --------------------------------------------------------
            (
                standard_name,
                computed_standard_name,
                computed,
                computed_axes,
                k_axis,
            ) = FormulaTerms.formula(f, cr, default_to_zero, strict)

            if computed is None:
                # No non-parametric vertical coordinates were
                # computed
                continue

            # --------------------------------------------------------
            # Convert the computed domain ancillary construct to an
            # auxiliary coordinate construct, and insert it into the
            # field construct.
            # --------------------------------------------------------
            c = f._AuxiliaryCoordinate(source=computed, copy=False)
            c.clear_properties()
            c.long_name = (
                f"Computed from parametric {standard_name} "
                "vertical coordinates"
            )
            if computed_standard_name:
                c.standard_name = computed_standard_name

            logger.detail(
                "Non-parametric coordinates:\n"
                f"{c.dump(display=False, _level=1)}"
            )  # pragma: no cover

            key = f.set_construct(c, axes=computed_axes, copy=False)

            # Reference the new coordinates from the coordinate
            # reference construct
            cr.set_coordinate(key)

            logger.debug(
                "Non-parametric coordinates construct key: {key!r}\n"
                "Updated coordinate reference construct:\n"
                f"{cr.dump(display=False, _level=1)}"
            )  # pragma: no cover

        return f

    def match_by_construct(self, *identities, OR=False, **conditions):
        """Whether or not there are particular metadata constructs.

        .. note:: The API changed at version 3.1.0

        .. versionadded:: 3.0.0

        .. seealso:: `match`, `match_by_property`, `match_by_rank`,
                     `match_by_identity`, `match_by_ncvar`,
                     `match_by_units`, `construct`

        :Parameters:

            identities: optional
                Select the unique construct returned by
                ``f.construct(*identities)``. See `construct` for
                details.

            conditions: optional
                Identify the metadata constructs that have any of the
                given identities or construct keys, and whose data satisfy
                conditions.

                A construct identity or construct key (as defined by the
                *identities* parameter) is given as a keyword name and a
                condition on its data is given as the keyword value.

                The condition is satisfied if any of its data values
                equals the value provided.

                *Parameter example:*
                  ``longitude=180.0``

                *Parameter example:*
                  ``time=cf.dt('1959-12-16')``

                *Parameter example:*
                  ``latitude=cf.ge(0)``

                *Parameter example:*
                  ``latitude=cf.ge(0), air_pressure=500``

                *Parameter example:*
                  ``**{'latitude': cf.ge(0), 'long_name=soil_level': 4}``

            OR: `bool`, optional
                If True then return `True` if at least one metadata
                construct matches at least one of the criteria given by
                the *identities* or *conditions* arguments. By default
                `True` is only returned if the field constructs matches
                each of the given criteria.

            mode: deprecated at version 3.1.0
                Use the *OR* parameter instead.

            constructs: deprecated at version 3.1.0

        :Returns:

            `bool`
                Whether or not the field construct contains the specified
                metadata constructs.

        **Examples**

            TODO

        """
        if identities:
            if identities[0] == "or":
                _DEPRECATION_ERROR_ARG(
                    self,
                    "match_by_construct",
                    "or",
                    message="Use 'OR=True' instead.",
                    version="3.1.0",
                )  # pragma: no cover

            if identities[0] == "and":
                _DEPRECATION_ERROR_ARG(
                    self,
                    "match_by_construct",
                    "and",
                    message="Use 'OR=False' instead.",
                    version="3.1.0",
                )  # pragma: no cover

        if not identities and not conditions:
            return True

        constructs = self.constructs

        if not constructs:
            return False

        n = 0

        self_cell_methods = self.cell_methods(todict=True)

        for identity in identities:
            cms = False
            try:
                cms = ": " in identity
            except TypeError:
                cms = False

            if cms:
                cms = CellMethod.create(identity)
                for cm in cms:
                    axes = [
                        self.domain_axis(axis, key=True, default=axis)
                        for axis in cm.get_axes(())
                    ]
                    if axes:
                        cm.set_axes(axes)

            if not cms:
                filtered = constructs(identity)
                if filtered:
                    # Check for cell methods
                    if set(filtered.construct_types().values()) == {
                        "cell_method"
                    }:
                        key = tuple(self_cell_methods)[-1]
                        filtered = self.cell_method(
                            identity, filter_by_key=(key,), default=None
                        )
                        if filtered is None:
                            if not OR:
                                return False

                            n -= 1

                    n += 1
                elif not OR:
                    return False
            else:
                cell_methods = tuple(self_cell_methods.values())[-len(cms) :]
                for cm0, cm1 in zip(cms, cell_methods):
                    if cm0.has_axes() and set(cm0.get_axes()) != set(
                        cm1.get_axes(())
                    ):
                        if not OR:
                            return False

                        n -= 1
                        break

                    if cm0.has_method() and (
                        cm0.get_method() != cm1.get_method(None)
                    ):
                        if not OR:
                            return False

                        n -= 1
                        break

                    ok = True
                    for key, value in cm0.qualifiers():
                        if value != cm1.get_qualifier(key, None):
                            if not OR:
                                return False

                            ok = False
                            break

                    if not ok:
                        n -= 1
                        break

                n += 1

        if conditions:
            for identity, value in conditions.items():
                if self.subspace("test", **{identity: value}):
                    n += 1
                elif not OR:
                    return False

        if OR:
            return bool(n)

        return True

    @_inplace_enabled(default=False)
    def moving_window(
        self,
        method,
        window_size=None,
        axis=None,
        weights=None,
        mode=None,
        cval=None,
        origin=0,
        scale=None,
        radius="earth",
        great_circle=False,
        inplace=False,
    ):
        """Perform moving window calculations along an axis.

        Moving mean, sum, and integral calculations are possible.

        By default moving means are unweighted, but weights based on
        the axis cell sizes (or custom weights) may applied to the
        calculation via the *weights* parameter.

        By default moving integrals must be weighted.

        When appropriate, a new cell method construct is created to
        describe the calculation.

        .. note:: The `moving_window` method can not, in general, be
                  emulated by the `convolution_filter` method, as the
                  latter i) can not change the window weights as the
                  filter passes through the axis; and ii) does not
                  update the cell method constructs.

        .. versionadded:: 3.3.0

        .. seealso:: `bin`, `collapse`, `convolution_filter`, `radius`,
                     `weights`

        :Parameters:

            method: `str`
                Define the moving window method. The method is given
                by one of the following strings (see
                https://ncas-cms.github.io/cf-python/analysis.html#collapse-methods
                for precise definitions):

                ==================  ============================  ========
                *method*            Description                   Weighted
                ==================  ============================  ========
                ``'sum'``           The sum of the values.        Never

                ``'mean'``          The weighted or unweighted    May be
                                    mean of the values.

                ``'integral'``      The integral of values.       Always
                ==================  ============================  ========

                * Methods that are "Never" weighted ignore the
                  *weights* parameter, even if it is set.

                * Methods that "May be" weighted will only be weighted
                  if the *weights* parameter is set.

                * Methods that are "Always" weighted require the
                  *weights* parameter to be set.

            window_size: `int`
                Specify the size of the window used to calculate the
                moving window.

                *Parameter example:*
                  A 5-point moving window is set with
                  ``window_size=5``.

            axis: `str` or `int`
                Select the domain axis over which the filter is to be
                applied, defined by that which would be selected by
                passing the given axis description to a call of the
                field construct's `domain_axis` method. For example,
                for a value of ``'X'``, the domain axis construct
                returned by ``f.domain_axis('X')`` is selected.

            weights: optional
                Specify the weights for the moving window. The weights
                are, those that would be returned by this call of the
                field construct's `weights` method:
                ``f.weights(weights, axes=axis, radius=radius,
                great_circle=great_circle, data=True)``. See the
                *axis*, *radius* and *great_circle* parameters and
                `cf.Field.weights` for details.

                .. note:: By default *weights* is `None`, resulting in
                          **unweighted calculations**.

                .. note:: Setting *weights* to `True` is generally a
                          good way to ensure that the moving window
                          calculations are appropriately weighted
                          according to the field construct's
                          metadata. In this case, if it is not
                          possible to create weights for the selected
                          *axis* then an exception will be raised.

                *Parameter example:*
                  To specify weights on the cell sizes of the selected
                  axis: ``weights=True``.

            mode: `str`, optional
                The *mode* parameter determines how the input array is
                extended when the filter overlaps an array border. The
                default value is ``'constant'`` or, if the dimension
                being convolved is cyclic (as ascertained by the
                `iscyclic` method), ``'wrap'``. The valid values and
                their behaviours are as follows:

                ==============  ==========================  ===========================
                *mode*          Description                 Behaviour
                ==============  ==========================  ===========================
                ``'reflect'``   The input is extended by    ``(c b a | a b c | c b a)``
                                reflecting about the edge

                ``'constant'``  The input is extended by    ``(k k k | a b c | k k k)``
                                filling all values beyond
                                the edge with the same
                                constant value (``k``),
                                defined by the *cval*
                                parameter.

                ``'nearest'``   The input is extended by    ``(a a a | a b c | c c c)``
                                replicating the last point

                ``'mirror'``    The input is extended by    ``(c b | a b c | b a)``
                                reflecting about the
                                centre of the last point.

                ``'wrap'``      The input is extended by    ``(a b c | a b c | a b c)``
                                wrapping around to the
                                opposite edge.
                ==============  ==========================  ===========================

                The position of the window relative to each value can
                be changed by using the *origin* parameter.

            cval: scalar, optional
                Value to fill past the edges of the array if *mode* is
                ``'constant'``. Ignored for other modes. Defaults to
                `None`, in which case the edges of the array will be
                filled with missing data. The only other valid value
                is ``0``.

                *Parameter example:*
                   To extend the input by filling all values beyond
                   the edge with zero: ``cval=0``

            origin: `int`, optional
                Controls the placement of the filter. Defaults to 0,
                which is the centre of the window. If the window size,
                defined by the *window_size* parameter, is even then
                then a value of 0 defines the index defined by
                ``window_size/2 -1``.

                *Parameter example:*
                  For a window size of 5, if ``origin=0`` then the
                  window is centred on each point. If ``origin=-2``
                  then the window is shifted to include the previous
                  four points. If ``origin=1`` then the window is
                  shifted to include the previous point and the and
                  the next three points.

            radius: optional
                Specify the radius used for calculating the areas of
                cells defined in spherical polar coordinates. The
                radius is that which would be returned by this call of
                the field construct's `~cf.Field.radius` method:
                ``f.radius(radius)``. See the `cf.Field.radius` for
                details.

                By default *radius* is ``'earth'`` which means that if
                and only if the radius can not found from the datums
                of any coordinate reference constructs, then the
                default radius taken as 6371229 metres.

            great_circle: `bool`, optional
                If True then allow, if required, the derivation of i)
                area weights from polygon geometry cells by assuming
                that each cell part is a spherical polygon composed of
                great circle segments; and ii) and the derivation of
                line-length weights from line geometry cells by
                assuming that each line part is composed of great
                circle segments.

            scale: number, optional
                If set to a positive number then scale the weights so
                that they are less than or equal to that number. By
                default the weights are scaled to lie between 0 and 1
                (i.e.  *scale* is 1).

                Ignored if the moving window method is not
                weighted. The *scale* parameter can not be set for
                moving integrals.

                *Parameter example:*
                  To scale all weights so that they lie between 0 and
                  0.5: ``scale=0.5``.

            {{inplace: `bool`, optional}}

        :Returns:

            `Field` or `None`
                The field construct of moving window values, or `None`
                if the operation was in-place.

        **Examples**

        >>> f = cf.example_field(0)
        >>> print(f)
        Field: specific_humidity (ncvar%q)
        ----------------------------------
        Data            : specific_humidity(latitude(5), longitude(8)) 1
        Cell methods    : area: mean
        Dimension coords: latitude(5) = [-75.0, ..., 75.0] degrees_north
                        : longitude(8) = [22.5, ..., 337.5] degrees_east
                        : time(1) = [2019-01-01 00:00:00]
        >>> print(f.array)
        [[0.007 0.034 0.003 0.014 0.018 0.037 0.024 0.029]
         [0.023 0.036 0.045 0.062 0.046 0.073 0.006 0.066]
         [0.11  0.131 0.124 0.146 0.087 0.103 0.057 0.011]
         [0.029 0.059 0.039 0.07  0.058 0.072 0.009 0.017]
         [0.006 0.036 0.019 0.035 0.018 0.037 0.034 0.013]]
        >>> print(f.coordinate('X').bounds.array)
        [[  0.  45.]
         [ 45.  90.]
         [ 90. 135.]
         [135. 180.]
         [180. 225.]
         [225. 270.]
         [270. 315.]
         [315. 360.]]
        >>> f.iscyclic('X')
        True
        >>> f.iscyclic('Y')
        False

        Create a weighted 3-point running mean for the cyclic 'X'
        axis:

        >>> g = f.moving_window('mean', 3, axis='X', weights=True)
        >>> print(g)
        Field: specific_humidity (ncvar%q)
        ----------------------------------
        Data            : specific_humidity(latitude(5), longitude(8)) 1
        Cell methods    : area: mean longitude(8): mean
        Dimension coords: latitude(5) = [-75.0, ..., 75.0] degrees_north
                        : longitude(8) = [22.5, ..., 337.5] degrees_east
                        : time(1) = [2019-01-01 00:00:00]
        >>> print(g.array)
        [[0.02333 0.01467 0.017   0.01167 0.023   0.02633 0.03    0.02   ]
         [0.04167 0.03467 0.04767 0.051   0.06033 0.04167 0.04833 0.03167]
         [0.084   0.12167 0.13367 0.119   0.112   0.08233 0.057   0.05933]
         [0.035   0.04233 0.056   0.05567 0.06667 0.04633 0.03267 0.01833]
         [0.01833 0.02033 0.03    0.024   0.03    0.02967 0.028   0.01767]]
        >>> print(g.coordinate('X').bounds.array)
        [[-45.  90.]
         [  0. 135.]
         [ 45. 180.]
         [ 90. 225.]
         [135. 270.]
         [180. 315.]
         [225. 360.]
         [270. 405.]]

        Create an unweighted 3-point running mean for the cyclic 'X'
        axis:

        >>> g = f.moving_window('mean', 3, axis='X')

        Create an weighted 4-point running integral for the non-cyclic
        'Y' axis:

        >>> g = f.moving_window('integral', 4, axis='Y', weights=True)
        >>> g.Units
        <Units: 0.0174532925199433 rad>
        >>> print(g.array)
        [[   --    --    --    --   --    --   --   --]
         [ 8.37 11.73 10.05 13.14 8.88 11.64 4.59 4.02]
         [ 8.34 11.79 10.53 13.77 8.88 11.64 4.89 3.54]
         [   --    --    --    --   --    --   --   --]
         [   --    --    --    --   --    --   --   --]]
        >>> print(g.coordinate('Y').bounds.array)
        [[-90.  30.]
         [-90.  60.]
         [-60.  90.]
         [-30.  90.]
         [ 30.  90.]]
        >>> g = f.moving_window('integral', 4, axis='Y', weights=True, cval=0)
        >>> print(g.array)
        [[ 7.5   9.96  8.88 11.04  7.14  9.48  4.32  3.51]
         [ 8.37 11.73 10.05 13.14  8.88 11.64  4.59  4.02]
         [ 8.34 11.79 10.53 13.77  8.88 11.64  4.89  3.54]
         [ 7.65 10.71  9.18 11.91  7.5   9.45  4.71  1.56]
         [ 1.05  2.85  1.74  3.15  2.28  3.27  1.29  0.9 ]]

        """
        method_values = ("mean", "sum", "integral")
        if method not in method_values:
            raise ValueError(
                f"Non-valid 'method' parameter value: {method!r}. "
                f"Expected one of {method_values!r}"
            )

        if cval is not None and cval != 0:
            raise ValueError("The cval parameter must be None or 0")

        window_size = int(window_size)

        # Construct new field
        f = _inplace_enabled_define_and_cleanup(self)

        # Find the axis for the moving window
        axis = f.domain_axis(axis, key=True)
        iaxis = self.get_data_axes().index(axis)

        if method == "sum" or weights is False:
            weights = None

        if method == "integral":
            measure = True
            if weights is None:
                raise ValueError(
                    "Must set weights parameter for 'integral' method"
                )

            if scale is not None:
                raise ValueError(
                    "Can't set the 'scale' parameter for moving integrals"
                )
        else:
            if scale is None:
                scale = 1.0

            measure = False

        if weights is not None:
            if isinstance(weights, Data):
                if weights.ndim > 1:
                    raise ValueError(
                        f"The input weights (shape {weights.shape}) do not "
                        f"match the selected axis (size {f.shape[iaxis]})"
                    )

                if weights.ndim == 1:
                    if weights.shape[0] != f.shape[iaxis]:
                        raise ValueError(
                            f"The input weights (size {weights.size}) do not "
                            f"match the selected axis (size {f.shape[iaxis]})"
                        )

            # Get the data weights
            w = f.weights(
                weights,
                axes=axis,
                measure=measure,
                scale=scale,
                radius=radius,
                great_circle=great_circle,
                data=True,
            )

            # Multiply the field by the (possibly adjusted) weights
            if numpy_can_cast(w.dtype, f.dtype):
                f *= w
            else:
                f = f * w

        # Create the window weights
        window = numpy_full((window_size,), 1.0)
        if weights is None and method == "mean":
            # If there is no data weighting, make sure that the sum of
            # the window weights is 1.
            window /= window.size

        f.convolution_filter(
            window,
            axis=axis,
            mode=mode,
            cval=cval,
            origin=origin,
            update_bounds=True,
            inplace=True,
        )

        if weights is not None and method == "mean":
            # Divide the field by the running sum of the adjusted data
            # weights
            w.convolution_filter(
                window=window,
                axis=iaxis,
                mode=mode,
                cval=0,
                origin=origin,
                inplace=True,
            )
            if numpy_can_cast(w.dtype, f.dtype):
                f /= w
            else:
                f = f / w

        # Add a cell method
        if f.domain_axis(axis).get_size() > 1 or method == "integral":
            f._update_cell_methods(
                method=method, domain_axes=f.domain_axes(axis, todict=True)
            )

        return f

    @_deprecated_kwarg_check("i")
    @_inplace_enabled(default=False)
    def convolution_filter(
        self,
        window=None,
        axis=None,
        mode=None,
        cval=None,
        origin=0,
        update_bounds=True,
        inplace=False,
        weights=None,
        i=False,
    ):
        """Convolve the field construct along the given axis with the
        specified filter.

        The magnitude of the integral of the filter (i.e. the sum of
        the window weights defined by the *window* parameter) affects
        the convolved values. For example, window weights of ``[0.2,
        0.2 0.2, 0.2, 0.2]`` will produce a non-weighted 5-point
        running mean; and window weights of ``[1, 1, 1, 1, 1]`` will
        produce a 5-point running sum. Note that the window weights
        returned by functions of the `scipy.signal.windows` package do
        not necessarily sum to 1 (see the examples for details).

        .. note:: The `moving_window` method can not, in general, be
                  emulated by the `convolution_filter` method, as the
                  latter i) can not change the window weights as the
                  filter passes through the axis; and ii) does not
                  update the cell method constructs.

        .. seealso:: `collapse`, `derivative`, `moving_window`,
                     `cf.relative_vorticity`

        :Parameters:

            window: sequence of numbers
                Specify the window weights to use for the filter.

                *Parameter example:*
                  An unweighted 5-point moving average can be computed
                  with ``window=[0.2, 0.2, 0.2, 0.2, 0.2]``

                Note that the `scipy.signal.windows` package has suite
                of window functions for creating window weights for
                filtering (see the examples for details).

                .. versionadded:: 3.3.0 (replaces the old weights
                                  parameter)

            axis:
                Select the domain axis over which the filter is to be
                applied, defined by that which would be selected by
                passing the given axis description to a call of the field
                construct's `domain_axis` method. For example, for a value
                of ``'X'``, the domain axis construct returned by
                ``f.domain_axis('X')`` is selected.

            mode: `str`, optional
                The *mode* parameter determines how the input array is
                extended when the filter overlaps an array border. The
                default value is ``'constant'`` or, if the dimension being
                convolved is cyclic (as ascertained by the `iscyclic`
                method), ``'wrap'``. The valid values and their behaviours
                are as follows:

                ==============  ==========================  ===========================
                *mode*          Description                 Behaviour
                ==============  ==========================  ===========================
                ``'reflect'``   The input is extended by    ``(c b a | a b c | c b a)``
                                reflecting about the edge

                ``'constant'``  The input is extended by    ``(k k k | a b c | k k k)``
                                filling all values beyond
                                the edge with the same
                                constant value (``k``),
                                defined by the *cval*
                                parameter.

                ``'nearest'``   The input is extended by    ``(a a a | a b c | d d d)``
                                replicating the last point

                ``'mirror'``    The input is extended by    ``(c b | a b c | b a)``
                                reflecting about the
                                centre of the last point.

                ``'wrap'``      The input is extended by    ``(a b c | a b c | a b c)``
                                wrapping around to the
                                opposite edge.
                ==============  ==========================  ===========================

                The position of the window relative to each value can be
                changed by using the *origin* parameter.

            cval: scalar, optional
                Value to fill past the edges of the array if *mode* is
                ``'constant'``. Ignored for other modes. Defaults to
                `None`, in which case the edges of the array will be
                filled with missing data.

                *Parameter example:*
                   To extend the input by filling all values beyond the
                   edge with zero: ``cval=0``

            origin: `int`, optional
                Controls the placement of the filter. Defaults to 0, which
                is the centre of the window. If the window has an even
                number of weights then then a value of 0 defines the index
                defined by ``width/2 -1``.

                *Parameter example:*
                  For a weighted moving average computed with a weights
                  window of ``[0.1, 0.15, 0.5, 0.15, 0.1]``, if
                  ``origin=0`` then the average is centred on each
                  point. If ``origin=-2`` then the average is shifted to
                  include the previous four points. If ``origin=1`` then
                  the average is shifted to include the previous point and
                  the and the next three points.

            update_bounds: `bool`, optional
                If False then the bounds of a dimension coordinate
                construct that spans the convolved axis are not
                altered. By default, the bounds of a dimension coordinate
                construct that spans the convolved axis are updated to
                reflect the width and origin of the window.

            {{inplace: `bool`, optional}}

            {{i: deprecated at version 3.0.0}}

            weights: deprecated at version 3.3.0
                Use the *window* parameter instead.

        :Returns:

            `Field` or `None`
                The convolved field construct, or `None` if the operation
                was in-place.

        **Examples**

        >>> f = cf.example_field(2)
        >>> print(f)
        Field: air_potential_temperature (ncvar%air_potential_temperature)
        ------------------------------------------------------------------
        Data            : air_potential_temperature(time(36), latitude(5), longitude(8)) K
        Cell methods    : area: mean
        Dimension coords: time(36) = [1959-12-16 12:00:00, ..., 1962-11-16 00:00:00]
                        : latitude(5) = [-75.0, ..., 75.0] degrees_north
                        : longitude(8) = [22.5, ..., 337.5] degrees_east
                        : air_pressure(1) = [850.0] hPa
        >>> print(f.array[:, 0, 0])
        [210.7 305.3 249.4 288.9 231.1 200.  234.4 289.2 204.3 203.6 261.8 256.2
         212.3 231.7 255.1 213.9 255.8 301.2 213.3 200.1 204.6 203.2 244.6 238.4
         304.5 269.8 267.9 282.4 215.  288.7 217.3 307.1 299.3 215.9 290.2 239.9]
        >>> print(f.coordinate('T').bounds.dtarray[0])
        [cftime.DatetimeGregorian(1959-12-01 00:00:00)
         cftime.DatetimeGregorian(1960-01-01 00:00:00)]
        >>> print(f.coordinate('T').bounds.dtarray[2])
        [cftime.DatetimeGregorian(1960-02-01 00:00:00)
         cftime.DatetimeGregorian(1960-03-01 00:00:00)]

        Create a 5-point (non-weighted) running mean:

        >>> g = f.convolution_filter([0.2, 0.2, 0.2, 0.2, 0.2], 'T')
        >>> print(g)
        Field: air_potential_temperature (ncvar%air_potential_temperature)
        ------------------------------------------------------------------
        Data            : air_potential_temperature(time(36), latitude(5), longitude(8)) K
        Cell methods    : area: mean
        Dimension coords: time(36) = [1959-12-16 12:00:00, ..., 1962-11-16 00:00:00]
                        : latitude(5) = [-75.0, ..., 75.0] degrees_north
                        : longitude(8) = [22.5, ..., 337.5] degrees_east
                        : air_pressure(1) = [850.0] hPa
        >>> print(g.array[:, 0, 0])
        [ -- -- 257.08 254.94 240.76 248.72 231.8 226.3 238.66 243.02 227.64
         233.12 243.42 233.84 233.76 251.54 247.86 236.86 235.0 224.48 213.16
         218.18 239.06 252.1 265.04 272.6 267.92 264.76 254.26 262.1 265.48
         265.66 265.96 270.48 -- --]
        >>> print(g.coordinate('T').bounds.dtarray[0])
        [cftime.DatetimeGregorian(1959-12-01 00:00:00)
         cftime.DatetimeGregorian(1960-03-01 00:00:00)]
        >>> print(g.coordinate('T').bounds.dtarray[2])
        [cftime.DatetimeGregorian(1959-12-01 00:00:00)
         cftime.DatetimeGregorian(1960-05-01 00:00:00)]

        Create a 5-point running sum:

        >>> g = f.convolution_filter([1, 1, 1, 1, 1], 'T')
        >>> print(g)
        Field: air_potential_temperature (ncvar%air_potential_temperature)
        ------------------------------------------------------------------
        Data            : air_potential_temperature(time(36), latitude(5), longitude(8)) K
        Cell methods    : area: mean
        Dimension coords: time(36) = [1959-12-16 12:00:00, ..., 1962-11-16 00:00:00]
                        : latitude(5) = [-75.0, ..., 75.0] degrees_north
                        : longitude(8) = [22.5, ..., 337.5] degrees_east
                        : air_pressure(1) = [850.0] hPa
        >>> print(g.array[:, 0, 0])
        [ -- -- 1285.4 1274.7 1203.8 1243.6 1159.0 1131.5 1193.3 1215.1
         1138.2 1165.6 1217.1 1169.2 1168.8 1257.7 1239.3 1184.3 1175.0
         1122.4 1065.8 1090.9 1195.3 1260.5 1325.2 1363.0 1339.6 1323.8
         1271.3 1310.5 1327.4 1328.3 1329.8 1352.4 -- --]
        >>> print(g.coordinate('T').bounds.dtarray[0])
        [cftime.DatetimeGregorian(1959-12-01 00:00:00)
         cftime.DatetimeGregorian(1960-03-01 00:00:00)]
        >>> print(g.coordinate('T').bounds.dtarray[2])
        [cftime.DatetimeGregorian(1959-12-01 00:00:00)
         cftime.DatetimeGregorian(1960-05-01 00:00:00)]

        Calculate a convolution along the time axis with Gaussian window
        weights, using the "nearest" mode at the border of the edges of
        the time dimension (note that the window weights returned by
        `scipy.signal.windows` functions do not necessarily sum to 1):

        >>> import scipy.signal.windows
        >>> gaussian_window = scipy.signal.windows.gaussian(3, std=0.4)
        >>> print(gaussian_window)
        [0.04393693 1.         0.04393693]
        >>> g = f.convolution_filter(gaussian_window, 'T', mode='nearest')
        >>> print(g.array[:, 0, 0])
        [233.37145775 325.51538316 275.50732596 310.01169661 252.58076685
         220.4526426  255.89394793 308.47513278 225.95212089 224.07900476
         282.00220208 277.03050023 233.73682991 252.23612278 274.67829762
         236.34737939 278.43191451 321.81081556 235.32558483 218.46124456
         222.31976533 222.93647058 264.00254989 262.52577025 326.82874967
         294.94950081 292.16197475 303.61714525 240.09238279 307.69393641
         243.47762505 329.79781991 322.27901629 241.80082237 310.22645435
         263.19096851]
        >>> print(g.coordinate('T').bounds.dtarray[0])
        [cftime.DatetimeGregorian(1959-12-01 00:00:00)
         cftime.DatetimeGregorian(1960-02-01 00:00:00)]
        >>> print(g.coordinate('T').bounds.dtarray[1])
        [cftime.DatetimeGregorian(1959-12-01 00:00:00)
         cftime.DatetimeGregorian(1960-03-01 00:00:00)]

        """
        if weights is not None:
            _DEPRECATION_ERROR_KWARGS(
                self,
                "convolution_filter",
                {"weights": weights},
                message="Use keyword 'window' instead.",
                version="3.3.0",
            )  # pragma: no cover

        if isinstance(window, str):
            _DEPRECATION_ERROR(
                "A string-valued 'window' parameter has been deprecated "
                "at version 3.0.0 and is no longer available. Provide a "
                "sequence of numerical window weights instead. "
                "scipy.signal.windows may be used to generate particular "
                "window functions."
            )  # pragma: no cover

        if isinstance(window[0], str):
            _DEPRECATION_ERROR(
                "A string-valued 'window' parameter element has been "
                "deprecated at version 3.0.0 and is no longer available. "
                "Provide a sequence of numerical window weights instead. "
                "scipy.signal.windows may be used to generate particular "
                "window functions."
            )  # pragma: no cover

        # Retrieve the axis
        axis_key = self.domain_axis(axis, key=True)
        iaxis = self.get_data_axes().index(axis_key)

        # Default mode to 'wrap' if the axis is cyclic
        if mode is None:
            if self.iscyclic(axis_key):
                mode = "wrap"
            else:
                mode = "constant"

        # Construct new field
        f = _inplace_enabled_define_and_cleanup(self)

        f.data.convolution_filter(
            window=window,
            axis=iaxis,
            mode=mode,
            cval=cval,
            origin=origin,
            inplace=True,
        )

        # Update the bounds of the convolution axis if necessary
        if update_bounds:
            coord = f.dimension_coordinate(
                filter_by_axis=(axis_key,), default=None
            )
            if coord is not None and coord.has_bounds():
                old_bounds = coord.bounds.array
                length = old_bounds.shape[0]
                new_bounds = numpy_empty((length, 2))
                len_weights = len(window)
                lower_offset = len_weights // 2 + origin
                upper_offset = len_weights - 1 - lower_offset
                if mode == "wrap":
                    if coord.direction():
                        new_bounds[:, 0] = coord.roll(
                            0, upper_offset
                        ).bounds.array[:, 0]
                        new_bounds[:, 1] = (
                            coord.roll(0, -lower_offset).bounds.array[:, 1]
                            + coord.period()
                        )
                    else:
                        new_bounds[:, 0] = (
                            coord.roll(0, upper_offset).bounds.array[:, 0]
                            + 2 * coord.period()
                        )
                        new_bounds[:, 1] = (
                            coord.roll(0, -lower_offset).bounds.array[:, 1]
                            + coord.period()
                        )
                else:
                    new_bounds[upper_offset:length, 0] = old_bounds[
                        0 : length - upper_offset, 0
                    ]
                    new_bounds[0:upper_offset, 0] = old_bounds[0, 0]
                    new_bounds[0 : length - lower_offset, 1] = old_bounds[
                        lower_offset:length, 1
                    ]
                    new_bounds[length - lower_offset : length, 1] = old_bounds[
                        length - 1, 1
                    ]

                coord.set_bounds(
                    self._Bounds(data=Data(new_bounds, units=coord.Units))
                )

        return f

    def convert(
        self, *identity, full_domain=True, cellsize=False, **filter_kwargs
    ):
        """Convert a metadata construct into a new field construct.

        The new field construct has the properties and data of the
        metadata construct, and domain axis constructs corresponding to
        the data. By default it also contains other metadata constructs
        (such as dimension coordinate and coordinate reference constructs)
        that define its domain.

        Only metadata constructs that can have data may be converted
        and they can be converted even if they do not actually have
        any data. Constructs such as cell methods which cannot have
        data cannot be converted.

        The `cf.read` function allows a field construct to be derived
        directly from a netCDF variable that corresponds to a metadata
        construct. In this case, the new field construct will have a
        domain limited to that which can be inferred from the
        corresponding netCDF variable - typically only domain axis and
        dimension coordinate constructs. This will usually result in a
        different field construct to that created with the convert method.

        .. versionadded:: 3.0.0

        .. seealso:: `cf.read`, `construct`

        :Parameters:

            identity, filter_kwargs: optional
                Select the unique construct returned by
                ``f.construct(*identity, **filter_kwargs)``. See
                `construct` for details.

            full_domain: `bool`, optional
                If False then do not create a domain, other than domain
                axis constructs, for the new field construct. By default
                as much of the domain as possible is copied to the new
                field construct.

            cellsize: `bool`, optional
                If True then create a field construct from the selected
                metadata construct's cell sizes.

        :Returns:

            `Field`
                The new field construct.

        **Examples**

        TODO

        """
        key, construct = self.construct(
            *identity, item=True, default=(None, None), **filter_kwargs
        )
        if key is None:
            raise ValueError(
                f"Can't find metadata construct with identity {identity!r}"
            )

        f = super().convert(key, full_domain=full_domain)

        if cellsize:
            # Change the new field's data to cell sizes
            try:
                cs = construct.cellsize
            except AttributeError as error:
                raise ValueError(error)

            f.set_data(cs.data, set_axes=False, copy=False)

        return f

    @_inplace_enabled(default=False)
    def cumsum(
        self, axis, masked_as_zero=False, coordinate=None, inplace=False
    ):
        """Return the field cumulatively summed along the given axis.

        The cell bounds of the axis are updated to describe the range
        over which the sums apply, and a new "sum" cell method
        construct is added to the resulting field construct.

        .. versionadded:: 3.0.0

        .. seealso:: `collapse`, `convolution_filter`, `moving_window`,
                     `sum`

        :Parameters:

            axis:
                Select the domain axis over which the cumulative sums are
                to be calculated, defined by that which would be selected
                by passing the given axis description to a call of the
                field construct's `domain_axis` method. For example, for a
                value of ``'X'``, the domain axis construct returned by
                ``f.domain_axis('X')`` is selected.

            masked_as_zero: `bool`, optional
                If True then set missing data values to zero before
                calculating the cumulative sum. By default the output data
                will be masked at the same locations as the original data.
                .. note:: Sums produced entirely from masked elements will
                          always result in masked values in the output
                          data, regardless of the setting of
                          *masked_as_zero*.

            coordinate: `str`, optional
                Set how the cell coordinate values for the summed axis are
                defined, relative to the new cell bounds. By default they
                are unchanged from the original field construct. The
                *coordinate* parameter may be one of:

                ===============  =========================================
                *coordinate*     Description
                ===============  =========================================
                `None`           This is the default.
                                 Output coordinates are unchanged.
                ``'mid_range'``  An output coordinate is the average of
                                 its output coordinate bounds.
                ``'minimum'``    An output coordinate is the minimum of
                                 its output coordinate bounds.
                ``'maximum'``    An output coordinate is the maximum of
                                 its output coordinate bounds.
                ===============  =========================================

                *Parameter Example:*
                  ``coordinate='maximum'``
            {{inplace: `bool`, optional}}

        :Returns:
            `Field` or `None`
                The field construct with the cumulatively summed axis, or
                `None` if the operation was in-place.

        **Examples**
        >>> f = cf.example_field(2)
        >>> print(f)
        Field: air_potential_temperature (ncvar%air_potential_temperature)
        ------------------------------------------------------------------
        Data            : air_potential_temperature(time(36), latitude(5), longitude(8)) K
        Cell methods    : area: mean
        Dimension coords: time(36) = [1959-12-16 12:00:00, ..., 1962-11-16 00:00:00]
                        : latitude(5) = [-75.0, ..., 75.0] degrees_north
                        : longitude(8) = [22.5, ..., 337.5] degrees_east
                        : air_pressure(1) = [850.0] hPa
        >>> print(f.dimension_coordinate('T').bounds[[0, -1]].datetime_array)
        [[cftime.DatetimeGregorian(1959-12-01 00:00:00)
          cftime.DatetimeGregorian(1960-01-01 00:00:00)]
         [cftime.DatetimeGregorian(1962-11-01 00:00:00)
          cftime.DatetimeGregorian(1962-12-01 00:00:00)]]
        >>> print(f.array[:, 0, 0])
        [210.7 305.3 249.4 288.9 231.1 200.  234.4 289.2 204.3 203.6 261.8 256.2
         212.3 231.7 255.1 213.9 255.8 301.2 213.3 200.1 204.6 203.2 244.6 238.4
         304.5 269.8 267.9 282.4 215.  288.7 217.3 307.1 299.3 215.9 290.2 239.9]
        >>> g = f.cumsum('T')
        >>> print(g)
        Field: air_potential_temperature (ncvar%air_potential_temperature)
        ------------------------------------------------------------------
        Data            : air_potential_temperature(time(36), latitude(5), longitude(8)) K
        Cell methods    : area: mean time(36): sum
        Dimension coords: time(36) = [1959-12-16 12:00:00, ..., 1962-11-16 00:00:00]
                        : latitude(5) = [-75.0, ..., 75.0] degrees_north
                        : longitude(8) = [22.5, ..., 337.5] degrees_east
                        : air_pressure(1) = [850.0] hPa
        >>> print(g.dimension_coordinate('T').bounds[[0, -1]].datetime_array)
        [[cftime.DatetimeGregorian(1959-12-01 00:00:00)
          cftime.DatetimeGregorian(1960-01-01 00:00:00)]
         [cftime.DatetimeGregorian(1959-12-01 00:00:00)
          cftime.DatetimeGregorian(1962-12-01 00:00:00)]]
        >>> print(g.array[:, 0, 0])
        [ 210.7  516.   765.4 1054.3 1285.4 1485.4 1719.8 2009.  2213.3 2416.9
         2678.7 2934.9 3147.2 3378.9 3634.  3847.9 4103.7 4404.9 4618.2 4818.3
         5022.9 5226.1 5470.7 5709.1 6013.6 6283.4 6551.3 6833.7 7048.7 7337.4
         7554.7 7861.8 8161.1 8377.  8667.2 8907.1]
        >>> g = f.cumsum('latitude', masked_as_zero=True)
        >>> g = f.cumsum('latitude', coordinate='mid_range')
        >>> f.cumsum('latitude', inplace=True)

        """
        # Retrieve the axis
        axis_key = self.domain_axis(axis, key=True)
        if axis_key is None:
            raise ValueError(f"Invalid axis specifier: {axis!r}")

        # Construct new field
        f = _inplace_enabled_define_and_cleanup(self)

        # Get the axis index
        axis_index = f.get_data_axes().index(axis_key)

        f.data.cumsum(axis_index, masked_as_zero=masked_as_zero, inplace=True)

        if self.domain_axis(axis_key).get_size() > 1:
            # Update the bounds of the summed axis if necessary
            coord = f.dimension_coordinate(
                filter_by_axis=(axis_key,), default=None
            )
            if coord is not None and coord.has_bounds():
                bounds = coord.get_bounds()
                bounds[:, 0] = bounds[0, 0]

                data = coord.get_data(None, _fill_value=False)

                if coordinate is not None and data is not None:
                    if coordinate == "mid_range":
                        data[...] = (
                            (bounds[:, 0] + bounds[:, 1]) * 0.5
                        ).squeeze()
                    elif coordinate == "minimum":
                        data[...] = coord.lower_bounds
                    elif coordinate == "maximum":
                        data[...] = coord.upper_bounds
                    else:
                        raise ValueError(
                            "'coordinate' parameter must be one of "
                            "(None, 'mid_range', 'minimum', 'maximum'). "
                            f"Got {coordinate!r}"
                        )

            # Add a cell method
            f._update_cell_methods(
                method="sum", domain_axes=f.domain_axes(axis_key, todict=True)
            )

        return f

    @_inplace_enabled(default=False)
    def flip(self, axes=None, inplace=False, i=False, **kwargs):
        """Flip (reverse the direction of) axes of the field.

        .. seealso:: `domain_axis`, `flatten`, `insert_dimension`,
                     `squeeze`, `transpose`, `unsqueeze`

        :Parameters:

            axes: (sequence of) `str` or `int`, optional
                Select the domain axes to flip, defined by the domain
                axes that would be selected by passing each given axis
                description to a call of the field construct's
                `domain_axis` method. For example, for a value of
                ``'X'``, the domain axis construct returned by
                ``f.domain_axis('X')`` is selected.

                If no axes are provided then all axes are flipped.

            {{inplace: `bool`, optional}}

            {{i: deprecated at version 3.0.0}}

            kwargs: deprecated at version 3.0.0

        :Returns:

            `Field` or `None`
                The field construct with flipped axes, or `None` if
                the operation was in-place.

        **Examples**

        >>> g = f.flip()
        >>> g = f.flip('time')
        >>> g = f.flip(1)
        >>> g = f.flip(['time', 1, 'dim2'])
        >>> f.flip(['dim2'], inplace=True)

        """
        if kwargs:
            _DEPRECATION_ERROR_KWARGS(self, "flip", kwargs)  # pragma: no cover

        if axes is None and not kwargs:
            # Flip all the axes
            axes = set(self.get_data_axes(default=()))
            iaxes = list(range(self.ndim))
        else:
            if isinstance(axes, (str, int)):
                axes = (axes,)

            axes = set([self.domain_axis(axis, key=True) for axis in axes])

            data_axes = self.get_data_axes(default=())
            iaxes = [
                data_axes.index(axis)
                for axis in axes.intersection(self.get_data_axes())
            ]

        # Flip the requested axes in the field's data array
        f = _inplace_enabled_define_and_cleanup(self)
        super(Field, f).flip(iaxes, inplace=True)

        # Flip any constructs which span the flipped axes
        for key, construct in f.constructs.filter_by_data(todict=True).items():
            construct_axes = f.get_data_axes(key)
            construct_flip_axes = axes.intersection(construct_axes)
            if construct_flip_axes:
                iaxes = [
                    construct_axes.index(axis) for axis in construct_flip_axes
                ]
                construct.flip(iaxes, inplace=True)

        return f

    def argmax(self, axis=None):
        """Return the indices of the maximum values along an axis.

        If no axis is specified then the returned index locates the
        maximum of the whole data.

        .. seealso:: `argmin`, `where`

        :Parameters:

        :Returns:

        **Examples**

        >>> g = f.argmax('T')

        """
        print("This method is not ready for use.")
        return

    # Keep these commented lines for using with the future dask version
    #
    #        standard_name = None
    #
    #        if axis is not None:
    #            axis_key = self.domain_axis(
    #                axis, key=True, default=ValueError("TODO")
    #            )
    #            axis = self.get_data_axes.index(axis_key)
    #            standard_name = self.domain_axis_identity(
    #                axis_key, strict=True, default=None
    #            )
    #
    #        indices = self.data.argmax(axis, unravel=True)
    #
    #        if axis is None:
    #            return self[indices]
    #
    #        # What if axis_key does not span array?
    #        out = self.subspace(**{axis_key: [0]})
    #        out.squeeze(axis_key, inplace=True)
    #
    #        for i in indices.ndindex():
    #            out.data[i] = org.data[indices[i].datum()]
    #
    #        for key, c in tuple(
    #            out.constructs.filter_by_type(
    #                "dimension_coordinate",
    #                "auxiliary_coordinate",
    #                "cell_measure",
    #                "domain_ancillary",
    #                "field_ancillary",
    #            )
    #            .filter_by_axis("and", axis_key)
    #            .items()
    #        ):
    #
    #            out.del_construct(key)
    #
    #            if c.construct_type == (
    #                "cell_measure",
    #                "domain_ancillary",
    #                "field_ancillary",
    #            ):
    #                continue
    #
    #            aux = self._AuxiliaryCoordinate()
    #            aux.set_properties(c.properties())
    #
    #            c_data = c.get_data(None)
    #            if c_data is not None:
    #                data = Data.empty(indices.shape, dtype=c.dtype)
    #                for x in indices.ndindex():
    #                    data[x] = c_data[indices[x]]
    #
    #                aux.set_data(data, copy=False)
    #
    #            c_bounds_data = c.get_bounds_data(None)
    #            if c_bounds_data is not None:
    #                bounds = Data.empty(
    #                    indices.shape + (c_bounds_data.shape[-1],),
    #                    dtype=c_bounds_data.dtype,
    #                )
    #                for x in indices.ndindex():
    #                    bounds[x] = c_bounds_data[indices[x]]
    #
    #                aux.set_bounds(
    #                    self._Bounds(data=bounds, copy=False), copy=False
    #                )
    #
    #            out.set_construct(aux, axes=out.get_data_axes(), copy=False)
    #
    #        if standard_name:
    #            cm = CellMethod()
    #            cm.create(standard_name + ": maximum")
    #
    #        return out

    @_deprecated_kwarg_check("i")
    def squeeze(self, axes=None, inplace=False, i=False, **kwargs):
        """Remove size 1 axes from the data.

        By default all size 1 axes are removed, but particular size 1 axes
        may be selected for removal.

        Squeezed domain axis constructs are not removed from the metadata
        constructs, nor from the domain of the field construct.

        .. seealso:: `domain_axis`, `flatten`, `insert_dimension`, `flip`,
                     `remove_axes`, `transpose`, `unsqueeze`

        :Parameters:

            axes: (sequence of) `str` or `int`, optional
                Select the domain axes to squeeze, defined by the domain
                axes that would be selected by passing each given axis
                description to a call of the field construct's
                `domain_axis` method. For example, for a value of ``'X'``,
                the domain axis construct returned by
                ``f.domain_axis('X')`` is selected.

                If no axes are provided then all size 1 axes are squeezed.

            {{inplace: `bool`, optional}}

            {{i: deprecated at version 3.0.0}}

            kwargs: deprecated at version 3.0.0

        :Returns:

            `Field` or `None`
                The field construct with squeezed data, or `None` if the
                operation was in-place.

                **Examples**

        >>> g = f.squeeze()
        >>> g = f.squeeze('time')
        >>> g = f.squeeze(1)
        >>> g = f.squeeze(['time', 1, 'dim2'])
        >>> f.squeeze(['dim2'], inplace=True)

        """
        if kwargs:
            _DEPRECATION_ERROR_KWARGS(
                self, "squeeze", kwargs
            )  # pragma: no cover

        data_axes = self.get_data_axes()

        if axes is None:
            domain_axes = self.domain_axes(todict=True)
            axes = [
                axis
                for axis in data_axes
                if domain_axes[axis].get_size(None) == 1
            ]
        else:
            if isinstance(axes, (str, int)):
                axes = (axes,)

            axes = [self.domain_axis(x, key=True) for x in axes]
            axes = set(axes).intersection(data_axes)

        iaxes = [data_axes.index(axis) for axis in axes]

        # Squeeze the field's data array
        return super().squeeze(iaxes, inplace=inplace)

    @_inplace_enabled(default=False)
    def swapaxes(self, axis0, axis1, inplace=False, i=False):
        """Interchange two axes of the data.

        .. seealso:: `flatten`, `flip`, `insert_dimension`, `squeeze`,
                     `transpose`

        :Parameters:

            axis0, axis1: TODO
                Select the axes to swap. Each axis is identified by its
                original integer position.

            {{inplace: `bool`, optional}}

        :Returns:

            `Field` or `None`
                The field construct with data with swapped axis
                positions. If the operation was in-place then `None` is
                returned.

        **Examples**

        >>> f.shape
        (1, 2, 3)
        >>> f.swapaxes(1, 0).shape
        (2, 1, 3)
        >>> f.swapaxes(0, -1).shape
        (3, 2, 1)
        >>> f.swapaxes(1, 1).shape
        (1, 2, 3)
        >>> f.swapaxes(-1, -1).shape
        (1, 2, 3)

        """
        data_axes = self.get_data_axes(default=None)

        da_key0 = self.domain_axis(axis0, key=True)
        da_key1 = self.domain_axis(axis1, key=True)

        if da_key0 not in data_axes:
            raise ValueError(
                f"Can't swapaxes: Bad axis specification: {axis0!r}"
            )

        if da_key1 not in data_axes:
            raise ValueError(
                f"Can't swapaxes: Bad axis specification: {axis1!r}"
            )

        axis0 = data_axes.index(da_key0)
        axis1 = data_axes.index(da_key1)

        f = _inplace_enabled_define_and_cleanup(self)
        super(Field, f).swapaxes(axis0, axis1, inplace=True)

        if data_axes is not None:
            data_axes = list(data_axes)
            data_axes[axis1], data_axes[axis0] = (
                data_axes[axis0],
                data_axes[axis1],
            )
            f.set_data_axes(data_axes)

        return f

    @_deprecated_kwarg_check("i")
    def transpose(
        self,
        axes=None,
        constructs=False,
        inplace=False,
        items=True,
        i=False,
        **kwargs,
    ):
        """Permute the axes of the data array.

        By default the order of the axes is reversed, but any ordering may
        be specified by selecting the axes of the output in the required
        order.

        By default metadata constructs are not transposed, but they may be
        if the *constructs* parameter is set.

        .. seealso:: `domain_axis`, `flatten`, `insert_dimension`, `flip`,
                     `squeeze`, `unsqueeze`

        :Parameters:

            axes: (sequence of) `str` or `int`, optional
                Select the domain axis order, defined by the domain axes
                that would be selected by passing each given axis
                description to a call of the field construct's
                `domain_axis` method. For example, for a value of ``'X'``,
                the domain axis construct returned by
                ``f.domain_axis('X')`` is selected.

                Each dimension of the field construct's data must be
                provided, or if no axes are specified then the axis order
                is reversed.

            constructs: `bool`
                If True then metadata constructs are also transposed so
                that their axes are in the same relative order as in the
                transposed data array of the field. By default metadata
                constructs are not altered.

            {{inplace: `bool`, optional}}

            items: deprecated at version 3.0.0
                Use the *constructs* parameter instead.

            {{i: deprecated at version 3.0.0}}

            kwargs: deprecated at version 3.0.0

        :Returns:

            `Field` or `None`
                The field construct with transposed data, or `None` if the
                operation was in-place.

        **Examples**

        >>> f.ndim
        3
        >>> g = f.transpose()
        >>> g = f.transpose(['time', 1, 'dim2'])
        >>> f.transpose(['time', -2, 'dim2'], inplace=True)

        """
        if not items:
            _DEPRECATION_ERROR_KWARGS(
                self,
                "transpose",
                {"items": items},
                "Use keyword 'constructs' instead.",
            )  # pragma: no cover

        if kwargs:
            _DEPRECATION_ERROR_KWARGS(
                self, "transpose", kwargs
            )  # pragma: no cover

        if axes is None:
            iaxes = list(range(self.ndim - 1, -1, -1))
        else:
            data_axes = self.get_data_axes(default=())
            if isinstance(axes, (str, int)):
                axes = (axes,)

            axes2 = [self.domain_axis(x, key=True) for x in axes]

            if sorted(axes2) != sorted(data_axes):
                raise ValueError(
                    f"Can't transpose {self.__class__.__name__}: "
                    f"Bad axis specification: {axes!r}"
                )

            iaxes = [data_axes.index(axis) for axis in axes2]

        # Transpose the field's data array
        return super().transpose(iaxes, constructs=constructs, inplace=inplace)

    @_deprecated_kwarg_check("i")
    @_inplace_enabled(default=False)
    def unsqueeze(self, inplace=False, i=False, axes=None, **kwargs):
        """Insert size 1 axes into the data array.

        All size 1 domain axes which are not spanned by the field
        construct's data are inserted.

        The axes are inserted into the slowest varying data array positions.

        .. seealso:: `flatten`, `flip`, `insert_dimension`, `squeeze`,
                     `transpose`

        :Parameters:

            {{inplace: `bool`, optional}}

            {{i: deprecated at version 3.0.0}}

            axes: deprecated at version 3.0.0

            kwargs: deprecated at version 3.0.0

        :Returns:

            `Field` or `None`
                The field construct with size-1 axes inserted in its data,
                or `None` if the operation was in-place.

        **Examples**

        >>> g = f.unsqueeze()
        >>> f.unsqueeze(['dim2'], inplace=True)

        """
        if kwargs:
            _DEPRECATION_ERROR_KWARGS(
                self, "unsqueeze", kwargs
            )  # pragma: no cover

        if axes is not None:
            _DEPRECATION_ERROR_KWARGS(
                self,
                "unsqueeze",
                {"axes": axes},
                "All size one domain axes missing from the data are "
                "inserted. Use method 'insert_dimension' to insert an "
                "individual size one domain axis.",
            )  # pragma: no cover

        f = _inplace_enabled_define_and_cleanup(self)

        size_1_axes = self.domain_axes(filter_by_size=(1,), todict=True)
        for axis in set(size_1_axes).difference(self.get_data_axes()):
            f.insert_dimension(axis, position=0, inplace=True)

        return f

    def cell_method(
        self,
        *identity,
        default=ValueError(),
        key=False,
        item=False,
        **filter_kwargs,
    ):
        """Select a cell method construct.

        {{unique construct}}

        .. versionadded:: 3.0.0

        .. seealso:: `construct`, `cell_methods`

        :Parameters:

            identity: optional
                Select cell method constructs that have an identity,
                defined by their `!identities` methods, that matches
                any of the given values.

                Additionally, the values are matched against construct
                identifiers, with or without the ``'key%'`` prefix.

                Additionally, if for a given value
                ``f.domain_axes(value)`` returns a unique domain axis
                construct then any cell method constructs that span
                exactly that axis are selected. See `domain_axes` for
                details.

                If no values are provided then all cell method
                constructs are selected.

                {{value match}}

                {{displayed identity}}

            {{key: `bool`, optional}}

            {{item: `bool`, optional}}

                .. versionadded:: (cfdm) 3.9.0

            default: optional
                Return the value of the *default* parameter if there
                is no unique construct.

                {{default Exception}}

            {{filter_kwargs: optional}}

                .. versionadded:: (cfdm) 3.9.0

        :Returns:

                {{Returns construct}}

        **Examples**

        """
        return self._construct(
            "cell_method",
            "cell_methods",
            identity,
            key=key,
            item=item,
            default=default,
            **filter_kwargs,
        )

    def field_ancillary(
        self,
        *identity,
        default=ValueError(),
        key=False,
        item=False,
        **filter_kwargs,
    ):
        """Select a field ancillary construct.

        {{unique construct}}

        .. versionadded:: 3.0.0

        .. seealso:: `construct`, `field_ancillaries`

        :Parameters:

            identity: optional
                Select field ancillary constructs that have an
                identity, defined by their `!identities` methods, that
                matches any of the given values.

                Additionally, the values are matched against construct
                identifiers, with or without the ``'key%'`` prefix.

                If no values are provided then all field ancillary
                constructs are selected.

                {{value match}}

                {{displayed identity}}

            {{key: `bool`, optional}}

            {{item: `bool`, optional}}

                .. versionadded:: (cfdm) 3.9.0

            default: optional
                Return the value of the *default* parameter if there
                is no unique construct.

                {{default Exception}}

            {{filter_kwargs: optional}}

                .. versionadded:: (cfdm) 3.9.0

        :Returns:

                {{Returns construct}}

        **Examples**

        """
        return self._construct(
            "field_ancillary",
            "field_ancillaries",
            identity,
            key=key,
            item=item,
            default=default,
            **filter_kwargs,
        )

    def domain_axis_position(self, *identity, **filter_kwargs):
        """Return the position in the data of a domain axis construct.

        .. versionadded:: 3.0.0

        .. seealso:: `domain_axis`

        :Parameters:

            identity, filter_kwargs: optional
                Select the unique domain axis construct returned by
                ``f.domain_axis(*identity, **filter_kwargs)``. See
                `domain_axis` for details.

        :Returns:

            `int`
                The position in the field construct's data of the
                selected domain axis construct.

        **Examples**

        >>> f
        <CF Field: air_temperature(time(12), latitude(64), longitude(128)) K>
        >>> f.get_data_axes()
        ('domainaxis0', 'domainaxis1', 'domainaxis2')
        >>> f.domain_axis_position('T')
        0
        >>> f.domain_axis_position('latitude')
        1
        >>> f.domain_axis_position('domainaxis1')
        1
        >>> f.domain_axis_position(2)
        2
        >>> f.domain_axis_position(-2)
        1

        """
        key = self.domain_axis(*identity, key=True)
        return self.get_data_axes().index(key)

    def axes_names(self, *identities, **kwargs):
        """Return canonical identities for each domain axis construct.

        :Parameters:

            kwargs: deprecated at version 3.0.0

        :Returns:

            `dict`
                The canonical name for the domain axis construct.

        **Examples**

        >>> f.axis_names()
        {'domainaxis0': 'atmosphere_hybrid_height_coordinate',
         'domainaxis1': 'grid_latitude',
         'domainaxis2': 'grid_longitude'}

        """
        if kwargs:
            _DEPRECATION_ERROR_KWARGS(
                self, "axes_names", kwargs
            )  # pragma: no cover

        out = self.domain_axes(todict=True).copy()

        for key in tuple(out):
            value = self.constructs.domain_axis_identity(key)
            if value is not None:
                out[key] = value
            else:
                del out[key]

        return out

    def axis_size(
        self, *identity, default=ValueError(), axes=None, **filter_kwargs
    ):
        """Return the size of a domain axis construct.

        :Parameters:

            identity, filter_kwargs: optional
                Select the unique domain axis construct returned by
                ``f.domain_axis(*identity, **filter_kwargs)``. See
                `domain_axis` for details.

            default: optional
                Return the value of the *default* parameter if a domain
                axis construct can not be found. If set to an `Exception`
                instance then it will be raised instead.

            axes: deprecated at version 3.0.0

        :Returns:

            `int`
                The size of the selected domain axis

        **Examples**

        >>> f
        <CF Field: eastward_wind(time(3), air_pressure(5), latitude(110), longitude(106)) m s-1>
        >>> f.axis_size('longitude')
        106
        >>> f.axis_size('Z')
        5

        """
        if axes:
            _DEPRECATION_ERROR_KWARGS(
                self, "axis_size", "Use keyword 'identity' instead."
            )  # pragma: no cover

        axis = self.domain_axis(*identity, default=None, **filter_kwargs)
        if axis is None:
            return self._default(default)

        return axis.get_size(default=default)

    def get_data_axes(self, *identity, default=ValueError(), **filter_kwargs):
        """Return domain axis constructs spanned by data.

        Specifically, returns the keys of the domain axis constructs
        spanned by the field's data, or the data of a metadata construct.

        .. versionadded:: 3.0.0

        .. seealso:: `del_data_axes`, `has_data_axes`,
                     `set_data_axes`, `construct`

        :Parameters:

            identity, filter_kwargs: optional
                Select the unique construct returned by
                ``f.construct(*identity, **filter_kwargs)``. See
                `construct` for details.

                If neither *identity* nor *filter_kwargs* are set then
                the domain of the field constructs's data are
                returned.

            default: optional
                Return the value of the *default* parameter if the
                data axes have not been set.

                {{default Exception}}

        :Returns:

            `tuple` of `str`
                The keys of the domain axis constructs spanned by the
                data.

        **Examples**

        >>> f.set_data_axes(['domainaxis0', 'domainaxis1'])
        >>> f.get_data_axes()
        ('domainaxis0', 'domainaxis1')
        >>> f.del_data_axes()
        ('domainaxis0', 'domainaxis1')
        >>> print(f.del_dataxes(None))
        None
        >>> print(f.get_data_axes(default=None))
        None

        """
        if not identity and not filter_kwargs:
            # Get axes of the Field data array
            return super().get_data_axes(default=default)

        key = self.construct(*identity, key=True, **filter_kwargs)

        axes = super().get_data_axes(key, default=None)
        if axes is None:
            return self._default(
                default, "Can't get axes for non-existent construct"
            )

        return axes

    def grad_xy(
        self,
        x_wrap=None,
        one_sided_at_boundary=False,
        radius=None,
    ):
        r"""Calculate the (X, Y) gradient vector.

        The horizontal gradient vector of a scalar function is
        calculated from a field that has dimension coordinates of X
        and Y, in either Cartesian (e.g. plane projection) or
        spherical polar coordinate systems.

        The horizontal gradient vector in Cartesian coordinates is
        given by:

        .. math:: \nabla f(x, y) = \left(
                                   \frac{\partial f}{\partial x},
                                   \frac{\partial f}{\partial y}
                                   \right)

        The horizontal gradient vector in spherical polar coordinates
        is given by:

        .. math:: \nabla f(\theta, \phi) = \left(
                                           \frac{1}{r}
                                           \frac{\partial f}{\partial \theta},
                                           \frac{1}{r \sin\theta}
                                           \frac{\partial f}{\partial \phi}
                                           \right)

        where *r* is radial distance to the origin, :math:`\theta` is
        the polar angle with respect to polar axis, and :math:`\phi`
        is the azimuthal angle.

        The gradient vector components are calculated using centred
        finite differences apart from at the boundaries (see the
        *x_wrap* and *one_sided_at_boundary* parameters). If missing
        values are present then missing values will be returned at all
        points where a centred finite difference could not be
        calculated.

        .. versionadded:: 3.12.0

        .. seealso:: `derivative`, `iscyclic`, `laplacian_xy`,
                     `cf.curl_xy`, `cf.div_xy`

        :Parameters:

            x_wrap: `bool`, optional
                Whether the X axis is cyclic or not. By default
                *x_wrap* is set to the result of this call to the
                field construct's `iscyclic` method:
                ``f.iscyclic('X')``. If the X axis is cyclic then
                centred differences at one boundary will always use
                values from the other boundary, regardless of the
                setting of *one_sided_at_boundary*.

                The cyclicity of the Y axis is always set to the
                result of ``f.iscyclic('Y')``.

            one_sided_at_boundary: `bool`, optional
                If True then one-sided finite differences are
                calculated at the non-cyclic boundaries. By default
                missing values are set at non-cyclic boundaries.

            {{radius: optional}}

        :Returns:

            `FieldList`
                The horizontal gradient vector of the scalar field.

        **Examples**

        >>> f = cf.example_field(0)
        >>> print(f)
        Field: specific_humidity (ncvar%q)
        ----------------------------------
        Data            : specific_humidity(latitude(5), longitude(8)) 1
        Cell methods    : area: mean
        Dimension coords: latitude(5) = [-75.0, ..., 75.0] degrees_north
                        : longitude(8) = [22.5, ..., 337.5] degrees_east
                        : time(1) = [2019-01-01 00:00:00]
        >>> f[...] = 0.1
        >>> print(f.array)
        [[0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1]
         [0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1]
         [0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1]
         [0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1]
         [0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1]]
        >>> fx, fy = f.grad_xy(radius='earth')
        >>> fx, fy
        (<CF Field: long_name=X gradient of specific_humidity(latitude(5), longitude(8)) m-1.rad-1>,
         <CF Field: long_name=Y gradient of specific_humidity(latitude(5), longitude(8)) m-1.rad-1>)
        >>> print(fx.array)
        [[0. 0. 0. 0. 0. 0. 0. 0.]
         [0. 0. 0. 0. 0. 0. 0. 0.]
         [0. 0. 0. 0. 0. 0. 0. 0.]
         [0. 0. 0. 0. 0. 0. 0. 0.]
         [0. 0. 0. 0. 0. 0. 0. 0.]]
        >>> print(fy.array)
        [[-- -- -- -- -- -- -- --]
         [0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0]
         [0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0]
         [0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0]
         [-- -- -- -- -- -- -- --]]
        >>> fx, fy = f.grad_xy(radius='earth', one_sided_at_boundary=True)
        >>> print(fy.array)
        [[0. 0. 0. 0. 0. 0. 0. 0.]
         [0. 0. 0. 0. 0. 0. 0. 0.]
         [0. 0. 0. 0. 0. 0. 0. 0.]
         [0. 0. 0. 0. 0. 0. 0. 0.]
         [0. 0. 0. 0. 0. 0. 0. 0.]]

        """
        f = self.copy()
        identity = f.identity()

        x_key, x_coord = f.dimension_coordinate(
            "X", item=True, default=(None, None)
        )
        y_key, y_coord = f.dimension_coordinate(
            "Y", item=True, default=(None, None)
        )

        if x_coord is None:
            raise ValueError("Field has no unique 'X' dimension coordinate")

        if y_coord is None:
            raise ValueError("Field has no unique 'Y' dimension coordinate")

        if x_wrap is None:
            x_wrap = f.iscyclic(x_key)

        x_units = x_coord.Units
        y_units = y_coord.Units

        # Check for spherical polar coordinates
        latlon = (x_units.islongitude and y_units.islatitude) or (
            x_units.units == "degrees" and y_units.units == "degrees"
        )

        if latlon:
            # --------------------------------------------------------
            # Spherical polar coordinates
            # --------------------------------------------------------
            # Convert latitude and longitude units to radians, so that
            # the units of the result are nice.
            x_coord.Units = _units_radians
            y_coord.Units = _units_radians

            # Get theta as a field that will broadcast to f, and
            # adjust its values so that theta=0 is at the north pole.
            theta = np.pi / 2 - f.convert(y_key, full_domain=True)

            r = f.radius(default=radius)

            X = f.derivative(
                x_key, wrap=x_wrap, one_sided_at_boundary=one_sided_at_boundary
            ) / (theta.sin() * r)

            Y = (
                f.derivative(
                    y_key,
                    wrap=None,
                    one_sided_at_boundary=one_sided_at_boundary,
                )
                / r
            )

            # Reset latitude and longitude coordinate units
            X.dimension_coordinate("X").Units = x_units
            X.dimension_coordinate("Y").Units = y_units

            Y.dimension_coordinate("X").Units = x_units
            Y.dimension_coordinate("Y").Units = y_units
        else:
            # --------------------------------------------------------
            # Cartesian coordinates
            # --------------------------------------------------------
            X = f.derivative(
                x_key, wrap=x_wrap, one_sided_at_boundary=one_sided_at_boundary
            )

            Y = f.derivative(
                y_key, wrap=None, one_sided_at_boundary=one_sided_at_boundary
            )

        # Set the standard name and long name
        X.set_property("long_name", f"X gradient of {identity}")
        Y.set_property("long_name", f"Y gradient of {identity}")
        X.del_property("standard_name", None)
        Y.del_property("standard_name", None)

        return FieldList((X, Y))

    @_inplace_enabled(default=False)
    @_manage_log_level_via_verbosity
    def halo(
        self,
        size,
        axes=None,
        tripolar=None,
        fold_index=-1,
        inplace=False,
        verbose=None,
    ):
        """Expand the field construct by adding a halo to its data.

        The halo may be applied over a subset of the data dimensions and
        each dimension may have a different halo size (including
        zero). The halo region is populated with a copy of the proximate
        values from the original data.

        The metadata constructs are similarly extended where appropriate.

        **Cyclic axes**

        A cyclic axis that is expanded with a halo of at least size 1 is
        no longer considered to be cyclic.

        **Tripolar domains**

        Global tripolar domains are a special case in that a halo added to
        the northern end of the "Y" axis must be filled with values that
        are flipped in "X" direction. Such domains can not be identified
        from the field construct's metadata, so need to be explicitly
        indicated with the *tripolar* parameter.

        .. versionadded:: 3.5.0

        :Parameters:

            size:  `int` or `dict`
                Specify the size of the halo for each axis.

                If *size* is a non-negative `int` then this is the halo
                size that is applied to all of the axes defined by the
                *axes* parameter.

                Alternatively, halo sizes may be assigned to axes
                individually by providing a `dict` for which a key
                specifies an axis (by passing the axis description to a
                call of the field construct's `domain_axis` method. For
                example, for a value of ``'X'``, the domain axis construct
                returned by ``f.domain_axis('X')``) with a corresponding
                value of the halo size for that axis. Axes not specified
                by the dictionary are not expanded, and the *axes*
                parameter must not also be set.

                *Parameter example:*
                  Specify a halo size of 1 for all otherwise selected
                  axes: ``size=1``

                *Parameter example:*
                  Specify a halo size of zero ``size=0``. This results in
                  no change to the data shape.

                *Parameter example:*
                  For data with three dimensions, specify a halo size of 3
                  for the first dimension and 1 for the second dimension:
                  ``size={0: 3, 1: 1}``. This is equivalent to ``size={0:
                  3, 1: 1, 2: 0}``

                *Parameter example:*
                  Specify a halo size of 2 for the "longitude" and
                  "latitude" axes: ``size=2, axes=['latutude',
                  'longitude']``, or equivalently ``size={'latutude': 2,
                  'longitude': 2}``.

            axes: (sequence of) `str` or `int`, optional
                Select the domain axes to be expanded, defined by the
                domain axes that would be selected by passing each given
                axis description to a call of the field construct's
                `domain_axis` method. For example, for a value of ``'X'``,
                the domain axis construct returned by
                ``f.domain_axis('X')`` is selected.

                By default, or if *axes* is `None`, all axes that span the
                data are selected. No axes are expanded if *axes* is an
                empty sequence.

                *Parameter example:*
                  ``axes='X'``

                *Parameter example:*
                  ``axes=['Y']``

                *Parameter example:*
                  ``axes=['X', 'Y']``

                *Parameter example:*
                  ``axes='longitude'``

                *Parameter example:*
                  ``axes=2``

                *Parameter example:*
                  ``axes='ncdim%i'``

            tripolar: `dict`, optional
                A dictionary defining the "X" and "Y" axes of a global
                tripolar domain. This is necessary because in the global
                tripolar case the "X" and "Y" axes need special treatment,
                as described above. It must have keys ``'X'`` and ``'Y'``,
                whose values identify the corresponding domain axis
                construct by passing the value to a call of the field
                construct's `domain_axis` method. For example, for a value
                of ``'ncdim%i'``, the domain axis construct returned by
                ``f.domain_axis('ncdim%i')``.

                The "X" and "Y" axes must be a subset of those identified
                by the *size* or *axes* parameter.

                See the *fold_index* parameter.

                *Parameter example:*
                  Define the "X" and Y" axes by their netCDF dimension
                  names: ``tripolar={'X': 'ncdim%i', 'Y': 'ncdim%j'}``

                *Parameter example:*
                  Define the "X" and Y" axes by positions 2 and 1
                  respectively of the data: ``tripolar={'X': 2, 'Y': 1}``

            fold_index: `int`, optional
                Identify which index of the "Y" axis corresponds to the
                fold in "X" axis of a tripolar grid. The only valid values
                are ``-1`` for the last index, and ``0`` for the first
                index. By default it is assumed to be the last
                index. Ignored if *tripolar* is `None`.

            {{inplace: `bool`, optional}}

            {{verbose: `int` or `str` or `None`, optional}}

        :Returns:

            `Field` or `None`
                The expanded field construct, or `None` if the operation
                was in-place.

        **Examples**

        >>> f = cf.example_field(0)
        >>> print(f)
        Field: specific_humidity (ncvar%q)
        ----------------------------------
        Data            : specific_humidity(latitude(5), longitude(8)) 1
        Cell methods    : area: mean
        Dimension coords: latitude(5) = [-75.0, ..., 75.0] degrees_north
                        : longitude(8) = [22.5, ..., 337.5] degrees_east
                        : time(1) = [2019-01-01 00:00:00]
        >>> print(f.array)
        [[0.007 0.034 0.003 0.014 0.018 0.037 0.024 0.029]
         [0.023 0.036 0.045 0.062 0.046 0.073 0.006 0.066]
         [0.11  0.131 0.124 0.146 0.087 0.103 0.057 0.011]
         [0.029 0.059 0.039 0.07  0.058 0.072 0.009 0.017]
         [0.006 0.036 0.019 0.035 0.018 0.037 0.034 0.013]]
        >>> print(f.coordinate('X').array)
        [ 22.5  67.5 112.5 157.5 202.5 247.5 292.5 337.5]

        >>> g = f.halo(1)
        >>> print(g)
        Field: specific_humidity (ncvar%q)
        ----------------------------------
        Data            : specific_humidity(latitude(7), longitude(10)) 1
        Cell methods    : area: mean
        Dimension coords: latitude(7) = [-75.0, ..., 75.0] degrees_north
                        : longitude(10) = [22.5, ..., 337.5] degrees_east
                        : time(1) = [2019-01-01 00:00:00]
        >>> print(g.array)
        [[0.007 0.007 0.034 0.003 0.014 0.018 0.037 0.024 0.029 0.029]
         [0.007 0.007 0.034 0.003 0.014 0.018 0.037 0.024 0.029 0.029]
         [0.023 0.023 0.036 0.045 0.062 0.046 0.073 0.006 0.066 0.066]
         [0.11  0.11  0.131 0.124 0.146 0.087 0.103 0.057 0.011 0.011]
         [0.029 0.029 0.059 0.039 0.07  0.058 0.072 0.009 0.017 0.017]
         [0.006 0.006 0.036 0.019 0.035 0.018 0.037 0.034 0.013 0.013]
         [0.006 0.006 0.036 0.019 0.035 0.018 0.037 0.034 0.013 0.013]]
        >>> print(g.coordinate('X').array)
        [ 22.5  22.5  67.5 112.5 157.5 202.5 247.5 292.5 337.5 337.5]

        >>> g = f.halo(1, axes='Y')
        >>> print(g)
        Field: specific_humidity (ncvar%q)
        ----------------------------------
        Data            : specific_humidity(latitude(7), longitude(8)) 1
        Cell methods    : area: mean
        Dimension coords: latitude(7) = [-75.0, ..., 75.0] degrees_north
                        : longitude(8) = [22.5, ..., 337.5] degrees_east
                        : time(1) = [2019-01-01 00:00:00]
        >>> print(g.array)
        [[0.007 0.034 0.003 0.014 0.018 0.037 0.024 0.029]
         [0.007 0.034 0.003 0.014 0.018 0.037 0.024 0.029]
         [0.023 0.036 0.045 0.062 0.046 0.073 0.006 0.066]
         [0.11  0.131 0.124 0.146 0.087 0.103 0.057 0.011]
         [0.029 0.059 0.039 0.07  0.058 0.072 0.009 0.017]
         [0.006 0.036 0.019 0.035 0.018 0.037 0.034 0.013]
         [0.006 0.036 0.019 0.035 0.018 0.037 0.034 0.013]]
        >>> h = f.halo({'Y': 1})
        >>> h.equals(g)
        True

        >>> g = f.halo({'Y': 2, 'X': 1})
        >>> print(g)
        Field: specific_humidity (ncvar%q)
        ----------------------------------
        Data            : specific_humidity(latitude(9), longitude(10)) 1
        Cell methods    : area: mean
        Dimension coords: latitude(9) = [-75.0, ..., 75.0] degrees_north
                        : longitude(10) = [22.5, ..., 337.5] degrees_east
                        : time(1) = [2019-01-01 00:00:00]
        >>> print(g.array)
        [[0.007 0.007 0.034 0.003 0.014 0.018 0.037 0.024 0.029 0.029]
         [0.023 0.023 0.036 0.045 0.062 0.046 0.073 0.006 0.066 0.066]
         [0.007 0.007 0.034 0.003 0.014 0.018 0.037 0.024 0.029 0.029]
         [0.023 0.023 0.036 0.045 0.062 0.046 0.073 0.006 0.066 0.066]
         [0.11  0.11  0.131 0.124 0.146 0.087 0.103 0.057 0.011 0.011]
         [0.029 0.029 0.059 0.039 0.07  0.058 0.072 0.009 0.017 0.017]
         [0.006 0.006 0.036 0.019 0.035 0.018 0.037 0.034 0.013 0.013]
         [0.029 0.029 0.059 0.039 0.07  0.058 0.072 0.009 0.017 0.017]
         [0.006 0.006 0.036 0.019 0.035 0.018 0.037 0.034 0.013 0.013]]

        """
        f = _inplace_enabled_define_and_cleanup(self)

        # Set the halo size for each axis.
        data_axes = f.get_data_axes(default=())
        if isinstance(size, dict):
            if axes is not None:
                raise ValueError(
                    "Can't set existing axes when size is a dict."
                )

            axis_halo = {
                self.domain_axis(k, key=True): v for k, v in size.items()
            }

            if not set(data_axes).issuperset(axis_halo):
                raise ValueError(
                    f"Can't apply halo: Bad axis specification: {size!r}"
                )
        else:
            if axes is None:
                axes = data_axes

            if isinstance(axes, (str, int)):
                axes = (axes,)

            axis_halo = {self.domain_axis(k, key=True): size for k in axes}

        if tripolar:
            # Find the X and Y axes of a tripolar grid
            tripolar = tripolar.copy()
            X_axis = tripolar.pop("X", None)
            Y_axis = tripolar.pop("Y", None)

            if X_axis is None:
                raise ValueError("Must provide a tripolar 'X' axis.")

            if Y_axis is None:
                raise ValueError("Must provide a tripolar 'Y' axis.")

            X = self.domain_axis(X_axis, key=True)
            Y = self.domain_axis(Y_axis, key=True)

            try:
                i_X = data_axes.index(X)
            except ValueError:
                raise ValueError(f"Axis {X_axis!r} is not spanned by the data")

            try:
                i_Y = data_axes.index(Y)
            except ValueError:
                raise ValueError(f"Axis {Y_axis!r} is not spanned by the data")

            tripolar["X"] = i_X
            tripolar["Y"] = i_Y

            tripolar_axes = {X: "X", Y: "Y"}

        # Add halos to the field construct's data
        size = {data_axes.index(axis): h for axis, h, in axis_halo.items()}

        f.data.halo(
            size=size,
            tripolar=tripolar,
            fold_index=fold_index,
            inplace=True,
            verbose=verbose,
        )

        # Change domain axis sizes
        for axis, h in axis_halo.items():
            d = f.domain_axis(axis)
            d.set_size(d.get_size() + 2 * h)

        # Add halos to metadata constructs
        for key, c in f.constructs.filter_by_data(todict=True).items():
            construct_axes = f.get_data_axes(key)
            construct_size = {
                construct_axes.index(axis): h
                for axis, h in axis_halo.items()
                if axis in construct_axes
            }

            if not construct_size:
                # This construct does not span an expanded axis
                continue

            construct_tripolar = False
            if tripolar and set(construct_axes).issuperset(tripolar_axes):
                construct_tripolar = {
                    axis_type: construct_axes.index(axis)
                    for axis, axis_type in tripolar_axes.items()
                }

            c.halo(
                size=construct_size,
                tripolar=construct_tripolar,
                fold_index=fold_index,
                inplace=True,
                verbose=verbose,
            )

        return f

    def percentile(
        self, ranks, axes=None, interpolation="linear", squeeze=False, mtol=1
    ):
        """Compute percentiles of the data along the specified axes.

        The default is to compute the percentiles along a flattened
        version of the data.

        If the input data are integers, or floats smaller than float64, or
        the input data contains missing values, then output data type is
        float64. Otherwise, the output data type is the same as that of
        the input.

        If multiple percentile ranks are given then a new, leading data
        dimension is created so that percentiles can be stored for each
        percentile rank.

        The output field construct has a new dimension coordinate
        construct that records the percentile ranks represented by its
        data.

        .. versionadded:: 3.0.4

        .. seealso:: `bin`, `collapse`, `digitize`, `where`

        :Parameters:

            ranks: (sequence of) number
                Percentile ranks, or sequence of percentile ranks, to
                compute, which must be between 0 and 100 inclusive.

            axes: (sequence of) `str` or `int`, optional
                Select the domain axes over which to calculate the
                percentiles, defined by the domain axes that would be
                selected by passing each given axis description to a call
                of the field construct's `domain_axis` method. For
                example, for a value of ``'X'``, the domain axis construct
                returned by ``f.domain_axis('X')`` is selected.

                By default, or if *axes* is `None`, all axes are selected.

            interpolation: `str`, optional
                Specify the interpolation method to use when the desired
                percentile lies between two data values ``i < j``:

                ===============  =========================================
                *interpolation*  Description
                ===============  =========================================
                ``'linear'``     ``i+(j-i)*fraction``, where ``fraction``
                                 is the fractional part of the index
                                 surrounded by ``i`` and ``j``
                ``'lower'``      ``i``
                ``'higher'``     ``j``
                ``'nearest'``    ``i`` or ``j``, whichever is nearest.
                ``'midpoint'``   ``(i+j)/2``
                ===============  =========================================

                By default ``'linear'`` interpolation is used.

            squeeze: `bool`, optional
                If True then all size 1 axes are removed from the returned
                percentiles data. By default axes over which percentiles
                have been calculated are left in the result as axes with
                size 1, meaning that the result is guaranteed to broadcast
                correctly against the original data.

            mtol: number, optional
                Set the fraction of input data elements which is allowed
                to contain missing data when contributing to an individual
                output data element. Where this fraction exceeds *mtol*,
                missing data is returned. The default is 1, meaning that a
                missing datum in the output array occurs when its
                contributing input array elements are all missing data. A
                value of 0 means that a missing datum in the output array
                occurs whenever any of its contributing input array
                elements are missing data. Any intermediate value is
                permitted.

                *Parameter example:*
                  To ensure that an output array element is a missing
                  datum if more than 25% of its input array elements are
                  missing data: ``mtol=0.25``.

        :Returns:

            `Field`
                The percentiles of the original data.

        **Examples**

        >>> f = cf.example_field(0)
        >>> print(f)
        Field: specific_humidity
        ------------------------
        Data            : specific_humidity(latitude(5), longitude(8)) 1
        Cell methods    : area: mean
        Dimension coords: time(1) = [2019-01-01 00:00:00]
                        : latitude(5) = [-75.0, ..., 75.0] degrees_north
                        : longitude(8) = [22.5, ..., 337.5] degrees_east
        >>> print(f.array)
        [[0.007 0.034 0.003 0.014 0.018 0.037 0.024 0.029]
         [0.023 0.036 0.045 0.062 0.046 0.073 0.006 0.066]
         [0.11  0.131 0.124 0.146 0.087 0.103 0.057 0.011]
         [0.029 0.059 0.039 0.07  0.058 0.072 0.009 0.017]
         [0.006 0.036 0.019 0.035 0.018 0.037 0.034 0.013]]
        >>> p = f.percentile([20, 40, 50, 60, 80])
        >>> print(p)
        Field: specific_humidity
        ------------------------
        Data            : specific_humidity(long_name=Percentile ranks for latitude, longitude dimensions(5), latitude(1), longitude(1)) 1
        Dimension coords: time(1) = [2019-01-01 00:00:00]
                        : latitude(1) = [0.0] degrees_north
                        : longitude(1) = [180.0] degrees_east
                        : long_name=Percentile ranks for latitude, longitude dimensions(5) = [20, ..., 80]
        >>> print(p.array)
        [[[0.0164]]
         [[0.032 ]]
         [[0.036 ]]
         [[0.0414]]
         [[0.0704]]]

        Find the standard deviation of the values above the 80th percentile:

        >>> p80 = f.percentile(80)
        >>> print(p80)
        Field: specific_humidity
        ------------------------
        Data            : specific_humidity(latitude(1), longitude(1)) 1
        Dimension coords: time(1) = [2019-01-01 00:00:00]
                        : latitude(1) = [0.0] degrees_north
                        : longitude(1) = [180.0] degrees_east
                        : long_name=Percentile ranks for latitude, longitude dimensions(1) = [80]
        >>> g = f.where(f<=p80, cf.masked)
        >>> print(g.array)
        [[  --    --    --    --    --    -- -- --]
         [  --    --    --    --    -- 0.073 -- --]
         [0.11 0.131 0.124 0.146 0.087 0.103 -- --]
         [  --    --    --    --    -- 0.072 -- --]
         [  --    --    --    --    --    -- -- --]]
        >>> g.collapse('standard_deviation', weights=True).data
        <CF Data(1, 1): [[0.024609938742357642]] 1>

        Find the mean of the values above the 45th percentile along the
        X axis:

        >>> p45 = f.percentile(45, axes='X')
        >>> print(p45.array)
        [[0.0189 ]
         [0.04515]
         [0.10405]
         [0.04185]
         [0.02125]]
        >>> g = f.where(f<=p45, cf.masked)
        >>> print(g.array)
        [[  -- 0.034    --    --    -- 0.037 0.024 0.029]
         [  --    --    -- 0.062 0.046 0.073    -- 0.066]
         [0.11 0.131 0.124 0.146    --    --    --    --]
         [  -- 0.059    -- 0.07  0.058 0.072    --    --]
         [  -- 0.036    -- 0.035   --  0.037 0.034    --]]
        >>> print(g.collapse('X: mean', weights=True).array)
        [[0.031  ]
         [0.06175]
         [0.12775]
         [0.06475]
         [0.0355 ]]

        Find the histogram bin boundaries associated with given
        percentiles, and digitize the data based on these bins:

        >>> bins = f.percentile([0, 10, 50, 90, 100], squeeze=True)
        >>> print(bins.array)
        [0.003  0.0088 0.036  0.1037 0.146 ]
        >>> i = f.digitize(bins, closed_ends=True)
        >>> print(i.array)
        [[0 1 0 1 1 2 1 1]
         [1 2 2 2 2 2 0 2]
         [3 3 3 3 2 2 2 1]
         [1 2 2 2 2 2 1 1]
         [0 2 1 1 1 2 1 1]]

        """
        data_axes = self.get_data_axes(default=())

        if axes is None:
            axes = data_axes[:]
            iaxes = list(range(self.ndim))
        else:
            if isinstance(axes, (str, int)):
                axes = (axes,)

            axes = set([self.domain_axis(axis, key=True) for axis in axes])
            iaxes = [
                data_axes.index(axis)
                for axis in axes.intersection(self.get_data_axes())
            ]

        data = self.data.percentile(
            ranks,
            axes=iaxes,
            interpolation=interpolation,
            squeeze=False,
            mtol=mtol,
        )

        # ------------------------------------------------------------
        # Initialize the output field with the percentile data
        # ------------------------------------------------------------
        out = type(self)()
        out.set_properties(self.properties())

        for axis in [
            axis
            for axis in self.domain_axes(todict=True)
            if axis not in data_axes
        ]:
            out.set_construct(self._DomainAxis(1), key=axis)

        out_data_axes = []
        if data.ndim == self.ndim:
            for n, axis in zip(data.shape, data_axes):
                out_data_axes.append(
                    out.set_construct(self._DomainAxis(n), key=axis)
                )
        elif data.ndim == self.ndim + 1:
            for n, axis in zip(data.shape[1:], data_axes):
                out_data_axes.append(
                    out.set_construct(self._DomainAxis(n), key=axis)
                )

            out_data_axes.insert(
                0, out.set_construct(self._DomainAxis(data.shape[0]))
            )

        out.set_data(data, axes=out_data_axes, copy=False)

        # ------------------------------------------------------------
        # Create dimension coordinate constructs for the percentile
        # axes
        # ------------------------------------------------------------
        if axes:
            for key, c in self.dimension_coordinates(
                filter_by_axis=axes, axis_mode="subset", todict=True
            ).items():
                c_axes = self.get_data_axes(key)

                c = c.copy()

                bounds = c.get_bounds_data(
                    c.get_data(None, _fill_value=False), _fill_value=False
                )
                if bounds is not None and bounds.shape[0] > 1:
                    bounds = Data(
                        [bounds.min().datum(), bounds.max().datum()],
                        units=c.Units,
                    )

                    data = bounds.mean()
                    c.set_data(data, copy=False)

                    bounds.insert_dimension(inplace=True)
                    c.set_bounds(self._Bounds(data=bounds), copy=False)

                out.set_construct(c, axes=c_axes, key=key, copy=False)

        # TODO optimise constructs access?
        other_axes = set(
            [
                axis
                for axis in self.domain_axes(todict=True)
                if axis not in axes or self.domain_axis(axis).size == 1
            ]
        )

        # ------------------------------------------------------------
        # Copy constructs to the output field
        # ------------------------------------------------------------
        if other_axes:
            for key, c in self.constructs.filter_by_axis(
                *other_axes, axis_mode="subset", todict=True
            ).items():
                c_axes = self.get_data_axes(key)
                out.set_construct(c, axes=c_axes, key=key)

        # ------------------------------------------------------------
        # Copy coordinate reference constructs to the output field
        # ------------------------------------------------------------
        out_coordinates = out.coordinates(todict=True)
        out_domain_ancillaries = out.domain_ancillaries(todict=True)

        for cr_key, ref in self.coordinate_references(todict=True).items():
            ref = ref.copy()

            for c_key in ref.coordinates():
                if c_key not in out_coordinates:
                    ref.del_coordinate(c_key)

            for (
                term,
                da_key,
            ) in ref.coordinate_conversion.domain_ancillaries().items():
                if da_key not in out_domain_ancillaries:
                    ref.coordinate_conversion.set_domain_ancillary(term, None)

            out.set_construct(ref, key=cr_key, copy=False)

        # ------------------------------------------------------------
        # Create a dimension coordinate for the percentile ranks
        # ------------------------------------------------------------
        dim = DimensionCoordinate()
        data = Data(ranks).squeeze()
        data.override_units(Units(), inplace=True)
        if not data.shape:
            data.insert_dimension(inplace=True)
        dim.set_data(data, copy=False)

        if out.ndim == self.ndim:
            axis = out.set_construct(self._DomainAxis(1))
        else:
            axis = out_data_axes[0]

        axes = sorted(axes)
        if len(axes) == 1:
            dim.long_name = (
                "Percentile ranks for "
                + self.constructs.domain_axis_identity(axes[0])
                + " dimensions"
            )
        else:
            dim.long_name = (
                "Percentile ranks for "
                + ", ".join(map(self.constructs.domain_axis_identity, axes))
                + " dimensions"
            )

        out.set_construct(dim, axes=axis, copy=False)

        if squeeze:
            out.squeeze(inplace=True)

        return out

    @_inplace_enabled(default=False)
    def flatten(self, axes=None, return_axis=False, inplace=False):
        """Flatten axes of the field.

        Any subset of the domain axes may be flattened.

        The shape of the data may change, but the size will not.

        Metadata constructs whose data spans the flattened axes will
        either themselves be flattened, or else removed.

        Cell method constructs that apply to the flattened axes will
        be removed or, if possible, have their axis specifications
        changed to standard names.

        The flattening is executed in row-major (C-style) order. For
        example, the array ``[[1, 2], [3, 4]]`` would be flattened
        across both dimensions to ``[1 2 3 4]``.

        .. versionadded:: 3.0.2

        .. seealso:: `compress`, `insert_dimension`, `flip`, `swapaxes`,
                     `transpose`

        :Parameters:

            axes: (sequence of) `str` or `int`, optional
                Select the domain axes to be flattened, defined by the
                domain axes that would be selected by passing each
                given axis description to a call of the field
                construct's `domain_axis` method. For example, for a
                value of ``'X'``, the domain axis construct returned
                by ``f.domain_axis('X')`` is selected.

                If no axes are provided then all axes spanned by the
                field construct's data are flattened.

                No axes are flattened if *axes* is an empty sequence.

            return_axis: `bool`, optional
                If True then also return either the key of the
                flattened domain axis construct; or `None` if the axes
                to be flattened do not span the data.

            {{inplace: `bool`, optional}}

        :Returns:

            `Field` or `None`, [`str` or `None`]
                The new, flattened field construct, or `None` if the
                operation was in-place.

                If *return_axis* is True then also return either the
                key of the flattened domain axis construct; or `None`
                if the axes to be flattened do not span the data.

        **Examples**

        See `cf.Data.flatten` for more examples of how the data are
        flattened.

        >>> f.shape
        (1, 2, 3, 4)
        >>> f.flatten().shape
        (24,)
        >>> f.flatten([]).shape
        (1, 2, 3, 4)
        >>> f.flatten([1, 3]).shape
        (1, 8, 3)
        >>> f.flatten([0, -1], inplace=True)
        >>> f.shape
        (4, 2, 3)

        >>> print(t)
        Field: air_temperature (ncvar%ta)
        ---------------------------------
        Data            : air_temperature(atmosphere_hybrid_height_coordinate(1), grid_latitude(10), grid_longitude(9)) K
        Cell methods    : grid_latitude(10): grid_longitude(9): mean where land (interval: 0.1 degrees) time(1): maximum
        Field ancils    : air_temperature standard_error(grid_latitude(10), grid_longitude(9)) = [[0.76, ..., 0.32]] K
        Dimension coords: atmosphere_hybrid_height_coordinate(1) = [1.5]
                        : grid_latitude(10) = [2.2, ..., -1.76] degrees
                        : grid_longitude(9) = [-4.7, ..., -1.18] degrees
                        : time(1) = [2019-01-01 00:00:00]
        Auxiliary coords: latitude(grid_latitude(10), grid_longitude(9)) = [[53.941, ..., 50.225]] degrees_N
                        : longitude(grid_longitude(9), grid_latitude(10)) = [[2.004, ..., 8.156]] degrees_E
                        : long_name=Grid latitude name(grid_latitude(10)) = [--, ..., b'kappa']
        Cell measures   : measure:area(grid_longitude(9), grid_latitude(10)) = [[2391.9657, ..., 2392.6009]] km2
        Coord references: grid_mapping_name:rotated_latitude_longitude
                        : standard_name:atmosphere_hybrid_height_coordinate
        Domain ancils   : ncvar%a(atmosphere_hybrid_height_coordinate(1)) = [10.0] m
                        : ncvar%b(atmosphere_hybrid_height_coordinate(1)) = [20.0]
                        : surface_altitude(grid_latitude(10), grid_longitude(9)) = [[0.0, ..., 270.0]] m
        >>> print(t.flatten())
        Field: air_temperature (ncvar%ta)
        ---------------------------------
        Data            : air_temperature(key%domainaxis4(90)) K
        Cell methods    : grid_latitude: grid_longitude: mean where land (interval: 0.1 degrees) time(1): maximum
        Field ancils    : air_temperature standard_error(key%domainaxis4(90)) = [0.76, ..., 0.32] K
        Dimension coords: time(1) = [2019-01-01 00:00:00]
        Auxiliary coords: latitude(key%domainaxis4(90)) = [53.941, ..., 50.225] degrees_N
                        : longitude(key%domainaxis4(90)) = [2.004, ..., 8.156] degrees_E
        Cell measures   : measure:area(key%domainaxis4(90)) = [2391.9657, ..., 2392.6009] km2
        Coord references: grid_mapping_name:rotated_latitude_longitude
                        : standard_name:atmosphere_hybrid_height_coordinate
        Domain ancils   : surface_altitude(key%domainaxis4(90)) = [0.0, ..., 270.0] m
        >>> print(t.flatten(['grid_latitude', 'grid_longitude']))
        Field: air_temperature (ncvar%ta)
        ---------------------------------
        Data            : air_temperature(atmosphere_hybrid_height_coordinate(1), key%domainaxis4(90)) K
        Cell methods    : grid_latitude: grid_longitude: mean where land (interval: 0.1 degrees) time(1): maximum
        Field ancils    : air_temperature standard_error(key%domainaxis4(90)) = [0.76, ..., 0.32] K
        Dimension coords: atmosphere_hybrid_height_coordinate(1) = [1.5]
                        : time(1) = [2019-01-01 00:00:00]
        Auxiliary coords: latitude(key%domainaxis4(90)) = [53.941, ..., 50.225] degrees_N
                        : longitude(key%domainaxis4(90)) = [2.004, ..., 8.156] degrees_E
        Cell measures   : measure:area(key%domainaxis4(90)) = [2391.9657, ..., 2392.6009] km2
        Coord references: grid_mapping_name:rotated_latitude_longitude
                        : standard_name:atmosphere_hybrid_height_coordinate
        Domain ancils   : ncvar%a(atmosphere_hybrid_height_coordinate(1)) = [10.0] m
                        : ncvar%b(atmosphere_hybrid_height_coordinate(1)) = [20.0]
                        : surface_altitude(key%domainaxis4(90)) = [0.0, ..., 270.0] m

        >>> t.domain_axes.keys()
        >>> dict_keys(
        ...     ['domainaxis0', 'domainaxis1', 'domainaxis2', 'domainaxis3'])
        >>> t.flatten(return_axis=True)
        (<CF Field: air_temperature(key%domainaxis4(90)) K>,
         'domainaxis4')
        >>> t.flatten('grid_longitude', return_axis=True)
        (<CF Field: air_temperature(atmosphere_hybrid_height_coordinate(1), grid_latitude(10), grid_longitude(9)) K>,
         'domainaxis2')
        >>> t.flatten('time', return_axis=True)
        (<CF Field: air_temperature(atmosphere_hybrid_height_coordinate(1), grid_latitude(10), grid_longitude(9)) K>,
         None)

        """
        f = _inplace_enabled_define_and_cleanup(self)

        data_axes = self.get_data_axes()

        if axes is None:
            axes = data_axes
        else:
            if isinstance(axes, (str, int)):
                axes = (axes,)

            axes = [self.domain_axis(x, key=True) for x in axes]
            axes = set(axes).intersection(data_axes)

        # Note that it is important to sort the iaxes, as we rely on
        # the first iaxis in the list being the left-most flattened
        # axis
        iaxes = sorted([data_axes.index(axis) for axis in axes])

        if not len(iaxes):
            if inplace:
                f = None
            if return_axis:
                return f, None
            return f

        if len(iaxes) == 1:
            if inplace:
                f = None
            if return_axis:
                return f, tuple(axes)[0]
            return f

        #        # Make sure that the metadata constructs have the same
        #        # relative axis order as the data (pre-flattening)
        #        f.transpose(f.get_data_axes(), constructs=True, inplace=True)

        # Create the new data axes
        shape = f.shape
        new_data_axes = [
            axis for i, axis in enumerate(data_axes) if i not in iaxes
        ]
        new_axis_size = numpy_prod([shape[i] for i in iaxes])
        new_axis = f.set_construct(self._DomainAxis(new_axis_size))
        new_data_axes.insert(iaxes[0], new_axis)

        # Flatten the field's data
        super(Field, f).flatten(iaxes, inplace=True)

        # Set the new data axes
        f.set_data_axes(new_data_axes)

        # Modify or remove cell methods that span the flatten axes
        for key, cm in f.cell_methods(todict=True).items():
            cm_axes = set(cm.get_axes(()))
            if not cm_axes or cm_axes.isdisjoint(axes):
                continue

            if cm_axes.difference(axes):
                f.del_construct(key)
                continue

            if cm_axes.issubset(axes):
                cm_axes = list(cm_axes)
                set_axes = True
                for i, a in enumerate(cm_axes):
                    sn = None
                    for c in f.coordinates(
                        filter_by_axis=(a,), axis_mode="exact", todict=True
                    ).values():
                        sn = c.get_property("standard_name", None)
                        if sn is not None:
                            break

                    #                    for ctype in (
                    #                        "dimension_coordinate",
                    #                        "auxiliary_coordinate",
                    #                    ):
                    #                        for c in (
                    #                            f.constructs.filter_by_type(ctype, view=True)
                    #                            .filter_by_axis(a, mode="exact", view=True)
                    #                            .values()
                    #                        ):
                    #                            sn = c.get_property("standard_name", None)
                    #                            if sn is not None:
                    #                                break
                    #
                    #                        if sn is not None:
                    #                            break

                    if sn is None:
                        f.del_construct(key)
                        set_axes = False
                        break
                    else:
                        cm_axes[i] = sn

                if set_axes:
                    cm.set_axes(cm_axes)

        # Flatten the constructs that span all of the flattened axes,
        # or all of the flattened axes all bar some which have size 1.
        #        d = dict(f.constructs.filter_by_axis('exact', *axes))
        #        axes2 = [axis for axis in axes
        #                 if f.domain_axes[axis].get_size() > 1]
        #        if axes2 != axes:
        #            d.update(f.constructs.filter_by_axis(
        #                'subset', *axes).filter_by_axis('and', *axes2))

        # Flatten the constructs that span all of the flattened axes,
        # and no others.
        for key, c in f.constructs.filter_by_axis(
            *axes, axis_mode="and", todict=True
        ).items():
            c_axes = f.get_data_axes(key)
            c_iaxes = sorted(
                [c_axes.index(axis) for axis in axes if axis in c_axes]
            )
            c.flatten(c_iaxes, inplace=True)
            new_data_axes = [
                axis for i, axis in enumerate(c_axes) if i not in c_iaxes
            ]
            new_data_axes.insert(c_iaxes[0], new_axis)
            f.set_data_axes(new_data_axes, key=key)

        # Remove constructs that span some, but not all, of the
        # flattened axes
        for key in f.constructs.filter_by_axis(
            *axes, axis_mode="or", todict=True
        ):
            f.del_construct(key)

        # Remove the domain axis constructs for the flattened axes
        for key in axes:
            f.del_construct(key)

        if return_axis:
            return f, new_axis

        return f

    @_deprecated_kwarg_check("i")
    @_inplace_enabled(default=False)
    def roll(self, axis, shift, inplace=False, i=False, **kwargs):
        """Roll the field along a cyclic axis.

        A unique axis is selected with the axes and kwargs parameters.

        .. versionadded:: 1.0

        .. seealso:: `anchor`, `axis`, `cyclic`, `iscyclic`, `period`

        :Parameters:

            axis:
                The cyclic axis to be rolled, defined by that which would
                be selected by passing the given axis description to a
                call of the field construct's `domain_axis` method. For
                example, for a value of ``'X'``, the domain axis construct
                returned by ``f.domain_axis('X')`` is selected.

            shift: `int`
                The number of places by which the selected cyclic axis is
                to be rolled.

            {{inplace: `bool`, optional}}

            {{i: deprecated at version 3.0.0}}

            kwargs: deprecated at version 3.0.0

        :Returns:

            `Field`
                The rolled field.

        **Examples**

        Roll the data of the "X" axis one elements to the right:

        >>> f.roll('X', 1)

        Roll the data of the "X" axis three elements to the left:

        >>> f.roll('X', -3)

        """
        # TODODASK - allow multiple roll axes

        axis = self.domain_axis(
            axis,
            key=True,
            default=ValueError(
                f"Can't roll: Bad axis specification: {axis!r}"
            ),
        )

        f = _inplace_enabled_define_and_cleanup(self)

        axis = f._parse_axes(axis)

        # Roll the metadata constructs in-place
        shift = f._roll_constructs(axis, shift)

        iaxes = self._axis_positions(axis, parse=False)
        if iaxes:
            # TODODASK - remove these two lines when multiaxis rolls
            #            are allowed at v4.0.0
            iaxis = iaxes[0]
            shift = shift[0]

            super(Field, f).roll(iaxis, shift, inplace=True)

        return f

    @_deprecated_kwarg_check("i")
    @_manage_log_level_via_verbosity
    def where(
        self,
        condition,
        x=None,
        y=None,
        inplace=False,
        construct=None,
        i=False,
        verbose=None,
        item=None,
        **item_options,
    ):
        """Assign to data elements depending on a condition.

        Data can be changed by assigning to elements that are selected by
        a condition based on the data values of the field construct or on
        its metadata constructs.

        Different values can be assigned to where the conditions are, and
        are not, met.

        **Missing data**

        Data array elements may be set to missing values by assigning them
        to the `cf.masked` constant, or by assignment missing data
        elements of array-valued *x* and *y* parameters.

        By default the data mask is "hard", meaning that masked values can
        not be changed by assigning them to another value. This behaviour
        may be changed by setting the `hardmask` attribute of the field
        construct to `False`, thereby making the data mask "soft" and
        allowing masked elements to be set to non-masked values.

        .. seealso:: `cf.masked`, `hardmask`, `indices`, `mask`,
                     `subspace`, `__setitem__`

        :Parameters:

            condition:
                The condition which determines how to assign values to the
                data.

                In general it may be any scalar or array-like object (such
                as a `numpy`, `Data` or `Field` object) that is
                broadcastable to the shape of the data. Assignment from
                the *x* and *y* parameters will be done where elements of
                the condition evaluate to `True` and `False` respectively.

                *Parameter example:*
                  ``f.where(f.data<0, x=-999)`` will set all data values
                  that are less than zero to -999.

                *Parameter example:*
                  ``f.where(True, x=-999)`` will set all data values to
                  -999. This is equivalent to ``f[...] = -999``.

                *Parameter example:*
                  ``f.where(False, y=-999)`` will set all data values to
                  -999. This is equivalent to ``f[...] = -999``.

                *Parameter example:*
                  If field construct ``f`` has shape ``(5, 3)`` then
                  ``f.where([True, False, True], x=-999, y=cf.masked)``
                  will set data values in columns 0 and 2 to -999, and
                  data values in column 1 to missing data. This works
                  because the condition has shape ``(3,)`` which
                  broadcasts to the field construct's shape.

                If, however, *condition* is a `Query` object then this
                implies a condition defined by applying the query to the
                field construct's data (or a metadata construct's data if
                the *construct* parameter is set).

                *Parameter example:*
                  ``f.where(cf.lt(0), x=-999)`` will set all data values
                  that are less than zero to -999. This is equivalent to
                  ``f.where(f.data<0, x=-999)``.

                If *condition* is another field construct then it is first
                transformed so that it is broadcastable to the data being
                assigned to. This is done by using the metadata constructs
                of the two field constructs to create a mapping of
                physically identical dimensions between the fields, and
                then manipulating the dimensions of other field
                construct's data to ensure that they are broadcastable. If
                either of the field constructs does not have sufficient
                metadata to create such a mapping then an exception will
                be raised. In this case, any manipulation of the
                dimensions must be done manually, and the `Data` instance
                of *construct* (rather than the field construct itself)
                may be used for the condition.

                *Parameter example:*
                  If field construct ``f`` has shape ``(5, 3)`` and ``g =
                  f.transpose() < 0`` then ``f.where(g, x=-999)`` will set
                  all data values that are less than zero to -999,
                  provided there are sufficient metadata for the data
                  dimensions to be mapped. However, ``f.where(g.data,
                  x=-999)`` will always fail in this example, because the
                  shape of the condition is ``(3, 5)``, which does not
                  broadcast to the shape of the ``f``.

            x, y: *optional*
                Specify the assignment values. Where the condition
                evaluates to `True`, assign to the field construct's data
                from *x*, and where the condition evaluates to `False`,
                assign to the field construct's data from *y*. The *x* and
                *y* parameters are each one of:

                * `None`. The appropriate data elements array are
                  unchanged. This the default.

                * Any scalar or array-like object (such as a `numpy`,
                  `Data` or `Field` object) that is broadcastable to the
                  shape of the data.

            ..

                *Parameter example:*
                  ``f.where(condition)``, for any ``condition``, returns a
                  field construct with identical data values.

                *Parameter example:*
                  ``f.where(cf.lt(0), x=-f.data, y=cf.masked)`` will
                  change the sign of all negative data values, and set all
                  other data values to missing data.

                If *x* or *y* is another field construct then it is first
                transformed so that its data is broadcastable to the data
                being assigned to. This is done by using the metadata
                constructs of the two field constructs to create a mapping
                of physically identical dimensions between the fields, and
                then manipulating the dimensions of other field
                construct's data to ensure that they are broadcastable. If
                either of the field constructs does not have sufficient
                metadata to create such a mapping then an exception will
                be raised. In this case, any manipulation of the
                dimensions must be done manually, and the `Data` instance
                of *x* or *y* (rather than the field construct itself) may
                be used for the condition.

                *Parameter example:*
                  If field construct ``f`` has shape ``(5, 3)`` and ``g =
                  f.transpose() * 10`` then ``f.where(cf.lt(0), x=g)``
                  will set all data values that are less than zero to the
                  equivalent elements of field construct ``g``, provided
                  there are sufficient metadata for the data dimensions to
                  be mapped. However, ``f.where(cf.lt(0), x=g.data)`` will
                  always fail in this example, because the shape of the
                  condition is ``(3, 5)``, which does not broadcast to the
                  shape of the ``f``.

            construct: `str`, optional
                Define the condition by applying the *construct* parameter
                to the given metadata construct's data, rather then the
                data of the field construct. Must be

                * The identity or key of a metadata coordinate construct
                  that has data.

            ..

                The *construct* parameter selects the metadata construct
                that is returned by this call of the field construct's
                `construct` method: ``f.construct(construct)``. See
                `cf.Field.construct` for details.

                *Parameter example:*
                  ``f.where(cf.wi(-30, 30), x=cf.masked,
                  construct='latitude')`` will set all data values within
                  30 degrees of the equator to missing data.

            {{inplace: `bool`, optional}}

            {{i: deprecated at version 3.0.0}}

            item: deprecated at version 3.0.0
                Use the *construct* parameter instead.

            item_options: deprecated at version 3.0.0

        :Returns:

            `Field` or `None`
                A new field construct with an updated data array, or
                `None` if the operation was in-place.

        **Examples**

        Set data array values to 15 everywhere:

        >>> f.where(True, 15)

        This example could also be done with subspace assignment:

        >>> f[...] = 15

        Set all negative data array values to zero and leave all other
        elements unchanged:

        >>> g = f.where(f<0, 0)

        Multiply all positive data array elements by -1 and set other data
        array elements to 3.14:

        >>> g = f.where(f>0, -f, 3.14)

        Set all values less than 280 and greater than 290 to missing data:

        >>> g = f.where((f < 280) | (f > 290), cf.masked)

        This example could also be done with a `Query` object:

        >>> g = f.where(cf.wo(280, 290), cf.masked)

        or equivalently:

        >>> g = f.where(f==cf.wo(280, 290), cf.masked)

        Set data array elements in the northern hemisphere to missing data
        in-place:

        >>> condition = f.domain_mask(latitude=cf.ge(0))
        >>> f.where(condition, cf.masked, inplace=True)

        Missing data can only be changed if the mask is "soft":

        >>> f[0] = cf.masked
        >>> g = f.where(True, 99)
        >>> print(g[0],array)
        [--]
        >>> f.hardmask = False
        >>> g = f.where(True, 99)
        >>> print(g[0],array)
        [99]

        This in-place example could also be done with subspace assignment
        by indices:

        >>> northern_hemisphere = f.indices(latitude=cf.ge(0))
        >>> f.subspace[northern_hemisphere] = cf.masked

        Set a polar rows to their zonal-mean values:

        >>> condition = f.domain_mask(latitude=cf.set([-90, 90]))
        >>> g = f.where(condition, f.collapse('longitude: mean'))

        """
        if item is not None:
            _DEPRECATION_ERROR_KWARGS(
                self,
                "where",
                {"item": item},
                "Use keyword 'construct' instead.",
            )  # pragma: no cover

        if item_options:
            _DEPRECATION_ERROR_KWARGS(
                self, "where", {"item_options": item_options}
            )  # pragma: no cover

        if x is None and y is None:
            if inplace:
                return
            return self.copy()

        self_class = self.__class__

        if isinstance(condition, self_class):
            # --------------------------------------------------------
            # Condition is another field construct
            # --------------------------------------------------------
            condition = self._conform_for_assignment(condition)

        elif construct is not None:
            if not isinstance(condition, Query):
                raise ValueError(
                    "A condition on a metadata construct must be a Query "
                    "object"
                )

            # Apply the Query to a metadata construct of the field,
            # making sure that the construct's data is broadcastable
            # to the field's data.
            g = self.transpose(self.get_data_axes(), constructs=True)

            key = g.construct_key(
                construct,
                default=ValueError(
                    f"Can't identify unique {construct!r} construct"
                ),
            )
            construct = g.constructs[key]

            construct_data_axes = g.get_data_axes(key, default=None)
            if construct_data_axes is None:
                raise ValueError(
                    f"Can't identify construct data axes for {construct!r}."
                )

            data_axes = g.get_data_axes()

            construct_data = construct.get_data(None, _fill_value=False)
            if construct_data is None:
                raise ValueError(f"{construct!r} has no data")

            if construct_data_axes != data_axes:
                s = [
                    i
                    for i, axis in enumerate(construct_data_axes)
                    if axis not in data_axes
                ]
                if s:
                    construct_data.squeeze(s, inplace=True)
                    construct_data_axes = [
                        axis
                        for axis in construct_data_axes
                        if axis not in data_axes
                    ]

                for i, axis in enumerate(data_axes):
                    if axis not in construct_data_axes:
                        construct_data.insert_dimension(i, inplace=True)

            condition = condition.evaluate(construct_data)

        if x is not None and isinstance(x, self_class):
            x = self._conform_for_assignment(x)

        if y is not None and isinstance(y, self_class):
            y = self._conform_for_assignment(y)

        return super().where(condition, x, y, inplace=inplace, verbose=verbose)

    @property
    def subspace(self):
        """Create a subspace of the field construct.

        Creation of a new field construct which spans a subspace of the
        domain of an existing field construct is achieved either by
        identifying indices based on the metadata constructs (subspacing
        by metadata) or by indexing the field construct directly
        (subspacing by index).

        The subspacing operation, in either case, also subspaces any
        metadata constructs of the field construct (e.g. coordinate
        metadata constructs) which span any of the domain axis constructs
        that are affected. The new field construct is created with the
        same properties as the original field construct.

        **Subspacing by metadata**

        Subspacing by metadata, signified by the use of round brackets,
        selects metadata constructs and specifies conditions on their
        data. Indices for subspacing are then automatically inferred from
        where the conditions are met.

        Metadata constructs and the conditions on their data are defined
        by keyword parameters.

        * Any domain axes that have not been identified remain unchanged.

        * Multiple domain axes may be subspaced simultaneously, and it
          doesn't matter which order they are specified in.

        * Subspace criteria may be provided for size 1 domain axes that
          are not spanned by the field construct's data.

        * Explicit indices may also be assigned to a domain axis
          identified by a metadata construct, with either a Python `slice`
          object, or a sequence of integers or booleans.

        * For a dimension that is cyclic, a subspace defined by a slice or
          by a `Query` instance is assumed to "wrap" around the edges of
          the data.

        * Conditions may also be applied to multi-dimensional metadata
          constructs. The "compress" mode is still the default mode (see
          the positional arguments), but because the indices may not be
          acting along orthogonal dimensions, some missing data may still
          need to be inserted into the field construct's data.

        **Subspacing by index**

        Subspacing by indexing, signified by the use of square brackets,
        uses rules that are very similar to the numpy indexing rules, the
        only differences being:

        * An integer index i specified for a dimension reduces the size of
          this dimension to unity, taking just the i-th element, but keeps
          the dimension itself, so that the rank of the array is not
          reduced.

        * When two or more dimensions’ indices are sequences of integers
          then these indices work independently along each dimension
          (similar to the way vector subscripts work in Fortran). This is
          the same indexing behaviour as on a Variable object of the
          netCDF4 package.

        * For a dimension that is cyclic, a range of indices specified by
          a `slice` that spans the edges of the data (such as ``-2:3`` or
          ``3:-2:-1``) is assumed to "wrap" around, rather then producing
          a null result.


        .. seealso:: `indices`, `squeeze`, `where`, `__getitem__`

        :Parameters:

            positional arguments: *optional*
                There are three modes of operation, each of which provides
                a different type of subspace:

                ==============  ==========================================
                *argument*      Description
                ==============  ==========================================
                ``'compress'``  This is the default mode. Unselected
                                locations are removed to create the
                                returned subspace. Note that if a
                                multi-dimensional metadata construct is
                                being used to define the indices then some
                                missing data may still be inserted at
                                unselected locations.

                ``'envelope'``  The returned subspace is the smallest that
                                contains all of the selected
                                indices. Missing data is inserted at
                                unselected locations within the envelope.

                ``'full'``      The returned subspace has the same domain
                                as the original field construct. Missing
                                data is inserted at unselected locations.

                ``'test'``      May be used on its own or in addition to
                                one of the other positional arguments. Do
                                not create a subspace, but return `True`
                                or `False` depending on whether or not it
                                is possible to create specified the
                                subspace.
                ==============  ==========================================

            keyword parameters: *optional*
                A keyword name is an identity of a metadata construct, and
                the keyword value provides a condition for inferring
                indices that apply to the dimension (or dimensions)
                spanned by the metadata construct's data. Indices are
                created that select every location for which the metadata
                construct's data satisfies the condition.

        :Returns:

            `Field` or `bool`
                An independent field construct containing the subspace of
                the original field. If the ``'test'`` positional argument
                has been set then return `True` or `False` depending on
                whether or not it is possible to create specified
                subspace.

        **Examples**

        There are further worked examples
        :ref:`in the tutorial <Subspacing-by-metadata>`.

        >>> g = f.subspace(X=112.5)
        >>> g = f.subspace(X=112.5, latitude=cf.gt(-60))
        >>> g = f.subspace(latitude=cf.eq(-45) | cf.ge(20))
        >>> g = f.subspace(X=[1, 2, 4], Y=slice(None, None, -1))
        >>> g = f.subspace(X=cf.wi(-100, 200))
        >>> g = f.subspace(X=slice(-2, 4))
        >>> g = f.subspace(Y=[True, False, True, True, False])
        >>> g = f.subspace(T=410.5)
        >>> g = f.subspace(T=cf.dt('1960-04-16'))
        >>> g = f.subspace(T=cf.wi(cf.dt('1962-11-01'),
        ...                        cf.dt('1967-03-17 07:30')))
        >>> g = f.subspace('compress', X=[1, 2, 4, 6])
        >>> g = f.subspace('envelope', X=[1, 2, 4, 6])
        >>> g = f.subspace('full', X=[1, 2, 4, 6])
        >>> g = f.subspace(latitude=cf.wi(51, 53))

        >>> g = f.subspace[::-1, 0]
        >>> g = f.subspace[:, :, 1]
        >>> g = f.subspace[:, 0]
        >>> g = f.subspace[..., 6:3:-1, 3:6]
        >>> g = f.subspace[0, [2, 3, 9], [4, 8]]
        >>> g = t.subspace[0, :, -2]
        >>> g = f.subspace[0, [2, 3, 9], [4, 8]]
        >>> g = f.subspace[:, -2:3]
        >>> g = f.subspace[:, 3:-2:-1]
        >>> g = f.subspace[..., [True, False, True, True, False]]

        """
        return SubspaceField(self)

    def section(self, axes=None, stop=None, **kwargs):
        """Return a FieldList of m dimensional sections of a Field of n
        dimensions, where M <= N.

        :Parameters:

            axes: optional
                A query for the m axes that define the sections of the
                Field as accepted by the Field object's axes method. The
                keyword arguments are also passed to this method. See
                TODO cf.Field.axes for details. If an axis is returned that is
                not a data axis it is ignored, since it is assumed to be a
                dimension coordinate of size 1.

            stop: `int`, optional
                Stop after taking this number of sections and return. If
                *stop* is `None` all sections are taken.

        :Returns:

            `FieldList`
                The sections of the field construct.

        **Examples**

        Section a field into 2-d longitude/time slices, checking the
        units:

        >>> f.section({None: 'longitude', units: 'radians'},
        ...           {None: 'time',
        ...            'units': 'days since 2006-01-01 00:00:00'})

        Section a field into 2-d longitude/latitude slices, requiring
        exact names:

        >>> f.section(['latitude', 'longitude'], exact=True)

        Section a field into 2-d longitude/latitude slices, showing
        the results:

        >>> f
        <CF Field: eastward_wind(model_level_number(6), latitude(145), longitude(192)) m s-1>
        >>> f.section(('X', 'Y'))
        [<CF Field: eastward_wind(model_level_number(1), latitude(145), longitude(192)) m s-1>,
         <CF Field: eastward_wind(model_level_number(1), latitude(145), longitude(192)) m s-1>,
         <CF Field: eastward_wind(model_level_number(1), latitude(145), longitude(192)) m s-1>,
         <CF Field: eastward_wind(model_level_number(1), latitude(145), longitude(192)) m s-1>,
         <CF Field: eastward_wind(model_level_number(1), latitude(145), longitude(192)) m s-1>,
         <CF Field: eastward_wind(model_level_number(1), latitude(145), longitude(192)) m s-1>]

        """
        return FieldList(_section(self, axes, data=False, stop=stop, **kwargs))

    @_deprecated_kwarg_check("i")
    @_inplace_enabled(default=False)
    def regrids(
        self,
        dst,
        method=None,
        src_cyclic=None,
        dst_cyclic=None,
        use_src_mask=True,
        use_dst_mask=False,
        fracfield=False,
        src_axes=None,
        dst_axes=None,
        axis_order=None,
        ignore_degenerate=True,
        inplace=False,
        i=False,
        _compute_field_mass=None,
        return_operator=False,
    ):
        """Return the field regridded onto a new latitude-longitude
        grid.

        Regridding, also called remapping or interpolation, is the
        process of changing the grid underneath field data values
        while preserving the qualities of the original data.

        The regridding method must be specified. First-order
        conservative interpolation conserves the global area integral
        of the field, but may not give approximations to the values as
        good as linear interpolation. Second-order conservative
        interpolation also takes into account the gradient across the
        source cells, so in general gives a smoother, more accurate
        representation of the source field especially when going from
        a coarser to a finer grid. Linear interpolation is
        available. The latter method is particular useful for cases
        when the latitude and longitude coordinate cell boundaries are
        not known nor inferable. Higher order patch recovery is
        available as an alternative to linear interpolation. This
        typically results in better approximations to values and
        derivatives compared to the latter, but the weight matrix can
        be larger than the linear matrix, which can be an issue when
        regridding close to the memory limit on a machine. Nearest
        neighbour interpolation is also available. Nearest source to
        destination is particularly useful for regridding integer
        fields such as land use.


        **Metadata**

        The field construct's domain must have well defined X and Y
        axes with latitude and longitude coordinate values, which may
        be stored as dimension coordinate objects or two dimensional
        auxiliary coordinate objects. If the latitude and longitude
        coordinates are two dimensional then the X and Y axes must be
        defined by dimension coordinates if present or by the netCDF
        dimensions. In the latter case the X and Y axes must be
        specified using the *src_axes* or *dst_axes* keyword. The same
        is true for the destination grid, if it provided as part of
        another field.

        The cyclicity of the X axes of the source field and
        destination grid is taken into account. If an X axis is in
        fact cyclic but is not registered as such by its parent field
        (see `cf.Field.iscyclic`), then the cyclicity may be set with
        the *src_cyclic* or *dst_cyclic* parameters. In the case of
        two dimensional latitude and longitude dimension coordinates
        without bounds it will be necessary to specify *src_cyclic* or
        *dst_cyclic* manually if the field is global.

        The output field construct's coordinate objects which span the
        X and/or Y axes are replaced with those from the destination
        grid. Any fields contained in coordinate reference objects
        will also be regridded, if possible.


        **Mask**

        The data array mask of the field is automatically taken into
        account, such that the regridded data array will be masked in
        regions where the input data array is masked. By default the
        mask of the destination grid is not taken into account. If the
        destination field data has more than two dimensions then the
        mask, if used, is taken from the two dimensional section of
        the data where the indices of all axes other than X and Y are
        zero.


        **Implementation**

        The interpolation is carried out using the `ESMF` package, a
        Python interface to the Earth System Modeling Framework (ESMF)
        `regridding utility
        <https://www.earthsystemcog.org/projects/esmf/regridding>`_.


        **Logging**

        Whether ESMF logging is enabled or not is determined by
        `cf.regrid_logging`. If it is logging takes place after every
        call. By default logging is disabled.


        **Latitude-Longitude Grid**

        The canonical grid with independent latitude and longitude
        coordinates.


        **Curvilinear Grids**

        Grids in projection coordinate systems can be regridded as
        long as two dimensional latitude and longitude coordinates are
        present.


        **Rotated Pole Grids**

        Rotated pole grids can be regridded as long as two dimensional
        latitude and longitude coordinates are present. It may be
        necessary to explicitly identify the grid latitude and grid
        longitude coordinates as being the X and Y axes and specify
        the *src_cyclic* or *dst_cyclic* keywords.


        **Tripolar Grids**

        Tripolar grids are logically rectangular and so may be able to
        be regridded. If no dimension coordinates are present it will
        be necessary to specify which netCDF dimensions are the X and
        Y axes using the *src_axes* or *dst_axes* keywords.
        Connections across the bipole fold are not currently
        supported, but are not be necessary in some cases, for example
        if the points on either side are together without a gap. It
        will also be necessary to specify *src_cyclic* or *dst_cyclic*
        if the grid is global.

        .. versionadded:: 1.0.4

        .. seealso:: `regridc`

        :Parameters:

            dst: `Field` or `dict` or `RegridOperator`
                The destination grid. Must be one of:

                * `Field`. The grid is defined by the field
                  construct's domain.

                * `dict`. The grid is defined by a dictionary with
                  keys ``'latitude'`` and ``'longitude'`` whose values
                  are with either both 1-d dimension coordinates
                  constructs, or both 2-d auxiliary coordinate
                  constructs. In the 2-d case, both coordinate
                  constructs must have their axes in the same order
                  and this must be specified with the ``'axes'`` key
                  as either of the tuples ``('X', 'Y')`` or ``('Y',
                  'X')``.

                * `RegridOperator`. The grid is defined by a regrid
                  operator that has been returned by a previous call
                  to `regrids` with ``return_operator=True``.

                  This option can give large performance increases, as
                  greatest computational expense is often the creation
                  of the regrid operator, rather than running the
                  regrid operator to regrid the data.

                  The regrid operator defines the source grid and the
                  regridding weights, so the *method*, *axes*,
                  *ignore_degenerate*, *use_src_mask*, and
                  *use_dst_mask* parameters are not required and are
                  ignored if set.

                  An exception will be raised if the domain of the
                  source field being regridded is inconsistent with
                  source grid of the regrid operator.

                  The source field being regridded may, however, have
                  a different data mask to that of the source grid in
                  the regrid operator. In this case a new regrid
                  operator is automatically created, with the
                  associated loss in performance.

            {{method: `str`, optional}}

            src_cyclic: `bool`, optional
                Specifies whether the longitude for the source grid is
                periodic or not. If `None` then, if possible, this is
                determined automatically otherwise it defaults to
                False.

            dst_cyclic: `bool`, optional
                Specifies whether the longitude for the destination
                grid is periodic of not. If `None` then, if possible,
                this is determined automatically otherwise it defaults
                to False.

                .. note:: When *dst* is a regrid operator then
                          *dst_cyclic* is ignored, and its value is
                          set by the regrid operator's parameters.

            use_src_mask: `bool`, optional
                For all methods other than 'nearest_stod', this must
                be True as it does not make sense to set it to
                False. For the 'nearest_stod' method if it is True
                then points in the result that are nearest to a masked
                source point are masked. Otherwise, if it is False,
                then these points are interpolated to the nearest
                unmasked source points.

                .. note:: When *dst* is a regrid operator then
                          *use_src_mask* is ignored, and its value is
                          set by the regridding operator's parameters.

            use_dst_mask: `bool`, optional
                By default the mask of the data on the destination
                grid is not taken into account when performing
                regridding. If this option is set to true then it
                is. If the destination field has more than two
                dimensions then the first 2-d slice in index space is
                used for the mask e.g. for an field varying with (X,
                Y, Z, T) the mask is taken from the slice (X, Y, 0,
                0).

                .. note:: When *dst* is a regrid operator then
                          *use_dst_mask* is ignored, and its value is
                          set by the regrid operator's parameters.

            fracfield: `bool`, optional
                If the method of regridding is conservative the
                fraction of each destination grid cell involved in the
                regridding is returned instead of the regridded data
                if this is True. Otherwise this is ignored.

            src_axes: `dict`, optional
                A dictionary specifying the axes of the 2-d latitude
                and longitude coordinates of the source field when no
                1-d dimension coordinates are present. It must have
                keys ``'X'`` and ``'Y'``. TODO

                *Parameter example:*
                  ``src_axes={'X': 'ncdim%x', 'Y': 'ncdim%y'}``

                *Parameter example:*
                  ``src_axes={'X': 1, 'Y': 0}``

            dst_axes: `dict`, optional
                A dictionary specifying the axes of the 2-d latitude
                and longitude auxiliary coordinates of the destination
                grid when no dimension coordinates are present. It
                must have keys ``'X'`` and ``'Y'``.

                *Parameter example:*
                  ``dst_axes={'X': 'ncdim%x', 'Y': 'ncdim%y'}``

                .. note:: When *dst* is a regrid operator then
                          *dst_axes* is ignored, and its value is
                          set by the regrid operator's parameters.

            axis_order: sequence, optional
                A sequence of items specifying dimension coordinates
                as retrieved by the `dim` method. These determine the
                order in which to iterate over the other axes of the
                field when regridding X-Y slices. The slowest moving
                axis will be the first one specified. Currently the
                regridding weights are recalculated every time the
                mask of an X-Y slice changes with respect to the
                previous one, so this option allows the user to
                minimise how frequently the mask changes. TODO.

            ignore_degenerate: `bool`, optional
                True by default. Instructs ESMF to ignore degenerate
                cells when checking the grids for errors. Regridding
                will proceed and degenerate cells will be skipped, not
                producing a result, when set to True. Otherwise an
                error will be produced if degenerate cells are
                found. This will be present in the ESMF log files if
                `cf.regrid_logging` is set to True. As of ESMF 7.0.0
                this only applies to conservative regridding.  Other
                methods always skip degenerate cells.

                .. note:: When *dst* is a regrid operator then
                          *ignore_degenerate* is ignored, and its
                          value is set by the regrid operator's
                          parameters.

            {{inplace: `bool`, optional}}

            _compute_field_mass: `dict`, optional
                If this is a dictionary then the field masses of the
                source and destination fields are computed and
                returned within the dictionary. The keys of the
                dictionary indicates the lat-long slice of the field
                and the corresponding value is a tuple containing the
                source field construct's mass and the destination
                field construct's mass. The calculation is only done
                if conservative regridding is being performed. This is
                for debugging purposes.

            return_operator: `bool`, optional
                If True then do not perform the regridding, rather
                return a `RegridOperator` instance that defines the
                regridding operation, including the destination grid,
                and which can be used in subsequent calls to
                `regrids`.

                .. versionadded:: 3.10.0

            {{i: deprecated at version 3.0.0}}

        :Returns:

            `Field` or `None` or `RegridOperator`
                The regridded field construct, or `None` if the
                operation was in-place, or the regridding operator if
                *return_operator* is True.

        **Examples**

        Regrid field construct ``f`` conservatively onto a grid
        contained in field construct ``g``:

        >>> h = f.regrids(g, 'conservative')

        Regrid f to the grid of g using linear regridding and forcing
        the source field f to be treated as cyclic.

        >>> h = f.regrids(g, src_cyclic=True, method='linear')

        Regrid f to the grid of g using the mask of g.

        >>> h = f.regrids(g, 'conservative_1st', use_dst_mask=True)

        Regrid f to 2-d auxiliary coordinates lat and lon, which have
        their dimensions ordered "Y" first then "X".

        >>> lat
        <CF AuxiliaryCoordinate: latitude(110, 106) degrees_north>
        >>> lon
        <CF AuxiliaryCoordinate: longitude(110, 106) degrees_east>
        >>> h = f.regrids(
        ...         {'longitude': lon, 'latitude': lat, 'axes': ('Y', 'X')},
        ...         'conservative'
        ...     )

        Regrid field, f, on tripolar grid to latitude-longitude grid
        of field, g.

        >>> h = f.regrids(g, 'linear',
        ...               src_axes={'X': 'ncdim%x', 'Y': 'ncdim%y'},
        ...               src_cyclic=True)

        Regrid f to the grid of g iterating over the 'Z' axis last and
        the 'T' axis next to last to minimise the number of times the
        mask is changed.

        >>> h = f.regrids(g, 'nearest_dtos', axis_order='ZT')

        Store the regrid operator and use it for a subsequent regrids:

        >>> op = f.regrids(g, method='conservative', return_operator=True)
        >>> h = f.regrids(op)
        >>> h2 = f2.regrids(op)

        """
        # Initialise ESMF for regridding
        regrid_initialize()

        f = _inplace_enabled_define_and_cleanup(self)

        dst_regrid = isinstance(dst, RegridOperator)
        if dst_regrid:
            operator = dst
            method = regrid_get_operator_method(operator, method)

            if return_operator:
                return operator.copy()

            regridSrc2Dst = operator.regrid

            # Override input arguments with those stored in the regrid
            # operator
            dst = operator.get_parameter("dst")
            dst_axes = operator.get_parameter("dst_axes")
            dst_cyclic = operator.get_parameter("dst_cyclic")
            ignore_degenerate = operator.get_parameter("ignore_degenerate")
            use_dst_mask = operator.get_parameter("use_dst_mask")
            use_src_mask = operator.get_parameter("use_src_mask")
            operator_src_cyclic = operator.get_parameter("src_cyclic")

        dst_field = isinstance(dst, self.__class__)
        dst_dict = not dst_field and isinstance(dst, dict)

        # Check the method
        regrid_check_method(method)

        # Retrieve the source field's latitude and longitude
        # coordinates
        (
            src_axis_keys,
            src_axis_sizes,
            src_coord_keys,
            src_coords,
            src_coords_2D,
        ) = regrid_get_latlon(f, "source", method, axes=src_axes)

        # Retrieve the destination field's latitude and longitude
        # coordinates
        if dst_field:
            # dst is a field construct
            (
                dst_axis_keys,
                dst_axis_sizes,
                dst_coord_keys,
                dst_coords,
                dst_coords_2D,
            ) = regrid_get_latlon(dst, "destination", method, axes=dst_axes)
        elif dst_dict:
            # dst is a dictionary
            try:
                dst_coords = (dst["longitude"], dst["latitude"])
            except KeyError:
                raise ValueError(
                    "Keys 'longitude' and 'latitude' must be "
                    "specified for destination."
                )

            if dst_coords[0].ndim == 1:
                dst_coords_2D = False
                dst_axis_sizes = [coord.size for coord in dst_coords]
            elif dst_coords[0].ndim == 2:
                try:
                    axis_order = dst["axes"]
                except KeyError:
                    raise ValueError(
                        "Key 'axes' must be specified for 2-d "
                        "latitude/longitude coordinates."
                    )
                dst_coords_2D = True
                if axis_order == ("X", "Y"):
                    dst_axis_sizes = dst_coords[0].shape
                elif axis_order == ("Y", "X"):
                    dst_axis_sizes = dst_coords[0].shape[::-1]
                else:
                    raise ValueError(
                        "Keyword 'axes' must either be "
                        "('X', 'Y') or ('Y', 'X')."
                    )
                if dst_coords[0].shape != dst_coords[1].shape:
                    raise ValueError(
                        "Longitude and latitude coordinates for "
                        "destination must have the same shape."
                    )
            else:
                raise ValueError(
                    "Longitude and latitude coordinates for "
                    "destination must have 1 or 2 dimensions."
                )

            dst_axis_keys = None
        else:
            raise TypeError(
                "dst parameter must be Field, dict or RegridOperator. "
                f"Got: {type(dst)}"
            )

        # Automatically detect the cyclicity of the source longitude
        # axis if src_cyclic is None
        if src_cyclic is None:
            src_cyclic = f.iscyclic(src_axis_keys[0])

        # Check that src_cyclic agrees with that of the regrid
        # operator
        if dst_regrid and bool(src_cyclic) != bool(operator_src_cyclic):
            raise ValueError(
                f"Can't regrid {self!r} with regridding operator "
                f"{operator!r}: Cyclicity of the source field "
                "longitude axis does not match that of the "
                "regridding operator"
            )

        # Automatically detect the cyclicity of the destination
        # longitude if dst is not a dictionary and dst_cyclic is None
        if dst_field and dst_cyclic is None:
            dst_cyclic = dst.iscyclic(dst_axis_keys[0])
        elif dst_dict and dst_cyclic is None:
            dst = dst.copy()
            dst["longitude"] = dst["longitude"].autoperiod()
            dst_cyclic = dst["longitude"].isperiodic

        # Get the axis indices and their order for the source field
        (
            src_axis_indices,
            src_non_regridding_axis_indices,
            src_order,
        ) = regrid_get_axis_indices(f, src_axis_keys)

        # Get the axis indices and their order for the destination
        # field.
        if dst_field:
            dst = dst.copy()
            dst_axis_indices, _, dst_order = regrid_get_axis_indices(
                dst, dst_axis_keys
            )

        # Get the order of the X and Y axes for each 2-d auxiliary
        # coordinate.
        src_coord_order = None
        dst_coord_order = None
        if src_coords_2D:
            src_coord_order = regrid_get_coord_order(
                f, src_axis_keys, src_coord_keys
            )

        if dst_coords_2D:
            if dst_dict:
                if axis_order == ("X", "Y"):
                    dst_coord_order = [[0, 1], [0, 1]]
                elif axis_order == ("Y", "X"):
                    dst_coord_order = [[1, 0], [1, 0]]
                else:
                    raise ValueError(
                        "Keyword 'axes' must either be ('X', 'Y') or "
                        "('Y', 'X')."
                    )
            else:
                dst_coord_order = regrid_get_coord_order(
                    dst, dst_axis_keys, dst_coord_keys
                )

        # Get the shape of each section after it has been regridded.
        shape = regrid_get_section_shape(
            self, dst_axis_sizes, src_axis_indices
        )

        # Check that use_src_mask is True for all methods other than
        # nearest_stod
        regrid_check_use_src_mask(use_src_mask, method)

        # Bounds must be used if the regridding method is conservative.
        use_bounds = regrid_use_bounds(method)

        # Check the bounds of the coordinates
        regrid_check_bounds(src_coords, dst_coords, method)

        # Slice the source data into 2-d latitude/longitude sections,
        # also getting a list of dictionary keys in the order
        # requested. If axis_order has not been set, then the order is
        # random, and so in this case the order in which sections are
        # regridded is random.
        section_keys, sections = regrid_get_reordered_sections(
            self, axis_order, src_axis_keys, src_axis_indices
        )

        # Retrieve the destination field's mask if appropriate
        dst_mask = None
        if dst_field and use_dst_mask and dst.data.ismasked:
            # TODODASK: Just get the mask?
            dst_mask = regrid_get_destination_mask(
                dst, dst_order, axes=dst_axis_keys
            )

        if dst_regrid:
            srcgrid = create_Grid(
                src_coords,
                use_bounds,
                cyclic=src_cyclic,
                coords_2D=src_coords_2D,
                coord_order=src_coord_order,
            )
            if not grids_have_same_coords(
                srcgrid, regridSrc2Dst.srcfield.grid
            ):
                srcgrid.destroy()
                raise ValueError(
                    f"Can't regrid {self!r} with regridding operator "
                    f"{operator!r}: Source grid coordinates do not match."
                )
            srcgrid.destroy()

        # Get the destination ESMF Grid, Field and fractional Field
        if dst_regrid:
            dstgrid = regridSrc2Dst.dstfield.grid
            dstfield = regridSrc2Dst.dstfield
            dstfracfield = regridSrc2Dst.dst_frac_field
        else:
            dstgrid = create_Grid(
                dst_coords,
                use_bounds,
                mask=dst_mask,
                cyclic=dst_cyclic,
                coords_2D=dst_coords_2D,
                coord_order=dst_coord_order,
            )

            # dstfield will be reused to receive the regridded source
            # data for each section, one after the other
            dstfield = create_Field(dstgrid, "dstfield")
            dstfracfield = create_Field(dstgrid, "dstfracfield")

        # Regrid each section
        old_mask = None
        unmasked_grid_created = False
        destroy_old_Regrid = False
        for k in section_keys:
            d = sections[k]  # d is a Data object

            # Retrieve the source field's grid, create the ESMF Grid
            # and a handle to regridding.dst_dict
            src_data = d
            if src_non_regridding_axis_indices:
                src_data = src_data.squeeze(src_non_regridding_axis_indices)

            src_data = src_data.transpose(src_order).array

            if not (
                method == "nearest_stod" and use_src_mask
            ) and np.ma.is_masked(src_data):
                mask = src_data.mask
                if not np.array_equal(mask, old_mask):
                    # Release old memory
                    if old_mask is not None:
                        srcfracfield.destroy()  # noqa: F821
                        srcfield.destroy()  # noqa: F821
                        srcgrid.destroy()  # noqa: F821

                    # (Re)create the source ESMF Grid and Fields
                    srcgrid = create_Grid(
                        src_coords,
                        use_bounds,
                        mask=mask,
                        cyclic=src_cyclic,
                        coords_2D=src_coords_2D,
                        coord_order=src_coord_order,
                    )
                    srcfield = create_Field(srcgrid, "srcfield")
                    srcfracfield = create_Field(srcgrid, "srcfracfield")

                    # (Re)initialise the regridder
                    create_new_regridder = not dst_regrid
                    if dst_regrid:
                        create_new_regridder = not grids_have_same_masks(
                            srcgrid, regridSrc2Dst.srcfield.grid
                        )

                    if create_new_regridder:
                        if destroy_old_Regrid:
                            regridSrc2Dst.destroy()  # noqa: F821

                        regridSrc2Dst = create_Regrid(
                            srcfield,
                            dstfield,
                            srcfracfield,
                            dstfracfield,
                            method,
                            ignore_degenerate=ignore_degenerate,
                        )
                        destroy_old_Regrid = True

                    old_mask = mask
            else:
                # The source data for this section is either a) not
                # masked or b) has the same mask as the previous
                # section.
                if not unmasked_grid_created or old_mask is not None:
                    # Create the source ESMF Grid and Fields
                    srcgrid = create_Grid(
                        src_coords,
                        use_bounds,
                        cyclic=src_cyclic,
                        coords_2D=src_coords_2D,
                        coord_order=src_coord_order,
                    )
                    srcfield = create_Field(srcgrid, "srcfield")
                    srcfracfield = create_Field(srcgrid, "srcfracfield")

                    # Initialise the regridder. This also creates the
                    # weights needed for the regridding.
                    create_new_regridder = not dst_regrid
                    if dst_regrid:
                        create_new_regridder = not grids_have_same_masks(
                            srcgrid, regridSrc2Dst.srcfield.grid
                        )

                    if create_new_regridder:
                        if destroy_old_Regrid:
                            regridSrc2Dst.destroy()  # noqa: F821

                        regridSrc2Dst = create_Regrid(
                            srcfield,
                            dstfield,
                            srcfracfield,
                            dstfracfield,
                            method,
                            ignore_degenerate=ignore_degenerate,
                        )
                        destroy_old_Regrid = True

                    unmasked_grid_created = True
                    old_mask = None

            if return_operator:
                # Return the first created regrid operator, which
                # contaions the ESMF regrid operator and the input
                # parameters.
                parameters = {
                    "dst": dst.copy(),
                    "dst_axes": dst_axes,
                    "dst_cyclic": dst_cyclic,
                    "src_cyclic": src_cyclic,
                    "ignore_degenerate": ignore_degenerate,
                    "use_dst_mask": use_dst_mask,
                    "use_src_mask": use_src_mask,
                }
                return regrid_create_operator(
                    regridSrc2Dst, "regrids", parameters
                )

            # Fill the source and destination fields (the destination
            # field gets filled with a fill value, the source field
            # with the section's data)
            regrid_fill_fields(
                src_data, srcfield, dstfield, f.fill_value(default="netCDF")
            )

            # Run regridding (dstfield is an ESMF Field)
            dstfield = run_Regrid(regridSrc2Dst, srcfield, dstfield)

            # Compute field mass if requested for conservative regridding
            if (
                _compute_field_mass is not None
                and method in conservative_regridding_methods
            ):
                # Update the _compute_field_mass dictionary in-place,
                # thereby making the field mass available after
                # returning
                regrid_compute_field_mass(
                    f,
                    _compute_field_mass,
                    k,
                    srcgrid,
                    srcfield,
                    srcfracfield,
                    dstgrid,
                    dstfield,
                )

            # Get the regridded data or frac field as a numpy array
            # (regridded_data is a numpy array)
            regridded_data = regrid_get_regridded_data(
                f, method, fracfield, dstfield, dstfracfield
            )

            # Insert regridded data, with axes in order of the
            # original section. This puts the regridded data back into
            # the sections dictionary, with the same key, as a new
            # Data object. Note that the reshape is necessary to
            # replace any size 1 dimensions that we squeezed out
            # earlier.
            sections[k] = Data(
                regridded_data.transpose(src_order).reshape(shape),
                units=f.Units,
            )

        # Release memory for source grid/fields in the data sections
        srcfracfield.destroy()
        srcfield.destroy()
        srcgrid.destroy()

        # Construct new data from regridded sections
        new_data = Data.reconstruct_sectioned_data(sections)

        # Construct new field.
        # Note: cannot call `_inplace_enabled_define_and_cleanup(self)` to
        # apply this if-else logic (it deletes the decorator attribute so
        # can only be used once)
        if inplace:
            f = self
        else:
            f = self.copy()

        # Update ancillary variables of new field
        # f._conform_ancillary_variables(src_axis_keys, keep_size_1=False)

        #        for k_s, new_size in zip(src_axis_keys, dst_axis_sizes):
        #            f.domain_axes[k_s].set_size(new_size)

        # Update coordinate references of new field
        regrid_update_coordinate_references(
            f,
            dst,
            src_axis_keys,
            dst_axis_sizes,
            method,
            use_dst_mask,
            src_cyclic=src_cyclic,
            dst_cyclic=dst_cyclic,
        )

        # Update coordinates of new field
        regrid_update_coordinates(
            f,
            dst,
            dst_dict,
            dst_coords,
            src_axis_keys,
            dst_axis_keys,
            dst_axis_sizes=dst_axis_sizes,
            dst_coords_2D=dst_coords_2D,
            dst_coord_order=dst_coord_order,
        )

        # Copy across destination fields coordinate references if necessary
        if not dst_dict:
            regrid_copy_coordinate_references(f, dst, dst_axis_keys)

        # Insert regridded data into new field
        f.set_data(new_data, axes=self.get_data_axes(), copy=False)

        # Set the cyclicity of the destination longitude
        key, x = f.dimension_coordinate("X", default=(None, None), item=True)
        if x is not None and x.Units.equivalent(Units("degrees")):
            f.cyclic(
                key,
                iscyclic=dst_cyclic,
                config={"coord": x, "period": Data(360.0, "degrees")},
            )

        # Explicitly release all the memory that will not be needed anymore
        if not dst_regrid:
            # explicitly release memory for destination ESMF objects
            # as they were only created locally (i.e. not originating
            # from an existing regrid operator) and they will not be
            # used anymore
            dstfracfield.destroy()
            dstfield.destroy()
            dstgrid.destroy()

            # explicitly release memory for ESMF Regrid and its
            # associated objects which is safe to do so since the
            # regrid operator was not returned so the weights will
            # not be used anymore
            del regridSrc2Dst

        return f

    @_deprecated_kwarg_check("i")
    @_inplace_enabled(default=False)
    def regridc(
        self,
        dst,
        axes=None,
        method=None,
        use_src_mask=True,
        use_dst_mask=False,
        fracfield=False,
        axis_order=None,
        ignore_degenerate=True,
        inplace=False,
        i=False,
        _compute_field_mass=None,
        return_operator=False,
    ):
        """Return the field with the specified Cartesian axes regridded
        onto a new grid.

        Between 1 and 3 dimensions may be regridded.

        Regridding, also called remapping or interpolation, is the
        process of changing the grid underneath field data values
        while preserving the qualities of the original data.

        The regridding method must be specified. First-order
        conservative interpolation conserves the global spatial
        integral of the field, but may not give approximations to the
        values as good as (multi)linear interpolation. Second-order
        conservative interpolation also takes into account the
        gradient across the source cells, so in general gives a
        smoother, more accurate representation of the source field
        especially when going from a coarser to a finer
        grid. (Multi)linear interpolation is available. The latter
        method is particular useful for cases when the latitude and
        longitude coordinate cell boundaries are not known nor
        inferable. Higher order patch recovery is available as an
        alternative to (multi)linear interpolation.  This typically
        results in better approximations to values and derivatives
        compared to the latter, but the weight matrix can be larger
        than the linear matrix, which can be an issue when regridding
        close to the memory limit on a machine. It is only available
        in 2-d. Nearest neighbour interpolation is also
        available. Nearest source to destination is particularly
        useful for regridding integer fields such as land use.

        **Metadata**

        The field construct's domain must have axes matching those
        specified in *src_axes*. The same is true for the destination
        grid, if it provided as part of another field. Optionally the
        axes to use from the destination grid may be specified
        separately in *dst_axes*.

        The output field construct's coordinate objects which span the
        specified axes are replaced with those from the destination
        grid. Any fields contained in coordinate reference objects
        will also be regridded, if possible.


        **Mask**

        The data array mask of the field is automatically taken into
        account, such that the regridded data array will be masked in
        regions where the input data array is masked. By default the
        mask of the destination grid is not taken into account. If the
        destination field data has more dimensions than the number of
        axes specified then, if used, its mask is taken from the 1-3
        dimensional section of the data where the indices of all axes
        other than X and Y are zero.


        **Implementation**

        The interpolation is carried out using the `ESMF` package, a
        Python interface to the Earth System Modeling Framework (ESMF)
        `regridding utility
        <https://www.earthsystemcog.org/projects/esmf/regridding>`_.


        **Logging**

        Whether ESMF logging is enabled or not is determined by
        `cf.regrid_logging`. If it is logging takes place after every
        call. By default logging is disabled.

        .. seealso:: `regrids`

        :Parameters:

            dst: `Field` or `dict` or `RegridOperator`
                The destination grid. Must be one of:

                * `Field`. The grid is defined by the field
                  construct's domain.

                * `dict`. The grid is defined by a dictionary with
                  keys ``'latitude'`` and ``'longitude'`` whose values
                  are with either both 1-d dimension coordinates
                  constructs, or both 2-d auxiliary coordinate
                  constructs. In the 2-d case, both coordinate
                  constructs must have their axes in the same order
                  and this must be specified with the ``'axes'`` key
                  as either of the tuples ``('X', 'Y')`` or ``('Y',
                  'X')``.

                * `RegridOperator`. The grid is defined by a regrid
                  operator that has been returned by a previous call
                  to `regridc` with ``return_operator=True``.

                  This option can give large performance increases, as
                  greatest computational expense is often the creation
                  of the regrid operator, rather than running the
                  regrid operator to regrid the data.

                  The regrid operator defines the source grid and the
                  regridding weights, so the *method*, *axes*,
                  *ignore_degenerate*, *use_src_mask*, and
                  *use_dst_mask* parameters are not required and are
                  ignored if set.

                  An exception will be raised if the domain of the
                  source field being regridded is inconsistent with
                  source grid of the regrid operator.

                  The source field being regridded may, however, have
                  a different data mask to that of the source grid in
                  the regrid operator. In this case a new regrid
                  operator is automatically created, with the
                  associated loss in performance.

            dst: `Field` or `dict` or `RegridOperator`
                The field containing the new grid or a dictionary with
                the axes specifiers as keys referencing dimension
                coordinates.

            axes: optional
                Select dimension coordinates from the source and
                destination fields for regridding. See `cf.Field.axes`
                TODO for options for selecting specific axes. However,
                the number of axes returned by `cf.Field.axes` TODO
                must be the same as the number of specifiers passed
                in. TODO Can only be None if regridoperator

                .. note:: When *dst* is a regrid operator then *axes*
                          is ignored, and its value is set by the
                          regrid operator's parameters.

            {{method: `str`, optional}}

            use_src_mask: `bool`, optional
                For all methods other than 'nearest_stod', this must
                be True as it does not make sense to set it to
                False. For the 'nearest_stod' method if it is True
                then points in the result that are nearest to a masked
                source point are masked. Otherwise, if it is False,
                then these points are interpolated to the nearest
                unmasked source points.

                .. note:: When *dst* is a regrid operator then
                          *use_src_mask* is ignored, and its value is
                          set by the regrid operator's parameters.

            use_dst_mask: `bool`, optional
                By default the mask of the data on the destination
                grid is not taken into account when performing
                regridding. If this option is set to True then it is.

                .. note:: When *dst* is a regrid operator then
                          *use_dst_mask* is ignored, and its value is
                          set by the regrid operator's parameters.

            fracfield: `bool`, optional
                If the method of regridding is conservative the
                fraction of each destination grid cell involved in the
                regridding is returned instead of the regridded data
                if this is True. Otherwise this is ignored.

            axis_order: sequence, optional
                A sequence of items specifying dimension coordinates
                as retrieved by the `dim` method. These determine the
                order in which to iterate over the other axes of the
                field when regridding slices. The slowest moving axis
                will be the first one specified. Currently the
                regridding weights are recalculated every time the
                mask of a slice changes with respect to the previous
                one, so this option allows the user to minimise how
                frequently the mask changes. TODO.

            ignore_degenerate: `bool`, optional
                True by default. Instructs ESMF to ignore degenerate
                cells when checking the grids for errors. Regridding
                will proceed and degenerate cells will be skipped, not
                producing a result, when set to True. Otherwise an
                error will be produced if degenerate cells are
                found. This will be present in the ESMF log files if
                cf.regrid_logging is set to True. As of ESMF 7.0.0
                this only applies to conservative regridding.  Other
                methods always skip degenerate cells.

                .. note:: When *dst* is a regrid operator then
                          *ignore_degenerate* is ignored, and its
                          value is set by the regrid operator's
                          parameters.

            {{inplace: `bool`, optional}}

            _compute_field_mass: `dict`, optional
                If this is a dictionary then the field masses of the
                source and destination fields are computed and
                returned within the dictionary. The keys of the
                dictionary indicates the lat/long slice of the field
                and the corresponding value is a tuple containing the
                source field construct's mass and the destination
                field construct's mass. The calculation is only done
                if conservative regridding is being performed. This is
                for debugging purposes.

            return_operator: `bool`, optional
                If True then do not perform the regridding, rather
                return a `RegridOperator` instance that defines the
                regridding operation, including the destination grid,
                and which can be used in subsequent calls to
                `regridc`.

                .. versionadded:: 3.10.0

            {{i: deprecated at version 3.0.0}}

        :Returns:

            `Field` or `None` or `RegridOperator`
                The regridded field construct, or `None` if the
                operation was in-place, or the regridding operator if
                *return_operator* is True.

        **Examples**

        Regrid the time axes of field ``f`` conservatively onto a grid
        contained in field ``g``:

        >>> h = f.regridc(g, axes='T', method='conservative')

        Regrid the T axis of field ``f`` conservatively onto the grid
        specified in the dimension coordinate ``t``:

        >>> h = f.regridc({'T': t}, axes=('T'), method='conservative_1st')

        Regrid the T axis of field ``f`` using linear interpolation onto
        a grid contained in field ``g``:

        >>> h = f.regridc(g, axes=('T'), method='linear')

        Regrid the X and Y axes of field ``f`` conservatively onto a grid
        contained in field ``g``:

        >>> h = f.regridc(g, axes=('X','Y'), method='conservative_1st')

        Regrid the X and T axes of field ``f`` conservatively onto a grid
        contained in field ``g`` using the destination mask:

        >>> h = f.regridc(g, axes=('X','Y'), method='linear',
        ...               use_dst_mask=True)

        Store the regrid operator and use it for a subsequent regrids:

        >>> op = f.regridc(g, axes='T', method='conservative',
        ...                return_operator=True)
        >>> h = f.regridc(op)
        >>> h2 = f2.regridc(op)

        """
        # Initialise ESMF for regridding
        regrid_initialize()

        f = _inplace_enabled_define_and_cleanup(self)

        dst_regrid = isinstance(dst, RegridOperator)

        if dst_regrid:
            operator = dst
            method = regrid_get_operator_method(operator, method)

            if return_operator:
                return operator.copy()

            regridSrc2Dst = operator.regrid

            # Override input arguments with those stored in the regrid
            # operator
            dst = operator.get_parameter("dst")
            axes = operator.get_parameter("axes")
            ignore_degenerate = operator.get_parameter("ignore_degenerate")
            use_dst_mask = operator.get_parameter("use_dst_mask")
            use_src_mask = operator.get_parameter("use_src_mask")
        elif axes is None:
            raise ValueError(
                "Must set the axes parameter when the dst parameter is a "
                f"{dst.__class__.__name__}"
            )

        dst_field = isinstance(dst, self.__class__)
        dst_dict = not dst_field and isinstance(dst, dict)

        # Check the method
        regrid_check_method(method)

        # Get the number of axes
        if isinstance(axes, str):
            axes = (axes,)

        n_axes = len(axes)
        if n_axes < 1 or n_axes > 3:
            raise ValueError(
                "Between 1 and 3 axes must be individually specified."
            )

        # Retrieve the source axis keys and dimension coordinates
        src_axis_keys, src_coords = get_cartesian_coords(f, "source", axes)

        # Retrieve the destination axis keys and dimension coordinates
        if dst_dict:
            dst_coords = []
            for axis in axes:
                try:
                    dst_coords.append(dst[axis])
                except KeyError:
                    raise ValueError(f"Axis {axis!r} not specified in dst.")

            dst_axis_keys = None
        elif dst_field:
            dst_axis_keys, dst_coords = get_cartesian_coords(
                dst, "destination", axes
            )
        else:
            raise TypeError(
                "dst parameter must be Field, dict or RegridOperator. "
                f"Got: {type(dst)}"
            )
        # Check that the units of the source and the destination
        # coords are equivalent and if so set the units of the source
        # coords to those of the destination coords
        for src_coord, dst_coord in zip(src_coords, dst_coords):
            if src_coord.Units.equivalent(dst_coord.Units):
                src_coord.units = dst_coord.units
            else:
                raise ValueError(
                    "Units of source and destination domains are not "
                    f"equivalent: {src_coord.Units!r}, {dst_coord.Units!r}"
                )

        # Get the axis indices and their order for the source field
        (
            src_axis_indices,
            src_non_regridding_axis_indices,
            src_order,
        ) = regrid_get_axis_indices(f, src_axis_keys)

        # Get the axis indices and their order for the destination field.
        if not dst_dict:
            dst_axis_indices, _, dst_order = regrid_get_axis_indices(
                dst, dst_axis_keys
            )

        # Pad out a single dimension with an extra one (see comments
        # in regrid_check_bounds). Variables ending in _ext pertain
        # the extra dimension.
        axis_keys_ext = []
        coords_ext = []
        src_axis_indices_ext = src_axis_indices
        src_order_ext = src_order
        # Proceed if there is only one regridding dimension, but more
        # than one dimension to the field that is not of size one.
        if n_axes == 1 and f.squeeze().ndim > 1:
            # Find the length and index of the longest axis not
            # including the axis along which regridding will be
            # performed.
            src_shape = np.array(f.shape)
            tmp = src_shape.copy()
            tmp[src_axis_indices] = -1
            max_length = -1
            max_ind = -1
            for ind, length in enumerate(tmp):
                if length > max_length:
                    max_length = length
                    max_ind = ind

            # If adding this extra dimension to the regridding axis
            # will not create sections that exceed 1 chunk of memory
            # proceed to get the coordinate and associated data for
            # the extra dimension.
            if src_shape[src_axis_indices].prod() * max_length * 8 < (
                float(chunksize())
            ):
                axis_keys_ext, coords_ext = get_cartesian_coords(
                    f, "source", [max_ind]
                )
                (
                    src_axis_indices_ext,
                    src_non_regridding_axis_indices,
                    src_order_ext,
                ) = regrid_get_axis_indices(f, axis_keys_ext + src_axis_keys)

        # Calculate shape of each regridded section
        shape = regrid_get_section_shape(
            f,
            [coord.size for coord in coords_ext + dst_coords],
            src_axis_indices_ext,
        )

        # Check that use_src_mask is True for all methods other than
        # nearest_stod
        regrid_check_use_src_mask(use_src_mask, method)

        # Check that the regridding axes span two dimensions if using
        # higher order patch recovery
        if method == "patch" and n_axes != 2:
            raise ValueError(
                "Higher order patch recovery is only available in 2-d."
            )

        # Check the bounds of the coordinates
        regrid_check_bounds(
            src_coords, dst_coords, method, ext_coords=coords_ext
        )

        # Deal with case of 1D nonconservative regridding
        nonconservative1D = False
        if (
            method not in conservative_regridding_methods
            and n_axes == 1
            and coords_ext == []
        ):
            # Method is not conservative, regridding is to be done
            # along one dimension and that dimension has not been
            # padded out with an extra one.
            nonconservative1D = True
            coords_ext = [
                DimensionCoordinate(
                    data=Data(
                        [
                            numpy_finfo("float32").epsneg,
                            numpy_finfo("float32").eps,
                        ]
                    )
                )
            ]

        # Section the data into slices of up to three dimensions
        # getting a list of reordered keys if required. Reordering on
        # an extended axis will not have any effect as all the items
        # in the keys will be None. Therefore it is only checked if
        # the axes specified in axis_order are in the regridding axes
        # as this is informative to the user.
        section_keys, sections = regrid_get_reordered_sections(
            self, axis_order, src_axis_keys, src_axis_indices_ext
        )

        # Use bounds if the regridding method is conservative.
        use_bounds = regrid_use_bounds(method)

        # Retrieve the destination field's mask if appropriate
        dst_mask = None
        if not dst_dict and use_dst_mask and dst.data.ismasked:
            # TODODASK: Just get the mask?
            dst_mask = regrid_get_destination_mask(
                dst,
                dst_order,
                axes=dst_axis_keys,
                cartesian=True,
                coords_ext=coords_ext,
            )

        # Get the destination ESMF Grid, Field and fractional Field
        if dst_regrid:
            srcgrid = create_Grid(
                coords_ext + src_coords, use_bounds, cartesian=True
            )
            if not grids_have_same_coords(
                srcgrid, regridSrc2Dst.srcfield.grid
            ):
                srcgrid.destroy()
                raise ValueError(
                    f"Can't regrid {self!r} with regridding operator "
                    f"{operator!r}: Source grid coordinates do not match."
                )
            srcgrid.destroy()

            dstgrid = regridSrc2Dst.dstfield.grid
            dstfield = regridSrc2Dst.dstfield
            dstfracfield = regridSrc2Dst.dst_frac_field
        else:
            dstgrid = create_Grid(
                coords_ext + dst_coords,
                use_bounds,
                mask=dst_mask,
                cartesian=True,
            )

            # dstfield will be reused to receive the regridded source
            # data for each section, one after the other
            dstfield = create_Field(dstgrid, "dstfield")
            dstfracfield = create_Field(dstgrid, "dstfracfield")

        # Regrid each section
        old_mask = None
        unmasked_grid_created = False
        destroy_old_Regrid = False
        for k in section_keys:
            d = sections[k]
            subsections = d.data.section(
                src_axis_indices_ext, chunks=True, min_step=2
            )

            for k2 in subsections.keys():
                d2 = subsections[k2]
                # Retrieve the source field's grid, create the ESMF
                # grid and a handle to regridding.
                src_data = d2
                if src_non_regridding_axis_indices:
                    src_data = src_data.squeeze(
                        src_non_regridding_axis_indices
                    )

                src_data = src_data.transpose(src_order_ext).array

                if nonconservative1D:
                    src_data = numpy_tile(src_data, (2, 1))

                if not (
                    method == "nearest_stod" and use_src_mask
                ) and numpy_ma_is_masked(src_data):
                    mask = src_data.mask
                    if not numpy_array_equal(mask, old_mask):
                        # Release old memory
                        if old_mask is not None:
                            srcfracfield.destroy()  # noqa: F821
                            srcfield.destroy()  # noqa: F821
                            srcgrid.destroy()  # noqa: F821

                        # (Re)create the source ESMF Grid and fields
                        srcgrid = create_Grid(
                            coords_ext + src_coords,
                            use_bounds,
                            mask=mask,
                            cartesian=True,
                        )
                        srcfield = create_Field(srcgrid, "srcfield")
                        srcfracfield = create_Field(srcgrid, "srcfracfield")

                        # (Re)initialise the regridder
                        create_new_regridder = not dst_regrid
                        if dst_regrid:
                            create_new_regridder = not grids_have_same_masks(
                                srcgrid, regridSrc2Dst.srcfield.grid
                            )

                        if create_new_regridder:
                            if destroy_old_Regrid:
                                regridSrc2Dst.destroy()  # noqa: F821

                            regridSrc2Dst = create_Regrid(
                                srcfield,
                                dstfield,
                                srcfracfield,
                                dstfracfield,
                                method=method,
                                ignore_degenerate=ignore_degenerate,
                            )
                            destroy_old_Regrid = True

                        old_mask = mask
                else:
                    if not unmasked_grid_created or old_mask is not None:
                        # Create the source ESMF Grid and Fields
                        srcgrid = create_Grid(
                            coords_ext + src_coords, use_bounds, cartesian=True
                        )
                        srcfield = create_Field(srcgrid, "srcfield")
                        srcfracfield = create_Field(srcgrid, "srcfracfield")

                        create_new_regridder = not dst_regrid
                        if dst_regrid:
                            create_new_regridder = not grids_have_same_masks(
                                srcgrid, regridSrc2Dst.srcfield.grid
                            )

                        if create_new_regridder:
                            if destroy_old_Regrid:
                                regridSrc2Dst.destroy()  # noqa: F821

                            regridSrc2Dst = create_Regrid(
                                srcfield,
                                dstfield,
                                srcfracfield,
                                dstfracfield,
                                method=method,
                                ignore_degenerate=ignore_degenerate,
                            )
                            destroy_old_Regrid = True

                        unmasked_grid_created = True
                        old_mask = None

                if return_operator:
                    # Return the first created regrid operator, which
                    # contaions the ESMF regrid operator and the input
                    # parameters.
                    parameters = {
                        "dst": dst.copy(),
                        "axes": axes,
                        "ignore_degenerate": ignore_degenerate,
                        "use_dst_mask": use_dst_mask,
                        "use_src_mask": use_src_mask,
                    }
                    return regrid_create_operator(
                        regridSrc2Dst, "regridc", parameters
                    )

                # Fill the source and destination fields
                regrid_fill_fields(
                    src_data,
                    srcfield,
                    dstfield,
                    f.fill_value(default="netCDF"),
                )

                # Run regridding
                dstfield = run_Regrid(regridSrc2Dst, srcfield, dstfield)

                # Compute field mass if requested for conservative regridding
                if (
                    _compute_field_mass is not None
                    and method in conservative_regridding_methods
                ):
                    regrid_compute_field_mass(
                        f,
                        _compute_field_mass,
                        k,
                        srcgrid,
                        srcfield,
                        srcfracfield,
                        dstgrid,
                        dstfield,
                    )

                # Get the regridded data or frac field as a numpy
                # array
                regridded_data = regrid_get_regridded_data(
                    f, method, fracfield, dstfield, dstfracfield
                )

                if nonconservative1D:
                    # For nonconservative regridding along one
                    # dimension where that dimension has not been
                    # padded out take only one of the two rows of data
                    # as they should be nearly identical.
                    regridded_data = regridded_data[0]

                # Insert regridded data, with axes in correct order
                subsections[k2] = Data(
                    regridded_data.squeeze()
                    .transpose(src_order_ext)
                    .reshape(shape),
                    units=f.Units,
                )

            sections[k] = Data.reconstruct_sectioned_data(subsections)

        # Release memory for source grid/fields created for data subsections
        srcfracfield.destroy()
        srcfield.destroy()
        srcgrid.destroy()

        # Construct new data from regridded sections
        new_data = Data.reconstruct_sectioned_data(sections)

        # # Update ancillary variables of new field
        # f._conform_ancillary_variables(src_axis_keys, keep_size_1=False)

        dst_axis_sizes = [c.size for c in dst_coords]

        # Update coordinate references of new field
        regrid_update_coordinate_references(
            f,
            dst,
            src_axis_keys,
            dst_axis_sizes,
            method,
            use_dst_mask,
            cartesian=True,
            axes=axes,
            n_axes=n_axes,
        )

        # Update coordinates of new field
        regrid_update_coordinates(
            f,
            dst,
            dst_dict,
            dst_coords,
            src_axis_keys,
            dst_axis_keys,
            cartesian=True,
        )

        # Copy across destination fields coordinate references if
        # necessary
        if not dst_dict:
            regrid_copy_coordinate_references(f, dst, dst_axis_keys)

        # Insert regridded data into new field
        f.set_data(new_data, axes=self.get_data_axes())

        # Explicitly release all the memory that will not be needed anymore
        if not dst_regrid:
            # explicitly release memory for destination ESMF objects
            # as they were only created locally (i.e. not originating
            # from an existing regrid operator) and they will not be
            # used anymore
            dstfracfield.destroy()
            dstfield.destroy()
            dstgrid.destroy()

            # explicitly release memory for ESMF Regrid and its
            # associated objects which is safe to do so since the
            # regrid operator was not returned so the weights will
            # not be used anymore
            del regridSrc2Dst

        return f

    @_deprecated_kwarg_check("i")
    @_inplace_enabled(default=False)
    def derivative(
        self,
        axis,
        wrap=None,
        one_sided_at_boundary=False,
        inplace=False,
        i=False,
        cyclic=None,
    ):
        """Calculate the derivative along the specified axis.

        The derivative is calculated using centred finite differences
        apart from at the boundaries (see the *one_sided_at_boundary*
        parameter). If missing values are present then missing values
        will be returned at all points where a centred finite
        difference could not be calculated.

        :Parameters:

            axis:
                The axis, defined by that which would be selected by
                passing the given axis description to a call of the
                field construct's `domain_axis` method. For example,
                for a value of ``'X'``, the domain axis construct
                returned by ``f.domain_axis('X')`` is selected.

            wrap: `bool`, optional
                Whether the axis is cyclic or not. By default *wrap*
                is set to the result of this call to the field
                construct's `iscyclic` method:
                ``f.iscyclic(axis)``. If the axis is cyclic then
                centred differences at one boundary will always use
                values from the other boundary, regardless of the
                setting of *one_sided_at_boundary*.

            one_sided_at_boundary: `bool`, optional
                If True, and the field is not cyclic or *wrap* is
                True, then one-sided finite differences are calculated
                at the non-cyclic boundaries. By default missing
                values are set at non-cyclic boundaries.

            {{inplace: `bool`, optional}}

            {{i: deprecated at version 3.0.0}}

        :Returns:

            `Field` or `None`
                The derivative of the field along the specified axis,
                or `None` if the operation was in-place.

        **Examples**

        >>> f = cf.example_field(0)
        >>> print(f.dimension_coordinate('X').array)
        [ 22.5  67.5 112.5 157.5 202.5 247.5 292.5 337.5]
        >>> print(f.dimension_coordinate('X').cellsize.array)
        [45. 45. 45. 45. 45. 45. 45. 45.]
        >>> f[...] = [45, 90, 135, 180, 225, 270, 315, 360]
        >>> f.iscyclic('X')
        True
        >>> print(f.array)
        [[ 45.  90. 135. 180. 225. 270. 315. 360.]
         [ 45.  90. 135. 180. 225. 270. 315. 360.]
         [ 45.  90. 135. 180. 225. 270. 315. 360.]
         [ 45.  90. 135. 180. 225. 270. 315. 360.]
         [ 45.  90. 135. 180. 225. 270. 315. 360.]]
        >>> print(f.derivative('X').array)
        [[-3.  1.  1.  1.  1.  1.  1. -3.]
         [-3.  1.  1.  1.  1.  1.  1. -3.]
         [-3.  1.  1.  1.  1.  1.  1. -3.]
         [-3.  1.  1.  1.  1.  1.  1. -3.]
         [-3.  1.  1.  1.  1.  1.  1. -3.]]
        >>> print(f.derivative('X', wrap=False).array)
        [[-- 1.0 1.0 1.0 1.0 1.0 1.0 --]
         [-- 1.0 1.0 1.0 1.0 1.0 1.0 --]
         [-- 1.0 1.0 1.0 1.0 1.0 1.0 --]
         [-- 1.0 1.0 1.0 1.0 1.0 1.0 --]
         [-- 1.0 1.0 1.0 1.0 1.0 1.0 --]]
        >>> print(
        ...   f.derivative('X', wrap=False, one_sided_at_boundary=True).array
        ... )
        [[1. 1. 1. 1. 1. 1. 1. 1.]
         [1. 1. 1. 1. 1. 1. 1. 1.]
         [1. 1. 1. 1. 1. 1. 1. 1.]
         [1. 1. 1. 1. 1. 1. 1. 1.]
         [1. 1. 1. 1. 1. 1. 1. 1.]]

        """
        if cyclic:
            _DEPRECATION_ERROR_KWARGS(
                self,
                "derivative",
                {"cyclic": cyclic},
                "Use the 'wrap' keyword instead",
            )  # pragma: no cover

        # Retrieve the axis
        axis_in = axis
        axis = self.domain_axis(axis, key=True, default=None)
        if axis is None:
            raise ValueError(f"Invalid axis specifier: {axis_in}")

        coord = self.dimension_coordinate(filter_by_axis=(axis,), default=None)
        if coord is None:
            raise ValueError(
                f"No dimension coordinates for axis defined by {axis_in}"
            )

        # Get the axis index
        axis_index = self.get_data_axes().index(axis)

        # Automatically detect the cyclicity of the axis if cyclic is
        # None
        cyclic = self.iscyclic(axis)
        if wrap is None:
            wrap = cyclic

        # Set the boundary conditions
        if wrap:
            mode = "wrap"
        elif one_sided_at_boundary:
            mode = "nearest"
        else:
            mode = "constant"

        f = _inplace_enabled_define_and_cleanup(self)

        # Find the differences of the data
        f.convolution_filter(
            [1, 0, -1], axis=axis, mode=mode, update_bounds=False, inplace=True
        )

        # Find the differences of the coordinates
        d = None
        if wrap and cyclic:
            period = coord.period()
            if period is None:
                raise ValueError(
                    "Can't calculate derivative when cyclic dimension "
                    f"coordinate {coord!r} has no period"
                )

            # Fix the boundary differences for cyclic periodic
            # coordinates. Need to extend the coordinates to include a
            # dummy value at each end, grabbed from the other end,
            # that maintains strict monotonicity.
            c_data = coord.data
            d2 = self._Data.empty((c_data.size + 2,), units=c_data.Units)
            if not coord.direction():
                period = -period

            d2[1:-1] = c_data
            d2[0] = c_data[-1] - period
            d2[-1] = c_data[0] + period
            c_data = d2
            d = d2.convolution_filter(
                window=[1, 0, -1], axis=0, mode="constant"
            )[1:-1]

        if d is None:
            d = coord.data.convolution_filter(
                window=[1, 0, -1], axis=0, mode=mode, cval=np.nan
            )

        # Reshape the coordinate differences so that they broadcast to
        # the data
        for _ in range(self.ndim - 1 - axis_index):
            d.insert_dimension(position=1, inplace=True)

        # Find the derivative
        f.data /= d

        # Update the standard name and long name
        f.set_property("long_name", f"{axis_in} derivative of {f.identity()}")
        f.del_property("standard_name", None)

        return f

    # ----------------------------------------------------------------
    # Aliases
    # ----------------------------------------------------------------
    def field_anc(
        self,
        *identity,
        key=False,
        default=ValueError(),
        item=False,
        **filter_kwargs,
    ):
        """Alias for `cf.Field.field_ancillary`."""
        return self.field_ancillary(
            *identity, key=key, default=default, item=item, **filter_kwargs
        )

    def field_ancs(self, *identities, **filter_kwargs):
        """Alias for `field_ancillaries`."""
        return self.field_ancillaries(*identities, **filter_kwargs)

    # ----------------------------------------------------------------
    # Deprecated attributes and methods
    # ----------------------------------------------------------------
    @property
    def _Axes(self):
        """Return domain axis constructs.

        Deprecated at version 3.0.0. Use `domain_axes` method instead.

        """
        raise DeprecationError(
            f"{self.__class__.__name__} attribute '_Axes' has been deprecated "
            "at version 3.0.0 and is no longer available and will be removed"
            "at v4.0.0"
            "Use 'domain_axes' instead."
        )

    @property
    def CellMethods(self):
        """Return cell method constructs.

        Deprecated at version 3.0.0. Use `cell_methods` method instead.

        """
        raise DeprecationError(
            f"{self.__class__.__name__} attribute 'CellMethods' has been "
            "deprecated at version 3.0.0 and is no longer available "
            "and will be removed at v4.0.0. "
            "Use 'cell_methods' instead."
        )

    @property
    def Items(self):
        """Return domain items as (construct key, construct) pairs.

        Deprecated at version 3.0.0. Use `constructs` method instead.

        """
        raise DeprecationError(
            f"{self.__class__.__name__} attribute 'Items' has been deprecated "
            "at version 3.0.0 and is no longer available "
            "and will be removed at v4.0.0. "
            "Use 'constructs' instead."
        )

    def CM(self, xxx):
        """Return cell method constructs.

        Deprecated at version 3.0.0.

        """
        raise DeprecationError(
            f"{self.__class__.__name__} method 'CM' has been deprecated "
            "at version 3.0.0 and is no longer available "
            "and will be removed at v4.0.0. "
        )

    def axis_name(self, *args, **kwargs):
        """Return the canonical name for an axis.

        Deprecated at version 3.0.0. Use `domain_axis_identity` method
        instead.

        """
        _DEPRECATION_ERROR_METHOD(
            self, "axis_name", "Use 'domain_axis_identity' method instead."
        )  # pragma: no cover

    def data_axes(self):
        """Return the domain axes for the data array dimensions.

        Deprecated at version 3.0.0. Use `get_data_axes` method instead.

        """
        _DEPRECATION_ERROR_METHOD(
            self, "data_axes", "Use 'get_data_axes' method instead."
        )  # pragma: no cover

    @_manage_log_level_via_verbosity
    def equivalent(self, other, rtol=None, atol=None, verbose=None):
        """True if two fields are equivalent, False otherwise.

        Deprecated at version 3.0.0.

        """
        _DEPRECATION_ERROR_METHOD(self, "equivalent", version="3.0.0")

    @classmethod
    def example_field(cls, n):
        """Return an example field construct.

        Deprecated at version 3.0.5. Use function `cf.example_field` instead.

        .. versionadded:: 3.0.4

        """
        _DEPRECATION_ERROR_METHOD(
            cls,
            "example_field",
            "Use function 'cf.example_field' instead.",
            version="3.0.5",
        )  # pragma: no cover

    def expand_dims(self, position=0, axes=None, i=False, **kwargs):
        """Insert a size 1 axis into the data array.

        Deprecated at version 3.0.0. Use `insert_dimension` method
        instead.

        """
        _DEPRECATION_ERROR_METHOD(
            self, "expand_dims", "Use 'insert_dimension' method instead."
        )  # pragma: no cover

    def field(
        self,
        description=None,
        role=None,
        axes=None,
        axes_all=None,
        axes_subset=None,
        axes_superset=None,
        exact=False,
        inverse=False,
        match_and=True,
        ndim=None,
        bounds=False,
    ):
        """Create an independent field from a domain item.

        Deprecated at version 3.0.0. Use 'convert' method instead.

        """
        _DEPRECATION_ERROR_METHOD(
            self, "field", "Use 'convert' method instead."
        )  # pragma: no cover

    def HDF_chunks(self, *chunksizes):
        """Deprecated at version 3.0.0.

        Use methods 'Data.nc_hdf5_chunksizes',
        'Data.nc_set_hdf5_chunksizes', 'Data.nc_clear_hdf5_chunksizes'
        instead.

        """
        _DEPRECATION_ERROR_METHOD(
            self,
            "HDF_chunks",
            "Use methods 'Data.nc_hdf5_chunksizes', "
            "'Data.nc_set_hdf5_chunksizes', "
            "'Data.nc_clear_hdf5_chunksizes' instead.",
        )  # pragma: no cover

    def insert_measure(
        self, item, key=None, axes=None, copy=True, replace=True
    ):
        """Insert a cell measure object into the field.

        Deprecated at version 3.0.0. Use method 'set_construct' instead.

        """
        _DEPRECATION_ERROR_METHOD(
            self, "insert_measure", "Use method 'set_construct' instead."
        )  # pragma: no cover

    def insert_dim(self, item, key=None, axes=None, copy=True, replace=True):
        """Insert a dimension coordinate object into the field.

        Deprecated at version 3.0.0. Use method 'set_construct' instead.

        """
        _DEPRECATION_ERROR_METHOD(
            self, "insert_dim", "Use method 'set_construct' instead."
        )  # pragma: no cover

    def insert_axis(self, axis, key=None, replace=True):
        """Insert a domain axis into the field.

        Deprecated at version 3.0.0. Use method 'set_construct' instead.

        """
        _DEPRECATION_ERROR_METHOD(
            self, "insert_axis", "Use method 'set_construct' instead."
        )  # pragma: no cover

    def insert_item(
        self, role, item, key=None, axes=None, copy=True, replace=True
    ):
        """Insert an item into the field.

        Deprecated at version 3.0.0. Use method 'set_construct' instead.

        """
        _DEPRECATION_ERROR_METHOD(
            self, "insert_item", "Use method 'set_construct' instead."
        )  # pragma: no cover

    def insert_aux(self, item, key=None, axes=None, copy=True, replace=True):
        """Insert an auxiliary coordinate object into the field.

        Deprecated at version 3.0.0. Use method 'set_construct' instead.

        """
        _DEPRECATION_ERROR_METHOD(
            self, "insert_aux", "Use method 'set_construct' instead."
        )  # pragma: no cover

    def insert_cell_methods(self, item):
        """Insert one or more cell method objects into the field.

        Deprecated at version 3.0.0. Use method 'set_construct' instead.

        """
        _DEPRECATION_ERROR_METHOD(
            self, "insert_cell_methods", "Use method 'set_construct' instead."
        )  # pragma: no cover

    def insert_domain_anc(
        self, item, key=None, axes=None, copy=True, replace=True
    ):
        """Insert a domain ancillary object into the field.

        Deprecated at version 3.0.0. Use method 'set_construct' instead.

        """
        _DEPRECATION_ERROR_METHOD(
            self, "insert_domain_anc", "Use method 'set_construct' instead."
        )  # pragma: no cover

    def insert_data(self, data, axes=None, copy=True, replace=True):
        """Insert a data array into the field.

        Deprecated at version 3.0.0. Use method 'set_data' instead.

        """
        _DEPRECATION_ERROR_METHOD(
            self, "insert_data", "Use method 'set_data' instead."
        )  # pragma: no cover

    def insert_field_anc(
        self, item, key=None, axes=None, copy=True, replace=True
    ):
        """Insert a field ancillary object into the field.

        Deprecated at version 3.0.0. Use method 'set_construct'
        instead.g

        """
        _DEPRECATION_ERROR_METHOD(
            self, "insert_field_anc", "Use method 'set_construct' instead."
        )  # pragma: no cover

    def insert_ref(self, item, key=None, axes=None, copy=True, replace=True):
        """Insert a coordinate reference object into the field.

        Deprecated at version 3.0.0. Use method 'set_construct' or
        'set_coordinate_reference' instead.

        """
        _DEPRECATION_ERROR_METHOD(
            self,
            "insert_ref",
            "Use method 'set_construct' or 'set_coordinate_reference' "
            "instead.",
        )  # pragma: no cover

    def item(
        self,
        *identity,
        key=False,
        default=ValueError(),
        item=False,
        **filter_kwargs,
    ):
        """Return a domain item of the field.

        Deprecated. Use `construct` method instead.

        """
        _DEPRECATION_ERROR_METHOD(
            self, "item", "Use 'construct' method instead."
        )  # pragma: no cover

    def items(self, *identities, **filter_kwargs):
        """Return domain items as (construct key, construct) pairs.

        Deprecated. Use `constructs` method instead.

        """
        _DEPRECATION_ERROR_METHOD(
            self, "items", "Use 'constructs' method instead."
        )  # pragma: no cover

    def item_axes(
        self,
        description=None,
        role=None,
        axes=None,
        axes_all=None,
        axes_subset=None,
        axes_superset=None,
        exact=False,
        inverse=False,
        match_and=True,
        ndim=None,
        default=None,
    ):
        """Return the axes of a domain item of the field.

        Deprecated at version 3.0.0. Use the 'get_data_axes' method
        instead.

        """
        _DEPRECATION_ERROR_METHOD(
            self, "item_axes", "Use method 'get_data_axes' instead."
        )  # pragma: no cover

    def items_axes(
        self,
        description=None,
        role=None,
        axes=None,
        axes_all=None,
        axes_subset=None,
        axes_superset=None,
        exact=False,
        inverse=False,
        match_and=True,
        ndim=None,
    ):
        """Return the axes of items of the field.

        Deprecated at version 3.0.0. Use the 'data_axes' method of
        attribute 'constructs' instead.

        """
        _DEPRECATION_ERROR_METHOD(
            self,
            "items_axes",
            "Use the 'data_axes' method of attribute 'constructs' instead.",
        )  # pragma: no cover

    def key_item(self, identity, default=ValueError(), **kwargs):
        """Return an item, or its identifier, from the field.

        Deprecated at version 3.0.0

        """
        _DEPRECATION_ERROR_METHOD(self, "key_item")

    def new_identifier(self, item_type):
        """Return a new, unused construct key.

        Deprecated at version 3.0.0. Use 'new_identifier' method of
        'constructs' attribute instead.

        """
        _DEPRECATION_ERROR_METHOD(
            self,
            " new_identifier",
            "Use 'new_identifier' method of 'constructs' attribute instead.",
        )  # pragma: no cover

    def remove_item(
        self,
        description=None,
        role=None,
        axes=None,
        axes_all=None,
        axes_subset=None,
        axes_superset=None,
        ndim=None,
        exact=False,
        inverse=False,
        match_and=True,
        key=False,
    ):
        """Remove and return an item from the field.

        Deprecated at version 3.0.0. Use `del_construct` method instead.

        """
        _DEPRECATION_ERROR_METHOD(
            self, "remove_item", "Use method 'del_construct' instead."
        )  # pragma: no cover

    def remove_items(
        self,
        description=None,
        role=None,
        axes=None,
        axes_all=None,
        axes_subset=None,
        axes_superset=None,
        ndim=None,
        exact=False,
        inverse=False,
        match_and=True,
    ):
        """Remove and return items from the field.

        Deprecated at version 3.0.0. Use `del_construct` method instead.

        """
        _DEPRECATION_ERROR_METHOD(
            self, "remove_items", "Use method 'del_construct' instead."
        )  # pragma: no cover

    def remove_axes(self, axes=None, **kwargs):
        """Remove and return axes from the field.

        Deprecated at version 3.0.0. Use method 'del_construct' instead.

        """
        _DEPRECATION_ERROR_METHOD(
            self, "remove_axes", "Use method 'del_construct' instead."
        )  # pragma: no cover

    def remove_axis(self, axes=None, size=None, **kwargs):
        """Remove and return a unique axis from the field.

        Deprecated at version 3.0.0. Use method 'del_construct' instead.

        """
        _DEPRECATION_ERROR_METHOD(
            self, "remove_axis", "Use method 'del_construct' instead."
        )  # pragma: no cover

    def remove_data(self, default=ValueError()):
        """Remove and return the data array.

        Deprecated at version 3.0.0. Use method 'del_data' instead.

        """
        _DEPRECATION_ERROR_METHOD(
            self, "remove_data", "Use method 'del_data' instead."
        )  # pragma: no cover

    def transpose_item(self, description=None, iaxes=None, **kwargs):
        """Permute the axes of a field item data array.

        Deprecated at version 3.0.0. Use method 'transpose_construct'
        instead.

        """
        _DEPRECATION_ERROR_METHOD(
            self, "transpose_item", "Use method 'transpose_construct' instead."
        )  # pragma: no cover

    def unlimited(self, *args):
        """Deprecated at version 3.0.0.

        Use methods `DomainAxis.nc_is_unlimited`, and
        `DomainAxis.nc_set_unlimited` instead.

        """
        _DEPRECATION_ERROR_METHOD(
            self,
            "unlimited",
            "Use methods 'DomainAxis.nc_is_unlimited', and "
            "'DomainAxis.nc_set_unlimited' instead.",
        )  # pragma: no cover
