Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/src/whatsnew/latest.rst
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ This document explains the changes made to Iris for this release
#. `@ESadek-MO`_ updated the error messages in :meth:`iris.cube.CubeList.concatenate`
to better explain the error. (:pull:`6005`)

#. `@trexfeathers`_ added the
:meth:`~iris.experimental.ugrid.mesh.MeshCoord.collapsed` method to
:class:`~iris.experimental.ugrid.mesh.MeshCoord`, enabling collapsing of
the :class:`~iris.cube.Cube` :attr:`~iris.cube.Cube.mesh_dim` (see
:ref:`cube-statistics-collapsing`). (:issue:`5377`, :pull:`6003`)


🐛 Bugs Fixed
=============
Expand Down
44 changes: 44 additions & 0 deletions lib/iris/experimental/ugrid/mesh.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
from abc import ABC, abstractmethod
from collections import namedtuple
from collections.abc import Container
from contextlib import contextmanager
from typing import Iterable
import warnings

from cf_units import Unit
from dask import array as da
Expand All @@ -25,6 +27,7 @@
from ...coords import AuxCoord, _DimensionalMetadata
from ...exceptions import ConnectivityNotFoundError, CoordinateNotFoundError
from ...util import array_equal, clip_string, guess_coord_axis
from ...warnings import IrisVagueMetadataWarning
from .metadata import ConnectivityMetadata, MeshCoordMetadata, MeshMetadata

# Configure the logger.
Expand Down Expand Up @@ -2839,6 +2842,47 @@ def __getitem__(self, keys):
# Translate "self[:,]" as "self.copy()".
return self.copy()

def collapsed(self, dims_to_collapse=None):
"""Return a copy of this coordinate, which has been collapsed along the specified dimensions.

Replaces the points & bounds with a simple bounded region.

The coordinate that is collapsed is a :class:`~iris.coords.AuxCoord`
copy of this :class:`MeshCoord`, since a :class:`MeshCoord`
does not have its own points/bounds - they are derived from the
associated :class:`Mesh`. See :meth:`iris.coords.AuxCoord.collapsed`.
"""

@contextmanager
def temp_suppress_warning():
"""Add IrisVagueMetadataWarning filter then removes it after yielding.

A workaround to mimic catch_warnings(), given python/cpython#73858.
"""
warnings.filterwarnings("ignore", category=IrisVagueMetadataWarning)
added_warning = warnings.filters[0]

yield

# (warnings.filters is usually mutable but this is not guaranteed).
new_filters = list(warnings.filters)
new_filters.remove(added_warning)
warnings.filters = new_filters

aux_coord = AuxCoord.from_coord(self)

# Reuse existing AuxCoord collapse logic, but with a custom
# mesh-specific warning.
message = (
"Collapsing a mesh coordinate - cannot check for contiguity."
f"Metadata may not be fully descriptive for '{self.name()}'."
)
warnings.warn(message, category=IrisVagueMetadataWarning)
with temp_suppress_warning():
collapsed_coord = aux_coord.collapsed(dims_to_collapse)

return collapsed_coord

def copy(self, points=None, bounds=None):
"""Make a copy of the MeshCoord.

Expand Down
26 changes: 23 additions & 3 deletions lib/iris/tests/stock/mesh.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,14 @@
_TEST_N_BOUNDS = 4


def sample_mesh(n_nodes=None, n_faces=None, n_edges=None, lazy_values=False):
def sample_mesh(
n_nodes=None,
n_faces=None,
n_edges=None,
lazy_values=False,
nodes_per_face=None,
masked_connecteds=False,
):
"""Make a test mesh.

Mesh has nodes, plus faces and/or edges, with face-coords and edge-coords,
Expand All @@ -38,6 +45,11 @@ def sample_mesh(n_nodes=None, n_faces=None, n_edges=None, lazy_values=False):
If not 0, face coords and a 'face_node_connectivity' are included.
lazy_values : bool, default=False
If True, all content values of coords and connectivities are lazy.
nodes_per_face : int or None
Number of nodes per face. Default is 4.
masked_connecteds : bool, default=False
If True, mask some of the connected spaces in the connectivity indices
arrays.

