diff --git a/docs/iris/src/whatsnew/3.0.2.rst b/docs/iris/src/whatsnew/3.0.2.rst index 44bce83643..92f1a76c6b 100644 --- a/docs/iris/src/whatsnew/3.0.2.rst +++ b/docs/iris/src/whatsnew/3.0.2.rst @@ -25,6 +25,12 @@ This document explains the changes made to Iris for this release where one cell's bounds align with the requested maximum and minimum, as reported in :issue:`3391`. (:pull:`4059`) + #. `@bjlittle`_ resolved a regression in arithmetic behaviour between a coordinate + and a cube which resulted in a ``NotYetImplementedError`` being raised, as reported + in :issue:`4000`. This fix supports ``+``, ``-``, ``*``, and ``/`` operations + between a coordinate and a cube, and for convenience additionally includes + :meth:`iris.cube.Cube.__neg__` support. (:pull:`4159`) + 📚 **Documentation** #. `@bjlittle`_ updated the ``intersphinx_mapping`` and fixed documentation @@ -46,7 +52,7 @@ This document explains the changes made to Iris for this release the dask 'test access'. This makes loading of netcdf files with a large number of variables significantly faster. (:pull:`4135`) - Note that, the contributions labelled ``pre-v3.1.0`` are part of the forthcoming + Note that, the above contributions labelled with ``pre-v3.1.0`` are part of the forthcoming Iris v3.1.0 release, but require to be included in this patch release. diff --git a/lib/iris/coords.py b/lib/iris/coords.py index 2f46b529f3..60b82c937e 100644 --- a/lib/iris/coords.py +++ b/lib/iris/coords.py @@ -405,28 +405,15 @@ def __binary_operator__(self, other, mode_constant): # Note: this method includes bounds handling code, but it only runs # within Coord type instances, as only these allow bounds to be set. - if isinstance(other, _DimensionalMetadata) or not isinstance( - other, (int, float, np.number) - ): - - def typename(obj): - if isinstance(obj, Coord): - result = "Coord" - else: - # We don't really expect this, but do something anyway. - result = self.__class__.__name__ - return result - - emsg = "{selftype} {operator} {othertype}".format( - selftype=typename(self), - operator=self._MODE_SYMBOL[mode_constant], - othertype=typename(other), + if isinstance(other, _DimensionalMetadata): + emsg = ( + f"{self.__class__.__name__} " + f"{self._MODE_SYMBOL[mode_constant]} " + f"{other.__class__.__name__}" ) raise iris.exceptions.NotYetImplementedError(emsg) - else: - # 'Other' is an array type : adjust points, and bounds if any. - result = NotImplemented + if isinstance(other, (int, float, np.number)): def op(values): if mode_constant == self._MODE_ADD: @@ -443,8 +430,14 @@ def op(values): new_values = op(self._values_dm.core_data()) result = self.copy(new_values) + if self.has_bounds(): result.bounds = op(self._bounds_dm.core_data()) + else: + # must return NotImplemented to ensure invocation of any + # associated reflected operator on the "other" operand + # see https://docs.python.org/3/reference/datamodel.html#emulating-numeric-types + result = NotImplemented return result @@ -463,8 +456,7 @@ def __div__(self, other): def __truediv__(self, other): return self.__binary_operator__(other, self._MODE_DIV) - def __radd__(self, other): - return self + other + __radd__ = __add__ def __rsub__(self, other): return (-self) + other @@ -475,8 +467,7 @@ def __rdiv__(self, other): def __rtruediv__(self, other): return self.__binary_operator__(other, self._MODE_RDIV) - def __rmul__(self, other): - return self * other + __rmul__ = __mul__ def __neg__(self): values = -self._core_values() diff --git a/lib/iris/cube.py b/lib/iris/cube.py index 129542fb21..af595fe1fa 100644 --- a/lib/iris/cube.py +++ b/lib/iris/cube.py @@ -3754,37 +3754,49 @@ def __ne__(self, other): def __hash__(self): return hash(id(self)) - def __add__(self, other): - return iris.analysis.maths.add(self, other) + __add__ = iris.analysis.maths.add def __iadd__(self, other): return iris.analysis.maths.add(self, other, in_place=True) __radd__ = __add__ - def __sub__(self, other): - return iris.analysis.maths.subtract(self, other) + __sub__ = iris.analysis.maths.subtract def __isub__(self, other): return iris.analysis.maths.subtract(self, other, in_place=True) + def __rsub__(self, other): + return (-self) + other + __mul__ = iris.analysis.maths.multiply - __rmul__ = iris.analysis.maths.multiply def __imul__(self, other): return iris.analysis.maths.multiply(self, other, in_place=True) + __rmul__ = __mul__ + __div__ = iris.analysis.maths.divide def __idiv__(self, other): return iris.analysis.maths.divide(self, other, in_place=True) - __truediv__ = iris.analysis.maths.divide + def __rdiv__(self, other): + data = 1 / self.core_data() + reciprocal = self.copy(data=data) + return iris.analysis.maths.multiply(reciprocal, other) - def __itruediv__(self, other): - return iris.analysis.maths.divide(self, other, in_place=True) + __truediv__ = __div__ + + __itruediv__ = __idiv__ + + __rtruediv__ = __rdiv__ __pow__ = iris.analysis.maths.exponentiate + + def __neg__(self): + return self.copy(data=-self.core_data()) + # END OPERATOR OVERLOADS def collapsed(self, coords, aggregator, **kwargs): diff --git a/lib/iris/fileformats/netcdf.py b/lib/iris/fileformats/netcdf.py index 2032f2349f..e33ef59433 100644 --- a/lib/iris/fileformats/netcdf.py +++ b/lib/iris/fileformats/netcdf.py @@ -2655,7 +2655,7 @@ def save( local_keys.update(different_value_keys) def is_valid_packspec(p): - """ Only checks that the datatype is valid. """ + """Only checks that the datatype is valid.""" if isinstance(p, dict): if "dtype" in p: return is_valid_packspec(p["dtype"]) diff --git a/lib/iris/tests/integration/test_netcdf.py b/lib/iris/tests/integration/test_netcdf.py index 267e5beb50..3bf1e6032e 100644 --- a/lib/iris/tests/integration/test_netcdf.py +++ b/lib/iris/tests/integration/test_netcdf.py @@ -516,7 +516,7 @@ def test_single_packed_signed(self): self._single_test("i2", "single_packed_signed.cdl") def test_single_packed_unsigned(self): - """Test saving a single CF-netCDF file with packing into unsigned. """ + """Test saving a single CF-netCDF file with packing into unsigned.""" self._single_test("u1", "single_packed_unsigned.cdl") def test_single_packed_manual_scale(self): diff --git a/lib/iris/tests/test_basic_maths.py b/lib/iris/tests/test_basic_maths.py index 2d045c684f..c14b943794 100644 --- a/lib/iris/tests/test_basic_maths.py +++ b/lib/iris/tests/test_basic_maths.py @@ -16,8 +16,9 @@ import iris import iris.analysis.maths -import iris.coords -import iris.exceptions +from iris.coords import AuxCoord, DimCoord +from iris.cube import Cube +from iris.exceptions import NotYetImplementedError import iris.tests.stock @@ -112,12 +113,12 @@ def test_minus_coord(self): xdim = a.ndim - 1 ydim = a.ndim - 2 - c_x = iris.coords.DimCoord( + c_x = DimCoord( points=np.arange(a.shape[xdim]), long_name="x_coord", units=self.cube.units, ) - c_y = iris.coords.AuxCoord( + c_y = AuxCoord( points=np.arange(a.shape[ydim]), long_name="y_coord", units=self.cube.units, @@ -150,12 +151,12 @@ def test_addition_coord(self): xdim = a.ndim - 1 ydim = a.ndim - 2 - c_x = iris.coords.DimCoord( + c_x = DimCoord( points=np.arange(a.shape[xdim]), long_name="x_coord", units=self.cube.units, ) - c_y = iris.coords.AuxCoord( + c_y = AuxCoord( points=np.arange(a.shape[ydim]), long_name="y_coord", units=self.cube.units, @@ -195,12 +196,12 @@ def test_addition_fail(self): xdim = a.ndim - 1 ydim = a.ndim - 2 - c_axis_length_fail = iris.coords.DimCoord( + c_axis_length_fail = DimCoord( points=np.arange(a.shape[ydim]), long_name="x_coord", units=self.cube.units, ) - c_unit_fail = iris.coords.AuxCoord( + c_unit_fail = AuxCoord( points=np.arange(a.shape[xdim]), long_name="x_coord", units="volts" ) @@ -208,7 +209,7 @@ def test_addition_fail(self): ValueError, iris.analysis.maths.add, a, c_axis_length_fail ) self.assertRaises( - iris.exceptions.NotYetImplementedError, + NotYetImplementedError, iris.analysis.maths.add, a, c_unit_fail, @@ -464,7 +465,7 @@ def test_divide_by_coordinate_dim2(self): def test_divide_by_singular_coordinate(self): a = self.cube - coord = iris.coords.DimCoord(points=2, long_name="foo", units="1") + coord = DimCoord(points=2, long_name="foo", units="1") c = iris.analysis.maths.divide(a, coord) self.assertCML(c, ("analysis", "division_by_singular_coord.cml")) @@ -474,7 +475,7 @@ def test_divide_by_singular_coordinate(self): def test_divide_by_different_len_coord(self): a = self.cube - coord = iris.coords.DimCoord( + coord = DimCoord( points=np.arange(10) * 2 + 5, standard_name="longitude", units="degrees", @@ -686,12 +687,12 @@ def setUp(self): self.data_1u = np.array([[9, 9, 9], [8, 8, 8]], dtype=np.uint64) self.data_2u = np.array([[3, 3, 3], [2, 2, 2]], dtype=np.uint64) - self.cube_1f = iris.cube.Cube(self.data_1f) - self.cube_2f = iris.cube.Cube(self.data_2f) - self.cube_1i = iris.cube.Cube(self.data_1i) - self.cube_2i = iris.cube.Cube(self.data_2i) - self.cube_1u = iris.cube.Cube(self.data_1u) - self.cube_2u = iris.cube.Cube(self.data_2u) + self.cube_1f = Cube(self.data_1f) + self.cube_2f = Cube(self.data_2f) + self.cube_1i = Cube(self.data_1i) + self.cube_2i = Cube(self.data_2i) + self.cube_1u = Cube(self.data_1u) + self.cube_2u = Cube(self.data_2u) self.ops = (operator.add, operator.sub, operator.mul, operator.truediv) self.iops = ( @@ -701,6 +702,22 @@ def setUp(self): operator.itruediv, ) + def common_neg(self, cube, data): + result1 = -cube + result2 = -data + self.assertIsInstance(result1, Cube) + self.assertIsNot(result1, cube) + self.assertArrayAlmostEqual(result1.data, result2) + + def test_neg_f(self): + self.common_neg(self.cube_1f, self.data_1f) + + def test_neg_i(self): + self.common_neg(self.cube_1i, self.data_1i) + + def test_neg_u(self): + self.common_neg(self.cube_1u, self.data_1u) + def test_operator(self): for test_op in self.ops: result1 = test_op(self.cube_1f, self.cube_2f) @@ -849,7 +866,7 @@ def setUp(self): mask=[[0, 1, 0], [0, 0, 1]], dtype=np.float64, ) - self.cube = iris.cube.Cube(self.data) + self.cube = Cube(self.data) def test_incompatible_dimensions(self): data3 = ma.MaskedArray( @@ -863,9 +880,61 @@ def test_increase_cube_dimensionality(self): with self.assertRaises(ValueError): # This would increase the dimensionality of the cube # due to auto-broadcasting. - cube_x = iris.cube.Cube(ma.MaskedArray([[9]], mask=[[0]])) + cube_x = Cube(ma.MaskedArray([[9]], mask=[[0]])) cube_x + ma.MaskedArray([[3, 3, 3, 3]], mask=[[0, 1, 0, 1]]) +class TestCoordMathOperations(tests.IrisTest): + def setUp(self): + self.value_cube = 100 + self.value = 10 + self.cube = Cube([self.value_cube]) + self.dim = DimCoord([self.value]) + self.aux = AuxCoord([self.value]) + self.i = int(self.value) + self.f = float(self.value) + self.np_i = np.int32(self.value) + self.np_f = np.float32(self.value) + self.numbers = (self.i, self.f, self.np_i, self.np_f) + self.ops = (operator.add, operator.sub, operator.mul, operator.truediv) + self.symbols = ("+", "-", "*", "/") + + def test_coord_op_coord__fail(self): + for op, symbol in zip(self.ops, self.symbols): + emsg = f"AuxCoord \{symbol} DimCoord" # noqa: W605 + with self.assertRaisesRegex(NotYetImplementedError, emsg): + _ = op(self.aux, self.dim) + + def test_coord_op_number(self): + for op in self.ops: + for number in self.numbers: + actual = op(self.aux, number) + expected = op(self.value, number) + self.assertIsInstance(actual, AuxCoord) + self.assertEqual(expected, actual.points) + + def test_number_op_coord(self): + for op in self.ops: + for number in self.numbers: + actual = op(number, self.dim) + expected = op(number, self.value) + self.assertIsInstance(actual, DimCoord) + self.assertEqual(expected, actual.points) + + def test_coord_op_cube(self): + for op in self.ops: + actual = op(self.aux, self.cube) + expected = op(self.value, self.value_cube) + self.assertIsInstance(actual, Cube) + self.assertEqual(expected, actual.data) + + def test_cube_op_coord(self): + for op in self.ops: + actual = op(self.cube, self.dim) + expected = op(self.value_cube, self.value) + self.assertIsInstance(actual, Cube) + self.assertEqual(actual.data, expected) + + if __name__ == "__main__": tests.main() diff --git a/lib/iris/tests/test_cube_to_pp.py b/lib/iris/tests/test_cube_to_pp.py index d0a1e0dcbb..f5d7a4482c 100644 --- a/lib/iris/tests/test_cube_to_pp.py +++ b/lib/iris/tests/test_cube_to_pp.py @@ -275,7 +275,7 @@ def test_365_calendar_export(self): class FakePPEnvironment: - """ fake a minimal PP environment for use in cross-section coords, as in PP save rules """ + """fake a minimal PP environment for use in cross-section coords, as in PP save rules""" y = [1, 2, 3, 4] z = [111, 222, 333, 444] diff --git a/lib/iris/tests/test_file_save.py b/lib/iris/tests/test_file_save.py index 1c2a27c70b..ee48969865 100644 --- a/lib/iris/tests/test_file_save.py +++ b/lib/iris/tests/test_file_save.py @@ -33,7 +33,7 @@ def save_by_filename(filename1, filename2, cube, saver_fn, iosaver=None): - """ Saves a cube to two different filenames using iris.save and the save method of the object representing the file type directly""" + """Saves a cube to two different filenames using iris.save and the save method of the object representing the file type directly""" # Save from object direct saver_fn(cube, filename1) @@ -46,7 +46,7 @@ def save_by_filename(filename1, filename2, cube, saver_fn, iosaver=None): def save_by_filehandle( filehandle1, filehandle2, cube, fn_saver, binary_mode=True ): - """ Saves a cube to two different filehandles using iris.save and the save method of the object representing the file type directly""" + """Saves a cube to two different filehandles using iris.save and the save method of the object representing the file type directly""" mode = "wb" if binary_mode else "w" # Save from object direct @@ -60,7 +60,7 @@ def save_by_filehandle( @tests.skip_data class TestSaveMethods(tests.IrisTest): - """ Base class for file saving tests. Loads data and creates/deletes tempfiles""" + """Base class for file saving tests. Loads data and creates/deletes tempfiles""" def setUp(self): self.cube1 = iris.load_cube( diff --git a/lib/iris/tests/unit/fileformats/test_rules.py b/lib/iris/tests/unit/fileformats/test_rules.py index 00b0e5c540..730ef13f23 100644 --- a/lib/iris/tests/unit/fileformats/test_rules.py +++ b/lib/iris/tests/unit/fileformats/test_rules.py @@ -238,7 +238,7 @@ def converter(field): class Test_scalar_cell_method(tests.IrisTest): - """ Tests for iris.fileformats.rules.scalar_cell_method() function """ + """Tests for iris.fileformats.rules.scalar_cell_method() function""" def setUp(self): self.cube = stock.simple_2d()