# Copyright Iris contributors
#
# This file is part of Iris and is released under the BSD license.
# See LICENSE in the root of the repository for full licensing details.
"""Iris data model representation of CF UGrid's Mesh and its constituent parts.
Eventual destination: dedicated module in :mod:`iris` root.
"""
from abc import ABC, abstractmethod
from collections import namedtuple
from collections.abc import Container
from typing import Iterable
from cf_units import Unit
from dask import array as da
import numpy as np
from ... import _lazy_data as _lazy
from ...common import CFVariableMixin, metadata_filter, metadata_manager_factory
from ...common.metadata import BaseMetadata
from ...config import get_logger
from ...coords import AuxCoord, _DimensionalMetadata
from ...exceptions import ConnectivityNotFoundError, CoordinateNotFoundError
from ...util import array_equal, clip_string, guess_coord_axis
from .metadata import ConnectivityMetadata, MeshCoordMetadata, MeshMetadata
# Configure the logger.
logger = get_logger(__name__, propagate=True, handler=False)
#: Numpy "threshold" printoptions default argument.
NP_PRINTOPTIONS_THRESHOLD = 10
#: Numpy "edgeitems" printoptions default argument.
NP_PRINTOPTIONS_EDGEITEMS = 2
#
# Mesh dimension names namedtuples.
#
#: Namedtuple for 1D mesh topology NetCDF variable dimension names.
Mesh1DNames = namedtuple("Mesh1DNames", ["node_dimension", "edge_dimension"])
#: Namedtuple for 2D mesh topology NetCDF variable dimension names.
Mesh2DNames = namedtuple(
"Mesh2DNames", ["node_dimension", "edge_dimension", "face_dimension"]
)
#
# Mesh coordinate manager namedtuples.
#
#: Namedtuple for 1D mesh :class:`~iris.coords.AuxCoord` coordinates.
Mesh1DCoords = namedtuple("Mesh1DCoords", ["node_x", "node_y", "edge_x", "edge_y"])
#: Namedtuple for 2D mesh :class:`~iris.coords.AuxCoord` coordinates.
Mesh2DCoords = namedtuple(
"Mesh2DCoords",
["node_x", "node_y", "edge_x", "edge_y", "face_x", "face_y"],
)
#: Namedtuple for ``node`` :class:`~iris.coords.AuxCoord` coordinates.
MeshNodeCoords = namedtuple("MeshNodeCoords", ["node_x", "node_y"])
#: Namedtuple for ``edge`` :class:`~iris.coords.AuxCoord` coordinates.
MeshEdgeCoords = namedtuple("MeshEdgeCoords", ["edge_x", "edge_y"])
#: Namedtuple for ``face`` :class:`~iris.coords.AuxCoord` coordinates.
MeshFaceCoords = namedtuple("MeshFaceCoords", ["face_x", "face_y"])
#
# Mesh connectivity manager namedtuples.
#
#: Namedtuple for 1D mesh :class:`~iris.experimental.ugrid.mesh.Connectivity` instances.
Mesh1DConnectivities = namedtuple("Mesh1DConnectivities", ["edge_node"])
#: Namedtuple for 2D mesh :class:`~iris.experimental.ugrid.mesh.Connectivity` instances.
Mesh2DConnectivities = namedtuple(
"Mesh2DConnectivities",
[
"face_node",
"edge_node",
"face_edge",
"face_face",
"edge_face",
"boundary_node",
],
)
[docs]
class Connectivity(_DimensionalMetadata):
"""CF-UGRID topology.
A CF-UGRID topology connectivity, describing the topological relationship
between two types of mesh element. One or more connectivities make up a
CF-UGRID topology - a constituent of a CF-UGRID mesh.
See: https://ugrid-conventions.github.io/ugrid-conventions
"""
UGRID_CF_ROLES = [
"edge_node_connectivity",
"face_node_connectivity",
"face_edge_connectivity",
"face_face_connectivity",
"edge_face_connectivity",
"boundary_node_connectivity",
"volume_node_connectivity",
"volume_edge_connectivity",
"volume_face_connectivity",
"volume_volume_connectivity",
]
def __init__(
self,
indices,
cf_role,
standard_name=None,
long_name=None,
var_name=None,
units=None,
attributes=None,
start_index=0,
location_axis=0,
):
"""Construct a single connectivity.
Parameters
----------
indices : :class:`numpy.ndarray` or :class:`numpy.ma.core.MaskedArray` or :class:`dask.array.Array`
2D array giving the topological connection relationship between
:attr:`location` elements and :attr:`connected` elements.
The :attr:`location_axis` dimension indexes over the
:attr:`location` dimension of the mesh - i.e. its length matches
the total number of :attr:`location` elements in the mesh. The
:attr:`connected_axis` dimension can be any length, corresponding
to the highest number of :attr:`connected` elements connected to a
:attr:`location` element. The array values are indices into the
:attr:`connected` dimension of the mesh. If the number of
:attr:`connected` elements varies between :attr:`location`
elements: use a :class:`numpy.ma.core.MaskedArray` and mask the
:attr:`location` elements' unused index 'slots'. Use a
:class:`dask.array.Array` to keep indices 'lazy'.
cf_role : str
Denotes the topological relationship that this connectivity
describes. Made up of this array's :attr:`location`, and the
:attr:`connected` element type that is indexed by the array.
See :attr:`UGRID_CF_ROLES` for valid arguments.
standard_name : str, optional
CF standard name of the connectivity.
(NOTE: this is not expected by the UGRID conventions, but will be
handled in Iris' standard way if provided).
long_name : str, optional
Descriptive name of the connectivity.
var_name : str, optional
The NetCDF variable name for the connectivity.
units : cf_units.Unit, optional
The :class:`~cf_units.Unit` of the connectivity's values.
Can be a string, which will be converted to a Unit object.
(NOTE: this is not expected by the UGRID conventions, but will be
handled in Iris' standard way if provided).
attributes : dict, optional
A dictionary containing other cf and user-defined attributes.
start_index : int, optional
Either ``0`` or ``1``. Default is ``0``. Denotes whether
:attr:`indices` uses 0-based or 1-based indexing (allows support
for Fortran and legacy NetCDF files).
location_axis : int, optional
Either ``0`` or ``1``. Default is ``0``. Denotes which axis
of :attr:`indices` varies over the :attr:`location` elements (the
alternate axis therefore varying over :attr:`connected` elements).
(This parameter allows support for fastest varying index being
either first or last).
E.g. for ``face_node_connectivity``, for 10 faces:
``indices.shape[location_axis] == 10``.
"""
def validate_arg_vs_list(arg_name, arg, valid_list):
if arg not in valid_list:
error_msg = (
f"Invalid {arg_name} . Got: {arg} . Must be one of: "
f"{valid_list} ."
)
raise ValueError(error_msg)
# Configure the metadata manager.
self._metadata_manager = metadata_manager_factory(ConnectivityMetadata)
validate_arg_vs_list("start_index", start_index, [0, 1])
# indices array will be 2-dimensional, so must be either 0 or 1.
validate_arg_vs_list("location_axis", location_axis, [0, 1])
validate_arg_vs_list("cf_role", cf_role, Connectivity.UGRID_CF_ROLES)
self._metadata_manager.start_index = start_index
self._metadata_manager.location_axis = location_axis
self._metadata_manager.cf_role = cf_role
self._connected_axis = 1 - location_axis
self._location, self._connected = cf_role.split("_")[:2]
super().__init__(
values=indices,
standard_name=standard_name,
long_name=long_name,
var_name=var_name,
units=units,
attributes=attributes,
)
@property
def _values(self):
# Overridden just to allow .setter override.
return super()._values
@_values.setter
def _values(self, values):
self._validate_indices(values, shapes_only=True)
# The recommended way of using the setter in super().
super(Connectivity, self.__class__)._values.fset(self, values)
@property
def cf_role(self):
"""The category of topological relationship that this connectivity describes.
**Read-only** - validity of :attr:`indices` is dependent on
:attr:`cf_role`. A new :class:`Connectivity` must therefore be defined
if a different :attr:`cf_role` is needed.
"""
return self._metadata_manager.cf_role
@property
def location(self):
"""Derived from the connectivity's :attr:`cf_role`.
Derived from the connectivity's :attr:`cf_role` - the first part, e.g.
``face`` in ``face_node_connectivity``. Refers to the elements that
vary along the :attr:`location_axis` of the connectivity's
:attr:`indices` array.
"""
return self._location
@property
def connected(self):
"""Derived from the connectivity's :attr:`cf_role`.
Derived from the connectivity's :attr:`cf_role` - the second part, e.g.
``node`` in ``face_node_connectivity``. Refers to the elements indexed
by the values in the connectivity's :attr:`indices` array.
"""
return self._connected
@property
def start_index(self):
"""The base value of the connectivity's :attr:`indices` array; either ``0`` or ``1``.
**Read-only** - validity of :attr:`indices` is dependent on
:attr:`start_index`. A new :class:`Connectivity` must therefore be
defined if a different :attr:`start_index` is needed.
"""
return self._metadata_manager.start_index
@property
def location_axis(self):
"""The axis of the connectivity's :attr:`indices` array.
The axis of the connectivity's :attr:`indices` array that varies
over the connectivity's :attr:`location` elements. Either ``0`` or ``1``.
**Read-only** - validity of :attr:`indices` is dependent on
:attr:`location_axis`. Use :meth:`transpose` to create a new, transposed
:class:`Connectivity` if a different :attr:`location_axis` is needed.
"""
return self._metadata_manager.location_axis
@property
def connected_axis(self):
"""Derived as the alternate value of :attr:`location_axis`.
Derived as the alternate value of :attr:`location_axis` - each must
equal either ``0`` or ``1``. The axis of the connectivity's
:attr:`indices` array that varies over the :attr:`connected` elements
associated with each :attr:`location` element.
"""
return self._connected_axis
@property
def indices(self):
"""The index values describing the topological relationship of the connectivity.
The index values describing the topological relationship of the
connectivity, as a NumPy array. Masked points indicate a
:attr:`location` element with fewer :attr:`connected` elements than
other :attr:`location` elements described in this array - unused index
'slots' are masked.
**Read-only** - index values are only meaningful when combined with
an appropriate :attr:`cf_role`, :attr:`start_index` and
:attr:`location_axis`. A new :class:`Connectivity` must therefore be
defined if different indices are needed.
"""
return self._values
[docs]
def indices_by_location(self, indices=None):
"""Return a view of the indices array.
Return a view of the indices array with :attr:`location_axis` **always** as
the first axis - transposed if necessary. Can optionally pass in an
identically shaped array on which to perform this operation (e.g. the
output from :meth:`core_indices` or :meth:`lazy_indices`).
Parameters
----------
indices : array, optional
The array on which to operate. If ``None``, will operate on
:attr:`indices`. Default is ``None``.
Returns
-------
result :
A view of the indices array Transposed - if necessary - to put
:attr:`location_axis` first.
"""
if indices is None:
indices = self.indices
if indices.shape != self.shape:
raise ValueError(
f"Invalid indices provided. Must be shape={self.shape} , "
f"got shape={indices.shape} ."
)
if self.location_axis == 0:
result = indices
elif self.location_axis == 1:
result = indices.transpose()
else:
raise ValueError("Invalid location_axis.")
return result
def _validate_indices(self, indices, shapes_only=False):
# Use shapes_only=True for a lower resource, less thorough validation
# of indices by just inspecting the array shape instead of inspecting
# individual masks. So will not catch individual location elements
# having unacceptably low numbers of associated connected elements.
def indices_error(message):
raise ValueError("Invalid indices provided. " + message)
indices = self._sanitise_array(indices, 0)
indices_dtype = indices.dtype
if not np.issubdtype(indices_dtype, np.integer):
indices_error(
f"dtype must be numpy integer subtype, got: {indices_dtype} ."
)
indices_min = indices.min()
if _lazy.is_lazy_data(indices_min):
indices_min = indices_min.compute()
if indices_min < self.start_index:
indices_error(
f"Lowest index: {indices_min} < start_index: {self.start_index} ."
)
indices_shape = indices.shape
if len(indices_shape) != 2:
indices_error(f"Expected 2-dimensional shape, got: shape={indices_shape} .")
len_req_fail = False
if shapes_only:
location_shape = indices_shape[self.connected_axis]
# Wrap as lazy to allow use of the same operations below
# regardless of shapes_only.
location_lengths = _lazy.as_lazy_data(np.asarray(location_shape))
else:
# Wouldn't be safe to use during __init__ validation, since
# lazy_location_lengths requires self.indices to exist. Safe here since
# shapes_only==False is only called manually, i.e. after
# initialisation.
location_lengths = self.lazy_location_lengths()
if self.location in ("edge", "boundary"):
if (location_lengths != 2).any().compute():
len_req_fail = "len=2"
else:
if self.location == "face":
min_size = 3
elif self.location == "volume":
if self.connected == "edge":
min_size = 6
else:
min_size = 4
else:
raise NotImplementedError
if (location_lengths < min_size).any().compute():
len_req_fail = f"len>={min_size}"
if len_req_fail:
indices_error(
f"Not all {self.location}s meet requirement: {len_req_fail} - "
f"needed to describe '{self.cf_role}' ."
)
[docs]
def validate_indices(self):
"""Perform a thorough validity check of this connectivity's :attr:`indices`.
Perform a thorough validity check of this connectivity's
:attr:`indices`. Includes checking the number of :attr:`connected`
elements associated with each :attr:`location` element (specified using
masks on the :attr:`indices` array) against the :attr:`cf_role`.
Raises a ``ValueError`` if any problems are encountered, otherwise
passes silently.
.. note::
While this uses lazy computation, it will still be a high
resource demand for a large :attr:`indices` array.
"""
self._validate_indices(self.indices, shapes_only=False)
def __eq__(self, other):
eq = NotImplemented
if isinstance(other, Connectivity):
# Account for the fact that other could be the transposed equivalent
# of self, which we consider 'safe' since the recommended
# interaction with the indices array is via indices_by_location, which
# corrects for this difference. (To enable this, location_axis does
# not participate in ConnectivityMetadata to ConnectivityMetadata
# equivalence).
if hasattr(other, "metadata"):
# metadata comparison
eq = self.metadata == other.metadata
if eq:
eq = (
self.shape == other.shape
and self.location_axis == other.location_axis
) or (
self.shape == other.shape[::-1]
and self.location_axis == other.connected_axis
)
if eq:
eq = array_equal(
self.indices_by_location(self.core_indices()),
other.indices_by_location(other.core_indices()),
)
return eq
[docs]
def transpose(self):
"""Transpose :class:`Connectivity`.
Create a new :class:`Connectivity`, identical to this one but with the
:attr:`indices` array transposed and the :attr:`location_axis` value flipped.
Returns
-------
:class:`Connectivity`
A new :class:`Connectivity` that is the transposed equivalent of
the original.
"""
new_connectivity = Connectivity(
indices=self.indices.transpose().copy(),
cf_role=self.cf_role,
standard_name=self.standard_name,
long_name=self.long_name,
var_name=self.var_name,
units=self.units,
attributes=self.attributes,
start_index=self.start_index,
location_axis=self.connected_axis,
)
return new_connectivity
[docs]
def lazy_indices(self):
"""Return a lazy array representing the connectivity's indices.
Accessing this method will never cause the :attr:`indices` values to be
loaded. Similarly, calling methods on, or indexing, the returned Array
will not cause the connectivity to have loaded :attr:`indices`.
If the :attr:`indices` have already been loaded for the connectivity,
the returned Array will be a new lazy array wrapper.
Returns
-------
A lazy array, representing the connectivity indices array.
"""
return super()._lazy_values()
[docs]
def core_indices(self):
"""Return the indices array at the core of this connectivity.
The indices array at the core of this connectivity, which may be a
NumPy array or a Dask array.
Returns
-------
:class:`numpy.ndarray` or :class:`numpy.ma.core.MaskedArray` or :class:`dask.array.Array`
"""
return super()._core_values()
[docs]
def has_lazy_indices(self):
"""Check if the connectivity's :attr:`indices` array is a lazy Dask array or not.
Returns
-------
bool
"""
return super()._has_lazy_values()
[docs]
def lazy_location_lengths(self):
"""Return a lazy array representing the number of :attr:`connected` elements.
Return a lazy array representing the number of :attr:`connected`
elements associated with each of the connectivity's :attr:`location`
elements, accounting for masks if present.
Accessing this method will never cause the :attr:`indices` values to be
loaded. Similarly, calling methods on, or indexing, the returned Array
will not cause the connectivity to have loaded :attr:`indices`.
The returned Array will be lazy regardless of whether the
:attr:`indices` have already been loaded.
Returns
-------
lazy array
A lazy array, representing the number of :attr:`connected`
elements associated with each :attr:`location` element.
"""
location_mask_counts = da.sum(
da.ma.getmaskarray(self.indices), axis=self.connected_axis
)
max_location_size = self.indices.shape[self.connected_axis]
return max_location_size - location_mask_counts
[docs]
def location_lengths(self):
"""Return a NumPy array representing the number of :attr:`connected` elements.
Return a NumPy array representing the number of :attr:`connected`
elements associated with each of the connectivity's :attr:`location`
elements, accounting for masks if present.
Returns
-------
NumPy array
A NumPy array, representing the number of :attr:`connected`
elements associated with each :attr:`location` element.
"""
return self.lazy_location_lengths().compute()
[docs]
def cube_dims(self, cube):
"""Not available on :class:`Connectivity`."""
raise NotImplementedError
[docs]
def xml_element(self, doc):
# Create the XML element as the camelCaseEquivalent of the
# class name
element = super().xml_element(doc)
element.setAttribute("cf_role", self.cf_role)
element.setAttribute("start_index", self.start_index)
element.setAttribute("location_axis", self.location_axis)
return element
[docs]
class Mesh(CFVariableMixin):
"""A container representing the UGRID ``cf_role`` ``mesh_topology``.
A container representing the UGRID ``cf_role`` ``mesh_topology``, supporting
1D network, 2D triangular, and 2D flexible mesh topologies.
.. note::
The 3D layered and fully 3D unstructured mesh topologies are not supported
at this time.
.. seealso::
The UGRID Conventions, https://ugrid-conventions.github.io/ugrid-conventions/
"""
# TBD: for volume and/or z-axis support include axis "z" and/or dimension "3"
#: The supported mesh axes.
AXES = ("x", "y")
#: Valid range of values for ``topology_dimension``.
TOPOLOGY_DIMENSIONS = (1, 2)
#: Valid mesh elements.
ELEMENTS = ("edge", "node", "face")
def __init__(
self,
topology_dimension,
node_coords_and_axes,
connectivities,
edge_coords_and_axes=None,
face_coords_and_axes=None,
standard_name=None,
long_name=None,
var_name=None,
units=None,
attributes=None,
node_dimension=None,
edge_dimension=None,
face_dimension=None,
):
"""Mesh initialise.
.. note::
The purpose of the :attr:`node_dimension`, :attr:`edge_dimension` and
:attr:`face_dimension` properties are to preserve the original NetCDF
variable dimension names. Note that, only :attr:`edge_dimension` and
:attr:`face_dimension` are UGRID attributes, and are only present for
:attr:`topology_dimension` ``>=2``.
"""
# TODO: support volumes.
# TODO: support (coord, "z")
self._metadata_manager = metadata_manager_factory(MeshMetadata)
# topology_dimension is read-only, so assign directly to the metadata manager
if topology_dimension not in self.TOPOLOGY_DIMENSIONS:
emsg = f"Expected 'topology_dimension' in range {self.TOPOLOGY_DIMENSIONS!r}, got {topology_dimension!r}."
raise ValueError(emsg)
self._metadata_manager.topology_dimension = topology_dimension
self.node_dimension = node_dimension
self.edge_dimension = edge_dimension
self.face_dimension = face_dimension
# assign the metadata to the metadata manager
self.standard_name = standard_name
self.long_name = long_name
self.var_name = var_name
self.units = units
self.attributes = attributes
# based on the topology_dimension, create the appropriate coordinate manager
def normalise(element, axis):
result = str(axis).lower()
if result not in self.AXES:
emsg = f"Invalid axis specified for {element} coordinate {coord.name()!r}, got {axis!r}."
raise ValueError(emsg)
return f"{element}_{result}"
if not isinstance(node_coords_and_axes, Iterable):
node_coords_and_axes = [node_coords_and_axes]
if not isinstance(connectivities, Iterable):
connectivities = [connectivities]
kwargs = {}
for coord, axis in node_coords_and_axes:
kwargs[normalise("node", axis)] = coord
if edge_coords_and_axes is not None:
for coord, axis in edge_coords_and_axes:
kwargs[normalise("edge", axis)] = coord
if face_coords_and_axes is not None:
for coord, axis in face_coords_and_axes:
kwargs[normalise("face", axis)] = coord
# check the UGRID minimum requirement for coordinates
if "node_x" not in kwargs:
emsg = "Require a node coordinate that is x-axis like to be provided."
raise ValueError(emsg)
if "node_y" not in kwargs:
emsg = "Require a node coordinate that is y-axis like to be provided."
raise ValueError(emsg)
if self.topology_dimension == 1:
self._coord_manager = _Mesh1DCoordinateManager(**kwargs)
self._connectivity_manager = _Mesh1DConnectivityManager(*connectivities)
elif self.topology_dimension == 2:
self._coord_manager = _Mesh2DCoordinateManager(**kwargs)
self._connectivity_manager = _Mesh2DConnectivityManager(*connectivities)
else:
emsg = f"Unsupported 'topology_dimension', got {topology_dimension!r}."
raise NotImplementedError(emsg)
[docs]
@classmethod
def from_coords(cls, *coords):
r"""Construct a :class:`Mesh` by derivation from one or more :class:`~iris.coords.Coord`.
The :attr:`~Mesh.topology_dimension`, :class:`~iris.coords.Coord`
membership and :class:`Connectivity` membership are all determined
based on the shape of the first :attr:`~iris.coords.Coord.bounds`:
* ``None`` or ``(n, <2)``:
Not supported
* ``(n, 2)``:
:attr:`~Mesh.topology_dimension` = ``1``.
:attr:`~Mesh.node_coords` and :attr:`~Mesh.edge_node_connectivity`
constructed from :attr:`~iris.coords.Coord.bounds`.
:attr:`~Mesh.edge_coords` constructed from
:attr:`~iris.coords.Coord.points`.
* ``(n, >=3)``:
:attr:`~Mesh.topology_dimension` = ``2``.
:attr:`~Mesh.node_coords` and :attr:`~Mesh.face_node_connectivity`
constructed from :attr:`~iris.coords.Coord.bounds`.
:attr:`~Mesh.face_coords` constructed from
:attr:`~iris.coords.Coord.points`.
Parameters
----------
*coords : Iterable of :class:`~iris.coords.Coord`
Coordinates to pass into the :class:`Mesh`.
All :attr:`~iris.coords.Coord.points` must have the same shapes;
all :attr:`~iris.coords.Coord.bounds` must have the same shapes,
and must not be ``None``.
Returns
-------
:class:`Mesh`
Notes
-----
.. note::
Any resulting duplicate nodes are not currently removed, due to the
computational intensity.
.. note::
:class:`Mesh` currently requires ``X`` and ``Y``
:class:`~iris.coords.Coord` specifically.
:meth:`iris.util.guess_coord_axis` is therefore attempted, else the
first two :class:`~iris.coords.Coord` are taken.
.. testsetup::
from iris import load_cube, sample_data_path
from iris.experimental.ugrid import (
PARSE_UGRID_ON_LOAD,
Mesh,
MeshCoord,
)
file_path = sample_data_path("mesh_C4_synthetic_float.nc")
with PARSE_UGRID_ON_LOAD.context():
cube_w_mesh = load_cube(file_path)
Examples
--------
::
# Reconstruct a cube-with-mesh after subsetting it.
>>> print(cube_w_mesh.mesh.name())
Topology data of 2D unstructured mesh
>>> mesh_coord_names = [
... coord.name() for coord in cube_w_mesh.coords(mesh_coords=True)
... ]
>>> print(f"MeshCoords: {mesh_coord_names}")
MeshCoords: ['latitude', 'longitude']
# Subsetting converts MeshCoords to AuxCoords.
>>> slices = [slice(None)] * cube_w_mesh.ndim
>>> slices[cube_w_mesh.mesh_dim()] = slice(-1)
>>> cube_sub = cube_w_mesh[tuple(slices)]
>>> print(cube_sub.mesh)
None
>>> orig_coords = [cube_sub.coord(c_name) for c_name in mesh_coord_names]
>>> for coord in orig_coords:
... print(f"{coord.name()}: {type(coord).__name__}")
latitude: AuxCoord
longitude: AuxCoord
>>> new_mesh = Mesh.from_coords(*orig_coords)
>>> new_coords = new_mesh.to_MeshCoords(location=cube_w_mesh.location)
# Replace the AuxCoords with MeshCoords.
>>> for ix in range(2):
... cube_sub.remove_coord(orig_coords[ix])
... cube_sub.add_aux_coord(new_coords[ix], cube_w_mesh.mesh_dim())
>>> print(cube_sub.mesh.name())
Topology data of 2D unstructured mesh
>>> for coord_name in mesh_coord_names:
... coord = cube_sub.coord(coord_name)
... print(f"{coord_name}: {type(coord).__name__}")
latitude: MeshCoord
longitude: MeshCoord
"""
# Validate points and bounds shape match.
def check_shape(array_name):
attr_name = f"core_{array_name}"
arrays = [getattr(coord, attr_name)() for coord in coords]
if any(a is None for a in arrays):
message = f"{array_name} missing from coords[{arrays.index(None)}] ."
raise ValueError(message)
shapes = [array.shape for array in arrays]
if shapes.count(shapes[0]) != len(shapes):
message = f"{array_name} shapes are not identical for all " f"coords."
raise ValueError(message)
for array in ("points", "bounds"):
check_shape(array)
# Determine dimensionality, using first coord.
first_coord = coords[0]
ndim = first_coord.ndim
if ndim != 1:
message = f"Expected coordinate ndim == 1, got: f{ndim} ."
raise ValueError(message)
bounds_shape = first_coord.core_bounds().shape
bounds_dim1 = bounds_shape[1]
if bounds_dim1 < 2:
message = (
f"Expected coordinate bounds.shape (n, >" f"=2), got: {bounds_shape} ."
)
raise ValueError(message)
elif bounds_dim1 == 2:
topology_dimension = 1
coord_centring = "edge"
conn_cf_role = "edge_node_connectivity"
else:
topology_dimension = 2
coord_centring = "face"
conn_cf_role = "face_node_connectivity"
# Create connectivity.
if first_coord.has_lazy_bounds():
array_lib = da
else:
array_lib = np
indices = array_lib.arange(np.prod(bounds_shape)).reshape(bounds_shape)
masking = array_lib.ma.getmaskarray(first_coord.core_bounds())
indices = array_lib.ma.masked_array(indices, masking)
connectivity = Connectivity(indices, conn_cf_role)
# Create coords.
node_coords = []
centre_coords = []
for coord in coords:
coord_kwargs = dict(
standard_name=coord.standard_name,
long_name=coord.long_name,
units=coord.units,
attributes=coord.attributes,
)
node_points = array_lib.ma.filled(coord.core_bounds(), 0.0).flatten()
node_coords.append(AuxCoord(points=node_points, **coord_kwargs))
centre_points = coord.core_points()
centre_coords.append(AuxCoord(points=centre_points, **coord_kwargs))
#####
# TODO: remove axis assignment once Mesh supports arbitrary coords.
# TODO: consider filtering coords as the first action in this method.
axes_present = [guess_coord_axis(coord) for coord in coords]
axes_required = ("X", "Y")
if all([req in axes_present for req in axes_required]):
axis_indices = [axes_present.index(req) for req in axes_required]
else:
message = (
"Unable to find 'X' and 'Y' using guess_coord_axis. Assuming "
"X=coords[0], Y=coords[1] ."
)
# TODO: reconsider logging level when we have consistent practice.
logger.info(message, extra=dict(cls=None))
axis_indices = range(len(axes_required))
def axes_assign(coord_list):
coords_sorted = [coord_list[ix] for ix in axis_indices]
return zip(coords_sorted, axes_required)
node_coords_and_axes = axes_assign(node_coords)
centre_coords_and_axes = axes_assign(centre_coords)
#####
# Construct the Mesh.
mesh_kwargs = dict(
topology_dimension=topology_dimension,
node_coords_and_axes=node_coords_and_axes,
connectivities=[connectivity],
)
mesh_kwargs[f"{coord_centring}_coords_and_axes"] = centre_coords_and_axes
return cls(**mesh_kwargs)
def __eq__(self, other):
result = NotImplemented
if isinstance(other, Mesh):
result = self.metadata == other.metadata
if result:
result = self.all_coords == other.all_coords
if result:
result = self.all_connectivities == other.all_connectivities
return result
def __hash__(self):
# Allow use in sets and as dictionary keys, as is done for :class:`iris.cube.Cube`.
# See https://github.com/SciTools/iris/pull/1772
return hash(id(self))
def __getstate__(self):
return (
self._metadata_manager,
self._coord_manager,
self._connectivity_manager,
)
def __ne__(self, other):
result = self.__eq__(other)
if result is not NotImplemented:
result = not result
return result
[docs]
def summary(self, shorten=False):
"""Return a string representation of the Mesh.
Parameters
----------
shorten : bool, default=False
If True, produce a oneline string form of the form <Mesh: ...>.
If False, produce a multi-line detailed print output.
Returns
-------
str
"""
if shorten:
result = self._summary_oneline()
else:
result = self._summary_multiline()
return result
def __repr__(self):
return self.summary(shorten=True)
def __str__(self):
return self.summary(shorten=False)
def _summary_oneline(self):
# We use the repr output to produce short one-line identity summary,
# similar to the object.__str__ output "<object at xxx>".
# This form also used in other str() constructions, like MeshCoord.
# By contrast, __str__ (below) produces a readable multi-line printout.
mesh_name = self.name()
if mesh_name in (None, "", "unknown"):
mesh_name = None
if mesh_name:
# Use a more human-readable form
mesh_string = f"<Mesh: '{mesh_name}'>"
else:
# Mimic the generic object.__str__ style.
mesh_id = id(self)
mesh_string = f"<Mesh object at {hex(mesh_id)}>"
return mesh_string
def _summary_multiline(self):
# Produce a readable multi-line summary of the Mesh content.
lines = []
n_indent = 4
indent_str = " " * n_indent
def line(text, i_indent=0):
indent = indent_str * i_indent
lines.append(f"{indent}{text}")
line(f"Mesh : '{self.name()}'")
line(f"topology_dimension: {self.topology_dimension}", 1)
for element in ("node", "edge", "face"):
if element == "node":
element_exists = True
else:
main_conn_name = f"{element}_node_connectivity"
main_conn = getattr(self, main_conn_name, None)
element_exists = main_conn is not None
if element_exists:
# Include a section for this element
line(element, 1)
# Print element dimension
dim_name = f"{element}_dimension"
dim = getattr(self, dim_name)
line(f"{dim_name}: '{dim}'", 2)
# Print defining connectivity (except node)
if element != "node":
main_conn_string = main_conn.summary(shorten=True, linewidth=0)
line(f"{main_conn_name}: {main_conn_string}", 2)
# Print coords
include_key = f"include_{element}s"
coords = self.coords(**{include_key: True})
if coords:
line(f"{element} coordinates", 2)
for coord in coords:
coord_string = coord.summary(shorten=True, linewidth=0)
line(coord_string, 3)
# Having dealt with essential info, now add any optional connectivities
# N.B. includes boundaries: as optional connectivity, not an "element"
optional_conn_names = (
"boundary_connectivity",
"face_face_connectivity",
"face_edge_connectivity",
"edge_face_connectivity",
)
optional_conns = [getattr(self, name, None) for name in optional_conn_names]
optional_conns = {
name: conn
for conn, name in zip(optional_conns, optional_conn_names)
if conn is not None
}
if optional_conns:
line("optional connectivities", 1)
for name, conn in optional_conns.items():
conn_string = conn.summary(shorten=True, linewidth=0)
line(f"{name}: {conn_string}", 2)
# Output the detail properties, basically those from CFVariableMixin
for name in BaseMetadata._members:
val = getattr(self, name, None)
if val is not None:
if name == "units":
show = val.origin != Unit(None)
elif isinstance(val, Container):
show = bool(val)
else:
show = val is not None
if show:
if name == "attributes":
# Use a multi-line form for this.
line("attributes:", 1)
max_attname_len = max(len(attr) for attr in val.keys())
for attrname, attrval in val.items():
attrname = attrname.ljust(max_attname_len)
if isinstance(attrval, str):
# quote strings
attrval = repr(attrval)
# and abbreviate really long ones
attrval = clip_string(attrval)
attr_string = f"{attrname} {attrval}"
line(attr_string, 2)
else:
line(f"{name}: {val!r}", 1)
result = "\n".join(lines)
return result
def __setstate__(self, state):
metadata_manager, coord_manager, connectivity_manager = state
self._metadata_manager = metadata_manager
self._coord_manager = coord_manager
self._connectivity_manager = connectivity_manager
def _set_dimension_names(self, node, edge, face, reset=False):
args = (node, edge, face)
currents = (
self.node_dimension,
self.edge_dimension,
self.face_dimension,
)
zipped = zip(args, currents)
if reset:
node, edge, face = [None if arg else current for arg, current in zipped]
else:
node, edge, face = [arg or current for arg, current in zipped]
self.node_dimension = node
self.edge_dimension = edge
self.face_dimension = face
if self.topology_dimension == 1:
result = Mesh1DNames(self.node_dimension, self.edge_dimension)
elif self.topology_dimension == 2:
result = Mesh2DNames(
self.node_dimension, self.edge_dimension, self.face_dimension
)
else:
message = f"Unsupported topology_dimension: {self.topology_dimension} ."
raise NotImplementedError(message)
return result
@property
def all_connectivities(self):
"""All the :class:`~iris.experimental.ugrid.mesh.Connectivity` instances of the :class:`Mesh`."""
return self._connectivity_manager.all_members
@property
def all_coords(self):
"""All the :class:`~iris.coords.AuxCoord` coordinates of the :class:`Mesh`."""
return self._coord_manager.all_members
@property
def boundary_node_connectivity(self):
"""The *optional* UGRID ``boundary_node_connectivity`` :class:`~iris.experimental.ugrid.mesh.Connectivity`.
The *optional* UGRID ``boundary_node_connectivity``
:class:`~iris.experimental.ugrid.mesh.Connectivity` of the
:class:`Mesh`.
"""
return self._connectivity_manager.boundary_node
@property
def edge_coords(self):
"""The *optional* UGRID ``edge`` :class:`~iris.coords.AuxCoord` coordinates of the :class:`Mesh`."""
return self._coord_manager.edge_coords
@property
def edge_dimension(self):
"""The *optionally required* UGRID NetCDF variable name for the ``edge`` dimension."""
return self._metadata_manager.edge_dimension
@edge_dimension.setter
def edge_dimension(self, name):
if not name or not isinstance(name, str):
edge_dimension = f"Mesh{self.topology_dimension}d_edge"
else:
edge_dimension = name
self._metadata_manager.edge_dimension = edge_dimension
@property
def edge_face_connectivity(self):
"""The *optional* UGRID ``edge_face_connectivity`` :class:`~iris.experimental.ugrid.mesh.Connectivity`.
The *optional* UGRID ``edge_face_connectivity``
:class:`~iris.experimental.ugrid.mesh.Connectivity` of the
:class:`Mesh`.
"""
return self._connectivity_manager.edge_face
@property
def edge_node_connectivity(self):
"""The UGRID ``edge_node_connectivity`` :class:`~iris.experimental.ugrid.mesh.Connectivity`.
The UGRID ``edge_node_connectivity``
:class:`~iris.experimental.ugrid.mesh.Connectivity` of the
:class:`Mesh`, which is **required** for :attr:`Mesh.topology_dimension`
of ``1``, and *optionally required* for
:attr:`Mesh.topology_dimension` ``>=2``.
"""
return self._connectivity_manager.edge_node
@property
def face_coords(self):
"""The *optional* UGRID ``face`` :class:`~iris.coords.AuxCoord` coordinates of the :class:`Mesh`."""
return self._coord_manager.face_coords
@property
def face_dimension(self):
"""The *optional* UGRID NetCDF variable name for the ``face`` dimension."""
return self._metadata_manager.face_dimension
@face_dimension.setter
def face_dimension(self, name):
if self.topology_dimension < 2:
face_dimension = None
if name:
# Tell the user it is not being set if they expected otherwise.
message = (
"Not setting face_dimension (inappropriate for "
f"topology_dimension={self.topology_dimension} ."
)
logger.debug(message, extra=dict(cls=self.__class__.__name__))
elif not name or not isinstance(name, str):
face_dimension = f"Mesh{self.topology_dimension}d_face"
else:
face_dimension = name
self._metadata_manager.face_dimension = face_dimension
@property
def face_edge_connectivity(self):
"""The *optional* UGRID ``face_edge_connectivity``:class:`~iris.experimental.ugrid.mesh.Connectivity`.
The *optional* UGRID ``face_edge_connectivity``
:class:`~iris.experimental.ugrid.mesh.Connectivity` of the
:class:`Mesh`.
"""
# optional
return self._connectivity_manager.face_edge
@property
def face_face_connectivity(self):
"""The *optional* UGRID ``face_face_connectivity`` :class:`~iris.experimental.ugrid.mesh.Connectivity`.
The *optional* UGRID ``face_face_connectivity``
:class:`~iris.experimental.ugrid.mesh.Connectivity` of the
:class:`Mesh`.
"""
return self._connectivity_manager.face_face
@property
def face_node_connectivity(self):
"""Return ``face_node_connectivity``:class:`~iris.experimental.ugrid.mesh.Connectivity`.
The UGRID ``face_node_connectivity``
:class:`~iris.experimental.ugrid.mesh.Connectivity` of the
:class:`Mesh`, which is **required** for :attr:`Mesh.topology_dimension`
of ``2``, and *optionally required* for :attr:`Mesh.topology_dimension`
of ``3``.
"""
return self._connectivity_manager.face_node
@property
def node_coords(self):
"""The **required** UGRID ``node`` :class:`~iris.coords.AuxCoord` coordinates of the :class:`Mesh`."""
return self._coord_manager.node_coords
@property
def node_dimension(self):
"""The NetCDF variable name for the ``node`` dimension."""
return self._metadata_manager.node_dimension
@node_dimension.setter
def node_dimension(self, name):
if not name or not isinstance(name, str):
node_dimension = f"Mesh{self.topology_dimension}d_node"
else:
node_dimension = name
self._metadata_manager.node_dimension = node_dimension
[docs]
def add_connectivities(self, *connectivities):
"""Add one or more :class:`~iris.experimental.ugrid.mesh.Connectivity` instances to the :class:`Mesh`.
Parameters
----------
*connectivities : iterable of object
A collection of one or more
:class:`~iris.experimental.ugrid.mesh.Connectivity` instances to
add to the :class:`Mesh`.
"""
self._connectivity_manager.add(*connectivities)
[docs]
def add_coords(
self,
node_x=None,
node_y=None,
edge_x=None,
edge_y=None,
face_x=None,
face_y=None,
):
"""Add one or more :class:`~iris.coords.AuxCoord` coordinates to the :class:`Mesh`.
Parameters
----------
node_x : optional
The ``x-axis`` like ``node`` :class:`~iris.coords.AuxCoord`.
node_y : optional
The ``y-axis`` like ``node`` :class:`~iris.coords.AuxCoord`.
edge_x : optional
The ``x-axis`` like ``edge`` :class:`~iris.coords.AuxCoord`.
edge_y : optional
The ``y-axis`` like ``edge`` :class:`~iris.coords.AuxCoord`.
face_x : optional
The ``x-axis`` like ``face`` :class:`~iris.coords.AuxCoord`.
face_y : optional
The ``y-axis`` like ``face`` :class:`~iris.coords.AuxCoord`.
"""
# Filter out absent arguments - only expecting face coords sometimes,
# same will be true of volumes in future.
kwargs = {
"node_x": node_x,
"node_y": node_y,
"edge_x": edge_x,
"edge_y": edge_y,
"face_x": face_x,
"face_y": face_y,
}
kwargs = {k: v for k, v in kwargs.items() if v}
self._coord_manager.add(**kwargs)
[docs]
def connectivities(
self,
item=None,
standard_name=None,
long_name=None,
var_name=None,
attributes=None,
cf_role=None,
contains_node=None,
contains_edge=None,
contains_face=None,
):
"""Return all :class:`~iris.experimental.ugrid.mesh.Connectivity`.
Return all :class:`~iris.experimental.ugrid.mesh.Connectivity`
instances from the :class:`Mesh` that match the provided criteria.
Criteria can be either specific properties or other objects with
metadata to be matched.
.. seealso::
:meth:`Mesh.connectivity` for matching exactly one connectivity.
Parameters
----------
item : str or object
Either,
* a :attr:`~iris.common.mixin.CFVariableMixin.standard_name`,
:attr:`~iris.common.mixin.CFVariableMixin.long_name`, or
:attr:`~iris.common.mixin.CFVariableMixin.var_name` which is
compared against the :meth:`~iris.common.mixin.CFVariableMixin.name`.
* a connectivity or metadata instance equal to that of
the desired objects e.g.,
:class:`~iris.experimental.ugrid.mesh.Connectivity` or
:class:`~iris.experimental.ugrid.metadata.ConnectivityMetadata`.
standard_name : str, optional
The CF standard name of the desired
:class:`~iris.experimental.ugrid.mesh.Connectivity`. If ``None``,
does not check for ``standard_name``.
long_name : str, optional
An unconstrained description of the
:class:`~iris.experimental.ugrid.mesh.Connectivity`. If ``None``,
does not check for ``long_name``.
var_name : str, optional
The NetCDF variable name of the desired
:class:`~iris.experimental.ugrid.mesh.Connectivity`. If ``None``,
does not check for ``var_name``.
attributes : dict, optional
A dictionary of attributes desired on the
:class:`~iris.experimental.ugrid.mesh.Connectivity`. If ``None``,
does not check for ``attributes``.
cf_role : str, optional
The UGRID ``cf_role`` of the desired
:class:`~iris.experimental.ugrid.mesh.Connectivity`.
contains_node : bool, optional
Contains the ``node`` element as part of the
:attr:`~iris.experimental.ugrid.metadata.ConnectivityMetadata.cf_role`
in the list of objects to be matched.
contains_edge : bool, optional
Contains the ``edge`` element as part of the
:attr:`~iris.experimental.ugrid.metadata.ConnectivityMetadata.cf_role`
in the list of objects to be matched.
contains_face : bool, optional
Contains the ``face`` element as part of the
:attr:`~iris.experimental.ugrid.metadata.ConnectivityMetadata.cf_role`
in the list of objects to be matched.
Returns
-------
list of :class:`~iris.experimental.ugrid.mesh.Connectivity`
A list of :class:`~iris.experimental.ugrid.mesh.Connectivity`
instances from the :class:`Mesh` that matched the given criteria.
"""
result = self._connectivity_manager.filters(
item=item,
standard_name=standard_name,
long_name=long_name,
var_name=var_name,
attributes=attributes,
cf_role=cf_role,
contains_node=contains_node,
contains_edge=contains_edge,
contains_face=contains_face,
)
return list(result.values())
[docs]
def connectivity(
self,
item=None,
standard_name=None,
long_name=None,
var_name=None,
attributes=None,
cf_role=None,
contains_node=None,
contains_edge=None,
contains_face=None,
):
"""Return a single :class:`~iris.experimental.ugrid.mesh.Connectivity`.
Return a single :class:`~iris.experimental.ugrid.mesh.Connectivity`
from the :class:`Mesh` that matches the provided criteria.
Criteria can be either specific properties or other objects with
metadata to be matched.
.. note::
If the given criteria do not return **precisely one**
:class:`~iris.experimental.ugrid.mesh.Connectivity`, then a
:class:`~iris.exceptions.ConnectivityNotFoundError` is raised.
.. seealso::
:meth:`Mesh.connectivities` for matching zero or more connectivities.
Parameters
----------
item : str or object
Either,
* a :attr:`~iris.common.mixin.CFVariableMixin.standard_name`,
:attr:`~iris.common.mixin.CFVariableMixin.long_name`, or
:attr:`~iris.common.mixin.CFVariableMixin.var_name` which is
compared against the :meth:`~iris.common.mixin.CFVariableMixin.name`.
* a connectivity or metadata instance equal to that of
the desired object e.g.,
:class:`~iris.experimental.ugrid.mesh.Connectivity` or
:class:`~iris.experimental.ugrid.metadata.ConnectivityMetadata`.
standard_name : str, optional
The CF standard name of the desired
:class:`~iris.experimental.ugrid.mesh.Connectivity`. If ``None``,
does not check for ``standard_name``.
long_name : str, optional
An unconstrained description of the
:class:`~iris.experimental.ugrid.mesh.Connectivity`. If ``None``,
does not check for ``long_name``.
var_name : str, optional
The NetCDF variable name of the desired
:class:`~iris.experimental.ugrid.mesh.Connectivity`. If ``None``,
does not check for ``var_name``.
attributes : dict, optional
A dictionary of attributes desired on the
:class:`~iris.experimental.ugrid.mesh.Connectivity`. If ``None``,
does not check for ``attributes``.
cf_role : str, optional
The UGRID ``cf_role`` of the desired
:class:`~iris.experimental.ugrid.mesh.Connectivity`.
contains_node : bool, optional
Contains the ``node`` element as part of the
:attr:`~iris.experimental.ugrid.metadata.ConnectivityMetadata.cf_role`
in the list of objects to be matched.
contains_edge : bool, optional
Contains the ``edge`` element as part of the
:attr:`~iris.experimental.ugrid.metadata.ConnectivityMetadata.cf_role`
in the list of objects to be matched.
contains_face : bool, optional
Contains the ``face`` element as part of the
:attr:`~iris.experimental.ugrid.metadata.ConnectivityMetadata.cf_role`
in the list of objects to be matched.
Returns
-------
:class:`~iris.experimental.ugrid.mesh.Connectivity`
The :class:`~iris.experimental.ugrid.mesh.Connectivity` from the
:class:`Mesh` that matched the given criteria.
"""
result = self._connectivity_manager.filter(
item=item,
standard_name=standard_name,
long_name=long_name,
var_name=var_name,
attributes=attributes,
cf_role=cf_role,
contains_node=contains_node,
contains_edge=contains_edge,
contains_face=contains_face,
)
return list(result.values())[0]
[docs]
def coord(
self,
item=None,
standard_name=None,
long_name=None,
var_name=None,
attributes=None,
axis=None,
include_nodes=None,
include_edges=None,
include_faces=None,
):
"""Return a single :class:`~iris.coords.AuxCoord` coordinate.
Return a single :class:`~iris.coords.AuxCoord` coordinate from the
:class:`Mesh` that matches the provided criteria.
Criteria can be either specific properties or other objects with
metadata to be matched.
.. note::
If the given criteria do not return **precisely one** coordinate,
then a :class:`~iris.exceptions.CoordinateNotFoundError` is raised.
.. seealso::
:meth:`Mesh.coords` for matching zero or more coordinates.
Parameters
----------
item : str or object, optional
Either,
* a :attr:`~iris.common.mixin.CFVariableMixin.standard_name`,
:attr:`~iris.common.mixin.CFVariableMixin.long_name`, or
:attr:`~iris.common.mixin.CFVariableMixin.var_name` which is
compared against the :meth:`~iris.common.mixin.CFVariableMixin.name`.
* a coordinate or metadata instance equal to that of
the desired coordinate e.g., :class:`~iris.coords.AuxCoord` or
:class:`~iris.common.metadata.CoordMetadata`.
standard_name : str, optional
The CF standard name of the desired coordinate. If ``None``, does not
check for ``standard_name``.
long_name : str, optional
An unconstrained description of the coordinate. If ``None``, does not
check for ``long_name``.
var_name : str, optional
The NetCDF variable name of the desired coordinate. If ``None``, does
not check for ``var_name``.
attributes : dict, optional
A dictionary of attributes desired on the coordinates. If ``None``,
does not check for ``attributes``.
axis : str, optional
The desired coordinate axis, see :func:`~iris.util.guess_coord_axis`.
If ``None``, does not check for ``axis``. Accepts the values ``X``,
``Y``, ``Z`` and ``T`` (case-insensitive).
include_node : bool, optional
Include all ``node`` coordinates in the list of objects to be matched.
include_edge : bool, optional
Include all ``edge`` coordinates in the list of objects to be matched.
include_face : bool, optional
Include all ``face`` coordinates in the list of objects to be matched.
Returns
-------
:class:`~iris.coords.AuxCoord`
The :class:`~iris.coords.AuxCoord` coordinate from the :class:`Mesh`
that matched the given criteria.
"""
result = self._coord_manager.filter(
item=item,
standard_name=standard_name,
long_name=long_name,
var_name=var_name,
attributes=attributes,
axis=axis,
include_nodes=include_nodes,
include_edges=include_edges,
include_faces=include_faces,
)
return list(result.values())[0]
[docs]
def coords(
self,
item=None,
standard_name=None,
long_name=None,
var_name=None,
attributes=None,
axis=None,
include_nodes=None,
include_edges=None,
include_faces=None,
):
"""Return all :class:`~iris.coords.AuxCoord` coordinates from the :class:`Mesh`.
Return all :class:`~iris.coords.AuxCoord` coordinates from the :class:`Mesh` that
match the provided criteria.
Criteria can be either specific properties or other objects with
metadata to be matched.
.. seealso::
:meth:`Mesh.coord` for matching exactly one coordinate.
Parameters
----------
item : str or object, optional
Either,
* a :attr:`~iris.common.mixin.CFVariableMixin.standard_name`,
:attr:`~iris.common.mixin.CFVariableMixin.long_name`, or
:attr:`~iris.common.mixin.CFVariableMixin.var_name` which is
compared against the :meth:`~iris.common.mixin.CFVariableMixin.name`.
* a coordinate or metadata instance equal to that of
the desired coordinates e.g., :class:`~iris.coords.AuxCoord` or
:class:`~iris.common.metadata.CoordMetadata`.
standard_name : str, optional
The CF standard name of the desired coordinate. If ``None``, does not
check for ``standard_name``.
long_name : str, optional
An unconstrained description of the coordinate. If ``None``, does not
check for ``long_name``.
var_name : str, optional
The NetCDF variable name of the desired coordinate. If ``None``, does
not check for ``var_name``.
attributes : dict, optional
A dictionary of attributes desired on the coordinates. If ``None``,
does not check for ``attributes``.
axis : str, optional
The desired coordinate axis, see :func:`~iris.util.guess_coord_axis`.
If ``None``, does not check for ``axis``. Accepts the values ``X``,
``Y``, ``Z`` and ``T`` (case-insensitive).
include_node : bool, optional
Include all ``node`` coordinates in the list of objects to be matched.
include_edge : bool, optional
Include all ``edge`` coordinates in the list of objects to be matched.
include_face : bool, optional
Include all ``face`` coordinates in the list of objects to be matched.
Returns
-------
list of :class:`~iris.coords.AuxCoord`
A list of :class:`~iris.coords.AuxCoord` coordinates from the
:class:`Mesh` that matched the given criteria.
"""
result = self._coord_manager.filters(
item=item,
standard_name=standard_name,
long_name=long_name,
var_name=var_name,
attributes=attributes,
axis=axis,
include_nodes=include_nodes,
include_edges=include_edges,
include_faces=include_faces,
)
return list(result.values())
[docs]
def remove_connectivities(
self,
item=None,
standard_name=None,
long_name=None,
var_name=None,
attributes=None,
cf_role=None,
contains_node=None,
contains_edge=None,
contains_face=None,
):
"""Remove one or more :class:`~iris.experimental.ugrid.mesh.Connectivity`.
Remove one or more :class:`~iris.experimental.ugrid.mesh.Connectivity`
from the :class:`Mesh` that match the provided criteria.
Criteria can be either specific properties or other objects with
metadata to be matched.
Parameters
----------
item : str or object, optional
Either,
* a :attr:`~iris.common.mixin.CFVariableMixin.standard_name`,
:attr:`~iris.common.mixin.CFVariableMixin.long_name`, or
:attr:`~iris.common.mixin.CFVariableMixin.var_name` which is
compared against the :meth:`~iris.common.mixin.CFVariableMixin.name`.
* a connectivity or metadata instance equal to that of
the desired objects e.g.,
:class:`~iris.experimental.ugrid.mesh.Connectivity` or
:class:`~iris.experimental.ugrid.metadata.ConnectivityMetadata`.
standard_name : str, optional
The CF standard name of the desired
:class:`~iris.experimental.ugrid.mesh.Connectivity`. If ``None``,
does not check for ``standard_name``.
long_name : str, optional
An unconstrained description of the
:class:`~iris.experimental.ugrid.mesh.Connectivity`. If ``None``,
does not check for ``long_name``.
var_name : str, optional
The NetCDF variable name of the desired
:class:`~iris.experimental.ugrid.mesh.Connectivity`. If ``None``,
does not check for ``var_name``.
attributes : dict, optional
A dictionary of attributes desired on the
:class:`~iris.experimental.ugrid.mesh.Connectivity`. If ``None``,
does not check for ``attributes``.
cf_role : str, optional
The UGRID ``cf_role`` of the desired
:class:`~iris.experimental.ugrid.mesh.Connectivity`.
contains_node : bool, optional
Contains the ``node`` element as part of the
:attr:`~iris.experimental.ugrid.metadata.ConnectivityMetadata.cf_role`
in the list of objects to be matched for potential removal.
contains_edge : bool, optional
Contains the ``edge`` element as part of the
:attr:`~iris.experimental.ugrid.metadata.ConnectivityMetadata.cf_role`
in the list of objects to be matched for potential removal.
contains_face : bool, optional
Contains the ``face`` element as part of the
:attr:`~iris.experimental.ugrid.metadata.ConnectivityMetadata.cf_role`
in the list of objects to be matched for potential removal.
Returns
-------
list of :class:`~iris.experimental.ugrid.mesh.Connectivity`
A list of :class:`~iris.experimental.ugrid.mesh.Connectivity`
instances removed from the :class:`Mesh` that matched the given
criteria.
"""
return self._connectivity_manager.remove(
item=item,
standard_name=standard_name,
long_name=long_name,
var_name=var_name,
attributes=attributes,
cf_role=cf_role,
contains_node=contains_node,
contains_edge=contains_edge,
contains_face=contains_face,
)
[docs]
def remove_coords(
self,
item=None,
standard_name=None,
long_name=None,
var_name=None,
attributes=None,
axis=None,
include_nodes=None,
include_edges=None,
include_faces=None,
):
"""Remove one or more :class:`~iris.coords.AuxCoord` from the :class:`Mesh`.
Remove one or more :class:`~iris.coords.AuxCoord` from the :class:`Mesh`
that match the provided criteria.
Criteria can be either specific properties or other objects with
metadata to be matched.
Parameters
----------
item : str or object, optional
Either,
* a :attr:`~iris.common.mixin.CFVariableMixin.standard_name`,
:attr:`~iris.common.mixin.CFVariableMixin.long_name`, or
:attr:`~iris.common.mixin.CFVariableMixin.var_name` which is
compared against the :meth:`~iris.common.mixin.CFVariableMixin.name`.
* a coordinate or metadata instance equal to that of
the desired coordinates e.g., :class:`~iris.coords.AuxCoord` or
:class:`~iris.common.metadata.CoordMetadata`.
standard_name : str, optional
The CF standard name of the desired coordinate. If ``None``, does not
check for ``standard_name``.
long_name : str, optional
An unconstrained description of the coordinate. If ``None``, does not
check for ``long_name``.
var_name : str, optional
The NetCDF variable name of the desired coordinate. If ``None``, does
not check for ``var_name``.
attributes : dict, optional
A dictionary of attributes desired on the coordinates. If ``None``,
does not check for ``attributes``.
axis : str, optional
The desired coordinate axis, see :func:`~iris.util.guess_coord_axis`.
If ``None``, does not check for ``axis``. Accepts the values ``X``,
``Y``, ``Z`` and ``T`` (case-insensitive).
include_node : bool, optional
Include all ``node`` coordinates in the list of objects to be matched
for potential removal.
include_edge : bool, optional
Include all ``edge`` coordinates in the list of objects to be matched
for potential removal.
include_face : bool, optional
Include all ``face`` coordinates in the list of objects to be matched
for potential removal.
Returns
-------
list of :class:`~iris.coords.AuxCoord`
A list of :class:`~iris.coords.AuxCoord` coordinates removed from
the :class:`Mesh` that matched the given criteria.
"""
# Filter out absent arguments - only expecting face coords sometimes,
# same will be true of volumes in future.
kwargs = {
"item": item,
"standard_name": standard_name,
"long_name": long_name,
"var_name": var_name,
"attributes": attributes,
"axis": axis,
"include_nodes": include_nodes,
"include_edges": include_edges,
"include_faces": include_faces,
}
kwargs = {k: v for k, v in kwargs.items() if v}
return self._coord_manager.remove(**kwargs)
[docs]
def xml_element(self, doc):
"""Create the :class:`xml.dom.minidom.Element` that describes this :class:`Mesh`.
Parameters
----------
doc : object
The parent :class:`xml.dom.minidom.Document`.
Returns
-------
:class:`xml.dom.minidom.Element`
The :class:`xml.dom.minidom.Element` that will describe this
:class:`Mesh`, and the dictionary of attributes that require
to be added to this element.
"""
pass
# the MeshCoord will always have bounds, perhaps points. However the MeshCoord.guess_points() may
# be a very useful part of its behaviour.
# after using MeshCoord.guess_points(), the user may wish to add the associated MeshCoord.points into
# the Mesh as face_coordinates.
# def to_AuxCoord(self, location, axis):
# # factory method
# # return the lazy AuxCoord(...) for the given location and axis
#
# def to_AuxCoords(self, location):
# # factory method
# # return the lazy AuxCoord(...), AuxCoord(...)
[docs]
def to_MeshCoord(self, location, axis):
"""Generate a :class:`~iris.experimental.ugrid.mesh.MeshCoord`.
Generate a :class:`~iris.experimental.ugrid.mesh.MeshCoord` that
references the current :class:`Mesh`, and passing through the
``location`` and ``axis`` arguments.
.. seealso::
:meth:`to_MeshCoords` for generating a series of mesh coords.
Parameters
----------
location : str
The ``location`` argument for
:class:`~iris.experimental.ugrid.mesh.MeshCoord` instantiation.
axis : str
The ``axis`` argument for
:class:`~iris.experimental.ugrid.mesh.MeshCoord` instantiation.
Returns
-------
:class:`~iris.experimental.ugrid.mesh.MeshCoord`
A :class:`~iris.experimental.ugrid.mesh.MeshCoord` referencing the
current :class:`Mesh`.
"""
return MeshCoord(mesh=self, location=location, axis=axis)
[docs]
def to_MeshCoords(self, location):
r"""Generate a tuple of :class:`~iris.experimental.ugrid.mesh.MeshCoord`.
Generate a tuple of
:class:`~iris.experimental.ugrid.mesh.MeshCoord`, each referencing
the current :class:`Mesh`, one for each :attr:`AXES` value, passing
through the ``location`` argument.
.. seealso::
:meth:`to_MeshCoord` for generating a single mesh coord.
Parameters
----------
location : str
The ``location`` argument for :class:`MeshCoord` instantiation.
Returns
-------
tuple of :class:`~iris.experimental.ugrid.mesh.MeshCoord`
Tuple of :class:`~iris.experimental.ugrid.mesh.MeshCoord`
referencing the current :class:`Mesh`. One for each value in
:attr:`AXES`, using the value for the ``axis`` argument.
"""
# factory method
result = [self.to_MeshCoord(location=location, axis=ax) for ax in self.AXES]
return tuple(result)
[docs]
def dimension_names_reset(self, node=False, edge=False, face=False):
"""Reset the name used for the NetCDF variable.
Reset the name used for the NetCDF variable representing the ``node``,
``edge`` and/or ``face`` dimension to ``None``.
Parameters
----------
node : bool, optional, default=False
Reset the name of the ``node`` dimension if ``True``. Default
is ``False``.
edge : bool, default=False
Reset the name of the ``edge`` dimension if ``True``. Default
is ``False``.
face : bool, default=False
Reset the name of the ``face`` dimension if ``True``. Default
is ``False``.
"""
return self._set_dimension_names(node, edge, face, reset=True)
[docs]
def dimension_names(self, node=None, edge=None, face=None):
"""Assign the name to be used for the NetCDF variable.
Assign the name to be used for the NetCDF variable representing
the ``node``, ``edge`` and ``face`` dimension.
The default value of ``None`` will not be assigned to clear the
associated ``node``, ``edge`` or ``face``. Instead use
:meth:`Mesh.dimension_names_reset`.
Parameters
----------
node : str, optional
The name to be used for the NetCDF variable representing the
``node`` dimension.
edge : str, optional
The name to be used for the NetCDF variable representing the
``edge`` dimension.
face : str, optional
The name to be used for the NetCDF variable representing the
``face`` dimension.
"""
return self._set_dimension_names(node, edge, face, reset=False)
@property
def cf_role(self):
"""The UGRID ``cf_role`` attribute of the :class:`Mesh`."""
return "mesh_topology"
@property
def topology_dimension(self):
"""UGRID ``topology_dimension`` attribute.
The UGRID ``topology_dimension`` attribute represents the highest
dimensionality of all the geometric elements (node, edge, face) represented
within the :class:`Mesh`.
"""
return self._metadata_manager.topology_dimension
class _Mesh1DCoordinateManager:
"""TBD: require clarity on coord_systems validation.
TBD: require clarity on __eq__ support
TBD: rationalise self.coords() logic with other manager and Cube.
"""
REQUIRED = (
"node_x",
"node_y",
)
OPTIONAL = (
"edge_x",
"edge_y",
)
def __init__(self, node_x, node_y, edge_x=None, edge_y=None):
# initialise all the coordinates
self.ALL = self.REQUIRED + self.OPTIONAL
self._members = {member: None for member in self.ALL}
# required coordinates
self.node_x = node_x
self.node_y = node_y
# optional coordinates
self.edge_x = edge_x
self.edge_y = edge_y
def __eq__(self, other):
# TBD: this is a minimalist implementation and requires to be revisited
return id(self) == id(other)
def __getstate__(self):
return self._members
def __iter__(self):
for item in self._members.items():
yield item
def __ne__(self, other):
result = self.__eq__(other)
if result is not NotImplemented:
result = not result
return result
def __repr__(self):
args = [f"{member}={coord!r}" for member, coord in self if coord is not None]
return f"{self.__class__.__name__}({', '.join(args)})"
def __setstate__(self, state):
self._members = state
def __str__(self):
args = [f"{member}" for member, coord in self if coord is not None]
return f"{self.__class__.__name__}({', '.join(args)})"
def _remove(self, **kwargs):
result = {}
members = self.filters(**kwargs)
for member in members.keys():
if member in self.REQUIRED:
dmsg = f"Ignoring request to remove required coordinate {member!r}"
logger.debug(dmsg, extra=dict(cls=self.__class__.__name__))
else:
result[member] = members[member]
setattr(self, member, None)
return result
def _setter(self, element, axis, coord, shape):
axis = axis.lower()
member = f"{element}_{axis}"
# enforce the UGRID minimum coordinate requirement
if element == "node" and coord is None:
emsg = f"{member!r} is a required coordinate, cannot set to 'None'."
raise ValueError(emsg)
if coord is not None:
if not isinstance(coord, AuxCoord):
emsg = f"{member!r} requires to be an 'AuxCoord', got {type(coord)}."
raise TypeError(emsg)
guess_axis = guess_coord_axis(coord)
if guess_axis and guess_axis.lower() != axis:
emsg = f"{member!r} requires a {axis}-axis like 'AuxCoord', got a {guess_axis.lower()}-axis like."
raise TypeError(emsg)
if coord.climatological:
emsg = f"{member!r} cannot be a climatological 'AuxCoord'."
raise TypeError(emsg)
if shape is not None and coord.shape != shape:
emsg = (
f"{member!r} requires to have shape {shape!r}, got {coord.shape!r}."
)
raise ValueError(emsg)
self._members[member] = coord
def _shape(self, element):
coord = getattr(self, f"{element}_x")
shape = coord.shape if coord is not None else None
if shape is None:
coord = getattr(self, f"{element}_y")
if coord is not None:
shape = coord.shape
return shape
@property
def _edge_shape(self):
return self._shape(element="edge")
@property
def _node_shape(self):
return self._shape(element="node")
@property
def all_members(self):
return Mesh1DCoords(**self._members)
@property
def edge_coords(self):
return MeshEdgeCoords(edge_x=self.edge_x, edge_y=self.edge_y)
@property
def edge_x(self):
return self._members["edge_x"]
@edge_x.setter
def edge_x(self, coord):
self._setter(element="edge", axis="x", coord=coord, shape=self._edge_shape)
@property
def edge_y(self):
return self._members["edge_y"]
@edge_y.setter
def edge_y(self, coord):
self._setter(element="edge", axis="y", coord=coord, shape=self._edge_shape)
@property
def node_coords(self):
return MeshNodeCoords(node_x=self.node_x, node_y=self.node_y)
@property
def node_x(self):
return self._members["node_x"]
@node_x.setter
def node_x(self, coord):
self._setter(element="node", axis="x", coord=coord, shape=self._node_shape)
@property
def node_y(self):
return self._members["node_y"]
@node_y.setter
def node_y(self, coord):
self._setter(element="node", axis="y", coord=coord, shape=self._node_shape)
def _add(self, coords):
member_x, member_y = coords._fields
# deal with the special case where both members are changing
if coords[0] is not None and coords[1] is not None:
cache_x = self._members[member_x]
cache_y = self._members[member_y]
self._members[member_x] = None
self._members[member_y] = None
try:
setattr(self, member_x, coords[0])
setattr(self, member_y, coords[1])
except (TypeError, ValueError):
# restore previous valid state
self._members[member_x] = cache_x
self._members[member_y] = cache_y
# now, re-raise the exception
raise
else:
# deal with the case where one or no member is changing
if coords[0] is not None:
setattr(self, member_x, coords[0])
if coords[1] is not None:
setattr(self, member_y, coords[1])
def add(self, node_x=None, node_y=None, edge_x=None, edge_y=None):
"""Use self.remove(edge_x=True) to remove a coordinate.
Use self.remove(edge_x=True) to remove a coordinate e.g., using the
pattern self.add(edge_x=None) will not remove the edge_x coordinate.
"""
self._add(MeshNodeCoords(node_x, node_y))
self._add(MeshEdgeCoords(edge_x, edge_y))
def filter(self, **kwargs):
# TODO: rationalise commonality with MeshConnectivityManager.filter and Cube.coord.
result = self.filters(**kwargs)
if len(result) > 1:
names = ", ".join(f"{member}={coord!r}" for member, coord in result.items())
emsg = (
f"Expected to find exactly 1 coordinate, but found {len(result)}. "
f"They were: {names}."
)
raise CoordinateNotFoundError(emsg)
if len(result) == 0:
item = kwargs["item"]
if item is not None:
if not isinstance(item, str):
item = item.name()
name = (
item
or kwargs["standard_name"]
or kwargs["long_name"]
or kwargs["var_name"]
or None
)
name = "" if name is None else f"{name!r} "
emsg = f"Expected to find exactly 1 {name}coordinate, but found none."
raise CoordinateNotFoundError(emsg)
return result
def filters(
self,
item=None,
standard_name=None,
long_name=None,
var_name=None,
attributes=None,
axis=None,
include_nodes=None,
include_edges=None,
include_faces=None,
):
# TBD: support coord_systems?
# Preserve original argument before modifying.
face_requested = include_faces
# Rationalise the tri-state behaviour.
args = [include_nodes, include_edges, include_faces]
state = not any(set(filter(lambda arg: arg is not None, args)))
include_nodes, include_edges, include_faces = map(
lambda arg: arg if arg is not None else state, args
)
def populated_coords(coords_tuple):
return list(filter(None, list(coords_tuple)))
members = []
if include_nodes:
members += populated_coords(self.node_coords)
if include_edges:
members += populated_coords(self.edge_coords)
if hasattr(self, "face_coords"):
if include_faces:
members += populated_coords(self.face_coords)
elif face_requested:
dmsg = "Ignoring request to filter non-existent 'face_coords'"
logger.debug(dmsg, extra=dict(cls=self.__class__.__name__))
result = metadata_filter(
members,
item=item,
standard_name=standard_name,
long_name=long_name,
var_name=var_name,
attributes=attributes,
axis=axis,
)
# Use the results to filter the _members dict for returning.
result_ids = [id(r) for r in result]
result_dict = {k: v for k, v in self._members.items() if id(v) in result_ids}
return result_dict
def remove(
self,
item=None,
standard_name=None,
long_name=None,
var_name=None,
attributes=None,
axis=None,
include_nodes=None,
include_edges=None,
):
return self._remove(
item=item,
standard_name=standard_name,
long_name=long_name,
var_name=var_name,
attributes=attributes,
axis=axis,
include_nodes=include_nodes,
include_edges=include_edges,
)
class _Mesh2DCoordinateManager(_Mesh1DCoordinateManager):
OPTIONAL = (
"edge_x",
"edge_y",
"face_x",
"face_y",
)
def __init__(
self,
node_x,
node_y,
edge_x=None,
edge_y=None,
face_x=None,
face_y=None,
):
super().__init__(node_x, node_y, edge_x=edge_x, edge_y=edge_y)
# optional coordinates
self.face_x = face_x
self.face_y = face_y
@property
def _face_shape(self):
return self._shape(element="face")
@property
def all_members(self):
return Mesh2DCoords(**self._members)
@property
def face_coords(self):
return MeshFaceCoords(face_x=self.face_x, face_y=self.face_y)
@property
def face_x(self):
return self._members["face_x"]
@face_x.setter
def face_x(self, coord):
self._setter(element="face", axis="x", coord=coord, shape=self._face_shape)
@property
def face_y(self):
return self._members["face_y"]
@face_y.setter
def face_y(self, coord):
self._setter(element="face", axis="y", coord=coord, shape=self._face_shape)
def add(
self,
node_x=None,
node_y=None,
edge_x=None,
edge_y=None,
face_x=None,
face_y=None,
):
super().add(node_x=node_x, node_y=node_y, edge_x=edge_x, edge_y=edge_y)
self._add(MeshFaceCoords(face_x, face_y))
def remove(
self,
item=None,
standard_name=None,
long_name=None,
var_name=None,
attributes=None,
axis=None,
include_nodes=None,
include_edges=None,
include_faces=None,
):
return self._remove(
item=item,
standard_name=standard_name,
long_name=long_name,
var_name=var_name,
attributes=attributes,
axis=axis,
include_nodes=include_nodes,
include_edges=include_edges,
include_faces=include_faces,
)
class _MeshConnectivityManagerBase(ABC):
# Override these in subclasses.
REQUIRED: tuple = NotImplemented
OPTIONAL: tuple = NotImplemented
def __init__(self, *connectivities):
cf_roles = [c.cf_role for c in connectivities]
for requisite in self.REQUIRED:
if requisite not in cf_roles:
message = f"{type(self).__name__} requires a {requisite} Connectivity."
raise ValueError(message)
self.ALL = self.REQUIRED + self.OPTIONAL
self._members = {member: None for member in self.ALL}
self.add(*connectivities)
def __eq__(self, other):
# TBD: this is a minimalist implementation and requires to be revisited
return id(self) == id(other)
def __getstate__(self):
return self._members
def __iter__(self):
for item in self._members.items():
yield item
def __ne__(self, other):
result = self.__eq__(other)
if result is not NotImplemented:
result = not result
return result
def __repr__(self):
args = [
f"{member}={connectivity!r}"
for member, connectivity in self
if connectivity is not None
]
return f"{self.__class__.__name__}({', '.join(args)})"
def __setstate__(self, state):
self._members = state
def __str__(self):
args = [
f"{member}" for member, connectivity in self if connectivity is not None
]
return f"{self.__class__.__name__}({', '.join(args)})"
@property
@abstractmethod
def all_members(self):
return NotImplemented
def add(self, *connectivities):
# Since Connectivity classes include their cf_role, no setters will be
# provided, just a means to add one or more connectivities to the
# manager.
# No warning is raised for duplicate cf_roles - user is trusted to
# validate their outputs.
add_dict = {}
for connectivity in connectivities:
if not isinstance(connectivity, Connectivity):
message = f"Expected Connectivity, got: {type(connectivity)} ."
raise TypeError(message)
cf_role = connectivity.cf_role
if cf_role not in self.ALL:
message = (
f"Not adding connectivity ({cf_role}: "
f"{connectivity!r}) - cf_role must be one of: {self.ALL} ."
)
logger.debug(message, extra=dict(cls=self.__class__.__name__))
else:
add_dict[cf_role] = connectivity
# Validate shapes.
proposed_members = {**self._members, **add_dict}
elements = set([c.location for c in proposed_members.values() if c is not None])
for element in elements:
counts = [
len(c.indices_by_location(c.lazy_indices()))
for c in proposed_members.values()
if c is not None and c.location == element
]
# Check is list values are identical.
if not counts.count(counts[0]) == len(counts):
message = (
f"Invalid Connectivities provided - inconsistent "
f"{element} counts."
)
raise ValueError(message)
self._members = proposed_members
def filter(self, **kwargs):
# TODO: rationalise commonality with MeshCoordManager.filter and Cube.coord.
result = self.filters(**kwargs)
if len(result) > 1:
names = ", ".join(
f"{member}={connectivity!r}" for member, connectivity in result.items()
)
message = (
f"Expected to find exactly 1 connectivity, but found "
f"{len(result)}. They were: {names}."
)
raise ConnectivityNotFoundError(message)
elif len(result) == 0:
item = kwargs["item"]
_name = item
if item is not None:
if not isinstance(item, str):
_name = item.name()
bad_name = _name or kwargs["standard_name"] or kwargs["long_name"] or ""
message = (
f"Expected to find exactly 1 {bad_name} connectivity, "
f"but found none."
)
raise ConnectivityNotFoundError(message)
return result
def filters(
self,
item=None,
standard_name=None,
long_name=None,
var_name=None,
attributes=None,
cf_role=None,
contains_node=None,
contains_edge=None,
contains_face=None,
):
members = [c for c in self._members.values() if c is not None]
if cf_role is not None:
members = [instance for instance in members if instance.cf_role == cf_role]
def element_filter(instances, loc_arg, loc_name):
if loc_arg is False:
filtered = [
instance
for instance in instances
if loc_name
not in (
instance.location,
instance.connected,
)
]
elif loc_arg is None:
filtered = instances
else:
# Interpret any other value as =True.
filtered = [
instance
for instance in instances
if loc_name in (instance.location, instance.connected)
]
return filtered
for arg, loc in (
(contains_node, "node"),
(contains_edge, "edge"),
(contains_face, "face"),
):
members = element_filter(members, arg, loc)
# No need to actually modify filtering behaviour - already won't return
# any face cf-roles if none are present.
supports_faces = any(["face" in role for role in self.ALL])
if contains_face and not supports_faces:
message = "Ignoring request to filter for non-existent 'face' cf-roles."
logger.debug(message, extra=dict(cls=self.__class__.__name__))
result = metadata_filter(
members,
item=item,
standard_name=standard_name,
long_name=long_name,
var_name=var_name,
attributes=attributes,
)
# Use the results to filter the _members dict for returning.
result_ids = [id(r) for r in result]
result_dict = {k: v for k, v in self._members.items() if id(v) in result_ids}
return result_dict
def remove(
self,
item=None,
standard_name=None,
long_name=None,
var_name=None,
attributes=None,
cf_role=None,
contains_node=None,
contains_edge=None,
contains_face=None,
):
removal_dict = self.filters(
item=item,
standard_name=standard_name,
long_name=long_name,
var_name=var_name,
attributes=attributes,
cf_role=cf_role,
contains_node=contains_node,
contains_edge=contains_edge,
contains_face=contains_face,
)
for cf_role in self.REQUIRED:
excluded = removal_dict.pop(cf_role, None)
if excluded:
message = (
f"Ignoring request to remove required connectivity "
f"({cf_role}: {excluded!r})"
)
logger.debug(message, extra=dict(cls=self.__class__.__name__))
for cf_role in removal_dict.keys():
self._members[cf_role] = None
return removal_dict
class _Mesh1DConnectivityManager(_MeshConnectivityManagerBase):
REQUIRED = ("edge_node_connectivity",)
OPTIONAL = ()
@property
def all_members(self):
return Mesh1DConnectivities(edge_node=self.edge_node)
@property
def edge_node(self):
return self._members["edge_node_connectivity"]
class _Mesh2DConnectivityManager(_MeshConnectivityManagerBase):
REQUIRED = ("face_node_connectivity",)
OPTIONAL = (
"edge_node_connectivity",
"face_edge_connectivity",
"face_face_connectivity",
"edge_face_connectivity",
"boundary_node_connectivity",
)
@property
def all_members(self):
return Mesh2DConnectivities(
face_node=self.face_node,
edge_node=self.edge_node,
face_edge=self.face_edge,
face_face=self.face_face,
edge_face=self.edge_face,
boundary_node=self.boundary_node,
)
@property
def boundary_node(self):
return self._members["boundary_node_connectivity"]
@property
def edge_face(self):
return self._members["edge_face_connectivity"]
@property
def edge_node(self):
return self._members["edge_node_connectivity"]
@property
def face_edge(self):
return self._members["face_edge_connectivity"]
@property
def face_face(self):
return self._members["face_face_connectivity"]
@property
def face_node(self):
return self._members["face_node_connectivity"]
[docs]
class MeshCoord(AuxCoord):
"""Geographic coordinate values of data on an unstructured mesh.
A MeshCoord references a `~iris.experimental.ugrid.mesh.Mesh`.
When contained in a `~iris.cube.Cube` it connects the cube to the Mesh.
It records (a) which 1-D cube dimension represents the unstructured mesh,
and (b) which mesh 'location' the cube data is mapped to -- i.e. is it
data on 'face's, 'edge's or 'node's.
A MeshCoord also specifies its 'axis' : 'x' or 'y'. Its values are then,
accordingly, longitudes or latitudes. The values are taken from the
appropriate coordinates and connectivities in the Mesh, determined by its
'location' and 'axis'.
Any cube with data on a mesh will have a MeshCoord for each axis,
i.e. an 'X' and a 'Y'.
The points and bounds contain coordinate values for the mesh elements,
which depends on location.
For 'node', the ``.points`` contains node locations.
For 'edge', the ``.bounds`` contains edge endpoints, and the ``.points`` contain
edge locations (typically centres), if the Mesh contains them (optional).
For 'face', the ``.bounds`` contain the face corners, and the ``.points`` contain the
face locations (typically centres), if the Mesh contains them (optional).
.. note::
As described above, it is possible for a MeshCoord to have bounds but
no points. This is not possible for a regular
:class:`~iris.coords.AuxCoord` or :class:`~iris.coords.DimCoord`.
.. note::
A MeshCoord can not yet actually be created with bounds but no points.
This is intended in future, but for now it raises an error.
"""
def __init__(
self,
mesh,
location,
axis,
):
# Setup the metadata.
self._metadata_manager = metadata_manager_factory(MeshCoordMetadata)
# Validate and record the class-specific constructor args.
if not isinstance(mesh, Mesh):
msg = (
"'mesh' must be an "
f"{Mesh.__module__}.{Mesh.__name__}, "
f"got {mesh}."
)
raise TypeError(msg)
# Handled as a readonly ".mesh" property.
# NOTE: currently *not* included in metadata. In future it might be.
self._mesh = mesh
if location not in Mesh.ELEMENTS:
msg = (
f"'location' of {location} is not a valid Mesh location', "
f"must be one of {Mesh.ELEMENTS}."
)
raise ValueError(msg)
# Held in metadata, readable as self.location, but cannot set it.
self._metadata_manager.location = location
if axis not in Mesh.AXES:
# The valid axes are defined by the Mesh class.
msg = (
f"'axis' of {axis} is not a valid Mesh axis', "
f"must be one of {Mesh.AXES}."
)
raise ValueError(msg)
# Held in metadata, readable as self.axis, but cannot set it.
self._metadata_manager.axis = axis
points, bounds = self._construct_access_arrays()
if points is None:
# TODO: we intend to support this in future, but it will require
# extra work to refactor the parent classes.
msg = "Cannot yet create a MeshCoord without points."
raise ValueError(msg)
# Get the 'coord identity' metadata from the relevant node-coordinate.
node_coord = self.mesh.coord(include_nodes=True, axis=self.axis)
node_metadict = node_coord.metadata._asdict()
# Use node metadata, unless location is face/edge.
use_metadict = node_metadict.copy()
if location != "node":
# Location is either "edge" or "face" - get the relevant coord.
kwargs = {f"include_{location}s": True, "axis": axis}
location_coord = self.mesh.coord(**kwargs)
# Take the MeshCoord metadata from the 'location' coord.
use_metadict = location_coord.metadata._asdict()
unit_unknown = Unit(None)
# N.B. at present, coords in a Mesh are stored+accessed by 'axis', which
# means they must have a standard_name. So ...
# (a) the 'location' (face/edge) coord *always* has a usable phenomenon
# identity.
# (b) we still want to check that location+node coords have the same
# phenomenon (i.e. physical meaning identity + units), **but** ...
# (c) we will accept/ignore some differences : not just "var_name", but
# also "long_name" *and* "attributes". So it is *only* "standard_name"
# and "units" that cause an error if they differ.
for key in ("standard_name", "units"):
bounds_value = use_metadict[key]
nodes_value = node_metadict[key]
if key == "units" and (
bounds_value == unit_unknown or nodes_value == unit_unknown
):
# Allow "any" unit to match no-units (for now)
continue
if bounds_value != nodes_value:
def fix_repr(val):
# Tidy values appearance by converting Unit to string, and
# wrapping strings in '', but leaving other types as a
# plain str() representation.
if isinstance(val, Unit):
val = str(val)
if isinstance(val, str):
val = repr(val)
return val
nodes_value, bounds_value = [
fix_repr(val) for val in (nodes_value, bounds_value)
]
msg = (
f"Node coordinate {node_coord!r} disagrees with the "
f"{location} coordinate {location_coord!r}, "
f'in having a "{key}" value of {nodes_value} '
f"instead of {bounds_value}."
)
raise ValueError(msg)
# Call parent constructor to handle the common constructor args.
super().__init__(points, bounds=bounds, **use_metadict)
# Define accessors for MeshCoord-specific properties mesh/location/axis.
# These are all read-only.
@property
def mesh(self):
return self._mesh
@property
def location(self):
return self._metadata_manager.location
@property
def axis(self):
return self._metadata_manager.axis
# Provide overrides to mimic the Coord-specific properties that are not
# supported by MeshCoord, i.e. "coord_system" and "climatological".
# These mimic the Coord properties, but always return fixed 'null' values.
# They can be set, to the 'null' value only, for the inherited init code.
@property
def coord_system(self):
"""The coordinate-system of a MeshCoord is always 'None'."""
return None
@coord_system.setter
def coord_system(self, value):
if value is not None:
msg = "Cannot set the coordinate-system of a MeshCoord."
raise ValueError(msg)
@property
def climatological(self):
"""The 'climatological' of a MeshCoord is always 'False'."""
return False
@climatological.setter
def climatological(self, value):
if value:
msg = "Cannot set 'climatological' on a MeshCoord."
raise ValueError(msg)
def __getitem__(self, keys):
# Disallow any sub-indexing, permitting *only* "self[:,]".
# We *don't* intend here to support indexing as such : the exception is
# just sufficient to enable cube slicing, when it does not affect the
# mesh dimension. This works because Cube.__getitem__ passes us keys
# "normalised" with iris.util._build_full_slice_given_keys.
if keys != (slice(None),):
msg = "Cannot index a MeshCoord."
raise ValueError(msg)
# Translate "self[:,]" as "self.copy()".
return self.copy()
[docs]
def copy(self, points=None, bounds=None):
"""Make a copy of the MeshCoord.
Parameters
----------
points, bounds : array, optional
Provided solely for signature compatibility with other types of
:class:`~iris.coords.Coord`.
In this case, if either is not 'None', an error is raised.
"""
# Override Coord.copy, so that we can ensure it does not duplicate the
# Mesh object (via deepcopy).
# This avoids copying Meshes.
# FOR NOW: also disallow changing points/bounds at all.
if points is not None or bounds is not None:
msg = "Cannot change the content of a MeshCoord."
raise ValueError(msg)
# Make a new MeshCoord with the same args : The Mesh is the *same*
# as the original (not a copy).
new_coord = MeshCoord(mesh=self.mesh, location=self.location, axis=self.axis)
return new_coord
[docs]
def __deepcopy__(self, memo):
"""Make this equivalent to "shallow" copy.
Returns a new MeshCoord based on the same Mesh.
Required to prevent cube copying from copying the Mesh, which would
prevent "cube.copy() == cube" : see notes for :meth:`copy`.
"""
return self.copy()
# Override _DimensionalMetadata.__eq__, to add 'mesh' comparison into the
# default implementation (which compares metadata, points and bounds).
# This is needed because 'mesh' is not included in our metadata.
def __eq__(self, other):
eq = NotImplemented
if isinstance(other, MeshCoord):
# *Don't* use the parent (_DimensionalMetadata) __eq__, as that
# will try to compare points and bounds arrays.
# Just compare the mesh, and the (other) metadata.
eq = self.mesh == other.mesh # N.B. 'mesh' not in metadata.
if eq is not NotImplemented and eq:
# Compare rest of metadata, but not points/bounds.
eq = self.metadata == other.metadata
return eq
# Exactly as for Coord.__hash__ : See there for why.
def __hash__(self):
return hash(id(self))
[docs]
def summary(self, *args, **kwargs):
# We need to specialise _DimensionalMetadata.summary, so that we always
# print the mesh+location of a MeshCoord.
if len(args) > 0:
shorten = args[0]
else:
shorten = kwargs.get("shorten", False)
# Get the default-form result.
if shorten:
# NOTE: we simply aren't interested in the values for the repr,
# so fix linewidth to suppress them
kwargs["linewidth"] = 1
# Plug private key, to get back the section structure info
section_indices = {}
kwargs["_section_indices"] = section_indices
result = super().summary(*args, **kwargs)
# Modify the generic 'default-form' result to produce what we want.
if shorten:
# Single-line form : insert mesh+location before the array part
# Construct a text detailing the mesh + location
mesh_string = self.mesh.name()
if mesh_string == "unknown":
# If no name, replace with the one-line summary
mesh_string = self.mesh.summary(shorten=True)
extra_str = f"mesh({mesh_string}) location({self.location}) "
# find where in the line the data-array text begins
i_line, i_array = section_indices["data"]
assert i_line == 0
# insert the extra text there
result = result[:i_array] + extra_str + result[i_array:]
# NOTE: this invalidates the original width calculation and may
# easily extend the result beyond the intended maximum linewidth.
# We do treat that as an advisory control over array printing, not
# an absolute contract, so just ignore the problem for now.
else:
# Multiline form
# find where the "location: ... " section is
i_location, i_namestart = section_indices["location"]
lines = result.split("\n")
location_line = lines[i_location]
# copy the indent spacing
indent = location_line[:i_namestart]
# use that to construct a suitable 'mesh' line
mesh_string = self.mesh.summary(shorten=True)
mesh_line = f"{indent}mesh: {mesh_string}"
# Move the 'location' line, putting it and the 'mesh' line right at
# the top, immediately after the header line.
del lines[i_location]
lines[1:1] = [mesh_line, location_line]
# Re-join lines to give the result
result = "\n".join(lines)
return result
def _construct_access_arrays(self):
"""Build lazy points and bounds arrays.
Build lazy points and bounds arrays, providing dynamic access via the
Mesh, according to the location and axis.
Returns
-------
array or None
Tuple of (points, bounds).
Lazy arrays which calculate the correct points and bounds from the
Mesh data, based on the location and axis.
The Mesh coordinates accessed are not identified on construction,
but discovered from the Mesh at the time of calculation, so that
the result is always based on current content in the Mesh.
"""
mesh, location, axis = self.mesh, self.location, self.axis
node_coord = self.mesh.coord(include_nodes=True, axis=axis)
if location == "node":
points_coord = node_coord
bounds_connectivity = None
elif location == "edge":
points_coord = self.mesh.coord(include_edges=True, axis=axis)
bounds_connectivity = mesh.edge_node_connectivity
elif location == "face":
points_coord = self.mesh.coord(include_faces=True, axis=axis)
bounds_connectivity = mesh.face_node_connectivity
# The points output is the points of the relevant element-type coord.
points = points_coord.core_points()
if bounds_connectivity is None:
bounds = None
else:
# Bounds are calculated from a connectivity and the node points.
# Data can be real or lazy, so operations must work in Dask, too.
indices = bounds_connectivity.core_indices()
# Normalise indices dimension order to [faces/edges, bounds]
indices = bounds_connectivity.indices_by_location(indices)
# Normalise the start index
indices = indices - bounds_connectivity.start_index
node_points = node_coord.core_points()
n_nodes = node_points.shape[0]
# Choose real/lazy array library, to suit array types.
lazy = _lazy.is_lazy_data(indices) or _lazy.is_lazy_data(node_points)
al = da if lazy else np
# NOTE: Dask cannot index with a multidimensional array, so we
# must flatten it and restore the shape later.
flat_inds = indices.flatten()
# NOTE: the connectivity array can have masked points, but we can't
# effectively index with those. So use a non-masked index array
# with "safe" index values, and post-mask the results.
flat_inds_nomask = al.ma.filled(flat_inds, -1)
# Note: *also* mask any places where the index is out of range.
missing_inds = (flat_inds_nomask < 0) | (flat_inds_nomask >= n_nodes)
flat_inds_safe = al.where(missing_inds, 0, flat_inds_nomask)
# Here's the core indexing operation.
# The comma applies all inds-array values to the *first* dimension.
bounds = node_points[flat_inds_safe,]
# Fix 'missing' locations, and restore the proper shape.
bounds = al.ma.masked_array(bounds, missing_inds)
bounds = bounds.reshape(indices.shape)
return points, bounds