"""
if lazy_values:
Expand All @@ -53,6 +65,8 @@ def sample_mesh(n_nodes=None, n_faces=None, n_edges=None, lazy_values=False):
n_faces = _TEST_N_FACES
if n_edges is None:
n_edges = _TEST_N_EDGES
if nodes_per_face is None:
nodes_per_face = _TEST_N_BOUNDS
node_x = AuxCoord(
1100 + arr.arange(n_nodes),
standard_name="longitude",
Expand All @@ -76,6 +90,9 @@ def sample_mesh(n_nodes=None, n_faces=None, n_edges=None, lazy_values=False):
conns = arr.arange(n_edges * 2, dtype=int)
# Missing nodes include #0-5, because we add 5.
conns = ((conns + 5) % n_nodes).reshape((n_edges, 2))
if masked_connecteds:
conns[0, -1] = -1
conns = arr.ma.masked_less(conns, 0)
edge_nodes = Connectivity(conns, cf_role="edge_node_connectivity")
connectivities.append(edge_nodes)

Expand All @@ -88,8 +105,11 @@ def sample_mesh(n_nodes=None, n_faces=None, n_edges=None, lazy_values=False):
else:
# Define a rather arbitrary face-nodes connectivity.
# Some nodes are left out, because n_faces*n_bounds < n_nodes.
conns = arr.arange(n_faces * _TEST_N_BOUNDS, dtype=int)
conns = (conns % n_nodes).reshape((n_faces, _TEST_N_BOUNDS))
conns = arr.arange(n_faces * nodes_per_face, dtype=int)
conns = (conns % n_nodes).reshape((n_faces, nodes_per_face))
if masked_connecteds:
conns[0, -1] = -1
conns = arr.ma.masked_less(conns, 0)
face_nodes = Connectivity(conns, cf_role="face_node_connectivity")
connectivities.append(face_nodes)

Expand Down
81 changes: 76 additions & 5 deletions lib/iris/tests/unit/experimental/ugrid/mesh/test_MeshCoord.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@
from iris.experimental.ugrid.mesh import Connectivity, Mesh, MeshCoord
import iris.tests.stock.mesh
from iris.tests.stock.mesh import sample_mesh, sample_meshcoord
from iris.warnings import IrisVagueMetadataWarning


@pytest.fixture(params=["face", "edge"])
def location_face_or_edge(request):
# Fixture to parametrise over location = face/edge
return request.param


class Test___init__(tests.IrisTest):
Expand Down Expand Up @@ -814,11 +821,6 @@ def coord_metadata_matches(self, test_coord, ref_coord):
for key in CoordMetadata._fields:
assert getattr(test_coord, key) == getattr(ref_coord, key)

@pytest.fixture(params=["face", "edge"])
def location_face_or_edge(self, request):
# Fixture to parametrise over location = face/edge
return request.param

@pytest.fixture(params=["x", "y"])
def axis_x_or_y(self, request):
# Fixture to parametrise over axis = X/Y
Expand Down Expand Up @@ -914,5 +916,74 @@ def test_faceedge_missing_units(self, location_face_or_edge, axis_x_or_y):
self.coord_metadata_matches(meshcoord, self.location_coord)


class Test_collapsed:
"""Very simple operation that in theory is fully tested elsewhere
(Test_auxcoord_conversion, and existing tests of AuxCoord.collapsed()),
but there is still need to check that the operation is valid for any
expected MeshCoord variety.
"""

@pytest.fixture(params=[False, True], ids=["real", "lazy"])
def lazy(self, request):
return request.param

@pytest.fixture(params=[4, 5], ids=["quads", "pentagons"])
def nodes_per_face(self, request):
return request.param

@pytest.fixture(params=[False, True], ids=["conn_no_masks", "conn_has_masks"])
def masked_connecteds(self, request):
return request.param

@staticmethod
@pytest.fixture
def mesh_coord(location_face_or_edge, lazy, nodes_per_face, masked_connecteds):
mesh = sample_mesh(
lazy_values=lazy,
nodes_per_face=nodes_per_face,
masked_connecteds=masked_connecteds,
)
coord = sample_meshcoord(
mesh=mesh,
location=location_face_or_edge,
)
return coord

@staticmethod
@pytest.fixture
def mesh_coord_basic():
return sample_meshcoord()

def test_works(self, mesh_coord):
"""Just check that the operation succeeds.

The points/bounds produced by collapsing a MeshCoord are suspect
(hence a warning is raised), so we will not assert for 'correct'
values.
"""
collapsed = mesh_coord.collapsed()
assert collapsed.points.shape == (1,)
assert collapsed.bounds.shape == (1, 2)

def test_warns(self, mesh_coord_basic):
"""Confirm that the correct warning has been raised.

Also confirm that the original AuxCoord warning has NOT been raised -
successfully suppressed.
"""
with pytest.warns(IrisVagueMetadataWarning) as record:
_ = mesh_coord_basic.collapsed()

# Len 1 means that no other warnings were raised.
assert len(record) == 1
message = record[0].message.args[0]
assert message.startswith("Collapsing a mesh coordinate")

def test_aux_collapsed_called(self, mesh_coord_basic):
with mock.patch.object(AuxCoord, "collapsed") as mocked:
_ = mesh_coord_basic.collapsed()
mocked.assert_called_once()


if __name__ == "__main__":
tests.main()