diff --git a/docs/src/whatsnew/latest.rst b/docs/src/whatsnew/latest.rst index 6d3e3bcc33..20f6829e05 100644 --- a/docs/src/whatsnew/latest.rst +++ b/docs/src/whatsnew/latest.rst @@ -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 ============= diff --git a/lib/iris/experimental/ugrid/mesh.py b/lib/iris/experimental/ugrid/mesh.py index a798f7af77..762e4dbdca 100644 --- a/lib/iris/experimental/ugrid/mesh.py +++ b/lib/iris/experimental/ugrid/mesh.py @@ -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 @@ -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. @@ -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. diff --git a/lib/iris/tests/stock/mesh.py b/lib/iris/tests/stock/mesh.py index 4d0e8ae658..cd092fb66c 100644 --- a/lib/iris/tests/stock/mesh.py +++ b/lib/iris/tests/stock/mesh.py @@ -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, @@ -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: @@ -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", @@ -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) @@ -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) diff --git a/lib/iris/tests/unit/experimental/ugrid/mesh/test_MeshCoord.py b/lib/iris/tests/unit/experimental/ugrid/mesh/test_MeshCoord.py index 78fa39060e..7bacd46755 100644 --- a/lib/iris/tests/unit/experimental/ugrid/mesh/test_MeshCoord.py +++ b/lib/iris/tests/unit/experimental/ugrid/mesh/test_MeshCoord.py @@ -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): @@ -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 @@ -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()