diff --git a/lib/iris/_concatenate.py b/lib/iris/_concatenate.py index d98e63da5a..7bb27eaf83 100644 --- a/lib/iris/_concatenate.py +++ b/lib/iris/_concatenate.py @@ -115,9 +115,9 @@ def __new__(mcs, coord, dims): kwargs["circular"] = coord.circular if isinstance(coord, iris.coords.DimCoord): # Mix the monotonic ordering into the metadata. - if coord.core_points()[0] == coord.core_points()[-1]: + if coord.points[0] == coord.points[-1]: order = _CONSTANT - elif coord.core_points()[-1] > coord.core_points()[0]: + elif coord.points[-1] > coord.points[0]: order = _INCREASING else: order = _DECREASING @@ -775,37 +775,21 @@ def _calculate_extents(self): self.dim_extents = [] for coord, order in zip(self.dim_coords, self.dim_order): if order == _CONSTANT or order == _INCREASING: - points = _Extent( - coord.core_points()[0], coord.core_points()[-1] - ) - if coord.core_bounds() is not None: + points = _Extent(coord.points[0], coord.points[-1]) + if coord.bounds is not None: bounds = ( - _Extent( - coord.core_bounds()[0, 0], - coord.core_bounds()[-1, 0], - ), - _Extent( - coord.core_bounds()[0, 1], - coord.core_bounds()[-1, 1], - ), + _Extent(coord.bounds[0, 0], coord.bounds[-1, 0]), + _Extent(coord.bounds[0, 1], coord.bounds[-1, 1]), ) else: bounds = None else: # The order must be decreasing ... - points = _Extent( - coord.core_points()[-1], coord.core_points()[0] - ) - if coord.core_bounds() is not None: + points = _Extent(coord.points[-1], coord.points[0]) + if coord.bounds is not None: bounds = ( - _Extent( - coord.core_bounds()[-1, 0], - coord.core_bounds()[0, 0], - ), - _Extent( - coord.core_bounds()[-1, 1], - coord.core_bounds()[0, 1], - ), + _Extent(coord.bounds[-1, 0], coord.bounds[0, 0]), + _Extent(coord.bounds[-1, 1], coord.bounds[0, 1]), ) else: bounds = None diff --git a/lib/iris/tests/unit/concatenate/__init__.py b/lib/iris/tests/unit/concatenate/__init__.py index cf671a6553..229476f3a6 100644 --- a/lib/iris/tests/unit/concatenate/__init__.py +++ b/lib/iris/tests/unit/concatenate/__init__.py @@ -3,4 +3,138 @@ # This file is part of Iris and is released under the LGPL license. # See COPYING and COPYING.LESSER in the root of the repository for full # licensing details. -"""Unit tests for the :mod:`iris._concatenate` package.""" +"""Unit-test infrastructure for the :mod:`iris._concatenate` package.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +import dask.array as da +import numpy as np + +from iris._concatenate import _CONSTANT, _DECREASING, _INCREASING +import iris.common +from iris.coords import AuxCoord, DimCoord + +__all__ = ["ExpectedItem", "N_POINTS", "SCALE_FACTOR", "create_metadata"] + +# number of coordinate points +N_POINTS: int = 10 + +# coordinate points multiplication scale factor +SCALE_FACTOR: int = 10 + + +METADATA = { + "standard_name": "air_temperature", + "long_name": "air temperature", + "var_name": "atemp", + "units": "kelvin", + "attributes": {}, + "coord_system": None, + "climatological": False, + "circular": False, +} + + +@dataclass +class ExpectedItem: + """Expected test result components of :class:`iris._concatenate._CoordMetaData`.""" + + defn: iris.common.DimCoordMetadata | iris.common.CoordMetadata + dims: tuple[int, ...] + points_dtype: np.dtype + bounds_dtype: np.dtype | None = None + kwargs: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class MetaDataItem: + """Test input and expected output from :class:`iris._concatenate._CoordMetaData`.""" + + coord: AuxCoord | DimCoord + dims: tuple[int, ...] + expected: ExpectedItem + + +def create_metadata( + dim_coord: bool = True, + scalar: bool = False, + order: int = None, + circular: bool | None = False, + coord_dtype: np.dtype = None, + lazy: bool = True, + with_bounds: bool | None = False, +) -> MetaDataItem: + """Construct payload for :class:`iris._concatenate.CoordMetaData` testing.""" + if coord_dtype is None: + coord_dtype = np.float32 + + if order is None: + order = _INCREASING + + array_lib = da if lazy else np + bounds = None + + if scalar: + points = array_lib.ones(1, dtype=coord_dtype) + order = _CONSTANT + + if with_bounds: + bounds = array_lib.array([0, 2], dtype=coord_dtype).reshape(1, 2) + else: + if order == _CONSTANT: + points = array_lib.ones(N_POINTS, dtype=coord_dtype) + else: + if order == _DECREASING: + start, stop, step = N_POINTS - 1, -1, -1 + else: + start, stop, step = 0, N_POINTS, 1 + points = ( + array_lib.arange(start, stop, step, dtype=coord_dtype) + * SCALE_FACTOR + ) + + if with_bounds: + offset = SCALE_FACTOR // 2 + bounds = array_lib.vstack( + [points.copy() - offset, points.copy() + offset] + ).T + + bounds_dtype = coord_dtype if with_bounds else None + + values = METADATA.copy() + values["circular"] = circular + CoordClass = DimCoord if dim_coord else AuxCoord + coord = CoordClass(points, bounds=bounds) + if dim_coord and lazy: + # creating a DimCoord *always* results in realized points/bounds. + assert not coord.has_lazy_points() + if with_bounds: + assert not coord.has_lazy_bounds() + metadata = iris.common.DimCoordMetadata(**values) + + if dim_coord: + coord.metadata = metadata + else: + # convert the DimCoordMetadata to a CoordMetadata instance + # and assign to the AuxCoord + coord.metadata = iris.common.CoordMetadata.from_metadata(metadata) + + dims = tuple([dim for dim in range(coord.ndim)]) + kwargs = {"scalar": scalar} + + if dim_coord: + kwargs["circular"] = circular + kwargs["order"] = order + + expected = ExpectedItem( + defn=metadata, + dims=dims, + points_dtype=coord_dtype, + bounds_dtype=bounds_dtype, + kwargs=kwargs, + ) + + return MetaDataItem(coord=coord, dims=dims, expected=expected) diff --git a/lib/iris/tests/unit/concatenate/test__CoordMetaData.py b/lib/iris/tests/unit/concatenate/test__CoordMetaData.py new file mode 100644 index 0000000000..6f29e1f65f --- /dev/null +++ b/lib/iris/tests/unit/concatenate/test__CoordMetaData.py @@ -0,0 +1,117 @@ +# Copyright Iris contributors +# +# This file is part of Iris and is released under the LGPL license. +# See COPYING and COPYING.LESSER in the root of the repository for full +# licensing details. +"""Unit-tests for :class:`iris._concatenate._CoordMetaData`.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from iris._concatenate import ( + _CONSTANT, + _DECREASING, + _INCREASING, + _CoordMetaData, +) + +from . import ExpectedItem, create_metadata + + +def check(actual: _CoordMetaData, expected: ExpectedItem) -> None: + """Assert actual and expected results.""" + assert actual.defn == expected.defn + assert actual.dims == expected.dims + assert actual.points_dtype == expected.points_dtype + assert actual.bounds_dtype == expected.bounds_dtype + assert actual.kwargs == expected.kwargs + + +@pytest.mark.parametrize("order", [_DECREASING, _INCREASING]) +@pytest.mark.parametrize("circular", [False, True]) +@pytest.mark.parametrize("coord_dtype", [np.int32, np.float32]) +@pytest.mark.parametrize("lazy", [False, True]) +@pytest.mark.parametrize("with_bounds", [False, True]) +def test_dim( + order: int, + circular: bool, + coord_dtype: np.dtype, + lazy: bool, + with_bounds: bool, +) -> None: + """Test :class:`iris._concatenate._CoordMetaData` with dim coord.""" + metadata = create_metadata( + dim_coord=True, + scalar=False, + order=order, + circular=circular, + coord_dtype=coord_dtype, + lazy=lazy, + with_bounds=with_bounds, + ) + actual = _CoordMetaData(coord=metadata.coord, dims=metadata.dims) + check(actual, metadata.expected) + + +@pytest.mark.parametrize("circular", [False, True]) +@pytest.mark.parametrize("coord_dtype", [np.int32, np.float32]) +@pytest.mark.parametrize("lazy", [False, True]) +@pytest.mark.parametrize("with_bounds", [False, True]) +def test_dim__scalar( + circular: bool, coord_dtype: np.dtype, lazy: bool, with_bounds: bool +) -> None: + """Test :class:`iris._concatenate._CoordMetaData` with scalar dim coord.""" + metadata = create_metadata( + dim_coord=True, + scalar=True, + order=_CONSTANT, + circular=circular, + coord_dtype=coord_dtype, + lazy=lazy, + with_bounds=with_bounds, + ) + actual = _CoordMetaData(coord=metadata.coord, dims=metadata.dims) + check(actual, metadata.expected) + + +@pytest.mark.parametrize("order", [_DECREASING, _INCREASING]) +@pytest.mark.parametrize("coord_dtype", [np.int32, np.float32]) +@pytest.mark.parametrize("lazy", [False, True]) +@pytest.mark.parametrize("with_bounds", [False, True]) +def test_aux( + order: int, coord_dtype: np.dtype, lazy: bool, with_bounds: bool +) -> None: + """Test :class:`iris._concatenate._CoordMetaData` with aux coord.""" + metadata = create_metadata( + dim_coord=False, + scalar=False, + order=order, + circular=None, + coord_dtype=coord_dtype, + lazy=lazy, + with_bounds=with_bounds, + ) + actual = _CoordMetaData(coord=metadata.coord, dims=metadata.dims) + check(actual, metadata.expected) + + +@pytest.mark.parametrize("coord_dtype", [np.int32, np.float32]) +@pytest.mark.parametrize("lazy", [False, True]) +@pytest.mark.parametrize("with_bounds", [False, True]) +def test_aux__scalar( + coord_dtype: np.dtype, lazy: bool, with_bounds: bool +) -> None: + """Test :class:`iris._concatenate._CoordMetaData` with scalar aux coord.""" + metadata = create_metadata( + dim_coord=False, + scalar=True, + order=_CONSTANT, + circular=None, + coord_dtype=coord_dtype, + lazy=lazy, + with_bounds=with_bounds, + ) + actual = _CoordMetaData(coord=metadata.coord, dims=metadata.dims) + check(actual, metadata.expected) diff --git a/lib/iris/tests/unit/concatenate/test__CoordSignature.py b/lib/iris/tests/unit/concatenate/test__CoordSignature.py new file mode 100644 index 0000000000..eb62c5ec64 --- /dev/null +++ b/lib/iris/tests/unit/concatenate/test__CoordSignature.py @@ -0,0 +1,121 @@ +# Copyright Iris contributors +# +# This file is part of Iris and is released under the LGPL license. +# See COPYING and COPYING.LESSER in the root of the repository for full +# licensing details. +"""Unit-tests for :class:`iris._concatenate._CoordSignature`.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +import numpy as np +import pytest + +from iris._concatenate import ( + _CONSTANT, + _DECREASING, + _INCREASING, + _CoordExtent, + _CoordMetaData, + _CoordSignature, + _Extent, +) +from iris.coords import DimCoord + +from . import N_POINTS, SCALE_FACTOR, create_metadata + + +@dataclass +class MockCubeSignature: + """Simple mock of :class:`iris._concatenate._CubeSignature`.""" + + aux_coords_and_dims: bool | None = None + cell_measures_and_dims: bool | None = None + ancillary_variables_and_dims: bool | None = None + derived_coords_and_dims: bool | None = None + dim_coords: list[DimCoord, ...] = field(default_factory=list) + dim_mapping: bool | None = None + dim_extents: list[_Extent, ...] = field(default_factory=list) + dim_order: list[int, ...] = field(default_factory=list) + dim_metadata: list[_CoordMetaData, ...] = field(default_factory=list) + + +@pytest.mark.parametrize("order", [_DECREASING, _INCREASING]) +@pytest.mark.parametrize("coord_dtype", [np.int32, np.float32]) +@pytest.mark.parametrize("lazy", [False, True]) +@pytest.mark.parametrize("with_bounds", [False, True]) +def test_dim( + order: int, coord_dtype: np.dtype, lazy: bool, with_bounds: bool +) -> None: + """Test extent calculation of vector dimension coordinates.""" + metadata = create_metadata( + dim_coord=True, + scalar=False, + order=order, + coord_dtype=coord_dtype, + lazy=lazy, + with_bounds=with_bounds, + ) + dim_metadata = [_CoordMetaData(metadata.coord, metadata.dims)] + cube_signature = MockCubeSignature( + dim_coords=[metadata.coord], dim_metadata=dim_metadata + ) + coord_signature = _CoordSignature(cube_signature) + assert len(coord_signature.dim_extents) == 1 + (actual,) = coord_signature.dim_extents + first, last = coord_dtype(0), coord_dtype((N_POINTS - 1) * SCALE_FACTOR) + if order == _CONSTANT: + emsg = f"Expected 'order' of '{_DECREASING}' or '{_INCREASING}', got '{order}'." + raise ValueError(emsg) + points_extent = _Extent(min=first, max=last) + bounds_extent = None + if with_bounds: + offset = SCALE_FACTOR // 2 + if order == _INCREASING: + bounds_extent = ( + _Extent(min=first - offset, max=last - offset), + _Extent(min=first + offset, max=last + offset), + ) + else: + bounds_extent = ( + _Extent(min=first + offset, max=last + offset), + _Extent(min=first - offset, max=last - offset), + ) + expected = _CoordExtent(points=points_extent, bounds=bounds_extent) + assert actual == expected + + +@pytest.mark.parametrize("coord_dtype", [np.int32, np.float32]) +@pytest.mark.parametrize("lazy", [False, True]) +@pytest.mark.parametrize("with_bounds", [False, True]) +def test_dim__scalar( + coord_dtype: np.dtype, lazy: bool, with_bounds: bool +) -> None: + """Test extent calculation of scalar dimension coordinates.""" + metadata = create_metadata( + dim_coord=True, + scalar=True, + order=_CONSTANT, + coord_dtype=coord_dtype, + lazy=lazy, + with_bounds=with_bounds, + ) + dim_metadata = [_CoordMetaData(metadata.coord, metadata.dims)] + cube_signature = MockCubeSignature( + dim_coords=[metadata.coord], dim_metadata=dim_metadata + ) + coord_signature = _CoordSignature(cube_signature) + assert len(coord_signature.dim_extents) == 1 + (actual,) = coord_signature.dim_extents + point = coord_dtype(1) + points_extent = _Extent(min=point, max=point) + bounds_extent = None + if with_bounds: + first, last = coord_dtype(0), coord_dtype(2) + bounds_extent = ( + _Extent(min=first, max=first), + _Extent(min=last, max=last), + ) + expected = _CoordExtent(points=points_extent, bounds=bounds_extent) + assert actual == expected