Skip to content

Commit e4246e1

Browse files
authored
Fixed cube arithmetic for cubes with meshes. (#4651)
* Use separate cml files for tests/unit/analysis/maths/test_slice multiple passes. * Roughly functional, but lots of tests to fix. * Fix * Fix mockist test for new-style Resolve. Mesh support not yet tested. * Fix _create_prepared_item when points/bounds passed in. * --no-edit * Simplify prepared-item usage. * Added tests * Added whatsnew. * Reference Iris issues for outstanding problems. * Small simplifications. * Updated statement on cube arithmetic with meshes. * Fix use of 'MathsAddOperationMixin'; add coord-mismatch tests on meshcubes. * Review changes. * Hack test which was failing.
1 parent 62047f7 commit e4246e1

25 files changed

Lines changed: 4979 additions & 82 deletions

File tree

docs/src/further_topics/ugrid/operations.rst

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -976,13 +976,26 @@ on dimensions other than the :meth:`~iris.cube.Cube.mesh_dim`, since such
976976

977977
Arithmetic
978978
----------
979-
.. |tagline: arithmetic| replace:: |pending|
979+
.. |tagline: arithmetic| replace:: |unchanged|
980980

981981
.. rubric:: |tagline: arithmetic|
982982

983-
:class:`~iris.cube.Cube` Arithmetic (described in :doc:`/userguide/cube_maths`)
984-
has not yet been adapted to handle :class:`~iris.cube.Cube`\s that include
985-
:class:`~iris.experimental.ugrid.MeshCoord`\s.
983+
Cube Arithmetic (described in :doc:`/userguide/cube_maths`)
984+
has been extended to handle :class:`~iris.cube.Cube`\s that include
985+
:class:`~iris.experimental.ugrid.MeshCoord`\s, and hence have a ``cube.mesh``.
986+
987+
Cubes with meshes can be combined in arithmetic operations like
988+
"ordinary" cubes. They can combine with other cubes without that mesh
989+
(and its dimension); or with a matching mesh, which may be on a different
990+
dimension.
991+
Arithmetic can also be performed between a cube with a mesh and a mesh
992+
coordinate with a matching mesh.
993+
994+
In all cases, the result will have the same mesh as the input cubes.
995+
996+
Meshes only match if they are fully equal -- i.e. they contain all the same
997+
coordinates and connectivities, with identical names, units, attributes and
998+
data content.
986999

9871000

9881001
.. todo:

docs/src/whatsnew/dev.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ This document explains the changes made to Iris for this release
3737
#. `@rcomer`_ implemented lazy aggregation for the
3838
:obj:`iris.analysis.PERCENTILE` aggregator. (:pull:`3901`)
3939

40+
#. `@pp-mo`_ fixed cube arithmetic operation for cubes with meshes.
41+
(:issue:`4454`, :pull:`4651`)
42+
43+
4044
🐛 Bugs Fixed
4145
=============
4246

lib/iris/common/resolve.py

Lines changed: 181 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313

1414
from collections import namedtuple
1515
from collections.abc import Iterable
16+
from dataclasses import dataclass
1617
import logging
18+
from typing import Any
1719

1820
from dask.array.core import broadcast_shapes
1921
import numpy as np
@@ -56,10 +58,42 @@
5658

5759
_PreparedFactory = namedtuple("PreparedFactory", ["container", "dependencies"])
5860

59-
_PreparedItem = namedtuple(
60-
"PreparedItem",
61-
["metadata", "points", "bounds", "dims", "container"],
62-
)
61+
62+
@dataclass
63+
class _PreparedItem:
64+
metadata: Any
65+
points: Any
66+
bounds: Any
67+
dims: Any
68+
container: Any
69+
mesh: Any = None
70+
location: Any = None
71+
axis: Any = None
72+
73+
def create_coord(self, metadata):
74+
from iris.experimental.ugrid.mesh import MeshCoord
75+
76+
if issubclass(self.container, MeshCoord):
77+
# Make a MeshCoord, for which we have mesh/location/axis.
78+
result = MeshCoord(
79+
mesh=self.mesh,
80+
location=self.location,
81+
axis=self.axis,
82+
)
83+
# Note: in this case we do also have "prepared metadata", but we
84+
# do *not* assign it as we do for an 'ordinary' Coord.
85+
# Instead, MeshCoord name/units/attributes are immutable, and set at
86+
# create time to those of the underlying mesh node coordinate.
87+
# cf https://github.com/SciTools/iris/issues/4670
88+
89+
else:
90+
# make a regular coord, for which we have points/bounds/metadata.
91+
result = self.container(self.points, bounds=self.bounds)
92+
# Also assign prepared metadata.
93+
result.metadata = metadata
94+
95+
return result
96+
6397

6498
_PreparedMetadata = namedtuple("PreparedMetadata", ["combined", "src", "tgt"])
6599

@@ -646,7 +680,13 @@ def _categorise_items(cube):
646680

647681
@staticmethod
648682
def _create_prepared_item(
649-
coord, dims, src_metadata=None, tgt_metadata=None
683+
coord,
684+
dims,
685+
src_metadata=None,
686+
tgt_metadata=None,
687+
points=None,
688+
bounds=None,
689+
container=None,
650690
):
651691
"""
652692
Convenience method that creates a :class:`~iris.common.resolve._PreparedItem`
@@ -658,35 +698,96 @@ def _create_prepared_item(
658698
* coord:
659699
The coordinate with the ``points`` and ``bounds`` to be extracted.
660700
661-
* dims:
662-
The dimensions that the ``coord`` spans on the resulting resolved :class:`~iris.cube.Cube`.
701+
* dims (int or tuple):
702+
The dimensions that the ``coord`` spans on the resulting resolved
703+
:class:`~iris.cube.Cube`.
704+
(Can also be a single dimension number).
663705
664706
* src_metadata:
665707
The coordinate metadata from the ``src`` :class:`~iris.cube.Cube`.
666708
667709
* tgt_metadata:
668710
The coordinate metadata from the ``tgt`` :class:`~iris.cube.Cube`.
669711
712+
* points:
713+
Override points array. When not given, use coord.points.
714+
715+
* bounds:
716+
Override bounds array. When not given, use coord.bounds.
717+
718+
* container:
719+
Override coord type (class constructor).
720+
When not given, use type(coord).
721+
670722
Returns:
671723
The :class:`~iris.common.resolve._PreparedItem`.
672724
725+
.. note::
726+
727+
If container or type(coord) is DimCoord/AuxCoord (i.e. not
728+
MeshCoord), then points+bounds define the built AuxCoord/DimCoord.
729+
Theses points+bounds come either from those args, or the 'coord'.
730+
Alternatively, when container or type(coord) is MeshCoord, then
731+
points==bounds==None and the preparted item contains
732+
mesh/location/axis properties for the resulting MeshCoord.
733+
These don't have override args: they *always* come from 'coord'.
734+
673735
"""
736+
if not isinstance(dims, Iterable):
737+
dims = (dims,)
738+
674739
if src_metadata is not None and tgt_metadata is not None:
675740
combined = src_metadata.combine(tgt_metadata)
676741
else:
677742
combined = src_metadata or tgt_metadata
678-
if not isinstance(dims, Iterable):
679-
dims = (dims,)
680743
prepared_metadata = _PreparedMetadata(
681744
combined=combined, src=src_metadata, tgt=tgt_metadata
682745
)
683-
bounds = coord.bounds
746+
747+
if container is None:
748+
container = type(coord)
749+
750+
from iris.experimental.ugrid.mesh import MeshCoord
751+
752+
if issubclass(container, MeshCoord):
753+
# Build a prepared-item to make a MeshCoord.
754+
# This case does *NOT* use points + bounds, so alternatives to the
755+
# coord content should not have been specified by the caller.
756+
assert points is None and bounds is None
757+
mesh = coord.mesh
758+
location = coord.location
759+
axis = coord.axis
760+
761+
else:
762+
# Build a prepared-item to make a DimCoord or AuxCoord.
763+
764+
# mesh/location/axis are not used.
765+
mesh = None
766+
location = None
767+
axis = None
768+
769+
# points + bounds default to those from the coordinate, but
770+
# alternative values may be specified.
771+
if points is None:
772+
points = coord.points
773+
bounds = coord.bounds
774+
# 'ELSE' points was passed: both points+bounds come from the args
775+
776+
# Always *copy* points+bounds, to avoid any possible direct (shared)
777+
# references to existing coord arrays.
778+
points = points.copy()
779+
if bounds is not None:
780+
bounds = bounds.copy()
781+
684782
result = _PreparedItem(
685783
metadata=prepared_metadata,
686-
points=coord.points.copy(),
687-
bounds=bounds if bounds is None else bounds.copy(),
688784
dims=dims,
689-
container=type(coord),
785+
points=points,
786+
bounds=bounds,
787+
mesh=mesh,
788+
location=location,
789+
axis=axis,
790+
container=container,
690791
)
691792
return result
692793

@@ -1422,30 +1523,64 @@ def _prepare_common_aux_payload(
14221523
(tgt_item,) = tgt_items
14231524
src_coord = src_item.coord
14241525
tgt_coord = tgt_item.coord
1425-
points, bounds = self._prepare_points_and_bounds(
1426-
src_coord,
1427-
tgt_coord,
1428-
src_item.dims,
1429-
tgt_item.dims,
1430-
ignore_mismatch=ignore_mismatch,
1431-
)
1432-
if points is not None:
1433-
src_type = type(src_coord)
1434-
tgt_type = type(tgt_coord)
1435-
# Downcast to aux if there are mixed container types.
1436-
container = src_type if src_type is tgt_type else AuxCoord
1437-
prepared_metadata = _PreparedMetadata(
1438-
combined=src_metadata.combine(tgt_item.metadata),
1439-
src=src_metadata,
1440-
tgt=tgt_item.metadata,
1441-
)
1442-
prepared_item = _PreparedItem(
1443-
metadata=prepared_metadata,
1444-
points=points.copy(),
1445-
bounds=bounds if bounds is None else bounds.copy(),
1446-
dims=tgt_item.dims,
1447-
container=container,
1526+
1527+
prepared_item = None
1528+
src_is_mesh, tgt_is_mesh = [
1529+
hasattr(coord, "mesh") for coord in (src_coord, tgt_coord)
1530+
]
1531+
if src_is_mesh and tgt_is_mesh:
1532+
# MeshCoords are a bit "special" ...
1533+
# In this case, we may need to produce an alternative form
1534+
# to the 'ordinary' _PreparedItem
1535+
# However, this only works if they have identical meshes..
1536+
if src_coord == tgt_coord:
1537+
prepared_item = self._create_prepared_item(
1538+
src_coord,
1539+
tgt_item.dims,
1540+
src_metadata=src_metadata,
1541+
tgt_metadata=tgt_item.metadata,
1542+
)
1543+
else:
1544+
emsg = (
1545+
f"Mesh coordinate {src_coord.name()!r} does not match between the "
1546+
f"LHS cube {self.lhs_cube.name()!r} and "
1547+
f"RHS cube {self.rhs_cube.name()!r}."
1548+
)
1549+
raise ValueError(emsg)
1550+
1551+
if prepared_item is None:
1552+
# Make a "normal" _PreparedItem, which is specified using
1553+
# points + bounds arrays.
1554+
# First, convert any un-matching MeshCoords to AuxCoord
1555+
if src_is_mesh:
1556+
src_coord = AuxCoord.from_coord(src_coord)
1557+
if tgt_is_mesh:
1558+
tgt_coord = AuxCoord.from_coord(tgt_coord)
1559+
points, bounds = self._prepare_points_and_bounds(
1560+
src_coord,
1561+
tgt_coord,
1562+
src_item.dims,
1563+
tgt_item.dims,
1564+
ignore_mismatch=ignore_mismatch,
14481565
)
1566+
if points is not None:
1567+
src_type = type(src_coord)
1568+
tgt_type = type(tgt_coord)
1569+
# Downcast to aux if there are mixed container types.
1570+
container = (
1571+
src_type if src_type is tgt_type else AuxCoord
1572+
)
1573+
prepared_item = self._create_prepared_item(
1574+
src_coord,
1575+
tgt_item.dims,
1576+
src_metadata=src_metadata,
1577+
tgt_metadata=tgt_item.metadata,
1578+
points=points,
1579+
bounds=bounds,
1580+
container=container,
1581+
)
1582+
1583+
if prepared_item is not None:
14491584
prepared_items.append(prepared_item)
14501585

14511586
def _prepare_common_dim_payload(
@@ -1499,16 +1634,13 @@ def _prepare_common_dim_payload(
14991634
)
15001635

15011636
if points is not None:
1502-
prepared_metadata = _PreparedMetadata(
1503-
combined=src_metadata.combine(tgt_metadata),
1504-
src=src_metadata,
1505-
tgt=tgt_metadata,
1506-
)
1507-
prepared_item = _PreparedItem(
1508-
metadata=prepared_metadata,
1509-
points=points.copy(),
1510-
bounds=bounds if bounds is None else bounds.copy(),
1511-
dims=(tgt_dim,),
1637+
prepared_item = self._create_prepared_item(
1638+
src_coord,
1639+
tgt_dim,
1640+
src_metadata=src_metadata,
1641+
tgt_metadata=tgt_metadata,
1642+
points=points,
1643+
bounds=bounds,
15121644
container=DimCoord,
15131645
)
15141646
self.prepared_category.items_dim.append(prepared_item)
@@ -2333,8 +2465,7 @@ def cube(self, data, in_place=False):
23332465

23342466
# Add the prepared dim coordinates.
23352467
for item in self.prepared_category.items_dim:
2336-
coord = item.container(item.points, bounds=item.bounds)
2337-
coord.metadata = item.metadata.combined
2468+
coord = item.create_coord(metadata=item.metadata.combined)
23382469
result.add_dim_coord(coord, item.dims)
23392470

23402471
# Add the prepared aux and scalar coordinates.
@@ -2343,8 +2474,8 @@ def cube(self, data, in_place=False):
23432474
+ self.prepared_category.items_scalar
23442475
)
23452476
for item in prepared_aux_coords:
2346-
coord = item.container(item.points, bounds=item.bounds)
2347-
coord.metadata = item.metadata.combined
2477+
# These items are "special"
2478+
coord = item.create_coord(metadata=item.metadata.combined)
23482479
try:
23492480
result.add_aux_coord(coord, item.dims)
23502481
except ValueError as err:

0 commit comments

Comments
 (0)