diff --git a/lib/iris/common/metadata.py b/lib/iris/common/metadata.py index fe68430db2..620b8db07d 100644 --- a/lib/iris/common/metadata.py +++ b/lib/iris/common/metadata.py @@ -1599,8 +1599,11 @@ def equal(self, other, lenient=None): return super().equal(other, lenient=lenient) -class MeshIndexSetMetadata(BaseMetadata): - """Metadata container for a :class:`~iris.mesh.components._MeshIndexSet`.""" +class _MeshIndexSetMetadata(BaseMetadata): + """Metadata container for a :class:`~iris.mesh.components._MeshIndexSet`. + + **Classed as experimental until ``_MeshIndexSet`` is no longer experimental.** + """ _members = ("mesh", "location", "start_index") @@ -1616,7 +1619,7 @@ def _combine_lenient(self, other): Parameters ---------- - other : MeshIndexSetMetadata + other : _MeshIndexSetMetadata The other metadata participating in the lenient combination. Returns @@ -1644,7 +1647,7 @@ def _compare_lenient(self, other): Parameters ---------- - other : MeshIndexSetMetadata + other : _MeshIndexSetMetadata The other metadata participating in the lenient comparison. Returns @@ -1668,7 +1671,7 @@ def _difference_lenient(self, other): Parameters ---------- - other : MeshIndexSetMetadata + other : _MeshIndexSetMetadata The other metadata participating in the lenient difference. Returns diff --git a/lib/iris/mesh/components.py b/lib/iris/mesh/components.py index dd0ad6d198..696d93ad71 100644 --- a/lib/iris/mesh/components.py +++ b/lib/iris/mesh/components.py @@ -42,8 +42,8 @@ from iris.common.metadata import ( ConnectivityMetadata, MeshCoordMetadata, - MeshIndexSetMetadata, MeshMetadata, + _MeshIndexSetMetadata, ) import iris.util @@ -3369,6 +3369,11 @@ def __init__( Index origin; default is ``0``. """ + if np.asanyarray(indices).ndim > 1: + raise ValueError( + f"`indices` must be 1D. Got {np.asanyarray(indices).ndim} dimensions." + ) + if not isinstance(mesh, MeshXY): raise TypeError(f"`mesh` must be `MeshXY`. Got {type(mesh)}") @@ -3376,10 +3381,16 @@ def __init__( msg = f"`location` must be in {_MeshXYMixin.ELEMENTS}. Got {location}" raise ValueError(msg) + if location == "face" and mesh.topology_dimension < 2: + raise ValueError( + f"`location` cannot be 'face' for a mesh with topology_dimension < 2. " + f"Got {location} and topology_dimension={mesh.topology_dimension}" + ) + if start_index not in [0, 1]: raise ValueError(f"`start_index must be 0 or 1. Got {start_index}") - self._metadata_manager = metadata_manager_factory(MeshIndexSetMetadata) + self._metadata_manager = metadata_manager_factory(_MeshIndexSetMetadata) # 'structure' is immutable after creation, so assign directly to the # metadata manager. Desired changes should be made by creating a new # instance. @@ -3409,17 +3420,17 @@ def __eq__(self, other) -> bool | NotImplementedType: # Don't check coords or connectivities as these are # fully derived using metadata. if result: - result = self.indices == other.indices + result = iris.util.array_equal(self.indices, other.indices) return result - def __getstate__(self) -> tuple[ArrayLike, MeshIndexSetMetadata]: + def __getstate__(self) -> tuple[ArrayLike, _MeshIndexSetMetadata]: return ( self.indices, self._metadata_manager, ) - def __setstate__(self, state: tuple[ArrayLike, MeshIndexSetMetadata]): + def __setstate__(self, state: tuple[ArrayLike, _MeshIndexSetMetadata]): indices, metadata_manager = state self._values = indices self._metadata_manager = metadata_manager @@ -3501,9 +3512,13 @@ def _calculate_node_bool_index(self): and c.connected == "node" ) ] - # Doesn't matter if connectivity is transposed or not in this case. # TODO: implement lazy_indices() and core_indices() for _MeshIndexSet - conn_indices = connectivity.core_indices()[self.indices] + # Respect the connectivity's location_axis before selecting the + # requested edges/faces. This preserves the connectivity's stored + # orientation while still handling transposed connectivities. + conn_indices = connectivity.indices_by_location( + connectivity.core_indices() + )[self.indices] al = da if _lazy.is_lazy_data(conn_indices) else np # Flatten and drop masked padding (ragged connectivities) by scattering # membership into a fixed-shape boolean mask. diff --git a/lib/iris/tests/stock/mesh.py b/lib/iris/tests/stock/mesh.py index 3d920d3630..6100b58cb8 100644 --- a/lib/iris/tests/stock/mesh.py +++ b/lib/iris/tests/stock/mesh.py @@ -106,7 +106,9 @@ def sample_mesh( if n_faces == 0: face_coords_and_axes = None + topology_dimension = 1 else: + topology_dimension = 2 # Define a rather arbitrary face-nodes connectivity. # Some nodes are left out, because n_faces*n_bounds < n_nodes. conns = arr.arange(n_faces * nodes_per_face, dtype=int) diff --git a/lib/iris/tests/test_coord_api.py b/lib/iris/tests/test_coord_api.py index fb801cc028..8dd7037903 100644 --- a/lib/iris/tests/test_coord_api.py +++ b/lib/iris/tests/test_coord_api.py @@ -419,10 +419,14 @@ def test_dim_coord_restrictions(self): with pytest.raises(ValueError, match="must be scalar or 1-dim"): iris.coords.DimCoord([[1, 2, 3], [4, 5, 6]]) # monotonic points - with pytest.raises(ValueError, match="must be strictly monotonic"): + with pytest.raises( + iris.exceptions.MonotonicityError, match="must be strictly monotonic" + ): iris.coords.DimCoord([1, 2, 99, 4, 5]) # monotonic bounds - with pytest.raises(ValueError, match="direction of monotonicity"): + with pytest.raises( + iris.exceptions.MonotonicityError, match="direction of monotonicity" + ): iris.coords.DimCoord([1, 2, 3], bounds=[[1, 12], [2, 9], [3, 6]]) # masked points emsg = "points array must not be masked" @@ -778,7 +782,9 @@ def test_guess_bounds(self): coord = iris.coords.AuxCoord.from_coord(coord) coord.points = points coord.bounds = None - with pytest.raises(ValueError, match="Need monotonic points"): + with pytest.raises( + iris.exceptions.MonotonicityError, match="Need monotonic points" + ): coord.guess_bounds() diff --git a/lib/iris/tests/unit/analysis/interpolation/test_RectilinearInterpolator.py b/lib/iris/tests/unit/analysis/interpolation/test_RectilinearInterpolator.py index 5a79b73afc..69fc760e7d 100644 --- a/lib/iris/tests/unit/analysis/interpolation/test_RectilinearInterpolator.py +++ b/lib/iris/tests/unit/analysis/interpolation/test_RectilinearInterpolator.py @@ -102,7 +102,7 @@ def test_interpolate_non_monotonic(self): iris.coords.AuxCoord([0, 3, 2], long_name="non-monotonic"), 1 ) msg = "Cannot interpolate over the non-monotonic coordinate non-monotonic." - with pytest.raises(ValueError, match=msg): + with pytest.raises(iris.exceptions.MonotonicityError, match=msg): RectilinearInterpolator(self.cube, ["non-monotonic"], LINEAR, EXTRAPOLATE) diff --git a/lib/iris/tests/unit/common/metadata/test_MeshIndexSetMetadata.py b/lib/iris/tests/unit/common/metadata/test_MeshIndexSetMetadata.py new file mode 100644 index 0000000000..a76c353215 --- /dev/null +++ b/lib/iris/tests/unit/common/metadata/test_MeshIndexSetMetadata.py @@ -0,0 +1,530 @@ +# 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. +"""Unit tests for the :class:`iris.common.metadata._MeshIndexSetMetadata`.""" + +from copy import deepcopy + +import pytest + +from iris.common.lenient import _LENIENT, _qualname +from iris.common.metadata import BaseMetadata, _MeshIndexSetMetadata + + +class Test__identity: + @pytest.fixture(autouse=True) + def _setup(self, mocker): + self.standard_name = mocker.sentinel.standard_name + self.long_name = mocker.sentinel.long_name + self.var_name = mocker.sentinel.var_name + self.units = mocker.sentinel.units + self.attributes = mocker.sentinel.attributes + self.mesh = mocker.sentinel.mesh + self.location = mocker.sentinel.location + self.start_index = mocker.sentinel.start_index + self.cls = _MeshIndexSetMetadata + + def test_repr(self, mocker): + metadata = self.cls( + standard_name=self.standard_name, + long_name=self.long_name, + var_name=self.var_name, + units=self.units, + attributes=self.attributes, + mesh=self.mesh, + location=self.location, + start_index=self.start_index, + ) + fmt = ( + "_MeshIndexSetMetadata(standard_name={!r}, long_name={!r}, " + "var_name={!r}, units={!r}, attributes={!r}, " + "mesh={!r}, location={!r}, start_index={!r})" + ) + expected = fmt.format( + self.standard_name, + self.long_name, + self.var_name, + self.units, + self.attributes, + self.mesh, + self.location, + self.start_index, + ) + assert expected == repr(metadata) + + def test__fields(self, mocker): + expected = ( + "standard_name", + "long_name", + "var_name", + "units", + "attributes", + "mesh", + "location", + "start_index", + ) + assert self.cls._fields == expected + + def test_bases(self, mocker): + assert issubclass(self.cls, BaseMetadata) + + +class Test__eq__: + @pytest.fixture(autouse=True) + def _setup(self, mocker): + self.values = dict( + standard_name=mocker.sentinel.standard_name, + long_name=mocker.sentinel.long_name, + var_name=mocker.sentinel.var_name, + units=mocker.sentinel.units, + attributes=mocker.sentinel.attributes, + mesh=mocker.sentinel.mesh, + location=mocker.sentinel.location, + start_index=mocker.sentinel.start_index, + ) + self.dummy = mocker.sentinel.dummy + self.cls = _MeshIndexSetMetadata + + def test_wraps_docstring(self, mocker): + assert BaseMetadata.__eq__.__doc__ == self.cls.__eq__.__doc__ + + def test_lenient_service(self, mocker): + qualname___eq__ = _qualname(self.cls.__eq__) + assert qualname___eq__ in _LENIENT + assert _LENIENT[qualname___eq__] + assert _LENIENT[self.cls.__eq__] + + def test_call(self, mocker): + other = mocker.sentinel.other + return_value = mocker.sentinel.return_value + metadata = self.cls(*(None,) * len(self.cls._fields)) + mocked = mocker.patch.object(BaseMetadata, "__eq__", return_value=return_value) + result = metadata.__eq__(other) + + assert return_value == result + assert 1 == mocked.call_count + (arg,), kwargs = mocked.call_args + assert other == arg + assert dict() == kwargs + + def test_op_lenient_same(self, mocker): + lmetadata = self.cls(**self.values) + rmetadata = self.cls(**self.values) + + mocker.patch("iris.common.metadata._LENIENT", return_value=True) + assert lmetadata.__eq__(rmetadata) + assert rmetadata.__eq__(lmetadata) + + def test_op_lenient_same_none_nonmember(self, mocker): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["long_name"] = None + rmetadata = self.cls(**right) + + mocker.patch("iris.common.metadata._LENIENT", return_value=True) + assert lmetadata.__eq__(rmetadata) + assert rmetadata.__eq__(lmetadata) + + def test_op_lenient_same_members_none(self, mocker): + for member in self.cls._members: + lmetadata = self.cls(**self.values) + right = self.values.copy() + right[member] = None + rmetadata = self.cls(**right) + + mocker.patch("iris.common.metadata._LENIENT", return_value=True) + assert not lmetadata.__eq__(rmetadata) + assert not rmetadata.__eq__(lmetadata) + + def test_op_lenient_different_nonmember(self, mocker): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["units"] = self.dummy + rmetadata = self.cls(**right) + + mocker.patch("iris.common.metadata._LENIENT", return_value=True) + assert not lmetadata.__eq__(rmetadata) + assert not rmetadata.__eq__(lmetadata) + + def test_op_strict_same(self, mocker): + lmetadata = self.cls(**self.values) + rmetadata = self.cls(**self.values) + + mocker.patch("iris.common.metadata._LENIENT", return_value=False) + assert lmetadata.__eq__(rmetadata) + assert rmetadata.__eq__(lmetadata) + + def test_op_strict_different_nonmember(self, mocker): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["long_name"] = self.dummy + rmetadata = self.cls(**right) + + mocker.patch("iris.common.metadata._LENIENT", return_value=False) + assert not lmetadata.__eq__(rmetadata) + assert not rmetadata.__eq__(lmetadata) + + def test_op_strict_different_nonmember_none(self, mocker): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["long_name"] = None + rmetadata = self.cls(**right) + + mocker.patch("iris.common.metadata._LENIENT", return_value=False) + assert not lmetadata.__eq__(rmetadata) + assert not rmetadata.__eq__(lmetadata) + + +class Test___lt__: + @pytest.fixture(autouse=True) + def _setup(self, mocker): + self.cls = _MeshIndexSetMetadata + values = [1] * len(self.cls._fields) + self.one = self.cls(*values) + + values_two = values[:] + values_two[2] = 2 + self.two = self.cls(*values_two) + + values_none = values[:] + values_none[2] = None + self.none = self.cls(*values_none) + + values_attrs = values[:] + values_attrs[4] = 10 + self.attributes = self.cls(*values_attrs) + + def test__ascending_lt(self, mocker): + result = self.one < self.two + assert result + + def test__descending_lt(self, mocker): + result = self.two < self.one + assert not result + + def test__none_rhs_operand(self, mocker): + result = self.one < self.none + assert not result + + def test__none_lhs_operand(self, mocker): + result = self.none < self.one + assert result + + def test__ignore_attributes(self, mocker): + result = self.one < self.attributes + assert not result + result = self.attributes < self.one + assert not result + + +class Test_combine: + @pytest.fixture(autouse=True) + def _setup(self, mocker): + self.cls = _MeshIndexSetMetadata + self.values = dict( + standard_name=mocker.sentinel.standard_name, + long_name=mocker.sentinel.long_name, + var_name=mocker.sentinel.var_name, + units=mocker.sentinel.units, + attributes=mocker.sentinel.attributes, + mesh=mocker.sentinel.mesh, + location=mocker.sentinel.location, + start_index=mocker.sentinel.start_index, + ) + self.dummy = mocker.sentinel.dummy + self.none = self.cls(*(None,) * len(self.cls._fields)) + + def test_wraps_docstring(self, mocker): + assert BaseMetadata.combine.__doc__ == self.cls.combine.__doc__ + + def test_lenient_service(self, mocker): + qualname_combine = _qualname(self.cls.combine) + assert qualname_combine in _LENIENT + assert _LENIENT[qualname_combine] + assert _LENIENT[self.cls.combine] + + def test_lenient_default(self, mocker): + other = mocker.sentinel.other + return_value = mocker.sentinel.return_value + mocked = mocker.patch.object(BaseMetadata, "combine", return_value=return_value) + result = self.none.combine(other) + + assert return_value == result + assert 1 == mocked.call_count + (arg,), kwargs = mocked.call_args + assert other == arg + assert dict(lenient=None) == kwargs + + def test_lenient(self, mocker): + other = mocker.sentinel.other + lenient = mocker.sentinel.lenient + return_value = mocker.sentinel.return_value + mocked = mocker.patch.object(BaseMetadata, "combine", return_value=return_value) + result = self.none.combine(other, lenient=lenient) + + assert return_value == result + assert 1 == mocked.call_count + (arg,), kwargs = mocked.call_args + assert other == arg + assert dict(lenient=lenient) == kwargs + + def test_op_lenient_same(self, mocker): + lmetadata = self.cls(**self.values) + rmetadata = self.cls(**self.values) + + mocker.patch("iris.common.metadata._LENIENT", return_value=True) + assert self.values == lmetadata.combine(rmetadata)._asdict() + assert self.values == rmetadata.combine(lmetadata)._asdict() + + def test_op_lenient_same_none_nonmember(self, mocker): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["var_name"] = None + rmetadata = self.cls(**right) + + mocker.patch("iris.common.metadata._LENIENT", return_value=True) + assert self.values == lmetadata.combine(rmetadata)._asdict() + assert self.values == rmetadata.combine(lmetadata)._asdict() + + def test_op_lenient_same_members_none(self, mocker): + for member in self.cls._members: + lmetadata = self.cls(**self.values) + right = self.values.copy() + right[member] = None + rmetadata = self.cls(**right) + expected = right.copy() + + mocker.patch("iris.common.metadata._LENIENT", return_value=True) + assert expected == lmetadata.combine(rmetadata)._asdict() + assert expected == rmetadata.combine(lmetadata)._asdict() + + def test_op_lenient_different_members(self, mocker): + for member in self.cls._members: + lmetadata = self.cls(**self.values) + right = self.values.copy() + right[member] = self.dummy + rmetadata = self.cls(**right) + expected = self.values.copy() + expected[member] = None + + mocker.patch("iris.common.metadata._LENIENT", return_value=True) + assert expected == lmetadata.combine(rmetadata)._asdict() + assert expected == rmetadata.combine(lmetadata)._asdict() + + def test_op_lenient_different_nonmember(self, mocker): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["units"] = self.dummy + rmetadata = self.cls(**right) + expected = self.values.copy() + expected["units"] = None + + mocker.patch("iris.common.metadata._LENIENT", return_value=True) + assert expected == lmetadata.combine(rmetadata)._asdict() + assert expected == rmetadata.combine(lmetadata)._asdict() + + def test_op_strict_same(self, mocker): + lmetadata = self.cls(**self.values) + rmetadata = self.cls(**self.values) + + mocker.patch("iris.common.metadata._LENIENT", return_value=False) + assert self.values == lmetadata.combine(rmetadata)._asdict() + assert self.values == rmetadata.combine(lmetadata)._asdict() + + def test_op_strict_different_nonmember(self, mocker): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["long_name"] = self.dummy + rmetadata = self.cls(**right) + expected = self.values.copy() + expected["long_name"] = None + + mocker.patch("iris.common.metadata._LENIENT", return_value=False) + assert expected == lmetadata.combine(rmetadata)._asdict() + assert expected == rmetadata.combine(lmetadata)._asdict() + + +class Test_difference: + @pytest.fixture(autouse=True) + def _setup(self, mocker): + self.cls = _MeshIndexSetMetadata + self.values = dict( + standard_name=mocker.sentinel.standard_name, + long_name=mocker.sentinel.long_name, + var_name=mocker.sentinel.var_name, + units=mocker.sentinel.units, + attributes=mocker.sentinel.attributes, + mesh=mocker.sentinel.mesh, + location=mocker.sentinel.location, + start_index=mocker.sentinel.start_index, + ) + self.dummy = mocker.sentinel.dummy + self.none = self.cls(*(None,) * len(self.cls._fields)) + + def test_wraps_docstring(self, mocker): + assert BaseMetadata.difference.__doc__ == self.cls.difference.__doc__ + + def test_lenient_service(self, mocker): + qualname_difference = _qualname(self.cls.difference) + assert qualname_difference in _LENIENT + assert _LENIENT[qualname_difference] + assert _LENIENT[self.cls.difference] + + def test_lenient_default(self, mocker): + other = mocker.sentinel.other + return_value = mocker.sentinel.return_value + mocked = mocker.patch.object( + BaseMetadata, "difference", return_value=return_value + ) + result = self.none.difference(other) + + assert return_value == result + assert 1 == mocked.call_count + (arg,), kwargs = mocked.call_args + assert other == arg + assert dict(lenient=None) == kwargs + + def test_lenient(self, mocker): + other = mocker.sentinel.other + lenient = mocker.sentinel.lenient + return_value = mocker.sentinel.return_value + mocked = mocker.patch.object( + BaseMetadata, "difference", return_value=return_value + ) + result = self.none.difference(other, lenient=lenient) + + assert return_value == result + assert 1 == mocked.call_count + (arg,), kwargs = mocked.call_args + assert other == arg + assert dict(lenient=lenient) == kwargs + + def test_op_lenient_same(self, mocker): + lmetadata = self.cls(**self.values) + rmetadata = self.cls(**self.values) + + mocker.patch("iris.common.metadata._LENIENT", return_value=True) + assert lmetadata.difference(rmetadata) is None + assert rmetadata.difference(lmetadata) is None + + def test_op_lenient_same_none_nonmember(self, mocker): + lmetadata = self.cls(**self.values) + right = self.values.copy() + right["var_name"] = None + rmetadata = self.cls(**right) + + mocker.patch("iris.common.metadata._LENIENT", return_value=True) + assert lmetadata.difference(rmetadata) is None + assert rmetadata.difference(lmetadata) is None + + def test_op_lenient_same_members_none(self, mocker): + for member in self.cls._members: + lmetadata = self.cls(**self.values) + member_value = getattr(lmetadata, member) + right = self.values.copy() + right[member] = None + rmetadata = self.cls(**right) + lexpected = deepcopy(self.none)._asdict() + lexpected[member] = (member_value, None) + rexpected = deepcopy(self.none)._asdict() + rexpected[member] = (None, member_value) + + mocker.patch("iris.common.metadata._LENIENT", return_value=True) + assert lexpected == lmetadata.difference(rmetadata)._asdict() + assert rexpected == rmetadata.difference(lmetadata)._asdict() + + def test_op_lenient_different_members(self, mocker): + for member in self.cls._members: + left = self.values.copy() + lmetadata = self.cls(**left) + right = self.values.copy() + right[member] = self.dummy + rmetadata = self.cls(**right) + lexpected = deepcopy(self.none)._asdict() + lexpected[member] = (left[member], right[member]) + rexpected = deepcopy(self.none)._asdict() + rexpected[member] = lexpected[member][::-1] + + mocker.patch("iris.common.metadata._LENIENT", return_value=True) + assert lexpected == lmetadata.difference(rmetadata)._asdict() + assert rexpected == rmetadata.difference(lmetadata)._asdict() + + def test_op_lenient_different_nonmember(self, mocker): + left = self.values.copy() + lmetadata = self.cls(**left) + right = self.values.copy() + right["units"] = self.dummy + rmetadata = self.cls(**right) + lexpected = deepcopy(self.none)._asdict() + lexpected["units"] = (left["units"], right["units"]) + rexpected = deepcopy(self.none)._asdict() + rexpected["units"] = lexpected["units"][::-1] + + mocker.patch("iris.common.metadata._LENIENT", return_value=True) + assert lexpected == lmetadata.difference(rmetadata)._asdict() + assert rexpected == rmetadata.difference(lmetadata)._asdict() + + def test_op_strict_same(self, mocker): + lmetadata = self.cls(**self.values) + rmetadata = self.cls(**self.values) + + mocker.patch("iris.common.metadata._LENIENT", return_value=False) + assert lmetadata.difference(rmetadata) is None + assert rmetadata.difference(lmetadata) is None + + def test_op_strict_different_nonmember(self, mocker): + left = self.values.copy() + lmetadata = self.cls(**left) + right = self.values.copy() + right["long_name"] = self.dummy + rmetadata = self.cls(**right) + lexpected = deepcopy(self.none)._asdict() + lexpected["long_name"] = (left["long_name"], right["long_name"]) + rexpected = deepcopy(self.none)._asdict() + rexpected["long_name"] = lexpected["long_name"][::-1] + + mocker.patch("iris.common.metadata._LENIENT", return_value=False) + assert lexpected == lmetadata.difference(rmetadata)._asdict() + assert rexpected == rmetadata.difference(lmetadata)._asdict() + + +class Test_equal: + @pytest.fixture(autouse=True) + def _setup(self, mocker): + self.cls = _MeshIndexSetMetadata + self.none = self.cls(*(None,) * len(self.cls._fields)) + + def test_wraps_docstring(self, mocker): + assert BaseMetadata.equal.__doc__ == self.cls.equal.__doc__ + + def test_lenient_service(self, mocker): + qualname_equal = _qualname(self.cls.equal) + assert qualname_equal in _LENIENT + assert _LENIENT[qualname_equal] + assert _LENIENT[self.cls.equal] + + def test_lenient_default(self, mocker): + other = mocker.sentinel.other + return_value = mocker.sentinel.return_value + mocked = mocker.patch.object(BaseMetadata, "equal", return_value=return_value) + result = self.none.equal(other) + + assert return_value == result + assert 1 == mocked.call_count + (arg,), kwargs = mocked.call_args + assert other == arg + assert dict(lenient=None) == kwargs + + def test_lenient(self, mocker): + other = mocker.sentinel.other + lenient = mocker.sentinel.lenient + return_value = mocker.sentinel.return_value + mocked = mocker.patch.object(BaseMetadata, "equal", return_value=return_value) + result = self.none.equal(other, lenient=lenient) + + assert return_value == result + assert 1 == mocked.call_count + (arg,), kwargs = mocked.call_args + assert other == arg + assert dict(lenient=lenient) == kwargs diff --git a/lib/iris/tests/unit/coords/test_DimCoord.py b/lib/iris/tests/unit/coords/test_DimCoord.py index 9c7ab6af19..3eb62f6613 100644 --- a/lib/iris/tests/unit/coords/test_DimCoord.py +++ b/lib/iris/tests/unit/coords/test_DimCoord.py @@ -16,6 +16,7 @@ import pytest from iris.coords import DimCoord +from iris.exceptions import MonotonicityError from iris.tests import _shared_utils from iris.tests.unit.coords import ( CoordTestMixin, @@ -80,7 +81,7 @@ def test_fail_bounds_shape_mismatch(self): def test_fail_nonmonotonic(self): msg = "must be strictly monotonic" - with pytest.raises(ValueError, match=msg): + with pytest.raises(MonotonicityError, match=msg): DimCoord([1, 2, 0, 3]) def test_no_masked_pts_real(self): @@ -512,7 +513,7 @@ def test_fail_not_monotonic(self): # Setting real points requires that they are monotonic. coord = DimCoord(self.pts_real, bounds=self.bds_real) msg = "strictly monotonic" - with pytest.raises(ValueError, match=msg): + with pytest.raises(MonotonicityError, match=msg): coord.points = np.array([3.0, 1.0, 2.0]) _shared_utils.assert_array_equal(coord.points, self.pts_real) @@ -577,7 +578,7 @@ def test_fail_not_monotonic(self): # Setting real bounds requires that they are monotonic. coord = DimCoord(self.pts_real, bounds=self.bds_real) msg = "strictly monotonic" - with pytest.raises(ValueError, match=msg): + with pytest.raises(MonotonicityError, match=msg): coord.bounds = np.array([[3.0, 2.0], [1.0, 0.0], [2.0, 1.0]]) _shared_utils.assert_array_equal(coord.bounds, self.bds_real) diff --git a/lib/iris/tests/unit/cube/test_Cube.py b/lib/iris/tests/unit/cube/test_Cube.py index b0766d77af..d996ea8721 100644 --- a/lib/iris/tests/unit/cube/test_Cube.py +++ b/lib/iris/tests/unit/cube/test_Cube.py @@ -2563,6 +2563,22 @@ def test_no_mesh(self): result = self.cube.mesh assert result is None + def test_mesh_scalar_index(self): + # Test correct scalar indexing of a mesh dimension. + from iris.experimental.mesh_coord_indexing import SETTING, Options + + cube = self.cube + mesh_dim = cube.mesh_dim() + indexing = [slice(None)] * cube.ndim + indexing[mesh_dim] = 0 + with SETTING.context(Options.MESH_INDEX_SET): + result = cube[tuple(indexing)] + assert result.mesh is not None + assert result.mesh_dim() is None + mesh_coords = result.coords(mesh_coords=True) + for mesh_coord in mesh_coords: + assert mesh_coord.shape == (1,) + class Test_location: @pytest.fixture(autouse=True) diff --git a/lib/iris/tests/unit/experimental/test_mesh_coord_indexing.py b/lib/iris/tests/unit/experimental/test_mesh_coord_indexing.py new file mode 100644 index 0000000000..9ddfca1ef8 --- /dev/null +++ b/lib/iris/tests/unit/experimental/test_mesh_coord_indexing.py @@ -0,0 +1,112 @@ +# 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. +"""Unit tests for the `iris.experimental.mesh_coord_indexing` module.""" + +import threading + +import pytest + +from iris.experimental import mesh_coord_indexing + + +@pytest.fixture(autouse=True) +def restore_setting_value(): + """Keep global runtime setting isolated between tests.""" + original_value = mesh_coord_indexing.SETTING.value + try: + yield + finally: + mesh_coord_indexing.SETTING.value = original_value + + +class TestOptions: + def test_members(self): + assert mesh_coord_indexing.Options.AUX_COORD.name == "AUX_COORD" + assert mesh_coord_indexing.Options.NEW_MESH.name == "NEW_MESH" + assert mesh_coord_indexing.Options.MESH_INDEX_SET.name == "MESH_INDEX_SET" + + +class TestSettingValue: + def test_default(self): + assert ( + mesh_coord_indexing.SETTING.value == mesh_coord_indexing.Options.AUX_COORD + ) + + def test_set_enum_member(self): + mesh_coord_indexing.SETTING.value = mesh_coord_indexing.Options.NEW_MESH + + assert mesh_coord_indexing.SETTING.value == mesh_coord_indexing.Options.NEW_MESH + + def test_set_member_value(self): + mesh_coord_indexing.SETTING.value = ( + mesh_coord_indexing.Options.MESH_INDEX_SET.value + ) + + assert ( + mesh_coord_indexing.SETTING.value + == mesh_coord_indexing.Options.MESH_INDEX_SET + ) + + def test_invalid_value(self): + with pytest.raises(ValueError, match="is not a valid Options"): + mesh_coord_indexing.SETTING.value = "not-an-option" + + +class TestSettingContext: + def test_temporary_override(self): + assert ( + mesh_coord_indexing.SETTING.value == mesh_coord_indexing.Options.AUX_COORD + ) + + with mesh_coord_indexing.SETTING.context(mesh_coord_indexing.Options.NEW_MESH): + assert ( + mesh_coord_indexing.SETTING.value + == mesh_coord_indexing.Options.NEW_MESH + ) + + assert ( + mesh_coord_indexing.SETTING.value == mesh_coord_indexing.Options.AUX_COORD + ) + + def test_restores_after_exception(self): + class LocalTestException(Exception): + pass + + with pytest.raises(LocalTestException): + with mesh_coord_indexing.SETTING.context( + mesh_coord_indexing.Options.MESH_INDEX_SET + ): + raise LocalTestException + + assert ( + mesh_coord_indexing.SETTING.value == mesh_coord_indexing.Options.AUX_COORD + ) + + def test_invalid_context_value(self): + with pytest.raises(ValueError, match="is not a valid Options"): + with mesh_coord_indexing.SETTING.context("not-an-option"): + pass + + +class TestThreadLocalSetting: + def test_independent_values_per_thread(self): + thread_state = {} + + mesh_coord_indexing.SETTING.value = mesh_coord_indexing.Options.NEW_MESH + + def worker(): + thread_state["before"] = mesh_coord_indexing.SETTING.value + mesh_coord_indexing.SETTING.value = ( + mesh_coord_indexing.Options.MESH_INDEX_SET + ) + thread_state["during"] = mesh_coord_indexing.SETTING.value + + thread = threading.Thread(target=worker) + thread.start() + thread.join() + + assert thread_state["before"] == mesh_coord_indexing.Options.AUX_COORD + assert thread_state["during"] == mesh_coord_indexing.Options.MESH_INDEX_SET + assert mesh_coord_indexing.SETTING.value == mesh_coord_indexing.Options.NEW_MESH diff --git a/lib/iris/tests/unit/fileformats/netcdf/saver/test_Saver__ugrid.py b/lib/iris/tests/unit/fileformats/netcdf/saver/test_Saver__ugrid.py index 85d3bd9f30..6658930370 100644 --- a/lib/iris/tests/unit/fileformats/netcdf/saver/test_Saver__ugrid.py +++ b/lib/iris/tests/unit/fileformats/netcdf/saver/test_Saver__ugrid.py @@ -19,6 +19,7 @@ from iris.cube import Cube, CubeList from iris.fileformats.netcdf import _thread_safe_nc from iris.mesh import Connectivity, MeshXY, save_mesh +from iris.mesh.components import _MeshIndexSet from iris.tests import _shared_utils from iris.tests.stock import realistic_4d @@ -1382,6 +1383,15 @@ def test_mesh_no_standard_name_coords_saves_as_unknown(self, check_save_mesh): for expected_coord_name in expected_coord_names: assert expected_coord_name in vars + def test_fail_mesh_index_set(self, check_save_mesh): + mesh = make_mesh(n_faces=3, n_edges=2) + index_set = _MeshIndexSet([0, 2], mesh=mesh, location="face") + + with pytest.raises( + ValueError, match="_MeshIndexSet saving is not yet supported" + ): + _ = check_save_mesh(index_set) + # WHEN MODIFYING THIS MODULE, CHECK IF ANY CORRESPONDING CHANGES ARE NEEDED IN # :mod:`iris.tests.unit.fileformats.netcdf.test_Saver__lazy.` diff --git a/lib/iris/tests/unit/mesh/components/test_MeshCoord.py b/lib/iris/tests/unit/mesh/components/test_MeshCoord.py index 4d1cad6c3f..cd337c9690 100644 --- a/lib/iris/tests/unit/mesh/components/test_MeshCoord.py +++ b/lib/iris/tests/unit/mesh/components/test_MeshCoord.py @@ -16,7 +16,9 @@ from iris.common.metadata import CoordMetadata from iris.coords import AuxCoord, Coord from iris.cube import Cube +from iris.experimental import mesh_coord_indexing from iris.mesh import Connectivity, MeshCoord, MeshXY +from iris.mesh.components import _MeshIndexSet, _MeshXYMixin from iris.tests._shared_utils import ( assert_array_all_close, assert_array_almost_equal, @@ -231,7 +233,6 @@ def test_fail_copy_newbounds(self): class Test__getitem__: def test_slice_wholeslice_1tuple(self): - # The only slicing case that we support, to enable cube slicing. meshcoord = sample_meshcoord() meshcoord2 = meshcoord[:,] assert meshcoord2 is not meshcoord @@ -240,7 +241,7 @@ def test_slice_wholeslice_1tuple(self): assert meshcoord2.mesh is meshcoord.mesh def test_slice_whole_slice_singlekey(self): - # A slice(None) also fails, if not presented in a 1-tuple. + # A slice(None) fails, if not presented in a 1-tuple. meshcoord = sample_meshcoord() with pytest.raises( ValueError, @@ -256,6 +257,80 @@ def test_fail_slice_part(self): ): meshcoord[:1] + def test_scalar_index_default_mode(self): + meshcoord = sample_meshcoord(location="face", axis="x") + result = meshcoord[(0,)] + + assert isinstance(result, AuxCoord) + assert result.shape == (1,) + assert result.points[0] == 3100 + + @pytest.mark.parametrize( + ("mode", "expected_mesh_type"), + [ + (mesh_coord_indexing.Options.NEW_MESH, MeshXY), + (mesh_coord_indexing.Options.MESH_INDEX_SET, _MeshIndexSet), + ], + ) + def test_scalar_index_mesh_modes(self, mode, expected_mesh_type): + meshcoord = sample_meshcoord(location="face", axis="x") + + with mesh_coord_indexing.SETTING.context(mode): + result = meshcoord[(0,)] + + assert isinstance(result, MeshCoord) + assert isinstance(result.mesh, expected_mesh_type) + assert result.shape == (1,) + assert result.points[0] == 3100 + + def test_new_mesh_mode(self): + meshcoord = sample_meshcoord(location="face", axis="x") + + with mesh_coord_indexing.SETTING.context(mesh_coord_indexing.Options.NEW_MESH): + result = meshcoord[([0, 2],)] + + assert isinstance(result, MeshCoord) + assert isinstance(result.mesh, MeshXY) + assert result.mesh is not meshcoord.mesh + assert result.shape == (2,) + + def test_mesh_index_set_mode(self): + meshcoord = sample_meshcoord(location="face", axis="x") + + with mesh_coord_indexing.SETTING.context( + mesh_coord_indexing.Options.MESH_INDEX_SET + ): + result = meshcoord[([0, 2],)] + + assert isinstance(result, MeshCoord) + assert isinstance(result.mesh, _MeshIndexSet) + assert result.mesh.mesh is meshcoord.mesh + assert result.mesh.location == meshcoord.location + assert result.shape == (2,) + + def test_mesh_index_set_mode_reindex_from_existing_index_set(self): + meshcoord = sample_meshcoord(location="face", axis="x") + + with mesh_coord_indexing.SETTING.context( + mesh_coord_indexing.Options.MESH_INDEX_SET + ): + first = meshcoord[([0, 1],)] + second = first[([0],)] + + assert isinstance(first.mesh, _MeshIndexSet) + assert isinstance(second.mesh, _MeshIndexSet) + assert second.mesh.mesh is meshcoord.mesh + assert second.shape == (1,) + + def test_bad_indexing_setting(self, monkeypatch): + meshcoord = sample_meshcoord(location="face", axis="x") + monkeypatch.setattr(mesh_coord_indexing.SETTING, "_value", "bad_value") + + with pytest.raises( + NotImplementedError, match="Unsupported mesh_coord_indexing" + ): + meshcoord[([0],)] + class Test__str_repr: @pytest.fixture(autouse=True) diff --git a/lib/iris/tests/unit/mesh/components/test_MeshIndexSet.py b/lib/iris/tests/unit/mesh/components/test_MeshIndexSet.py new file mode 100644 index 0000000000..bf93b9ee05 --- /dev/null +++ b/lib/iris/tests/unit/mesh/components/test_MeshIndexSet.py @@ -0,0 +1,753 @@ +# 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. +"""Unit tests for the :class:`iris.mesh.components._MeshIndexSet` class.""" + +import itertools +import re + +from dask import array as da +import numpy as np +import pytest + +from iris._lazy_data import is_lazy_data +from iris.coords import AuxCoord +from iris.mesh import MeshCoord, MeshXY +from iris.mesh.components import Connectivity, _MeshIndexSet +from iris.tests import _shared_utils +from iris.tests.stock.mesh import sample_mesh + + +@pytest.fixture(params=[False, True], ids=["real", "lazy"], autouse=True) +def lazy_values(request): + return request.param + + +@pytest.fixture +def mesh_2d(lazy_values): + return sample_mesh(lazy_values=lazy_values) + + +@pytest.fixture +def mesh_1d(lazy_values): + return sample_mesh(n_faces=0, lazy_values=lazy_values) + + +@pytest.fixture(params=["mesh_1d", "mesh_2d"]) +def meshes_all(request): + return request.getfixturevalue(request.param) + + +@pytest.fixture(params=["node", "edge", "face"]) +def locations_all(request): + return request.param + + +_MESH_LOCATION_COMBINED = [ + (mesh, loc) + for mesh, loc in itertools.product(["mesh_1d", "mesh_2d"], ["node", "edge", "face"]) + if not (mesh == "mesh_1d" and loc == "face") +] + + +@pytest.fixture(params=_MESH_LOCATION_COMBINED, ids=lambda x: f"{x[0]}-{x[1]}") +def meshes_locs_all(request): + mesh_name, loc = request.param + mesh = request.getfixturevalue(mesh_name) + return mesh, loc + + +def test_dummy(meshes_all): + assert isinstance(meshes_all, MeshXY) + + +class Test___init__: + def test_basic(self, meshes_locs_all): + mesh, location = meshes_locs_all + indices = [0, 2] + index_set = _MeshIndexSet(indices=indices, mesh=mesh, location=location) + + assert index_set.cf_role == "location_index_set" + assert index_set.mesh is mesh + assert index_set.location == location + assert index_set.start_index == 0 + assert index_set.topology_dimension == mesh.topology_dimension + _shared_utils.assert_array_equal(index_set.indices, indices) + + def test_numpy_array_indices(self, meshes_locs_all): + mesh, location = meshes_locs_all + indices = np.array([0, 2]) + index_set = _MeshIndexSet(indices=indices, mesh=mesh, location=location) + + _shared_utils.assert_array_equal(index_set.indices, indices) + + def test_fail_multidim_indices(self, meshes_locs_all): + mesh, location = meshes_locs_all + with pytest.raises(ValueError, match="`indices` must be 1D"): + _MeshIndexSet(indices=[[0, 1], [2, 3]], mesh=mesh, location=location) + + def test_fail_invalid_mesh(self): + with pytest.raises(TypeError, match="`mesh` must be `MeshXY`"): + _MeshIndexSet(indices=[0], mesh="not-a-mesh", location="face") + + def test_fail_invalid_location(self, mesh_2d): + with pytest.raises(ValueError, match="`location` must be in"): + _MeshIndexSet(indices=[0], mesh=mesh_2d, location="bad") + + def test_fail_location_mismatch(self, mesh_1d): + with pytest.raises(ValueError, match="`location` cannot be 'face'"): + _MeshIndexSet(indices=[0], mesh=mesh_1d, location="face") + + def test_fail_invalid_start_index(self, mesh_2d): + with pytest.raises(ValueError, match="`start_index must be 0 or 1"): + _MeshIndexSet(indices=[0], mesh=mesh_2d, location="face", start_index=3) + + def test___getstate____setstate__(self, meshes_locs_all): + mesh, location = meshes_locs_all + original = _MeshIndexSet(indices=[0, 1], mesh=mesh, location=location) + state = original.__getstate__() + + recreated = _MeshIndexSet.__new__(_MeshIndexSet) + # __setstate__ expects an initial values manager slot. + object.__setattr__(recreated, "_values_dm", None) + recreated.__setstate__(state) + + _shared_utils.assert_array_equal(state[0], original.indices) + assert state[1].mesh is original.mesh + assert state[1].location == original.location + assert state[1].start_index == original.start_index + _shared_utils.assert_array_equal(recreated.indices, original.indices) + assert recreated.mesh is original.mesh + assert recreated.location == original.location + assert recreated.start_index == original.start_index + + def test_scalar_index(self, meshes_locs_all): + mesh, location = meshes_locs_all + index_set = _MeshIndexSet(indices=2, mesh=mesh, location=location) + + _shared_utils.assert_array_equal(index_set.indices, np.array([2])) + coord = index_set.coord(location=location, axis="x") + assert coord.shape == (1,) + + +class Test___eq__: + def test_equal_same_object(self, meshes_locs_all): + """An instance is equal to itself.""" + mesh, location = meshes_locs_all + index_set = _MeshIndexSet(indices=[0, 2], mesh=mesh, location=location) + assert index_set == index_set + + def test_equal_identical(self, meshes_locs_all): + """Two independently constructed instances with identical args are equal.""" + mesh, location = meshes_locs_all + a = _MeshIndexSet(indices=[0, 2], mesh=mesh, location=location) + b = _MeshIndexSet(indices=[0, 2], mesh=mesh, location=location) + assert a == b + + def test_equal_with_metadata(self, meshes_locs_all): + """Equality holds when optional metadata fields also match.""" + mesh, location = meshes_locs_all + kwargs = dict( + indices=[1, 3], + mesh=mesh, + location=location, + long_name="test", + var_name="v", + attributes={"source": "test"}, + ) + assert _MeshIndexSet(**kwargs) == _MeshIndexSet(**kwargs) + + def test_not_equal_different_indices(self, meshes_locs_all): + """Instances with different indices are not equal.""" + mesh, location = meshes_locs_all + a = _MeshIndexSet(indices=[0, 2], mesh=mesh, location=location) + b = _MeshIndexSet(indices=[0, 3], mesh=mesh, location=location) + assert a != b + + def test_not_equal_different_metadata(self, meshes_locs_all): + """Instances with differing metadata are not equal even if indices match.""" + mesh, location = meshes_locs_all + a = _MeshIndexSet(indices=[0, 2], mesh=mesh, location=location, long_name="foo") + b = _MeshIndexSet(indices=[0, 2], mesh=mesh, location=location, long_name="bar") + assert a != b + + def test_not_equal_different_start_index(self, meshes_locs_all): + """Instances with different start_index values are not equal.""" + mesh, location = meshes_locs_all + a = _MeshIndexSet(indices=[0, 2], mesh=mesh, location=location, start_index=0) + b = _MeshIndexSet(indices=[0, 2], mesh=mesh, location=location, start_index=1) + assert a != b + + def test_not_equal_different_mesh(self, lazy_values): + """Instances referencing different meshes are not equal.""" + mesh_a = sample_mesh(lazy_values=lazy_values) + mesh_b = sample_mesh(lazy_values=lazy_values) + # Give the meshes distinct var_names so their metadata differs. + mesh_b.var_name = "different_mesh" + a = _MeshIndexSet(indices=[0, 2], mesh=mesh_a, location="node") + b = _MeshIndexSet(indices=[0, 2], mesh=mesh_b, location="node") + assert a != b + + def test_not_equal_different_location(self, mesh_2d): + """Instances with different locations are not equal.""" + a = _MeshIndexSet(indices=[0, 2], mesh=mesh_2d, location="node") + b = _MeshIndexSet(indices=[0, 2], mesh=mesh_2d, location="face") + assert a != b + + def test_not_equal_non_mesh_index_set(self, meshes_locs_all): + """Comparing with a non-_MeshIndexSet object returns NotImplemented.""" + mesh, location = meshes_locs_all + index_set = _MeshIndexSet(indices=[0, 2], mesh=mesh, location=location) + result = index_set.__eq__("not-a-mesh-index-set") + assert result is NotImplemented + + +class Test_properties: + def test_cf_role(self, meshes_locs_all): + mesh, location = meshes_locs_all + index_set = _MeshIndexSet([0], mesh=mesh, location=location) + assert index_set.cf_role == "location_index_set" + + def test_dimension_properties(self, meshes_locs_all): + mesh, location = meshes_locs_all + index_set = _MeshIndexSet([0], mesh=mesh, location=location) + assert index_set.node_dimension == "_MeshIndexSet_NotImplemented" + assert index_set.edge_dimension == "_MeshIndexSet_NotImplemented" + assert index_set.face_dimension == "_MeshIndexSet_NotImplemented" + + def test_metadata_properties(self, meshes_locs_all): + mesh, location = meshes_locs_all + indices = [0, 2] + index_set = _MeshIndexSet( + indices=indices, + mesh=mesh, + location=location, + long_name="my-index-set", + start_index=1, + ) + _shared_utils.assert_array_equal(index_set.indices, indices) + assert index_set.mesh is mesh + assert index_set.location == location + assert index_set.start_index == 1 + assert index_set.topology_dimension == mesh.topology_dimension + assert index_set.long_name == "my-index-set" + + +class Test_index_calculations: + def test_node_location_calculate_node_bool_index(self, meshes_all): + index_set = _MeshIndexSet(indices=[0, 2, 4], mesh=meshes_all, location="node") + result = index_set._calculate_node_bool_index() + + expected = np.zeros(meshes_all.node_coords.node_x.shape[0], dtype=bool) + expected[[0, 2, 4]] = True + _shared_utils.assert_array_equal(result, expected) + + def test_node_location_requires_monotonic_indices(self, meshes_locs_all): + mesh, location = meshes_locs_all + index_set = _MeshIndexSet(indices=[2, 1], mesh=mesh, location=location) + + if location == "node": + with pytest.raises( + ValueError, match="requires strictly increasing indices" + ): + index_set._calculate_node_bool_index() + else: + result = index_set._calculate_node_bool_index() + assert isinstance(result, (np.ndarray, da.Array)) + assert result.dtype == bool + + def test_edge_location_calculate_node_bool_index(self, meshes_all): + index_set = _MeshIndexSet(indices=[0, 2], mesh=meshes_all, location="edge") + result = index_set._calculate_node_bool_index() + + expected = np.zeros(meshes_all.node_coords.node_x.shape[0], dtype=bool) + expected[[5, 6, 9, 10]] = True + _shared_utils.assert_array_equal(result, expected) + + def test_face_location_calculate_node_bool_index(self, mesh_2d): + index_set = _MeshIndexSet(indices=[0, 2], mesh=mesh_2d, location="face") + result = index_set._calculate_node_bool_index() + + expected = np.zeros(mesh_2d.node_coords.node_x.shape[0], dtype=bool) + expected[[0, 1, 2, 3, 8, 9, 10, 11]] = True + _shared_utils.assert_array_equal(result, expected) + + def test_calculate_edge_indices(self, meshes_locs_all): + mesh, location = meshes_locs_all + index_set = _MeshIndexSet(indices=[1], mesh=mesh, location=location) + result = index_set._calculate_edge_indices() + + if location == "edge": + _shared_utils.assert_array_equal(result, np.array([1])) + else: + assert result is None + + def test_calculate_face_indices(self, mesh_2d, locations_all): + index_set = _MeshIndexSet(indices=[1], mesh=mesh_2d, location=locations_all) + result = index_set._calculate_face_indices() + + if locations_all == "face": + _shared_utils.assert_array_equal(result, np.array([1])) + else: + assert result is None + + +class Test_managers_and_views: + def test_coord_manager_subset(self, meshes_locs_all): + mesh, location = meshes_locs_all + index_set = _MeshIndexSet(indices=[0], mesh=mesh, location=location) + + coord_manager = index_set._coord_manager + match location: + case "node": + assert coord_manager.node_x.shape < mesh.node_coords.node_x.shape + assert coord_manager.node_y.shape < mesh.node_coords.node_y.shape + assert ( + coord_manager.node_x.core_points()[0] + in mesh.node_coords.node_x.points + ) + case "edge": + assert coord_manager.edge_x.shape < mesh.edge_coords.edge_x.shape + assert coord_manager.edge_y.shape < mesh.edge_coords.edge_y.shape + assert ( + coord_manager.edge_x.core_points()[0] + in mesh.edge_coords.edge_x.points + ) + case "face": + assert coord_manager.face_x.shape < mesh.face_coords.face_x.shape + assert coord_manager.face_y.shape < mesh.face_coords.face_y.shape + assert ( + coord_manager.face_x.core_points()[0] + in mesh.face_coords.face_x.points + ) + + def test_connectivity_manager_subset(self, meshes_locs_all): + mesh, location = meshes_locs_all + index_set = _MeshIndexSet(indices=[0, 2], mesh=mesh, location=location) + + connectivity_manager = index_set._connectivity_manager + assert connectivity_manager.is_view + match location: + case "node": + assert len(connectivity_manager.all_members) == 0 + case "edge": + assert connectivity_manager.edge_node is not None + assert not hasattr(connectivity_manager, "face_node") + assert ( + connectivity_manager.edge_node.shape[0] + < mesh.edge_node_connectivity.shape[0] + ) + case "face": + assert connectivity_manager.face_node is not None + assert connectivity_manager.edge_node is None + assert ( + connectivity_manager.face_node.shape[0] + < mesh.face_node_connectivity.shape[0] + ) + + def test_coord_manager_setter_forbidden(self, mesh_2d): + index_set = _MeshIndexSet(indices=[0], mesh=mesh_2d, location="face") + + with pytest.raises(NotImplementedError, match="Modification of _MeshIndexSet"): + index_set._coord_manager = mesh_2d._coord_manager + + def test_connectivity_manager_setter_forbidden(self, mesh_2d): + index_set = _MeshIndexSet(indices=[0], mesh=mesh_2d, location="face") + + with pytest.raises(NotImplementedError, match="Modification of _MeshIndexSet"): + index_set._connectivity_manager = mesh_2d._connectivity_manager + + def test_coord_manager_fail_non_lazy(self, mesh_2d): + index_set = _MeshIndexSet(indices=[0], mesh=mesh_2d, location="face") + + coord_manager = index_set._coord_manager + assert is_lazy_data(coord_manager.node_x.core_points()) + # Force realisation. + _ = coord_manager.node_x.points + # Attempting to access a second time triggers a laziness check, + # which fails. + with pytest.raises(ValueError, match="Non-lazy coordinate detected"): + _ = coord_manager.node_x.points + + def test_connectivity_manager_fail_non_lazy(self, mesh_2d): + index_set = _MeshIndexSet(indices=[0], mesh=mesh_2d, location="face") + + connectivity_manager = index_set._connectivity_manager + assert is_lazy_data(connectivity_manager.face_node.core_indices()) + # Force realisation. + _ = connectivity_manager.face_node.indices + # Attempting to access a second time triggers a laziness check, + # which fails. + with pytest.raises(ValueError, match="Non-lazy connectivity detected"): + _ = connectivity_manager.face_node.indices + + +class Test_unusual_connectivities: + @pytest.fixture + def mis_unusual_start_index(self, lazy_values): + mesh = sample_mesh(lazy_values=lazy_values) + face_node = mesh.face_node_connectivity + assert face_node.start_index == 0 + mesh.add_connectivities( + Connectivity( + indices=face_node.indices + 1, + cf_role=face_node.cf_role, + start_index=1, + ) + ) + index_set = _MeshIndexSet( + indices=[0, 2], mesh=mesh, location="face", start_index=1 + ) + return index_set + + def test_unusual_start_index(self, mis_unusual_start_index): + index_set = mis_unusual_start_index + _shared_utils.assert_array_equal(index_set.indices, np.array([0, 2])) + _shared_utils.assert_array_equal( + index_set.connectivity(cf_role="face_node_connectivity").indices, + np.array([[1, 2, 3, 4], [5, 6, 7, 8]]), + ) + + @pytest.fixture + def mis_unusual_transposition(self, lazy_values): + mesh = sample_mesh(lazy_values=lazy_values) + face_node = mesh.face_node_connectivity + mesh.add_connectivities(face_node.transpose()) + index_set = _MeshIndexSet(indices=[0, 2], mesh=mesh, location="face") + return index_set + + def test_unusual_transposition(self, mis_unusual_transposition): + index_set = mis_unusual_transposition + _shared_utils.assert_array_equal(index_set.indices, np.array([0, 2])) + _shared_utils.assert_array_equal( + index_set.connectivity(cf_role="face_node_connectivity").indices, + np.array([[0, 1, 2, 3], [4, 5, 6, 7]]).T, + ) + + @pytest.fixture + def varied_faces(self, lazy_values): + r"""A mesh with variable-sided faces, some sharing nodes. + + Two squares joined by a triangle, with a separate irregular pentagon: + 0 1 2 3 4 + 0* *-* * * + | | + 1* *-* * * + \| + 2* * *-* * + |\ | | + 3*-* *-* * + | | + 4*-* * * * + """ + node_x = AuxCoord( + [1.0, 2.0, 1.0, 2.0, 1.0, 2.0, 3.0, 0.0, 1.0, 2.0, 3.0, 0.0, 1.0], + standard_name="longitude", + ) + # Appease the linter. + ys = np.concat( + ( + np.array([0.0, 0.0, -1.0, -1.0, -2.0, -2.0, -2.0]), + np.array([-3.0, -3.0, -3.0, -3.0, -4.0, -4.0]), + ) + ) + node_y = AuxCoord(ys, standard_name="latitude") + if lazy_values: + node_x.points = node_x.lazy_points() + node_y.points = node_y.lazy_points() + + # Face sizes vary (4/3/4/5), so unused slots are masked. + face_node_indices = np.ma.masked_array( + data=[ + [0, 1, 3, 2, -1], + [3, 5, 4, -1, -1], + [2, 4, 7, 11, 12], + [5, 6, 10, 9, -1], + ], + mask=[ + [False, False, False, False, True], + [False, False, False, True, True], + [False, False, False, False, False], + [False, False, False, False, True], + ], + dtype=np.int64, + ) + if lazy_values: + face_node_indices = da.from_array(face_node_indices) + + face_coords = [ + # approximate face centroids + (AuxCoord([1.5, 1.67, 0.75, 2.0], standard_name="longitude"), "x"), + (AuxCoord([-0.5, -1.67, -2.5, -2.5], standard_name="latitude"), "y"), + ] + if lazy_values: + for coord, _ in face_coords: + coord.points = coord.lazy_points() + + mesh = MeshXY( + topology_dimension=2, + node_coords_and_axes=[(node_x, "x"), (node_y, "y")], + connectivities=[ + Connectivity( + indices=face_node_indices, + cf_role="face_node_connectivity", + ) + ], + face_coords_and_axes=face_coords, + ) + return mesh + + def test_varied_faces(self, varied_faces): + mesh = varied_faces + index_set = _MeshIndexSet(indices=[1, 3], mesh=mesh, location="face") + _shared_utils.assert_array_equal(index_set.indices, np.array([1, 3])) + _shared_utils.assert_array_equal( + index_set.connectivity(cf_role="face_node_connectivity").indices, + np.ma.masked_array( + data=[[0, 2, 1, -1, -1], [2, 3, 5, 4, -1]], + mask=[[0, 0, 0, 1, 1], [0, 0, 0, 0, 1]], + ), + ) + _shared_utils.assert_array_equal( + index_set.node_coords[0].points, np.array([2.0, 1.0, 2.0, 3.0, 2.0, 3.0]) + ) + _shared_utils.assert_array_equal( + index_set.node_coords[1].points, + np.array([-1.0, -2.0, -2.0, -2.0, -3.0, -3.0]), + ) + + +class Test_as_mesh: + def test_creation(self, meshes_locs_all): + mesh, location = meshes_locs_all + index_set = _MeshIndexSet(indices=[0, 2], mesh=mesh, location=location) + if location == "node": + with pytest.raises(NotImplementedError, match="with no edge or face"): + _ = index_set.as_mesh() + else: + new_mesh = index_set.as_mesh() + + assert isinstance(new_mesh, MeshXY) + assert new_mesh is not mesh + match location: + case "node": + assert ( + new_mesh.node_coords.node_x.shape + == index_set.node_coords.node_x.shape + ) + assert ( + new_mesh.node_coords.node_y.shape + == index_set.node_coords.node_x.shape + ) + case "edge": + assert ( + new_mesh.edge_coords.edge_x.shape + == index_set.edge_coords.edge_x.shape + ) + assert ( + new_mesh.edge_coords.edge_y.shape + == index_set.edge_coords.edge_y.shape + ) + case "face": + assert ( + new_mesh.face_coords.face_x.shape + == index_set.face_coords.face_x.shape + ) + assert ( + new_mesh.face_coords.face_y.shape + == index_set.face_coords.face_y.shape + ) + + def test_deep_copy_not_view(self, mesh_2d): + index_set = _MeshIndexSet(indices=[0, 2], mesh=mesh_2d, location="face") + new_mesh = index_set.as_mesh() + + _shared_utils.assert_array_equal( + new_mesh.face_coords.face_x.points, [3100, 3102] + ) + # Changing the original mesh should not affect the new mesh. + mesh_2d.face_coords.face_x.points = np.array([999, 998, 997]) + _shared_utils.assert_array_equal( + new_mesh.face_coords.face_x.points, [3100, 3102] + ) + + +class Test_meshcoord_interop: + @pytest.mark.parametrize("creator", [MeshCoord, _MeshIndexSet.to_MeshCoord]) + def test_meshcoord_from_index_set_location_must_match( + self, meshes_locs_all, mesh_2d, creator + ): + mesh, location = meshes_locs_all + index_set = _MeshIndexSet(indices=[0, 1], mesh=mesh, location=location) + + wrong_location = "face" if location != "face" else "edge" + with pytest.raises(ValueError, match="does not match the location"): + creator(index_set, location=wrong_location, axis="x") + + @pytest.mark.parametrize("creator", [MeshCoord, _MeshIndexSet.to_MeshCoord]) + def test_meshcoord_from_index_set(self, meshes_locs_all, mesh_2d, creator): + mesh, location = meshes_locs_all + index_set = _MeshIndexSet(indices=[0, 1], mesh=mesh, location=location) + meshcoord = creator(index_set, location=location, axis="x") + + assert meshcoord.mesh is index_set + assert meshcoord.shape == (2,) + + +class Test__str_repr: + @pytest.fixture(autouse=True) + def _setup(self, meshes_locs_all): + mesh, location = meshes_locs_all + mesh.rename("test_mesh") + self.mesh = mesh + self.location = location + self.index_set = _MeshIndexSet(indices=[0, 1], mesh=mesh, location=location) + + def test_repr_unnamed(self): + # When the index set has no name, repr mimics object.__str__ style. + result = repr(self.index_set) + assert re.match(r"<_MeshIndexSet object at 0x[0-9a-f]+>", result) + + def test_repr_named(self): + # When the index set has a name, repr uses the human-readable form. + self.index_set.long_name = "my_index_set" + result = repr(self.index_set) + assert result == "<_MeshIndexSet: 'my_index_set'>" + + def test_repr_var_name(self): + # var_name is used as the name when long_name is absent. + self.index_set.var_name = "idx" + result = repr(self.index_set) + assert result == "<_MeshIndexSet: 'idx'>" + + def test_str_contains_class_name(self): + result = str(self.index_set) + assert result.startswith("_MeshIndexSet : ") + + def test_str_contains_mesh_repr(self): + result = str(self.index_set) + assert "mesh: " in result + + def test_str_contains_location(self): + result = str(self.index_set) + assert f"location: {self.location}" in result + + def test_str_contains_start_index_default(self): + result = str(self.index_set) + assert "start_index: 0" in result + + def test_str_contains_start_index_nonzero(self): + index_set = _MeshIndexSet( + indices=[1, 2], + mesh=self.mesh, + location=self.location, + start_index=1, + ) + result = str(index_set) + assert "start_index: 1" in result + + def test_str_contains_mesh_info_summary(self): + result = str(self.index_set) + assert "mesh info summary:" in result + + def test_str_mesh_info_includes_topology_dimension(self): + result = str(self.index_set) + assert f"topology_dimension: {self.mesh.topology_dimension}" in result + + def test_str_nameless_mesh_uses_object_repr(self): + mesh = sample_mesh() # no name + index_set = _MeshIndexSet(indices=[0], mesh=mesh, location="node") + result = str(index_set) + assert re.search(r"mesh: ", result) + + def test_str_structure(self): + # Coarse structure check: the key header lines appear together at the top. + result = str(self.index_set) + expected_header = "\n".join( + [ + "_MeshIndexSet : 'unknown'", + " mesh: ", + f" location: {self.location}", + " start_index: 0", + " mesh info summary:", + ] + ) + assert result.startswith(expected_header) + + +class Test_deferred_views: + @pytest.mark.parametrize("update_mode", ["edit", "replace"]) + def test_coord_view_is_lazy_and_updates(self, mesh_2d, update_mode): + index_set = _MeshIndexSet(indices=[0, 2], mesh=mesh_2d, location="face") + + coord_before = index_set.coord(location="face", axis="x") + assert coord_before.has_lazy_points() + _shared_utils.assert_array_equal(coord_before.points, [3100, 3102]) + assert coord_before.bounds is None + + # Attempting edits on the index set has no effect. + ignored_points = [2000, 2002] + ignored_bounds = [[2000, 2001], [2002, 2003]] + coord_before.points = ignored_points + coord_before.bounds = ignored_bounds + coord_unchanged = index_set.coord(location="face", axis="x") + assert coord_unchanged.has_lazy_points() + _shared_utils.assert_array_equal(coord_unchanged.points, [3100, 3102]) + assert coord_unchanged.bounds is None + + new_points = [4400, 4401, 4402] + new_bounds = [[4400, 4401], [4401, 4402], [4402, 4403]] + if update_mode == "edit": + mesh_2d.face_coords.face_x.points = new_points + mesh_2d.face_coords.face_x.bounds = new_bounds + else: + replacement = mesh_2d.face_coords.face_x.copy( + points=new_points, bounds=new_bounds + ) + mesh_2d.add_coords(face_x=replacement) + + coord_after = index_set.coord(location="face", axis="x") + assert coord_after.has_lazy_points() + _shared_utils.assert_array_equal(coord_after.points, [4400, 4402]) + _shared_utils.assert_array_equal( + coord_after.bounds, [[4400, 4401], [4402, 4403]] + ) + + @pytest.mark.parametrize("update_mode", ["edit", "replace"]) + def test_connectivity_view_is_lazy_and_updates(self, mesh_2d, update_mode): + index_set = _MeshIndexSet(indices=[0, 2], mesh=mesh_2d, location="face") + + conn_before = index_set.connectivity(cf_role="face_node_connectivity") + assert conn_before.has_lazy_indices() + _shared_utils.assert_array_equal( + conn_before.indices, [[0, 1, 2, 3], [4, 5, 6, 7]] + ) + + # Attempting edits on the index set has no effect. + ignored_indices = [[100, 101, 102, 103], [104, 105, 106, 107]] + conn_before._values = ignored_indices + conn_unchanged = index_set.connectivity(cf_role="face_node_connectivity") + assert conn_unchanged.has_lazy_indices() + _shared_utils.assert_array_equal( + conn_unchanged.indices, [[0, 1, 2, 3], [4, 5, 6, 7]] + ) + + new_indices = np.array( + [ + [3, 2, 1, 0], + [4, 5, 6, 7], + [11, 10, 9, 8], + ] + ) + if update_mode == "edit": + mesh_2d.face_node_connectivity._values = new_indices + else: + replacement = mesh_2d.face_node_connectivity.copy(new_indices) + mesh_2d.add_connectivities(replacement) + + conn_after = index_set.connectivity(cf_role="face_node_connectivity") + assert conn_after.has_lazy_indices() + _shared_utils.assert_array_equal( + conn_after.indices, [[3, 2, 1, 0], [7, 6, 5, 4]] + ) diff --git a/lib/iris/tests/unit/mesh/components/test_MeshXY__from_coords.py b/lib/iris/tests/unit/mesh/components/test_MeshXY__from_coords.py index 5b086cafd2..f57b8489a4 100644 --- a/lib/iris/tests/unit/mesh/components/test_MeshXY__from_coords.py +++ b/lib/iris/tests/unit/mesh/components/test_MeshXY__from_coords.py @@ -8,9 +8,10 @@ import pytest from iris.coords import AuxCoord, DimCoord -from iris.mesh import Connectivity, MeshXY, logger +from iris.mesh import Connectivity, MeshCoord, MeshXY, logger from iris.tests import _shared_utils from iris.tests.stock import simple_2d_w_multidim_coords +from iris.tests.stock.mesh import sample_meshcoord class Test1Dim: @@ -241,3 +242,10 @@ def test_2d_coord(self): coord_1, coord_2 = cube.coords() with pytest.raises(ValueError, match="Expected coordinate ndim == 1"): _ = MeshXY.from_coords(coord_1, coord_2) + + +def test_type_validation(): + # Only AuxCoord or DimCoord supported. + mesh_coord = sample_meshcoord() + with pytest.raises(ValueError, match="Expected coords to be DimCoord or AuxCoord."): + _ = MeshXY.from_coords(mesh_coord, mesh_coord)