diff --git a/docs/iris/src/whatsnew/contributions_2.2.0/newfeature_2018-Oct-03_reverse_cube.txt b/docs/iris/src/whatsnew/contributions_2.2.0/newfeature_2018-Oct-03_reverse_cube.txt new file mode 100644 index 0000000000..c7e3bef8a4 --- /dev/null +++ b/docs/iris/src/whatsnew/contributions_2.2.0/newfeature_2018-Oct-03_reverse_cube.txt @@ -0,0 +1 @@ +* :func:`iris.util.reverse` can now be used to reverse a cube by specifying one or more coordinates. diff --git a/lib/iris/tests/test_util.py b/lib/iris/tests/test_util.py index 97bb4d627d..d8cfcf648c 100644 --- a/lib/iris/tests/test_util.py +++ b/lib/iris/tests/test_util.py @@ -93,31 +93,6 @@ def test_monotonic_strict(self): self.assertMonotonic(b) -class TestReverse(tests.IrisTest): - def test_simple(self): - a = np.arange(12).reshape(3, 4) - np.testing.assert_array_equal(a[::-1], iris.util.reverse(a, 0)) - np.testing.assert_array_equal(a[::-1, ::-1], iris.util.reverse(a, [0, 1])) - np.testing.assert_array_equal(a[:, ::-1], iris.util.reverse(a, 1)) - np.testing.assert_array_equal(a[:, ::-1], iris.util.reverse(a, [1])) - self.assertRaises(ValueError, iris.util.reverse, a, []) - self.assertRaises(ValueError, iris.util.reverse, a, -1) - self.assertRaises(ValueError, iris.util.reverse, a, 10) - self.assertRaises(ValueError, iris.util.reverse, a, [-1]) - self.assertRaises(ValueError, iris.util.reverse, a, [0, -1]) - - def test_single(self): - a = np.arange(36).reshape(3, 4, 3) - np.testing.assert_array_equal(a[::-1], iris.util.reverse(a, 0)) - np.testing.assert_array_equal(a[::-1, ::-1], iris.util.reverse(a, [0, 1])) - np.testing.assert_array_equal(a[:, ::-1, ::-1], iris.util.reverse(a, [1, 2])) - np.testing.assert_array_equal(a[..., ::-1], iris.util.reverse(a, 2)) - self.assertRaises(ValueError, iris.util.reverse, a, -1) - self.assertRaises(ValueError, iris.util.reverse, a, 10) - self.assertRaises(ValueError, iris.util.reverse, a, [-1]) - self.assertRaises(ValueError, iris.util.reverse, a, [0, -1]) - - class TestClipString(tests.IrisTest): def setUp(self): self.test_string = "Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." diff --git a/lib/iris/tests/unit/util/test_reverse.py b/lib/iris/tests/unit/util/test_reverse.py new file mode 100644 index 0000000000..f5a21d259b --- /dev/null +++ b/lib/iris/tests/unit/util/test_reverse.py @@ -0,0 +1,185 @@ +# (C) British Crown Copyright 2018, Met Office +# +# This file is part of Iris. +# +# Iris is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Iris is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Iris. If not, see . +"""Test function :func:`iris.util.reverse`.""" + +from __future__ import (absolute_import, division, print_function) +from six.moves import (filter, input, map, range, zip) # noqa + +# Import iris.tests first so that some things can be initialised before +# importing anything else. +import iris.tests as tests + +import unittest + +import iris +from iris.util import reverse +import numpy as np + + +class Test_array(tests.IrisTest): + def test_simple_array(self): + a = np.arange(12).reshape(3, 4) + self.assertArrayEqual(a[::-1], reverse(a, 0)) + self.assertArrayEqual(a[::-1, ::-1], reverse(a, [0, 1])) + self.assertArrayEqual(a[:, ::-1], reverse(a, 1)) + self.assertArrayEqual(a[:, ::-1], reverse(a, [1])) + + msg = 'Reverse was expecting a single axis or a 1d array *' + with self.assertRaisesRegexp(ValueError, msg): + reverse(a, []) + + msg = 'An axis value out of range for the number of dimensions *' + with self.assertRaisesRegexp(ValueError, msg): + reverse(a, -1) + with self.assertRaisesRegexp(ValueError, msg): + reverse(a, 10) + with self.assertRaisesRegexp(ValueError, msg): + reverse(a, [-1]) + with self.assertRaisesRegexp(ValueError, msg): + reverse(a, [0, -1]) + + msg = 'To reverse an array, provide an int *' + with self.assertRaisesRegexp(TypeError, msg): + reverse(a, 'latitude') + + def test_single_array(self): + a = np.arange(36).reshape(3, 4, 3) + self.assertArrayEqual(a[::-1], reverse(a, 0)) + self.assertArrayEqual(a[::-1, ::-1], reverse(a, [0, 1])) + self.assertArrayEqual(a[:, ::-1, ::-1], reverse(a, [1, 2])) + self.assertArrayEqual(a[..., ::-1], reverse(a, 2)) + + msg = 'Reverse was expecting a single axis or a 1d array *' + with self.assertRaisesRegexp(ValueError, msg): + reverse(a, []) + + msg = 'An axis value out of range for the number of dimensions *' + with self.assertRaisesRegexp(ValueError, msg): + reverse(a, -1) + with self.assertRaisesRegexp(ValueError, msg): + reverse(a, 10) + with self.assertRaisesRegexp(ValueError, msg): + reverse(a, [-1]) + with self.assertRaisesRegexp(ValueError, msg): + reverse(a, [0, -1]) + + with self.assertRaisesRegexp( + TypeError, 'To reverse an array, provide an int *'): + reverse(a, 'latitude') + + +class Test_cube(tests.IrisTest): + def setUp(self): + # On this cube pair, the coordinates to perform operations on have + # matching long names but the points array on one cube is reversed + # with respect to that on the other. + data = np.arange(12).reshape(3, 4) + self.a1 = iris.coords.DimCoord([1, 2, 3], long_name='a') + self.b1 = iris.coords.DimCoord([1, 2, 3, 4], long_name='b') + a2 = iris.coords.DimCoord([3, 2, 1], long_name='a') + b2 = iris.coords.DimCoord([4, 3, 2, 1], long_name='b') + self.span = iris.coords.AuxCoord(np.arange(12).reshape(3, 4), + long_name='spanning') + + self.cube1 = iris.cube.Cube( + data, dim_coords_and_dims=[(self.a1, 0), (self.b1, 1)], + aux_coords_and_dims=[(self.span, (0, 1))]) + + self.cube2 = iris.cube.Cube( + data, dim_coords_and_dims=[(a2, 0), (b2, 1)]) + + def test_cube_dim(self): + cube1_reverse0 = reverse(self.cube1, 0) + cube1_reverse1 = reverse(self.cube1, 1) + cube1_reverse_both = reverse(self.cube1, (0, 1)) + + self.assertArrayEqual(self.cube1.data[::-1], cube1_reverse0.data) + self.assertArrayEqual(self.cube2.coord('a').points, + cube1_reverse0.coord('a').points) + self.assertArrayEqual(self.cube1.coord('b').points, + cube1_reverse0.coord('b').points) + + self.assertArrayEqual(self.cube1.data[:, ::-1], cube1_reverse1.data) + self.assertArrayEqual(self.cube1.coord('a').points, + cube1_reverse1.coord('a').points) + self.assertArrayEqual(self.cube2.coord('b').points, + cube1_reverse1.coord('b').points) + + self.assertArrayEqual(self.cube1.data[::-1, ::-1], + cube1_reverse_both.data) + self.assertArrayEqual(self.cube2.coord('a').points, + cube1_reverse_both.coord('a').points) + self.assertArrayEqual(self.cube2.coord('b').points, + cube1_reverse_both.coord('b').points) + + def test_cube_coord(self): + cube1_reverse0 = reverse(self.cube1, self.a1) + cube1_reverse1 = reverse(self.cube1, 'b') + cube1_reverse_both = reverse(self.cube1, (self.a1, self.b1)) + cube1_reverse_spanning = reverse(self.cube1, 'spanning') + + self.assertArrayEqual(self.cube1.data[::-1], cube1_reverse0.data) + self.assertArrayEqual(self.cube2.coord('a').points, + cube1_reverse0.coord('a').points) + self.assertArrayEqual(self.cube1.coord('b').points, + cube1_reverse0.coord('b').points) + + self.assertArrayEqual(self.cube1.data[:, ::-1], cube1_reverse1.data) + self.assertArrayEqual(self.cube1.coord('a').points, + cube1_reverse1.coord('a').points) + self.assertArrayEqual(self.cube2.coord('b').points, + cube1_reverse1.coord('b').points) + + self.assertArrayEqual(self.cube1.data[::-1, ::-1], + cube1_reverse_both.data) + self.assertArrayEqual(self.cube2.coord('a').points, + cube1_reverse_both.coord('a').points) + self.assertArrayEqual(self.cube2.coord('b').points, + cube1_reverse_both.coord('b').points) + + self.assertArrayEqual(self.cube1.data[::-1, ::-1], + cube1_reverse_spanning.data) + self.assertArrayEqual(self.cube2.coord('a').points, + cube1_reverse_spanning.coord('a').points) + self.assertArrayEqual(self.cube2.coord('b').points, + cube1_reverse_spanning.coord('b').points) + self.assertArrayEqual( + self.span.points[::-1, ::-1], + cube1_reverse_spanning.coord('spanning').points) + + msg = 'Expected to find exactly 1 latitude coordinate, but found none.' + with self.assertRaisesRegexp( + iris.exceptions.CoordinateNotFoundError, msg): + reverse(self.cube1, 'latitude') + + msg = 'Reverse was expecting a single axis or a 1d array *' + with self.assertRaisesRegexp(ValueError, msg): + reverse(self.cube1, []) + + msg = ('coords_or_dims must be int, str, coordinate or sequence of ' + 'these. Got cube.') + with self.assertRaisesRegexp(TypeError, msg): + reverse(self.cube1, self.cube1) + + msg = ('coords_or_dims must be int, str, coordinate or sequence of ' + 'these.') + with self.assertRaisesRegexp(TypeError, msg): + reverse(self.cube1, 3.) + + +if __name__ == '__main__': + unittest.main() diff --git a/lib/iris/util.py b/lib/iris/util.py index 4f4c42dc4b..7e5b2c36a8 100644 --- a/lib/iris/util.py +++ b/lib/iris/util.py @@ -41,6 +41,7 @@ import iris import iris.exceptions +import iris.cube def broadcast_to_shape(array, shape, dim_map): @@ -406,16 +407,19 @@ def between(lh, rh, lh_inclusive=True, rh_inclusive=True): return lambda c: lh < c < rh -def reverse(array, axes): +def reverse(cube_or_array, coords_or_dims): """ - Reverse the array along the given axes. + Reverse the cube or array along the given dimensions. Args: - * array - The array to reverse - * axes - A single value or array of values of axes to reverse + * cube_or_array: :class:`iris.cube.Cube` or :class:`numpy.ndarray` + The cube or array to reverse. + * coords_or_dims: int, str, :class:`iris.coords.Coord` or sequence of these + Identify one or more dimensions to reverse. If cube_or_array is a + numpy array, use int or a sequence of ints, as in the examples below. + If cube_or_array is a Cube, a Coord or coordinate name (or sequence of + these) may be specified instead. :: @@ -447,20 +451,42 @@ def reverse(array, axes): [15 14 13 12]]] """ - index = [slice(None, None)] * array.ndim - axes = np.array(axes, ndmin=1) - if axes.ndim != 1: + index = [slice(None, None)] * cube_or_array.ndim + + if isinstance(coords_or_dims, iris.cube.Cube): + raise TypeError('coords_or_dims must be int, str, coordinate or ' + 'sequence of these. Got cube.') + + if iris.cube._is_single_item(coords_or_dims): + coords_or_dims = [coords_or_dims] + + axes = set() + for coord_or_dim in coords_or_dims: + if isinstance(coord_or_dim, int): + axes.add(coord_or_dim) + elif isinstance(cube_or_array, np.ndarray): + raise TypeError( + 'To reverse an array, provide an int or sequence of ints.') + else: + try: + axes.update(cube_or_array.coord_dims(coord_or_dim)) + except AttributeError: + raise TypeError('coords_or_dims must be int, str, coordinate ' + 'or sequence of these.') + + axes = np.array(list(axes), ndmin=1) + if axes.ndim != 1 or axes.size == 0: raise ValueError('Reverse was expecting a single axis or a 1d array ' 'of axes, got %r' % axes) - if np.min(axes) < 0 or np.max(axes) > array.ndim-1: + if np.min(axes) < 0 or np.max(axes) > cube_or_array.ndim-1: raise ValueError('An axis value out of range for the number of ' 'dimensions from the given array (%s) was received. ' - 'Got: %r' % (array.ndim, axes)) + 'Got: %r' % (cube_or_array.ndim, axes)) for axis in axes: index[axis] = slice(None, None, -1) - return array[tuple(index)] + return cube_or_array[tuple(index)] def monotonic(array, strict=False, return_direction=False):