diff --git a/.travis.yml b/.travis.yml index b92ecdbfb2..ac5d341307 100644 --- a/.travis.yml +++ b/.travis.yml @@ -31,6 +31,15 @@ install: export IRIS_TEST_DATA_REF="2f3a6bcf25f81bd152b3d66223394074c9069a96"; export IRIS_TEST_DATA_SUFFIX=$(echo "${IRIS_TEST_DATA_REF}" | sed "s/^v//"); + # Cut short doctest phase under Python 2 : now only supports Python 3 + # SEE : https://github.com/SciTools/iris/pull/3134 + # ------------ + - > + if [[ $TEST_TARGET == 'doctest' && ${TRAVIS_PYTHON_VERSION} != 3* ]]; then + echo "DOCTEST phase only valid in Python 3 : ABORTING during 'install'." + exit 0 + fi + # Install miniconda # ----------------- - > diff --git a/docs/iris/src/_templates/index.html b/docs/iris/src/_templates/index.html index 0c4b5d958f..31acded447 100644 --- a/docs/iris/src/_templates/index.html +++ b/docs/iris/src/_templates/index.html @@ -134,7 +134,7 @@ extra information on specific technical issues

  • -
  • diff --git a/docs/iris/src/_templates/layout.html b/docs/iris/src/_templates/layout.html index 53ae5105cb..8ecc35bade 100644 --- a/docs/iris/src/_templates/layout.html +++ b/docs/iris/src/_templates/layout.html @@ -37,7 +37,7 @@

    - Iris v2.1 + Iris v2.2

    A powerful, format-agnostic, community-driven Python library for analysing and diff --git a/docs/iris/src/userguide/interpolation_and_regridding.rst b/docs/iris/src/userguide/interpolation_and_regridding.rst index 4f2b06de44..e3cd622541 100644 --- a/docs/iris/src/userguide/interpolation_and_regridding.rst +++ b/docs/iris/src/userguide/interpolation_and_regridding.rst @@ -5,6 +5,8 @@ import numpy as np import iris + import warnings + warnings.simplefilter('ignore') ================================= Cube interpolation and regridding @@ -137,10 +139,9 @@ This cube has a "hybrid-height" vertical coordinate system, meaning that the ver coordinate is unevenly spaced in altitude: >>> print(column.coord('altitude').points) - [418.6983642578125 434.57049560546875 456.79278564453125 485.3664855957031 - 520.2932739257812 561.5751953125 609.2144775390625 663.214111328125 - 723.5769653320312 790.306640625 863.4072265625 942.88232421875 - 1028.737060546875 1120.9764404296875 1219.6051025390625] + [ 418.69836 434.5705 456.7928 485.3665 520.2933 561.5752 + 609.2145 663.2141 723.57697 790.30664 863.4072 942.8823 + 1028.737 1120.9764 1219.6051 ] We could regularise the vertical coordinate by defining 10 equally spaced altitude sample points between 400 and 1250 and interpolating our vertical coordinate onto @@ -184,9 +185,8 @@ For example, to mask values that lie beyond the range of the original data: >>> scheme = iris.analysis.Linear(extrapolation_mode='mask') >>> new_column = column.interpolate(sample_points, scheme) >>> print(new_column.coord('altitude').points) - [nan 494.44451904296875 588.888916015625 683.333251953125 777.77783203125 - 872.2222290039062 966.666748046875 1061.111083984375 1155.555419921875 - nan] + [ nan 494.44452 588.8889 683.33325 777.77783 872.2222 + 966.66675 1061.1111 1155.5554 nan] .. _caching_an_interpolator: diff --git a/docs/iris/src/whatsnew/2.2.rst b/docs/iris/src/whatsnew/2.2.rst new file mode 100644 index 0000000000..4edc305025 --- /dev/null +++ b/docs/iris/src/whatsnew/2.2.rst @@ -0,0 +1,36 @@ +What's New in Iris 2.2.0 +************************ + +:Release: 2.2.0a0 +:Date: + + +This document explains the new/changed features of Iris in the alpha release +of version 2.2.0 +(:doc:`View all changes `). + + +Iris 2.2.0 Features +=================== +.. _showcase: + +.. admonition:: 2-Dimensional Coordinate Plotting + + The iris plot functions :func:`~iris.plot.pcolor` and + :func:`~iris.plot.pcolormesh` now accommodate the plotting of 2-dimensional + coordinates as well as 1-dimensional coordinates. + + To enable this feature, each coordinate passed in for plotting will be + automatically checked for contiguity. Coordinate bounds must either be + contiguous, or the cube's data must be masked at the discontiguities in + order to avoid plotting errors. + + The iris plot functions :func:`iris.plot.quiver` and + :func:`iris.plot.streamplot` have been added, and these also work with + 2-dimensional plot coordinates. + +.. admonition:: 2-Dimensional Grid Vectors + + The iris functions :func:`iris.analysis.cartography.gridcell_angles` and + :func:`iris.analysis.cartography.rotate_grid_vectors` have been added, + allowing you to convert gridcell-oriented vectors to true-North/East ones. diff --git a/docs/iris/src/whatsnew/index.rst b/docs/iris/src/whatsnew/index.rst index 104c5074ca..c3a34303f0 100644 --- a/docs/iris/src/whatsnew/index.rst +++ b/docs/iris/src/whatsnew/index.rst @@ -9,6 +9,7 @@ Iris versions. .. toctree:: :maxdepth: 2 + 2.2.rst 2.1.rst 2.0.rst 1.13.rst diff --git a/lib/iris/__init__.py b/lib/iris/__init__.py index 2e7eba5892..882b52aaea 100644 --- a/lib/iris/__init__.py +++ b/lib/iris/__init__.py @@ -121,7 +121,7 @@ def callback(cube, field, filename): # Iris revision. -__version__ = '2.2.0dev0' +__version__ = '2.2.0a0' # Restrict the names imported when using "from iris import *" __all__ = ['load', 'load_cube', 'load_cubes', 'load_raw', diff --git a/lib/iris/analysis/_grid_angles.py b/lib/iris/analysis/_grid_angles.py new file mode 100644 index 0000000000..90876840af --- /dev/null +++ b/lib/iris/analysis/_grid_angles.py @@ -0,0 +1,451 @@ +# (C) British Crown Copyright 2010 - 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 . +""" +Code to implement vector rotation by angles, and inferring gridcell angles +from coordinate points and bounds. + +""" +from __future__ import (absolute_import, division, print_function) +from six.moves import (filter, input, map, range, zip) # noqa + +import numpy as np + +import cartopy.crs as ccrs +import iris + + +def _3d_xyz_from_latlon(lon, lat): + """ + Return locations of (lon, lat) in 3D space. + + Args: + + * lon, lat: (float array) + Arrays of longitudes and latitudes, in degrees. + Both the same shape. + + Returns: + + * xyz : (array, dtype=float64) + Cartesian coordinates on a unit sphere. + Shape is (3, ). + The x / y / z coordinates are in xyz[0 / 1 / 2]. + + """ + lon1 = np.deg2rad(lon).astype(np.float64) + lat1 = np.deg2rad(lat).astype(np.float64) + + x = np.cos(lat1) * np.cos(lon1) + y = np.cos(lat1) * np.sin(lon1) + z = np.sin(lat1) + + result = np.concatenate([array[np.newaxis] for array in (x, y, z)]) + + return result + + +def _latlon_from_xyz(xyz): + """ + Return arrays of lons+lats angles from xyz locations. + + Args: + + * xyz: (array) + Array of 3-D cartesian coordinates. + Shape (3, ). + x / y / z values are in xyz[0 / 1 / 2], + + Returns: + + * lonlat : (array) + longitude and latitude position angles, in degrees. + Shape (2, ). + The longitudes / latitudes are in lonlat[0 / 1]. + + """ + lons = np.rad2deg(np.arctan2(xyz[1], xyz[0])) + radii = np.sqrt(np.sum(xyz * xyz, axis=0)) + lats = np.rad2deg(np.arcsin(xyz[2] / radii)) + return np.array([lons, lats]) + + +def _angle(p, q, r): + """ + Estimate grid-angles to true-Eastward direction from positions in the same + grid row, but at increasing column (grid-Eastward) positions. + + {P, Q, R} are locations of consecutive points in the same grid row. + These could be successive points in a single grid, + e.g. {T(i-1,j), T(i,j), T(i+1,j)} + or a mixture of U/V and T gridpoints if row positions are aligned, + e.g. {v(i,j), f(i,j), v(i+1,j)}. + + Method: + + Calculate dot product of a unit-vector parallel to P-->R, unit-scaled, + with the unit eastward (true longitude) vector at Q. + This value is cos(required angle). + Discriminate between +/- angles by comparing latitudes of P and R. + Return NaN where any P-->R are zero. + + .. NOTE:: + + This method assumes that the vector PR is parallel to the surface + at the longitude of Q, as it uses the length of PR as the basis for + the cosine ratio. + That is only exact when Q is at the same longitude as the midpoint + of PR, and this typically causes errors which grow with increasing + gridcell angle. + However, we retain this method because it reproduces the "standard" + gridcell-orientation-angle arrays found in files output by the CICE + model, which presumably uses an equivalent calculation. + + Args: + + * p, q, r : (float array) + Arrays of angles, in degrees. + All the same shape. + Shape is (2, ). + Longitudes / latitudes are in array[0 / 1]. + + Returns: + + * angle : (float array) + Grid angles relative to true-East, in degrees. + Positive when grid-East is anticlockwise from true-East. + Shape is same as . + + """ + mid_lons = np.deg2rad(q[0]) + + pr = _3d_xyz_from_latlon(r[0], r[1]) - _3d_xyz_from_latlon(p[0], p[1]) + pr_norm = np.sqrt(np.sum(pr**2, axis=0)) + pr_top = pr[1] * np.cos(mid_lons) - pr[0] * np.sin(mid_lons) + + index = pr_norm == 0 + pr_norm[index] = 1 + + cosine = np.maximum(np.minimum(pr_top / pr_norm, 1), -1) + cosine[index] = 0 + + psi = np.arccos(cosine) * np.sign(r[1] - p[1]) + psi[index] = np.nan + + return np.rad2deg(psi) + + +def gridcell_angles(x, y=None, cell_angle_boundpoints='mid-lhs, mid-rhs'): + """ + Calculate gridcell orientations for an arbitrary 2-dimensional grid. + + The input grid is defined by two 2-dimensional coordinate arrays with the + same dimensions (ny, nx), specifying the geolocations of a 2D mesh. + + Input values may be coordinate points (ny, nx) or bounds (ny, nx, 4). + However, if points, the edges in the X direction are assumed to be + connected by wraparound. + + Input can be either two arrays, two coordinates, or a single cube + containing two suitable coordinates identified with the 'x' and'y' axes. + + Args: + + The inputs (x [,y]) can be any of the folliwing : + + * x (:class:`~iris.cube.Cube`): + a grid cube with 2D X and Y coordinates, identified by 'axis'. + The coordinates must be 2-dimensional with the same shape. + The two dimensions represent grid dimensions in the order Y, then X. + + * x, y (:class:`~iris.coords.Coord`): + X and Y coordinates, specifying grid locations on the globe. + The coordinates must be 2-dimensional with the same shape. + The two dimensions represent grid dimensions in the order Y, then X. + If there is no coordinate system, they are assumed to be true + longitudes and latitudes. Units must convertible to 'degrees'. + + * x, y (2-dimensional arrays of same shape (ny, nx)): + longitude and latitude cell center locations, in degrees. + The two dimensions represent grid dimensions in the order Y, then X. + + * x, y (3-dimensional arrays of same shape (ny, nx, 4)): + longitude and latitude cell bounds, in degrees. + The first two dimensions are grid dimensions in the order Y, then X. + The last index maps cell corners anticlockwise from bottom-left. + + Optional Args: + + * cell_angle_boundpoints (string): + Controls which gridcell bounds locations are used to calculate angles, + if the inputs are bounds or bounded coordinates. + Valid values are 'lower-left, lower-right', which takes the angle from + the lower left to the lower right corner, and 'mid-lhs, mid-rhs' which + takes an angles between the average of the left-hand and right-hand + pairs of corners. The default is 'mid-lhs, mid-rhs'. + + Returns: + + angles : (2-dimensional cube) + + Cube of angles of grid-x vector from true Eastward direction for + each gridcell, in degrees. + It also has "true" longitude and latitude coordinates, with no + coordinate system. + When the input has coords, then the output ones are identical if + the inputs are true-latlons, otherwise they are transformed + true-latlon versions. + When the input has bounded coords, then the output coords have + matching bounds and centrepoints (possibly transformed). + When the input is 2d arrays, or has unbounded coords, then the + output coords have matching points and no bounds. + When the input is 3d arrays, then the output coords have matching + bounds, and the centrepoints are an average of the 4 boundpoints. + + """ + cube = None + if hasattr(x, 'add_aux_coord'): + # Passed a cube : extract 'x' and ;'y' axis coordinates. + cube = x # Save for later checking. + x, y = cube.coord(axis='x'), cube.coord(axis='y') + + # Now should have either 2 coords or 2 arrays. + if not hasattr(x, 'shape') or not hasattr(y, 'shape'): + msg = ('Inputs (x,y) must have array shape property.' + 'Got type(x)={} and type(y)={}.') + raise ValueError(msg.format(type(x), type(y))) + + x_coord, y_coord = None, None + if hasattr(x, 'bounds') and hasattr(y, 'bounds'): + # x and y are Coords. + x_coord, y_coord = x.copy(), y.copy() + + # They must be angles : convert into degrees + for coord in (x_coord, y_coord): + if not coord.units.is_convertible('degrees'): + msg = ('Input X and Y coordinates must have angular ' + 'units. Got units of "{!s}" and "{!s}".') + raise ValueError(msg.format(x_coord.units, y_coord.units)) + coord.convert_units('degrees') + + if x_coord.ndim != 2 or y_coord.ndim != 2: + msg = ('Coordinate inputs must have 2-dimensional shape. ' + 'Got x-shape of {} and y-shape of {}.') + raise ValueError(msg.format(x_coord.shape, y_coord.shape)) + if x_coord.shape != y_coord.shape: + msg = ('Coordinate inputs must have same shape. ' + 'Got x-shape of {} and y-shape of {}.') + raise ValueError(msg.format(x_coord.shape, y_coord.shape)) + if cube: + x_dims, y_dims = (cube.coord_dims(co) for co in (x, y)) + if x_dims != y_dims: + msg = ('X and Y coordinates must have the same cube ' + 'dimensions. Got x-dims = {} and y-dims = {}.') + raise ValueError(msg.format(x_dims, y_dims)) + cs = x_coord.coord_system + if y_coord.coord_system != cs: + msg = ('Coordinate inputs must have same coordinate system. ' + 'Got x of {} and y of {}.') + raise ValueError(msg.format(cs, y_coord.coord_system)) + + # Base calculation on bounds if we have them, or points as a fallback. + if x_coord.has_bounds() and y_coord.has_bounds(): + x, y = x_coord.bounds, y_coord.bounds + else: + x, y = x_coord.points, y_coord.points + + # Make sure these arrays are ordinary lats+lons, in degrees. + if cs is not None: + # Transform points into true lats + lons. + crs_src = cs.as_cartopy_crs() + crs_pc = ccrs.PlateCarree() + + def transform_xy_arrays(x, y): + # Note: flatten, as transform_points is limited to 2D arrays. + shape = x.shape + x, y = (arr.flatten() for arr in (x, y)) + pts = crs_pc.transform_points(crs_src, x, y) + x = pts[..., 0].reshape(shape) + y = pts[..., 1].reshape(shape) + return x, y + + # Transform the main reference points into standard lats+lons. + x, y = transform_xy_arrays(x, y) + + # Likewise replace the original coordinates with transformed ones, + # because the calculation also needs the centrepoint values. + xpts, ypts = (coord.points for coord in (x_coord, y_coord)) + xbds, ybds = (coord.bounds for coord in (x_coord, y_coord)) + xpts, ypts = transform_xy_arrays(xpts, ypts) + xbds, ybds = transform_xy_arrays(xbds, ybds) + x_coord = iris.coords.AuxCoord( + points=xpts, bounds=xbds, + standard_name='longitude', units='degrees') + y_coord = iris.coords.AuxCoord( + points=ypts, bounds=ybds, + standard_name='latitude', units='degrees') + + elif hasattr(x, 'bounds') or hasattr(y, 'bounds'): + # One was a Coord, and the other not ? + is_and_not = ('x', 'y') + if hasattr(y, 'bounds'): + is_and_not = reversed(is_and_not) + msg = 'Input {!r} is a Coordinate, but {!r} is not.' + raise ValueError(msg.format(*is_and_not)) + + # Now have either 2 points arrays (ny, nx) or 2 bounds arrays (ny, nx, 4). + # Construct (lhs, mid, rhs) where these represent 3 points at increasing + # grid-x indices (columns). + # Also make suitable X and Y coordinates for the result cube. + if x.ndim == 2: + # Data is points arrays. + # Use previous + subsequent points along grid-x-axis as references. + + # PROBLEM: we assume that the rhs connects to the lhs, so we should + # really only use this if data is full-longitudes (as a 'circular' + # coordinate). + # This is mentioned in the docstring, but we have no general means of + # checking it. + + # NOTE: we take the 2d grid as presented, so the second dimension is + # the 'X' dim. Again, that is implicit + can't be checked. + mid = np.array([x, y]) + lhs = np.roll(mid, 1, 2) + rhs = np.roll(mid, -1, 2) + if not x_coord: + # Create coords for result cube : with no bounds. + y_coord = iris.coords.AuxCoord(x, standard_name='latitude', + units='degrees') + x_coord = iris.coords.AuxCoord(y, standard_name='longitude', + units='degrees') + else: + # Data is bounds arrays. + # Use gridcell corners at different grid-x positions as references. + # NOTE: so with bounds, we *don't* need full circular longitudes. + xyz = _3d_xyz_from_latlon(x, y) + # Support two different choices of reference points locations. + angle_boundpoints_vals = {'mid-lhs, mid-rhs': '03_to_12', + 'lower-left, lower-right': '0_to_1'} + bounds_pos = angle_boundpoints_vals.get(cell_angle_boundpoints) + if bounds_pos == '0_to_1': + lhs_xyz = xyz[..., 0] + rhs_xyz = xyz[..., 1] + elif bounds_pos == '03_to_12': + lhs_xyz = 0.5 * (xyz[..., 0] + xyz[..., 3]) + rhs_xyz = 0.5 * (xyz[..., 1] + xyz[..., 2]) + else: + msg = ('unrecognised cell_angle_boundpoints of "{}", ' + 'must be one of {}') + raise ValueError(msg.format(cell_angle_boundpoints, + list(angle_boundpoints_vals.keys()))) + if not x_coord: + # Create bounded coords for result cube. + # Use average of lhs+rhs points in 3d to get 'mid' points, + # as coords without points are not allowed. + mid_xyz = 0.5 * (lhs_xyz + rhs_xyz) + mid_latlons = _latlon_from_xyz(mid_xyz) + # Create coords with given bounds, and averaged centrepoints. + x_coord = iris.coords.AuxCoord( + points=mid_latlons[0], bounds=x, + standard_name='longitude', units='degrees') + y_coord = iris.coords.AuxCoord( + points=mid_latlons[1], bounds=y, + standard_name='latitude', units='degrees') + + # Convert lhs and rhs points back to latlon form -- IN DEGREES ! + lhs = _latlon_from_xyz(lhs_xyz) + rhs = _latlon_from_xyz(rhs_xyz) + # 'mid' is coord.points, whether from input or just made up. + mid = np.array([x_coord.points, y_coord.points]) + + # Do the angle calcs, and return as a suitable cube. + angles = _angle(lhs, mid, rhs) + result = iris.cube.Cube(angles, + long_name='gridcell_angle_from_true_east', + units='degrees') + result.add_aux_coord(x_coord, (0, 1)) + result.add_aux_coord(y_coord, (0, 1)) + return result + + +def rotate_grid_vectors(u_cube, v_cube, grid_angles_cube=None, + grid_angles_kwargs=None): + """ + Rotate distance vectors from grid-oriented to true-latlon-oriented. + + Can also rotate by arbitrary angles, if they are passed in. + + .. Note:: + + This operation overlaps somewhat in function with + :func:`iris.analysis.cartography.rotate_winds`. + However, that routine only rotates vectors according to transformations + between coordinate systems. + This function, by contrast, can rotate vectors by arbitrary angles. + Most commonly, the angles are estimated solely from grid sampling + points, using :func:`gridcell_angles` : This allows operation on + complex meshes defined by two-dimensional coordinates, such as most + ocean grids. + + Args: + + * u_cube, v_cube : (cube) + Cubes of grid-u and grid-v vector components. + Units should be differentials of true-distance, e.g. 'm/s'. + + Optional args: + + * grid_angles_cube : (cube) + gridcell orientation angles. + Units must be angular, i.e. can be converted to 'radians'. + If not provided, grid angles are estimated from 'u_cube' using the + :func:`gridcell_angles` method. + + * grid_angles_kwargs : (dict or None) + Additional keyword args to be passed to the :func:`gridcell_angles` + method, if it is used. + + Returns: + + true_u, true_v : (cube) + Cubes of true-north oriented vector components. + Units are same as inputs. + + .. Note:: + + Vector magnitudes will always be the same as the inputs. + + """ + u_out, v_out = (cube.copy() for cube in (u_cube, v_cube)) + if not grid_angles_cube: + grid_angles_kwargs = grid_angles_kwargs or {} + grid_angles_cube = gridcell_angles(u_cube, **grid_angles_kwargs) + gridangles = grid_angles_cube.copy() + gridangles.convert_units('radians') + uu, vv, aa = (cube.data for cube in (u_out, v_out, gridangles)) + mags = np.sqrt(uu*uu + vv*vv) + angs = np.arctan2(vv, uu) + aa + uu, vv = mags * np.cos(angs), mags * np.sin(angs) + + # Promote all to masked arrays, and also apply mask at bad (NaN) angles. + mask = np.isnan(aa) + for cube in (u_out, v_out, aa): + if hasattr(cube.data, 'mask'): + mask |= cube.data.mask + u_out.data = np.ma.masked_array(uu, mask=mask) + v_out.data = np.ma.masked_array(vv, mask=mask) + + return u_out, v_out diff --git a/lib/iris/analysis/_regrid.py b/lib/iris/analysis/_regrid.py index eb7a16d075..71304a5dde 100644 --- a/lib/iris/analysis/_regrid.py +++ b/lib/iris/analysis/_regrid.py @@ -231,8 +231,9 @@ def _regrid(src_data, x_dim, y_dim, data = np.empty(shape, dtype=dtype) # The interpolation class requires monotonically increasing - # coordinates, so flip the coordinate(s) and data if the aren't. - reverse_x = src_x_coord.points[0] > src_x_coord.points[1] + # coordinates, so flip the coordinate(s) and data if they aren't. + reverse_x = (src_x_coord.points[0] > src_x_coord.points[1] if + src_x_coord.points.size > 1 else False) reverse_y = src_y_coord.points[0] > src_y_coord.points[1] flip_index = [slice(None)] * src_data.ndim if reverse_x: diff --git a/lib/iris/analysis/cartography.py b/lib/iris/analysis/cartography.py index 4b68dcc949..a778f5f846 100644 --- a/lib/iris/analysis/cartography.py +++ b/lib/iris/analysis/cartography.py @@ -1,4 +1,4 @@ -# (C) British Crown Copyright 2010 - 2017, Met Office +# (C) British Crown Copyright 2010 - 2018, Met Office # # This file is part of Iris. # @@ -37,7 +37,24 @@ import iris.coord_systems import iris.exceptions from iris.util import _meshgrid - +from ._grid_angles import gridcell_angles, rotate_grid_vectors + +# List of contents to control Sphinx autodocs. +# Unfortunately essential to get docs for the grid_angles functions. +__all__ = [ + 'area_weights', + 'cosine_latitude_weights', + 'get_xy_contiguous_bounded_grids', + 'get_xy_grids', + 'gridcell_angles', + 'project', + 'rotate_grid_vectors', + 'rotate_pole', + 'rotate_winds', + 'unrotate_pole', + 'wrap_lons', + 'DistanceDifferential', + 'PartialDifferential'] # This value is used as a fall-back if the cube does not define the earth DEFAULT_SPHERICAL_EARTH_RADIUS = 6367470 @@ -332,7 +349,7 @@ def _quadrant_area(radian_lat_bounds, radian_lon_bounds, radius_of_earth): def area_weights(cube, normalize=False): - """ + r""" Returns an array of area weights, with the same dimensions as the cube. This is a 2D lat/lon area weights array, repeated over the non lat/lon @@ -440,7 +457,7 @@ def area_weights(cube, normalize=False): def cosine_latitude_weights(cube): - """ + r""" Returns an array of latitude weights, with the same dimensions as the cube. The weights are the cosine of latitude. @@ -950,7 +967,7 @@ def _transform_distance_vectors_tolerance_mask(src_crs, x, y, tgt_crs, def rotate_winds(u_cube, v_cube, target_cs): - """ + r""" Transform wind vectors to a different coordinate system. The input cubes contain U and V components parallel to the local X and Y diff --git a/lib/iris/analysis/geometry.py b/lib/iris/analysis/geometry.py index a415e10091..cca5d836ec 100644 --- a/lib/iris/analysis/geometry.py +++ b/lib/iris/analysis/geometry.py @@ -1,4 +1,4 @@ -# (C) British Crown Copyright 2010 - 2015, Met Office +# (C) British Crown Copyright 2010 - 2018, Met Office # # This file is part of Iris. # @@ -223,7 +223,7 @@ def geometry_area_weights(cube, geometry, normalize=False): else: slices.append(slice(None)) - weights[slices] = subweights + weights[tuple(slices)] = subweights # Fix for the limitation of iris.analysis.MEAN weights handling. # Broadcast the array to the full shape of the cube diff --git a/lib/iris/coords.py b/lib/iris/coords.py index e4a8eb35f2..ff2f170a2f 100644 --- a/lib/iris/coords.py +++ b/lib/iris/coords.py @@ -143,6 +143,85 @@ def __new__(cls, name_or_coord, minimum, maximum, 'groupby_point, groupby_slice') +def _discontiguity_in_2d_bounds(bds, abs_tol=1e-4): + """ + Check bounds of a 2-dimensional coordinate are contiguous + Args: + bds: Array of bounds of shape (X,Y,4) + abs_tol: tolerance + + Returns: + Bool, if there are no discontinuities + absolute difference along the x axis + absolute difference along the y axis + + """ + # Check bds has the shape (ny, nx, 4) + if not bds.ndim == 3 and bds.shape[2] == 4: + raise ValueError('2D coordinates must have 4 bounds per point ' + 'for 2D coordinate plotting') + + # Check ordering: + # i i+1 + # j @0 @1 + # j+1 @3 @2 + def mod360_diff(x1, x2): + diff = x1 - x2 + diff = (diff + 360.0 + 180.0) % 360.0 - 180.0 + return diff + + # Compare cell with the cell next to it (i+1) + diffs_along_x = mod360_diff(bds[:, :-1, 1], bds[:, 1:, 0]) + # Compare cell with the cell above it (j+1) + diffs_along_y = mod360_diff(bds[:-1, :, 3], bds[1:, :, 0]) + + def eq_diffs(x1): + return np.all(np.abs(x1) < abs_tol) + + match_y0_x1 = eq_diffs(diffs_along_x) + match_y1_x0 = eq_diffs(diffs_along_y) + + all_eq = match_y0_x1 and match_y1_x0 + + return all_eq, np.abs(diffs_along_x), np.abs(diffs_along_y) + + +def _get_2d_coord_bound_grid(bds): + """ + Function used that takes a bounds array for a 2-D coordinate variable with + 4 sides and returns the bounds grid. + + Cf standards requires the four vertices of the cell to be traversed + anti-clockwise if the coordinates are defined in a right handed coordinate + system. + + selects the zeroth vertex of each cell and then adds the column the first + vertex at the end. For the top row it uses the thirs vertex, with the + second added on to the end. + e.g. + # 0-0-0-0-1 + # 0-0-0-0-1 + # 3-3-3-3-2 + + + Args: + bounds: array of shape (X,Y,4) + + Returns: + array of shape (X+1, Y+1) + + """ + bds_shape = bds.shape + result = np.zeros((bds_shape[0] + 1, bds_shape[1] + 1)) + + result[:-1, :-1] = bds[:, :, 0] + result[:-1, -1] = bds[:, -1, 1] + result[-1, :-1] = bds[-1, :, 3] + result[-1, -1] = bds[-1, -1, 2] + + return result + + class Cell(collections.namedtuple('Cell', ['point', 'bound'])): """ An immutable representation of a single cell of a coordinate, including the @@ -951,15 +1030,22 @@ def cells(self): return _CellIterator(self) def _sanity_check_contiguous(self): - if self.ndim != 1: - raise iris.exceptions.CoordinateMultiDimError( - 'Invalid operation for {!r}. Contiguous bounds are not defined' - ' for multi-dimensional coordinates.'.format(self.name())) - if self.nbounds != 2: - raise ValueError( - 'Invalid operation for {!r}, with {} bounds. Contiguous bounds' - ' are only defined for coordinates with 2 bounds.'.format( - self.name(), self.nbounds)) + if self.ndim == 1: + if self.nbounds != 2: + raise ValueError('Invalid operation for {!r}, with {} bounds. ' + 'Contiguous bounds are only defined for 1D ' + 'coordinates with 2 bounds.'.format + (self.name(), self.nbounds)) + elif self.ndim == 2: + if self.nbounds != 4: + raise ValueError('Invalid operation for {!r}, with {} bounds. ' + 'Contiguous bounds are only defined for 2D ' + 'coordinates with 4 bounds.'.format + (self.name(), self.nbounds)) + else: + raise ValueError('Invalid operation for {!r}. Contiguous bounds ' + 'are not defined for coordinates with more than ' + '2 dimensions.'.format(self.name())) def is_contiguous(self, rtol=1e-05, atol=1e-08): """ @@ -979,19 +1065,30 @@ def is_contiguous(self, rtol=1e-05, atol=1e-08): """ if self.has_bounds(): self._sanity_check_contiguous() - return np.allclose(self.bounds[1:, 0], self.bounds[:-1, 1], - rtol=rtol, atol=atol) + if self.ndim == 1: + contiguous = np.allclose(self.bounds[1:, 0], + self.bounds[:-1, 1], + rtol=rtol, atol=atol) + elif self.ndim == 2: + contiguous, _, _ = _discontiguity_in_2d_bounds(self.bounds, + abs_tol=atol) else: - return False + contiguous = False + return contiguous def contiguous_bounds(self): """ - Returns the N+1 bound values for a contiguous bounded coordinate + Returns the N+1 bound values for a contiguous bounded 1D coordinate of length N. + Returns the (N+1, M+1) bound values for a contiguous bounded 2D + coordinate of shape (N, M). + + Assumes input is contiguous. + .. note:: - If the coordinate is does not have bounds, this method will + If the coordinate does not have bounds, this method will return bounds positioned halfway between the coordinate's points. """ @@ -1003,8 +1100,11 @@ def contiguous_bounds(self): self._sanity_check_contiguous() bounds = self.bounds - c_bounds = np.resize(bounds[:, 0], bounds.shape[0] + 1) - c_bounds[-1] = bounds[-1, 1] + if self.ndim == 1: + c_bounds = np.resize(bounds[:, 0], bounds.shape[0] + 1) + c_bounds[-1] = bounds[-1, 1] + elif self.ndim == 2: + c_bounds = _get_2d_coord_bound_grid(bounds) return c_bounds def is_monotonic(self): diff --git a/lib/iris/pandas.py b/lib/iris/pandas.py index b4e1e9787a..244a7c011b 100644 --- a/lib/iris/pandas.py +++ b/lib/iris/pandas.py @@ -130,18 +130,7 @@ def _as_pandas_coord(coord): def _assert_shared(np_obj, pandas_obj): """Ensure the pandas object shares memory.""" - if hasattr(pandas_obj, 'base'): - base = pandas_obj.base - else: - base = pandas_obj[0].base - - # Prior to Pandas 0.17, when pandas_obj is a Series, pandas_obj.values - # returns a view of the underlying array, and pandas_obj.base, which calls - # pandas_obj.values.base, returns the underlying array. In 0.17 and 0.18 - # pandas_obj.values returns the underlying array, so base may be None even - # if the array is shared. - if base is None: - base = pandas_obj.values + values = pandas_obj.values def _get_base(array): # Chase the stack of NumPy `base` references back to the original array @@ -149,7 +138,7 @@ def _get_base(array): array = array.base return array - base = _get_base(base) + base = _get_base(values) np_base = _get_base(np_obj) if base is not np_base: msg = ('Pandas {} does not share memory' diff --git a/lib/iris/plot.py b/lib/iris/plot.py index 64ce54c04c..7bf8ea07f3 100644 --- a/lib/iris/plot.py +++ b/lib/iris/plot.py @@ -31,13 +31,13 @@ import cartopy.crs as ccrs import cartopy.mpl.geoaxes from cartopy.geodesic import Geodesic +import cftime import matplotlib.axes import matplotlib.collections as mpl_collections import matplotlib.dates as mpl_dates import matplotlib.pyplot as plt from matplotlib.offsetbox import AnchoredText import matplotlib.ticker as mpl_ticker -import cftime import matplotlib.transforms as mpl_transforms import numpy as np import numpy.ma as ma @@ -51,7 +51,6 @@ import iris.palette from iris.util import _meshgrid - # Cynthia Brewer citation text. BREWER_CITE = 'Colours based on ColorBrewer.org' @@ -95,15 +94,17 @@ def get_span(coord): else: span = set(cube.coord_dims(coord)) return span + spans = list(map(get_span, coords)) for span, coord in zip(spans, coords): if not span: msg = 'The coordinate {!r} doesn\'t span a data dimension.' raise ValueError(msg.format(coord.name())) - if mode == iris.coords.BOUND_MODE and len(span) != 1: - raise ValueError('The coordinate {!r} is multi-dimensional and' - ' cannot be used in a cell-based plot.' - .format(coord.name())) + if mode == iris.coords.BOUND_MODE and len(span) not in [1, 2]: + raise ValueError('The coordinate {!r} has {} dimensions.' + 'Cell-based plotting is only supporting for' + 'coordinates with one or two dimensions.' + .format(coord.name()), len(span)) # Check the combination of coordinates spans enough (ndims) data # dimensions. @@ -129,7 +130,7 @@ def get_span(coord): return PlotDefn(plot_coords, transpose) -def _valid_bound_coord(coord): +def _valid_bound_dim_coord(coord): result = None if coord and coord.ndim == 1 and coord.nbounds: result = coord @@ -154,7 +155,7 @@ def _get_plot_defn(cube, mode, ndims=2): # When appropriate, restrict to 1D with bounds. if mode == iris.coords.BOUND_MODE: - coords = list(map(_valid_bound_coord, coords)) + coords = list(map(_valid_bound_dim_coord, coords)) def guess_axis(coord): axis = None @@ -172,6 +173,19 @@ def guess_axis(coord): aux_coords.sort(key=lambda coord: coord._as_defn()) coords[dim] = aux_coords[0] + # If plotting a 2 dimensional plot, check for 2d coordinates + if ndims == 2: + missing_dims = [dim for dim, coord in enumerate(coords) if + coord is None] + if missing_dims: + # Note that this only picks up coordinates that span the dims + two_dim_coords = cube.coords(dimensions=missing_dims) + two_dim_coords = [coord for coord in two_dim_coords + if coord.ndim == 2] + if len(two_dim_coords) >= 2: + two_dim_coords.sort(key=lambda coord: coord._as_defn()) + coords = two_dim_coords[:2] + if mode == iris.coords.POINT_MODE: # Allow multi-dimensional aux_coords to override the dim_coords # along the Z axis. This results in a preference for using the @@ -195,6 +209,7 @@ def sort_key(coord): return (order.get(axis, 0), coords.index(coord), coord and coord.name()) + sorted_coords = sorted(coords, key=sort_key) transpose = (sorted_coords != coords) @@ -256,6 +271,56 @@ def _invert_yaxis(v_coord, axes=None): axes.invert_yaxis() +def _check_contiguity_and_bounds(coord, data, abs_tol=1e-4, transpose=False): + """ + Check that the discontinuous bounds occur where the data is masked. + + If discontinuity occurs but data is masked, raise warning + If discontinuity occurs and data is NOT masked, raise error + + Args: + coords: + Array of the bounds of a 2D coord, of shape (X,Y,4) + data: + data of the the cube we are plotting + abs_tol: + tolerance when checking the contiguity + + """ + if transpose: + data = data.T + + bounds = coord.bounds + + both_dirs_contiguous, diffs_along_x, diffs_along_y = \ + iris.coords._discontiguity_in_2d_bounds(bounds, abs_tol=abs_tol) + + if not both_dirs_contiguous: + + # True where data exists + mask_invert = np.logical_not(data.mask) + + # Where a discontinuity occurs the grid will not be created correctly. + # This does not matter if the data is masked as this is not plotted. + # So for places where data exists (opposite of the mask) AND a + # diff exists. If any exist, raise a warning + not_masked_at_discontinuity_along_x = np.any( + np.logical_and(mask_invert[:, :-1], diffs_along_x)) + + not_masked_at_discontinuity_along_y = np.any( + np.logical_and(mask_invert[:-1, ], diffs_along_y)) + + # If discontinuity occurs but not masked, any grid will be created + # incorrectly, so raise a warning + if not_masked_at_discontinuity_along_x or \ + not_masked_at_discontinuity_along_y: + raise ValueError('The bounds of the {} coordinate are not' + ' contiguous and data is not masked where the' + ' discontiguity occurs. Not able to create a' + ' suitable mesh to give to' + ' Matplotlib'.format(coord.name())) + + def _draw_2d_from_bounds(draw_method_name, cube, *args, **kwargs): # NB. In the interests of clarity we use "u" and "v" to refer to the # horizontal and vertical axes on the matplotlib plot. @@ -263,10 +328,25 @@ def _draw_2d_from_bounds(draw_method_name, cube, *args, **kwargs): # Get & remove the coords entry from kwargs. coords = kwargs.pop('coords', None) if coords is not None: - plot_defn = _get_plot_defn_custom_coords_picked(cube, coords, mode) + plot_defn = _get_plot_defn_custom_coords_picked(cube, coords, mode, + ndims=2) else: plot_defn = _get_plot_defn(cube, mode, ndims=2) + twodim_contig_atol = kwargs.pop('two_dim_coord_contiguity_atol', + 1e-4) + for coord in plot_defn.coords: + if hasattr(coord, 'has_bounds'): + if coord.ndim == 2 and coord.has_bounds(): + try: + _check_contiguity_and_bounds(coord, data=cube.data, + abs_tol=twodim_contig_atol) + except ValueError: + if _check_contiguity_and_bounds(coord, data=cube.data, + abs_tol=twodim_contig_atol, + transpose=True) is True: + plot_defn.transpose = True + if _can_draw_map(plot_defn.coords): result = _map_common(draw_method_name, None, iris.coords.BOUND_MODE, cube, plot_defn, *args, **kwargs) @@ -309,7 +389,16 @@ def _draw_2d_from_bounds(draw_method_name, cube, *args, **kwargs): plot_arrays.append(values) u, v = plot_arrays - u, v = _broadcast_2d(u, v) + + # If the data is tranposed, 2D coordinates will also need to be + # tranposed. + if u.ndim == v.ndim == 2 and plot_defn.transpose is True: + u = u.T + v = v.T + + if u.ndim == v.ndim == 1: + u, v = _broadcast_2d(u, v) + axes = kwargs.pop('axes', None) draw_method = getattr(axes if axes else plt, draw_method_name) result = draw_method(u, v, data, *args, **kwargs) @@ -434,8 +523,8 @@ def _fixup_dates(coord, values): raise IrisError(msg) r = [nc_time_axis.CalendarDateTime( - cftime.datetime(*date), coord.units.calendar) - for date in dates] + cftime.datetime(*date), coord.units.calendar) + for date in dates] values = np.empty(len(r), dtype=object) values[:] = r return values @@ -675,8 +764,8 @@ def _ensure_cartopy_axes_and_determine_kwargs(x_coord, y_coord, kwargs): axes = kwargs.get('axes') if axes is None: if (isinstance(cs, iris.coord_systems.RotatedGeogCS) and - x_coord.points.max() > 180 and x_coord.points.max() < 360 and - x_coord.points.min() > 0): + x_coord.points.max() > 180 and x_coord.points.max() < 360 and + x_coord.points.min() > 0): # The RotatedGeogCS has 0 - 360 extent, different from the # assumptions made by Cartopy: rebase longitudes for the map axes # to set the datum longitude to the International Date Line. @@ -721,17 +810,22 @@ def _map_common(draw_method_name, arg_func, mode, cube, plot_defn, else: raise ValueError("Expected 1D or 2D XY coords") else: - try: - x, y = _meshgrid(x_coord.contiguous_bounds(), - y_coord.contiguous_bounds()) - # Exception translation. - except iris.exceptions.CoordinateMultiDimError: - raise ValueError("Could not get XY grid from bounds. " - "X or Y coordinate not 1D.") - except ValueError: - raise ValueError("Could not get XY grid from bounds. " - "X or Y coordinate doesn't have 2 bounds " - "per point.") + if not x_coord.ndim == y_coord.ndim == 2: + try: + x, y = _meshgrid(x_coord.contiguous_bounds(), + y_coord.contiguous_bounds()) + # Exception translation. + except iris.exceptions.CoordinateMultiDimError: + raise ValueError("Expected two 1D coords. Could not get XY" + " grid from bounds. X or Y coordinate not" + " 1D.") + except ValueError: + raise ValueError("Could not get XY grid from bounds. " + "X or Y coordinate doesn't have 2 bounds " + "per point.") + else: + x = x_coord.contiguous_bounds() + y = y_coord.contiguous_bounds() # Obtain the data array. data = cube.data @@ -1005,7 +1099,11 @@ def outline(cube, coords=None, color='k', linewidth=None, axes=None): def pcolor(cube, *args, **kwargs): """ - Draws a pseudocolor plot based on the given Cube. + Draws a pseudocolor plot based on the given 2-dimensional Cube. + + The cube must have either two 1-dimensional coordinates or two + 2-dimensional coordinates with contiguous bounds to plot against each + other. Kwargs: @@ -1019,6 +1117,11 @@ def pcolor(cube, *args, **kwargs): * axes: the :class:`matplotlib.axes.Axes` to use for drawing. Defaults to the current axes if none provided. + * two_dim_coord_contiguity_atol: absolute tolerance when checking for + contiguity between cells in a two dimensional coordinate. + + + See :func:`matplotlib.pyplot.pcolor` for details of other valid keyword arguments. @@ -1031,7 +1134,11 @@ def pcolor(cube, *args, **kwargs): def pcolormesh(cube, *args, **kwargs): """ - Draws a pseudocolor plot based on the given Cube. + Draws a pseudocolor plot based on the given 2-dimensional Cube. + + The cube must have either two 1-dimensional coordinates or two + 2-dimensional coordinates with contiguous bounds to plot against each + other. Kwargs: @@ -1045,6 +1152,9 @@ def pcolormesh(cube, *args, **kwargs): * axes: the :class:`matplotlib.axes.Axes` to use for drawing. Defaults to the current axes if none provided. + * two_dim_coord_contiguity_atol: absolute tolerance when checking for + contiguity between cells in a two dimensional coordinate. + See :func:`matplotlib.pyplot.pcolormesh` for details of other valid keyword arguments. @@ -1073,6 +1183,7 @@ def points(cube, *args, **kwargs): keyword arguments. """ + def _scatter_args(u, v, data, *args, **kwargs): return ((u, v) + args, kwargs) @@ -1080,6 +1191,139 @@ def _scatter_args(u, v, data, *args, **kwargs): *args, **kwargs) +def _vector_component_args(x_points, y_points, u_data, *args, **kwargs): + """ + Callback from _draw_2d_from_points for 'quiver' and 'streamlines'. + + Returns arguments (x, y, u, v), to be passed to the underlying matplotlib + call. + + "u_data" will always be "u_cube.data". + The matching "v_cube.data" component is stored in kwargs['_v_data']. + + """ + v_data = kwargs.pop('_v_data') + + # Rescale u+v values for plot distortion. + crs = kwargs.get('transform', None) + if crs: + if not isinstance(crs, (ccrs.PlateCarree, ccrs.RotatedPole)): + msg = ('Can only plot vectors provided in a lat-lon ' + 'projection, i.e. "cartopy.crs.PlateCarree" or ' + '"cartopy.crs.RotatedPole". This ' + "cubes coordinate system is {}.") + raise ValueError(msg.format(crs)) + # Given the above check, the Y points must be latitudes. + # We therefore **assume** they are in degrees : I'm not sure this + # is wise, but all the rest of this plot code does that, e.g. in + # _map_common. + # TODO: investigate degree units assumptions, here + elsewhere. + + # Implement a latitude scaling, but preserve the given magnitudes. + v_data = v_data.copy() + mags = np.sqrt(u_data * u_data + v_data * v_data) + v_data *= np.cos(np.deg2rad(y_points)) + scales = mags / np.sqrt(u_data * u_data + v_data * v_data) + u_data *= scales + v_data *= scales + + return ((x_points, y_points, u_data, v_data), kwargs) + + +def quiver(u_cube, v_cube, *args, **kwargs): + """ + Draws an arrow plot from two vector component cubes. + + Args: + + * u_cube, v_cube : (:class:`~iris.cube.Cube`) + u and v vector components. Must have same shape and units. + If the cubes have geographic coordinates, the values are treated as + true distance differentials, e.g. windspeeds, and *not* map coordinate + vectors. The components are aligned with the North and East of the + cube coordinate system. + + .. Note: + + At present, if u_cube and v_cube have geographic coordinates, then they + must be in a lat-lon coordinate system, though it may be a rotated one. + To transform wind values between coordinate systems, use + :func:`iris.analysis.cartography.rotate_vectors`. + To transform coordinate grid points, you will need to create + 2-dimensional arrays of x and y values. These can be transformed with + :meth:`cartopy.crs.CRS.transform_points`. + + Kwargs: + + * coords: (list of :class:`~iris.coords.Coord` or string) + Coordinates or coordinate names. Use the given coordinates as the axes + for the plot. The order of the given coordinates indicates which axis + to use for each, where the first element is the horizontal + axis of the plot and the second element is the vertical axis + of the plot. + + * axes: the :class:`matplotlib.axes.Axes` to use for drawing. + Defaults to the current axes if none provided. + + See :func:`matplotlib.pyplot.quiver` for details of other valid + keyword arguments. + + """ + # + # TODO: check u + v cubes for compatibility. + # + kwargs['_v_data'] = v_cube.data + return _draw_2d_from_points('quiver', _vector_component_args, u_cube, + *args, **kwargs) + + +def streamplot(u_cube, v_cube, *args, **kwargs): + """ + Draws a streamline plot from two vector component cubes. + + Args: + + * u_cube, v_cube : (:class:`~iris.cube.Cube`) + u and v vector components. Must have same shape and units. + If the cubes have geographic coordinates, the values are treated as + true distance differentials, e.g. windspeeds, and *not* map coordinate + vectors. The components are aligned with the North and East of the + cube coordinate system. + + .. Note: + + At present, if u_cube and v_cube have geographic coordinates, then they + must be in a lat-lon coordinate system, though it may be a rotated one. + To transform wind values between coordinate systems, use + :func:`iris.analysis.cartography.rotate_vectors`. + To transform coordinate grid points, you will need to create + 2-dimensional arrays of x and y values. These can be transformed with + :meth:`cartopy.crs.CRS.transform_points`. + + Kwargs: + + * coords: (list of :class:`~iris.coords.Coord` or string) + Coordinates or coordinate names. Use the given coordinates as the axes + for the plot. The order of the given coordinates indicates which axis + to use for each, where the first element is the horizontal + axis of the plot and the second element is the vertical axis + of the plot. + + * axes: the :class:`matplotlib.axes.Axes` to use for drawing. + Defaults to the current axes if none provided. + + See :func:`matplotlib.pyplot.quiver` for details of other valid + keyword arguments. + + """ + # + # TODO: check u + v cubes for compatibility. + # + kwargs['_v_data'] = v_cube.data + return _draw_2d_from_points('streamplot', _vector_component_args, u_cube, + *args, **kwargs) + + def plot(*args, **kwargs): """ Draws a line plot based on the given cube(s) or coordinate(s). diff --git a/lib/iris/tests/__init__.py b/lib/iris/tests/__init__.py index 8c64dc15b1..7550fce1ca 100644 --- a/lib/iris/tests/__init__.py +++ b/lib/iris/tests/__init__.py @@ -663,7 +663,23 @@ def assertArrayAllClose(self, a, b, rtol=1.0e-7, atol=0.0, **kwargs): For full details see underlying routine numpy.testing.assert_allclose. """ - np.testing.assert_allclose(a, b, rtol=rtol, atol=atol, **kwargs) + ok = np.allclose(a, b, atol=atol, rtol=rtol, equal_nan=True) + if not ok: + # Calculate errors above a pointwise tolerance : The method is + # taken from "numpy.core.numeric.isclose". + errors = (np.abs(a-b) - atol + rtol * np.abs(b)) + worst_inds = np.unravel_index(np.argmax(errors.flat), errors.shape) + # Build a more useful message than from np.testing.assert_allclose. + msg = ('\nARRAY CHECK FAILED "assertArrayAllClose" :' + '\n with shapes={} {}, atol={}, rtol={}' + '\n worst at element {} : a={} b={}' + '\n absolute error ~{:.3g}, equivalent to rtol ~{:.3e}') + aval, bval = a[worst_inds], b[worst_inds] + absdiff = np.abs(aval - bval) + equiv_rtol = absdiff / bval + raise AssertionError(msg.format( + a.shape, b.shape, atol, rtol, worst_inds, aval, bval, absdiff, + equiv_rtol)) @contextlib.contextmanager def temp_filename(self, suffix=''): @@ -1196,6 +1212,12 @@ class MyPlotTests(test.GraphicsTest): 'Test(s) require "python-stratify", which is not available.') +SKIP_2D_TESTS = True +skip_2d = unittest.skipIf( + SKIP_2D_TESTS, + 'Test(s) broken by WIP on 2d coords support -- temporarily disabled.') + + def no_warnings(func): """ Provides a decorator to ensure that there are no warnings raised diff --git a/lib/iris/tests/integration/test_regridding.py b/lib/iris/tests/integration/test_regridding.py index ef521aa395..47d155f3f2 100644 --- a/lib/iris/tests/integration/test_regridding.py +++ b/lib/iris/tests/integration/test_regridding.py @@ -109,5 +109,116 @@ def test_nearest(self): self.assertArrayShapeStats(res, (1, 6, 3, 4), 315.890808, 11.000724) +class TestZonalMean_global(tests.IrisTest): + def setUp(self): + np.random.seed(0) + self.src = iris.cube.Cube(np.random.random_integers(0, 10, (140, 1))) + s_crs = iris.coord_systems.GeogCS(6371229.0) + sy_coord = iris.coords.DimCoord( + np.linspace(-90, 90, 140), standard_name='latitude', + units='degrees', coord_system=s_crs) + sx_coord = iris.coords.DimCoord( + -180, bounds=[-180, 180], standard_name='longitude', + units='degrees', circular=True, coord_system=s_crs) + self.src.add_dim_coord(sy_coord, 0) + self.src.add_dim_coord(sx_coord, 1) + + def test_linear_same_crs_global(self): + # Regrid the zonal mean onto an identical coordinate system target, but + # on a different set of longitudes - which should result in no change. + points = [-150, -90, -30, 30, 90, 150] + bounds = [[-180, -120], [-120, -60], [-60, 0], [0, 60], [60, 120], + [120, 180]] + sx_coord = self.src.coord(axis='x') + sy_coord = self.src.coord(axis='y') + x_coord = sx_coord.copy(points, bounds=bounds) + grid = iris.cube.Cube( + np.zeros([sy_coord.points.size, x_coord.points.size])) + grid.add_dim_coord(sy_coord, 0) + grid.add_dim_coord(x_coord, 1) + + res = self.src.regrid(grid, iris.analysis.Linear()) + + # Ensure data remains unchanged. + # (the same along each column) + self.assertTrue( + np.array([(res.data[:, 0]-res.data[:, i]).max() for i in + range(1, res.shape[1])]).max() < 1e-10) + self.assertArrayAlmostEqual(res.data[:, 0], self.src.data.reshape(-1)) + + +class TestZonalMean_regional(TestZonalMean_global, tests.IrisTest): + def setUp(self): + super(TestZonalMean_regional, self).setUp() + + # Define a target grid and a target result (what we expect the + # regridder to return). + sx_coord = self.src.coord(axis='x') + sy_coord = self.src.coord(axis='y') + grid_crs = iris.coord_systems.RotatedGeogCS( + 37.5, 177.5, ellipsoid=iris.coord_systems.GeogCS(6371229.0)) + grid_x = sx_coord.copy(np.linspace(350, 370, 100)) + grid_x.circular = False + grid_x.coord_system = grid_crs + grid_y = sy_coord.copy(np.linspace(-10, 10, 100)) + grid_y.coord_system = grid_crs + grid = iris.cube.Cube( + np.zeros([grid_y.points.size, grid_x.points.size])) + grid.add_dim_coord(grid_y, 0) + grid.add_dim_coord(grid_x, 1) + + # The target result is derived by regridding a multi-column version of + # the source to the target (i.e. turning a zonal mean regrid into a + # conventional regrid). + self.tar = self.zonal_mean_as_multi_column(self.src).regrid( + grid, iris.analysis.Linear()) + self.grid = grid + + def zonal_mean_as_multi_column(self, src_cube): + # Munge the source (duplicate source latitudes) so that we can + # utilise linear regridding as a conventional problem (that is, to + # duplicate columns so that it is no longer a zonal mean problem). + src_cube2 = src_cube.copy() + src_cube2.coord(axis='x').points = -90 + src_cube2.coord(axis='x').bounds = [-180, 0] + src_cube.coord(axis='x').points = 90 + src_cube.coord(axis='x').bounds = [0, 180] + src_cubes = iris.cube.CubeList([src_cube, src_cube2]) + return src_cubes.concatenate_cube() + + def test_linear_rotated_regional(self): + # Ensure that zonal mean source data is linearly interpolated onto a + # high resolution target. + regridder = iris.analysis.Linear() + res = self.src.regrid(self.grid, regridder) + self.assertArrayAlmostEqual(res.data, self.tar.data) + + def test_linear_rotated_regional_no_extrapolation(self): + # Capture the case where our source remains circular but we don't use + # extrapolation. + regridder = iris.analysis.Linear(extrapolation_mode='nan') + res = self.src.regrid(self.grid, regridder) + self.assertArrayAlmostEqual(res.data, self.tar.data) + + def test_linear_rotated_regional_not_circular(self): + # Capture the case where our source is not circular but we utilise + # extrapolation. + regridder = iris.analysis.Linear() + self.src.coord(axis='x').circular = False + res = self.src.regrid(self.grid, regridder) + self.assertArrayAlmostEqual(res.data, self.tar.data) + + def test_linear_rotated_regional_no_extrapolation_not_circular(self): + # Confirm how zonal mean actually works in so far as, that + # extrapolation and circular source handling is the means by which + # these usecases are supported. + # In the case where the source is neither using extrapolation and is + # not circular, then 'nan' values will result (as we would expect). + regridder = iris.analysis.Linear(extrapolation_mode='nan') + self.src.coord(axis='x').circular = False + res = self.src.regrid(self.grid, regridder) + self.assertTrue(np.isnan(res.data).all()) + + if __name__ == "__main__": tests.main() diff --git a/lib/iris/tests/stock.py b/lib/iris/tests/stock/__init__.py similarity index 74% rename from lib/iris/tests/stock.py rename to lib/iris/tests/stock/__init__.py index 25df8eda33..5965d5a208 100644 --- a/lib/iris/tests/stock.py +++ b/lib/iris/tests/stock/__init__.py @@ -1,4 +1,4 @@ -# (C) British Crown Copyright 2010 - 2017, Met Office +# (C) British Crown Copyright 2010 - 2018, Met Office # # This file is part of Iris. # @@ -36,7 +36,8 @@ from iris.coords import DimCoord, AuxCoord import iris.tests as tests from iris.coord_systems import GeogCS, RotatedGeogCS - +from ._stock_2d_latlons import (sample_2d_latlons, + make_bounds_discontiguous_at_point) def lat_lon_cube(): """ @@ -46,12 +47,12 @@ def lat_lon_cube(): """ cube = Cube(np.arange(12, dtype=np.int32).reshape((3, 4))) cs = GeogCS(6371229) - coord = iris.coords.DimCoord(points=np.array([-1, 0, 1], dtype=np.int32), + coord = DimCoord(points=np.array([-1, 0, 1], dtype=np.int32), standard_name='latitude', units='degrees', coord_system=cs) cube.add_dim_coord(coord, 0) - coord = iris.coords.DimCoord(points=np.array([-1, 0, 1, 2], dtype=np.int32), + coord = DimCoord(points=np.array([-1, 0, 1, 2], dtype=np.int32), standard_name='longitude', units='degrees', coord_system=cs) @@ -99,7 +100,7 @@ def simple_1d(with_bounds=True): cube.units = '1' points = np.arange(11, dtype=np.int32) + 1 bounds = np.column_stack([np.arange(11, dtype=np.int32), np.arange(11, dtype=np.int32) + 1]) - coord = iris.coords.DimCoord(points, long_name='foo', units='1', bounds=bounds) + coord = DimCoord(points, long_name='foo', units='1', bounds=bounds) cube.add_dim_coord(coord, 0) return cube @@ -124,12 +125,15 @@ def simple_2d(with_bounds=True): cube = Cube(np.arange(12, dtype=np.int32).reshape((3, 4))) cube.long_name = 'thingness' cube.units = '1' - y_points = np.array([ 2.5, 7.5, 12.5]) + y_points = np.array([2.5, 7.5, 12.5]) y_bounds = np.array([[0, 5], [5, 10], [10, 15]], dtype=np.int32) - y_coord = iris.coords.DimCoord(y_points, long_name='bar', units='1', bounds=y_bounds if with_bounds else None) + y_coord = DimCoord(y_points, long_name='bar', units='1', + bounds=y_bounds if with_bounds else None) x_points = np.array([ -7.5, 7.5, 22.5, 37.5]) - x_bounds = np.array([[-15, 0], [0, 15], [15, 30], [30, 45]], dtype=np.int32) - x_coord = iris.coords.DimCoord(x_points, long_name='foo', units='1', bounds=x_bounds if with_bounds else None) + x_bounds = np.array([[-15, 0], [0, 15], [15, 30], [30, 45]], + dtype=np.int32) + x_coord = DimCoord(x_points, long_name='foo', units='1', + bounds=x_bounds if with_bounds else None) cube.add_dim_coord(y_coord, 0) cube.add_dim_coord(x_coord, 1) @@ -191,9 +195,8 @@ def simple_3d_w_multidim_coords(with_bounds=True): [[5, 15], [15, 20], [20, 35], [35, 50]], [[10, 20], [20, 25], [25, 40], [40, 60]]], dtype=np.int32) - y_coord = iris.coords.AuxCoord(points=y_points, long_name='bar', - units='1', - bounds=y_bounds if with_bounds else None) + y_coord = AuxCoord(points=y_points, long_name='bar', units='1', + bounds=y_bounds if with_bounds else None) x_points = np.array([[-7.5, 7.5, 22.5, 37.5], [-12.5, 4., 26.5, 47.5], [2.5, 14., 36.5, 44.]]) @@ -201,12 +204,10 @@ def simple_3d_w_multidim_coords(with_bounds=True): [[-25, 0], [0, 8], [8, 45], [45, 50]], [[-5, 10], [10, 18], [18, 55], [18, 70]]], dtype=np.int32) - x_coord = iris.coords.AuxCoord(points=x_points, long_name='foo', - units='1', - bounds=x_bounds if with_bounds else None) - wibble_coord = iris.coords.DimCoord(np.array([10., 30.], - dtype=np.float32), - long_name='wibble', units='1') + x_coord = AuxCoord(points=x_points, long_name='foo', units='1', + bounds=x_bounds if with_bounds else None) + wibble_coord = DimCoord(np.array([10., 30.], dtype=np.float32), + long_name='wibble', units='1') cube.add_dim_coord(wibble_coord, [0]) cube.add_aux_coord(y_coord, [1, 2]) @@ -238,13 +239,13 @@ def simple_3d(): cube = Cube(np.arange(24, dtype=np.int32).reshape((2, 3, 4))) cube.long_name = 'thingness' cube.units = '1' - wibble_coord = iris.coords.DimCoord(np.array([10., 30.], + wibble_coord = DimCoord(np.array([10., 30.], dtype=np.float32), long_name='wibble', units='1') - lon = iris.coords.DimCoord([-180, -90, 0, 90], + lon = DimCoord([-180, -90, 0, 90], standard_name='longitude', units='degrees', circular=True) - lat = iris.coords.DimCoord([90, 0, -90], + lat = DimCoord([90, 0, -90], standard_name='latitude', units='degrees') cube.add_dim_coord(wibble_coord, [0]) cube.add_dim_coord(lat, [1]) @@ -294,39 +295,46 @@ def track_1d(duplicate_x=False): array([ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) """ - cube = Cube(np.arange(11, dtype=np.int32), standard_name='air_temperature', units='K') - bounds = np.column_stack([np.arange(11, dtype=np.int32), np.arange(11, dtype=np.int32) + 1]) + cube = Cube(np.arange(11, dtype=np.int32), standard_name='air_temperature', + units='K') + bounds = np.column_stack([np.arange(11, dtype=np.int32), + np.arange(11, dtype=np.int32) + 1]) pts = bounds[:, 1] - coord = iris.coords.AuxCoord(pts, 'projection_x_coordinate', units='1', bounds=bounds) + coord = AuxCoord(pts, 'projection_x_coordinate', units='1', bounds=bounds) cube.add_aux_coord(coord, [0]) if duplicate_x: - coord = iris.coords.AuxCoord(pts, 'projection_x_coordinate', units='1', bounds=bounds) + coord = AuxCoord(pts, 'projection_x_coordinate', units='1', + bounds=bounds) cube.add_aux_coord(coord, [0]) - coord = iris.coords.AuxCoord(pts * 2, 'projection_y_coordinate', units='1', bounds=bounds * 2) + coord = AuxCoord(pts * 2, 'projection_y_coordinate', units='1', + bounds=bounds * 2) cube.add_aux_coord(coord, 0) return cube def simple_2d_w_multidim_and_scalars(): data = np.arange(50, dtype=np.int32).reshape((5, 10)) - cube = iris.cube.Cube(data, long_name='test 2d dimensional cube', units='meters') + cube = iris.cube.Cube(data, long_name='test 2d dimensional cube', + units='meters') # DimCoords - dim1 = iris.coords.DimCoord(np.arange(5, dtype=np.float32) * 5.1 + 3.0, long_name='dim1', units='meters') - dim2 = iris.coords.DimCoord(np.arange(10, dtype=np.int32), long_name='dim2', units='meters', - bounds=np.arange(20, dtype=np.int32).reshape(10, 2)) + dim1 = DimCoord(np.arange(5, dtype=np.float32) * 5.1 + 3.0, + long_name='dim1', units='meters') + dim2 = DimCoord(np.arange(10, dtype=np.int32), + long_name='dim2', units='meters', + bounds=np.arange(20, dtype=np.int32).reshape(10, 2)) # Scalars - an_other = iris.coords.AuxCoord(3.0, long_name='an_other', units='meters') - yet_an_other = iris.coords.DimCoord(23.3, standard_name='air_temperature', - long_name='custom long name', - var_name='custom_var_name', - units='K') + an_other = AuxCoord(3.0, long_name='an_other', units='meters') + yet_an_other = DimCoord(23.3, standard_name='air_temperature', + long_name='custom long name', + var_name='custom_var_name', units='K') # Multidim - my_multi_dim_coord = iris.coords.AuxCoord(np.arange(50, dtype=np.int32).reshape(5, 10), - long_name='my_multi_dim_coord', units='1', - bounds=np.arange(200, dtype=np.int32).reshape(5, 10, 4)) + my_multi_dim_coord = AuxCoord(np.arange(50, dtype=np.int32).reshape(5, 10), + long_name='my_multi_dim_coord', units='1', + bounds=np.arange(200, dtype=np.int32). + reshape(5, 10, 4)) cube.add_dim_coord(dim1, 0) cube.add_dim_coord(dim2, 1) @@ -361,18 +369,21 @@ def hybrid_height(): """ data = np.arange(12, dtype='i8').reshape((3, 4)) - orography = icoords.AuxCoord([10, 25, 50, 5], standard_name='surface_altitude', units='m') - model_level = icoords.AuxCoord([2, 1, 0], standard_name='model_level_number') - level_height = icoords.DimCoord([100, 50, 10], long_name='level_height', - units='m', attributes={'positive': 'up'}, - bounds=[[150, 75], [75, 20], [20, 0]]) - sigma = icoords.AuxCoord([0.8, 0.9, 0.95], long_name='sigma', - bounds=[[0.7, 0.85], [0.85, 0.97], [0.97, 1.0]]) - hybrid_height = iris.aux_factory.HybridHeightFactory(level_height, sigma, orography) + orography = AuxCoord([10, 25, 50, 5], standard_name='surface_altitude', + units='m') + model_level = AuxCoord([2, 1, 0], standard_name='model_level_number') + level_height = DimCoord([100, 50, 10], long_name='level_height', + units='m', attributes={'positive': 'up'}, + bounds=[[150, 75], [75, 20], [20, 0]]) + sigma = AuxCoord([0.8, 0.9, 0.95], long_name='sigma', + bounds=[[0.7, 0.85], [0.85, 0.97], [0.97, 1.0]]) + hybrid_height = iris.aux_factory.HybridHeightFactory(level_height, + sigma, orography) cube = iris.cube.Cube(data, standard_name='air_temperature', units='K', dim_coords_and_dims=[(level_height, 0)], - aux_coords_and_dims=[(orography, 1), (model_level, 0), (sigma, 0)], + aux_coords_and_dims=[(orography, 1), + (model_level, 0), (sigma, 0)], aux_factories=[hybrid_height]) return cube @@ -381,25 +392,25 @@ def simple_4d_with_hybrid_height(): cube = iris.cube.Cube(np.arange(3*4*5*6, dtype='i8').reshape(3, 4, 5, 6), "air_temperature", units="K") - cube.add_dim_coord(iris.coords.DimCoord(np.arange(3, dtype='i8'), "time", - units="hours since epoch"), 0) - cube.add_dim_coord(iris.coords.DimCoord(np.arange(4, dtype='i8')+10, - "model_level_number", units="1"), 1) - cube.add_dim_coord(iris.coords.DimCoord(np.arange(5, dtype='i8')+20, - "grid_latitude", - units="degrees"), 2) - cube.add_dim_coord(iris.coords.DimCoord(np.arange(6, dtype='i8')+30, - "grid_longitude", - units="degrees"), 3) - - cube.add_aux_coord(iris.coords.AuxCoord(np.arange(4, dtype='i8')+40, - long_name="level_height", - units="m"), 1) - cube.add_aux_coord(iris.coords.AuxCoord(np.arange(4, dtype='i8')+50, - long_name="sigma", units="1"), 1) - cube.add_aux_coord(iris.coords.AuxCoord(np.arange(5*6, dtype='i8').reshape(5, 6)+100, - long_name="surface_altitude", - units="m"), [2, 3]) + cube.add_dim_coord(DimCoord(np.arange(3, dtype='i8'), "time", + units="hours since epoch"), 0) + cube.add_dim_coord(DimCoord(np.arange(4, dtype='i8')+10, + "model_level_number", units="1"), 1) + cube.add_dim_coord(DimCoord(np.arange(5, dtype='i8')+20, + "grid_latitude", + units="degrees"), 2) + cube.add_dim_coord(DimCoord(np.arange(6, dtype='i8')+30, + "grid_longitude", + units="degrees"), 3) + + cube.add_aux_coord(AuxCoord(np.arange(4, dtype='i8')+40, + long_name="level_height", + units="m"), 1) + cube.add_aux_coord(AuxCoord(np.arange(4, dtype='i8')+50, + long_name="sigma", units="1"), 1) + cube.add_aux_coord(AuxCoord(np.arange(5*6, dtype='i8').reshape(5, 6)+100, + long_name="surface_altitude", + units="m"), [2, 3]) cube.add_aux_factory(iris.aux_factory.HybridHeightFactory( delta=cube.coord("level_height"), @@ -449,7 +460,8 @@ def realistic_4d(): Returns a realistic 4d cube. >>> print(repr(realistic_4d())) - + """ # the stock arrays were created in Iris 0.8 with: @@ -477,7 +489,8 @@ def realistic_4d(): if not os.path.isfile(data_path): raise IOError('Test data is not available at {}.'.format(data_path)) r = np.load(data_path) - # sort the arrays based on the order they were originally given. The names given are of the form 'arr_1' or 'arr_10' + # sort the arrays based on the order they were originally given. + # The names given are of the form 'arr_1' or 'arr_10' _, arrays = zip(*sorted(six.iteritems(r), key=lambda item: int(item[0][4:]))) lat_pts, lat_bnds, lon_pts, lon_bnds, level_height_pts, \ @@ -486,25 +499,37 @@ def realistic_4d(): ll_cs = RotatedGeogCS(37.5, 177.5, ellipsoid=GeogCS(6371229.0)) - lat = icoords.DimCoord(lat_pts, standard_name='grid_latitude', units='degrees', - bounds=lat_bnds, coord_system=ll_cs) - lon = icoords.DimCoord(lon_pts, standard_name='grid_longitude', units='degrees', - bounds=lon_bnds, coord_system=ll_cs) + lat = icoords.DimCoord(lat_pts, standard_name='grid_latitude', + units='degrees', bounds=lat_bnds, + coord_system=ll_cs) + lon = icoords.DimCoord(lon_pts, standard_name='grid_longitude', + units='degrees', bounds=lon_bnds, + coord_system=ll_cs) level_height = icoords.DimCoord(level_height_pts, long_name='level_height', units='m', bounds=level_height_bnds, attributes={'positive': 'up'}) - model_level = icoords.DimCoord(model_level_pts, standard_name='model_level_number', + model_level = icoords.DimCoord(model_level_pts, + standard_name='model_level_number', units='1', attributes={'positive': 'up'}) - sigma = icoords.AuxCoord(sigma_pts, long_name='sigma', units='1', bounds=sigma_bnds) - orography = icoords.AuxCoord(orography, standard_name='surface_altitude', units='m') - time = icoords.DimCoord(time_pts, standard_name='time', units='hours since 1970-01-01 00:00:00') - forecast_period = icoords.DimCoord(forecast_period_pts, standard_name='forecast_period', units='hours') + sigma = icoords.AuxCoord(sigma_pts, long_name='sigma', units='1', + bounds=sigma_bnds) + orography = icoords.AuxCoord(orography, standard_name='surface_altitude', + units='m') + time = icoords.DimCoord(time_pts, standard_name='time', + units='hours since 1970-01-01 00:00:00') + forecast_period = icoords.DimCoord(forecast_period_pts, + standard_name='forecast_period', + units='hours') - hybrid_height = iris.aux_factory.HybridHeightFactory(level_height, sigma, orography) + hybrid_height = iris.aux_factory.HybridHeightFactory(level_height, + sigma, orography) - cube = iris.cube.Cube(data, standard_name='air_potential_temperature', units='K', - dim_coords_and_dims=[(time, 0), (model_level, 1), (lat, 2), (lon, 3)], - aux_coords_and_dims=[(orography, (2, 3)), (level_height, 1), (sigma, 1), + cube = iris.cube.Cube(data, standard_name='air_potential_temperature', + units='K', + dim_coords_and_dims=[(time, 0), (model_level, 1), + (lat, 2), (lon, 3)], + aux_coords_and_dims=[(orography, (2, 3)), + (level_height, 1), (sigma, 1), (forecast_period, None)], attributes={'source': 'Iris test case'}, aux_factories=[hybrid_height]) @@ -516,7 +541,8 @@ def realistic_4d_no_derived(): Returns a realistic 4d cube without hybrid height >>> print(repr(realistic_4d())) - + """ cube = realistic_4d() @@ -534,24 +560,30 @@ def realistic_4d_w_missing_data(): data_archive = np.load(data_path) data = ma.masked_array(data_archive['arr_0'], mask=data_archive['arr_1']) - # sort the arrays based on the order they were originally given. The names given are of the form 'arr_1' or 'arr_10' + # sort the arrays based on the order they were originally given. + # The names given are of the form 'arr_1' or 'arr_10' ll_cs = GeogCS(6371229) - lat = iris.coords.DimCoord(np.arange(20, dtype=np.float32), standard_name='grid_latitude', - units='degrees', coord_system=ll_cs) - lon = iris.coords.DimCoord(np.arange(20, dtype=np.float32), standard_name='grid_longitude', - units='degrees', coord_system=ll_cs) - time = iris.coords.DimCoord([1000., 1003., 1006.], standard_name='time', - units='hours since 1970-01-01 00:00:00') - forecast_period = iris.coords.DimCoord([0.0, 3.0, 6.0], standard_name='forecast_period', units='hours') - pressure = iris.coords.DimCoord(np.array([ 800., 900., 1000.], dtype=np.float32), - long_name='pressure', units='hPa') + lat = DimCoord(np.arange(20, dtype=np.float32), + standard_name='grid_latitude', + units='degrees', coord_system=ll_cs) + lon = DimCoord(np.arange(20, dtype=np.float32), + standard_name='grid_longitude', + units='degrees', coord_system=ll_cs) + time = DimCoord([1000., 1003., 1006.], standard_name='time', + units='hours since 1970-01-01 00:00:00') + forecast_period = DimCoord([0.0, 3.0, 6.0], + standard_name='forecast_period', + units='hours') + pressure = DimCoord(np.array([800., 900., 1000.], dtype=np.float32), + long_name='pressure', units='hPa') cube = iris.cube.Cube(data, long_name='missing data test data', units='K', - dim_coords_and_dims=[(time, 0), (pressure, 1), (lat, 2), (lon, 3)], + dim_coords_and_dims=[(time, 0), (pressure, 1), + (lat, 2), (lon, 3)], aux_coords_and_dims=[(forecast_period, 0)], - attributes={'source':'Iris test case'}) + attributes={'source': 'Iris test case'}) return cube diff --git a/lib/iris/tests/stock/_stock_2d_latlons.py b/lib/iris/tests/stock/_stock_2d_latlons.py new file mode 100644 index 0000000000..395a50d8ea --- /dev/null +++ b/lib/iris/tests/stock/_stock_2d_latlons.py @@ -0,0 +1,350 @@ +# (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 . +""" +Extra stock routines for making and manipulating cubes with 2d coordinates, +to mimic ocean grid data. + +""" +from __future__ import (absolute_import, division, print_function) +from six.moves import (filter, input, map, range, zip) # noqa + +import numpy as np +import numpy.ma as ma + +from iris.analysis.cartography import unrotate_pole +from iris.cube import Cube +from iris.coords import AuxCoord, DimCoord +from iris.coord_systems import RotatedGeogCS + + +def expand_1d_x_and_y_bounds_to_2d_xy(x_bounds_1d, y_bounds_1d): + """ + Convert bounds for separate 1-D X and Y coords into bounds for the + equivalent 2D coordinates. + + The output arrays have 4 points per cell, for 4 'corners' of a gridcell, + in the usual anticlockwise order + (bottom-left, bottom-right, top-right, top-left). + + If 1-dimensional X and Y coords have shapes nx and ny, then their bounds + have shapes (nx, 2) and (ny, 2). + The equivalent 2d coordinates would have values which are a "meshgrid" of + the original 1-D points, both having the shape (ny, ny). + The outputs are 2d bounds arrays suitable for such 2d coordinates. + + Args: + + * x_bounds_1d, y_bounds_1d : (array) + Coordinate bounds arrays, with shapes (nx, 2) and (ny, 2). + + Result: + + * x_bounds_2d, y_bounds_2d : (array) + Expanded 2d bounds arrays, both of shape (ny, nx, 4). + + """ + shapes = [bds.shape for bds in (x_bounds_1d, y_bounds_1d)] + for shape in shapes: + if len(shape) != 2 or shape[1] != 2: + msg = ('One-dimensional bounds arrays must have shapes (ny, 2) ' + 'and (nx, 2). Got {} and {}.') + raise ValueError(msg.format(*shapes)) + + # Construct output arrays, which are both (ny, nx, 4). + nx, ny = [shape[0] for shape in shapes] + bds_2d_x = np.zeros((ny, nx, 4)) + bds_2d_y = bds_2d_x.copy() + + # Expand x bounds to 2D array (ny, nx, 4) : the same over all 'Y'. + # Bottom left+right corners are the original 1-D x bounds. + bds_2d_x[:, :, 0] = x_bounds_1d[:, 0].reshape((1, nx)) + bds_2d_x[:, :, 1] = x_bounds_1d[:, 1].reshape((1, nx)) + # Top left+right corners are the same as bottom left+right. + bds_2d_x[:, :, 2] = bds_2d_x[:, :, 1].copy() + bds_2d_x[:, :, 3] = bds_2d_x[:, :, 0].copy() + + # Expand y bounds to 2D array (ny, nx, 4) : the same over all 'X'. + # Left-hand lower+upper corners are the original 1-D y bounds. + bds_2d_y[:, :, 0] = y_bounds_1d[:, 0].reshape((ny, 1)) + bds_2d_y[:, :, 3] = y_bounds_1d[:, 1].reshape((ny, 1)) + # Right-hand lower+upper corners are the same as the left-hand ones. + bds_2d_y[:, :, 1] = bds_2d_y[:, :, 0].copy() + bds_2d_y[:, :, 2] = bds_2d_y[:, :, 3].copy() + + return bds_2d_x, bds_2d_y + + +def grid_coords_2d_from_1d(x_coord_1d, y_coord_1d): + """ + Calculate a pair of 2d X+Y coordinates from 1d ones, in a "meshgrid" style. + If the inputs are bounded, the outputs have 4 points per bounds in the + usual way, i.e. points 0,1,2,3 are the gridcell corners anticlockwise from + the bottom left. + + """ + for coord in (x_coord_1d, y_coord_1d): + if coord.ndim != 1: + msg = ('Input coords must be one-dimensional. ' + 'Coordinate "{}" has shape {}.') + raise ValueError(msg.format(coord.name(), coord.shape)) + + # Calculate centre-points as a mesh of the 2 inputs. + pts_2d_x, pts_2d_y = np.meshgrid(x_coord_1d.points, y_coord_1d.points) + if not x_coord_1d.has_bounds() or not y_coord_1d.has_bounds(): + bds_2d_x = None + bds_2d_y = None + else: + bds_2d_x, bds_2d_y = expand_1d_x_and_y_bounds_to_2d_xy( + x_coord_1d.bounds, y_coord_1d.bounds) + + # Make two new coords + return them. + result = [] + for pts, bds, name in zip((pts_2d_x, pts_2d_y), + (bds_2d_x, bds_2d_y), + ('longitude', 'latitude')): + coord = AuxCoord(pts, bounds=bds, standard_name=name, units='degrees') + result.append(coord) + + return result + + +def sample_2d_latlons(regional=False, rotated=False, transformed=False): + """ + Construct small 2d cubes with 2d X and Y coordinates. + + This makes cubes with 'expanded' coordinates (4 bounds per cell), analagous + to ORCA data. + The coordinates are always geographical, so either it has a coord system + or they are "true" lats + lons. + ( At present, they are always latitudes and longitudes, but maybe in a + rotated system. ) + The results always have fully contiguous bounds. + + Kwargs: + * regional (bool): + If False (default), results cover the whole globe, and there is + implicit connectivity between rhs + lhs of the array. + If True, coverage is regional and edges do not connect. + * rotated (bool): + If False, X and Y coordinates are true-latitudes and longitudes, with + an implicit coordinate system (i.e. None). + If True, the X and Y coordinates are lats+lons in a selected + rotated-latlon coordinate system. + * transformed (bool): + Build coords from rotated coords as for 'rotated', but then replace + their values with the equivalent "true" lats + lons, and no + coord-system (defaults to true-latlon). + In this case, the X and Y coords are no longer 'meshgrid' style, + i.e. the points + bounds values vary in *both* dimensions. + + .. note:: + + 'transformed' is an alternative to 'rotated' : when 'transformed' is + set, then 'rotated' has no effect. + + .. Some sample results printouts :: + + >>> print(sample_2d_latlons()) + test_data / (unknown) (-- : 5; -- : 6) + Auxiliary coordinates: + latitude x x + longitude x x + >>> + >>> print(sample_2d_latlons().coord(axis='x')[0, :2]) + AuxCoord(array([ 37.5 , 93.75]), + bounds=array([[ 0. , 65.625, 65.625, 0. ], + [ 65.625, 121.875, 121.875, 65.625]]), + standard_name='longitude', units=Unit('degrees')) + >>> print(np.round(sample_2d_latlons().coord(axis='x').points, 3)) + [[ 37.5 93.75 150. 206.25 262.5 318.75] + [ 37.5 93.75 150. 206.25 262.5 318.75] + [ 37.5 93.75 150. 206.25 262.5 318.75] + [ 37.5 93.75 150. 206.25 262.5 318.75] + [ 37.5 93.75 150. 206.25 262.5 318.75]] + >>> print(np.round(sample_2d_latlons().coord(axis='y').points, 3)) + [[-85. -85. -85. -85. -85. -85. ] + [-47.5 -47.5 -47.5 -47.5 -47.5 -47.5] + [-10. -10. -10. -10. -10. -10. ] + [ 27.5 27.5 27.5 27.5 27.5 27.5] + [ 65. 65. 65. 65. 65. 65. ]] + + + >>> print(np.round( + sample_2d_latlons(rotated=True).coord(axis='x').points, 3)) + [[ 37.5 93.75 150. 206.25 262.5 318.75] + [ 37.5 93.75 150. 206.25 262.5 318.75] + [ 37.5 93.75 150. 206.25 262.5 318.75] + [ 37.5 93.75 150. 206.25 262.5 318.75] + [ 37.5 93.75 150. 206.25 262.5 318.75]] + >>> print(sample_2d_latlons(rotated=True).coord(axis='y').coord_system) + RotatedGeogCS(75.0, 120.0) + + + >>> print( + sample_2d_latlons(transformed=True).coord(axis='y').coord_system) + None + >>> print(np.round( + sample_2d_latlons(transformed=True).coord(axis='x').points, 3)) + [[ -50.718 -40.983 -46.74 -71.938 -79.293 -70.146] + [ -29.867 17.606 77.936 157.145 -141.037 -93.172] + [ -23.139 31.007 87.699 148.322 -154.639 -100.505] + [ -16.054 41.218 92.761 143.837 -164.738 -108.105] + [ 10.86 61.78 100.236 137.285 175.511 -135.446]] + >>> print(np.round( + sample_2d_latlons(transformed=True).coord(axis='y').points, 3)) + [[-70.796 -74.52 -79.048 -79.26 -74.839 -70.96 ] + [-34.99 -46.352 -59.721 -60.34 -47.305 -35.499] + [ 1.976 -10.626 -22.859 -23.349 -11.595 1.37 ] + [ 38.914 25.531 14.312 13.893 24.585 38.215] + [ 74.197 60.258 51.325 51.016 59.446 73.268]] + >>> + + """ + def sample_cube(xargs, yargs): + # Make a test cube with given latitude + longitude coordinates. + # xargs/yargs are args for np.linspace (start, stop, N), to make the X + # and Y coordinate points. + x0, x1, nx = xargs + y0, y1, ny = yargs + # Data has cycling values, staggered a bit in successive rows. + data = np.zeros((ny, nx)) + data.flat[:] = np.arange(ny * nx) % (nx + 2) + # Build a 2d cube with longitude + latitude coordinates. + cube = Cube(data, long_name='test_data') + x_pts = np.linspace(x0, x1, nx, endpoint=True) + y_pts = np.linspace(y0, y1, ny, endpoint=True) + co_x = DimCoord(x_pts, standard_name='longitude', units='degrees') + co_y = DimCoord(y_pts, standard_name='latitude', units='degrees') + cube.add_dim_coord(co_y, 0) + cube.add_dim_coord(co_x, 1) + return cube + + # Start by making a "normal" cube with separate 1-D X and Y coords. + if regional: + # Make a small regional cube. + cube = sample_cube(xargs=(150., 243.75, 6), yargs=(-10., 40., 5)) + + # Add contiguous bounds. + for ax in ('x', 'y'): + cube.coord(axis=ax).guess_bounds() + else: + # Global data, but at a drastically reduced resolution. + cube = sample_cube(xargs=(37.5, 318.75, 6), yargs=(-85., 65., 5)) + + # Make contiguous bounds and adjust outer edges to ensure it is global. + for name in ('longitude', 'latitude'): + coord = cube.coord(name) + coord.guess_bounds() + bds = coord.bounds.copy() + # Make bounds global, by fixing lowest and uppermost values. + if name == 'longitude': + bds[0, 0] = 0.0 + bds[-1, 1] = 360.0 + else: + bds[0, 0] = -90.0 + bds[-1, 1] = 90.0 + coord.bounds = bds + + # Now convert the 1-d coords to 2-d equivalents. + # Get original 1-d coords. + co_1d_x, co_1d_y = [cube.coord(axis=ax).copy() for ax in ('x', 'y')] + + # Calculate 2-d equivalents. + co_2d_x, co_2d_y = grid_coords_2d_from_1d(co_1d_x, co_1d_y) + + # Remove the old grid coords. + for coord in (co_1d_x, co_1d_y): + cube.remove_coord(coord) + + # Add the new grid coords. + for coord in (co_2d_x, co_2d_y): + cube.add_aux_coord(coord, (0, 1)) + + if transformed or rotated: + # Put the cube locations into a rotated coord system. + pole_lat, pole_lon = 75.0, 120.0 + if transformed: + # Reproject coordinate values from rotated to true lat-lons. + co_x, co_y = [cube.coord(axis=ax) for ax in ('x', 'y')] + # Unrotate points. + lons, lats = co_x.points, co_y.points + lons, lats = unrotate_pole(lons, lats, pole_lon, pole_lat) + co_x.points, co_y.points = lons, lats + # Unrotate bounds. + lons, lats = co_x.bounds, co_y.bounds + # Note: save the shape, flatten + then re-apply the shape, because + # "unrotate_pole" uses "cartopy.crs.CRS.transform_points", which + # only works on arrays of 1 or 2 dimensions. + shape = lons.shape + lons, lats = unrotate_pole(lons.flatten(), lats.flatten(), + pole_lon, pole_lat) + co_x.bounds, co_y.bounds = lons.reshape(shape), lats.reshape(shape) + else: + # "Just" rotate operation : add a coord-system to each coord. + cs = RotatedGeogCS(pole_lat, pole_lon) + for coord in cube.coords(): + coord.coord_system = cs + + return cube + + +def make_bounds_discontiguous_at_point(cube, at_iy, at_ix): + """ + Meddle with the XY grid bounds of a cube to make the grid discontiguous. + + Changes the points and bounds of a single gridcell, so that it becomes + discontinuous with the next gridcell to its right. + Also masks the cube data at the given point. + + The cube must have bounded 2d 'x' and 'y' coordinates. + + TODO: add a switch to make a discontiguity in the *y*-direction instead ? + + """ + x_coord = cube.coord(axis='x') + y_coord = cube.coord(axis='y') + assert x_coord.shape == y_coord.shape + assert (coord.bounds.ndim == 3 and coord.shape[-1] == 4 + for coord in (x_coord, y_coord)) + + # For both X and Y coord, move points + bounds to create a discontinuity. + def adjust_coord(coord): + pts, bds = coord.points, coord.bounds + # Fetch the 4 bounds (bottom-left, bottom-right, top-right, top-left) + bds_bl, bds_br, bds_tr, bds_tl = bds[at_iy, at_ix] + # Make a discontinuity "at" (iy, ix), by moving the right-hand edge of + # the cell to the midpoint of the existing left+right bounds. + new_bds_br = 0.5 * (bds_bl + bds_br) + new_bds_tr = 0.5 * (bds_tl + bds_tr) + bds_br, bds_tr = new_bds_br, new_bds_tr + bds[at_iy, at_ix] = [bds_bl, bds_br, bds_tr, bds_tl] + # Also reset the cell midpoint to the middle of the 4 new corners, + # in case having a midpoint outside the corners might cause a problem. + new_pt = 0.25 * sum([bds_bl, bds_br, bds_tr, bds_tl]) + pts[at_iy, at_ix] = new_pt + # Write back the coord points+bounds (can only assign whole arrays). + coord.points, coord.bounds = pts, bds + + adjust_coord(x_coord) + adjust_coord(y_coord) + # Also mask the relevant data point. + data = cube.data # N.B. fetch all the data. + if not ma.isMaskedArray(data): + # Promote to masked array, to avoid converting mask to NaN. + data = ma.masked_array(data) + data[at_iy, at_ix] = ma.masked + cube.data = data diff --git a/lib/iris/tests/test_coding_standards.py b/lib/iris/tests/test_coding_standards.py index 39c73e8285..c24ac46cb3 100644 --- a/lib/iris/tests/test_coding_standards.py +++ b/lib/iris/tests/test_coding_standards.py @@ -93,7 +93,7 @@ class StandardReportWithExclusions(pep8.StandardReport): '*/iris/io/format_picker.py', '*/iris/tests/__init__.py', '*/iris/tests/pp.py', - '*/iris/tests/stock.py', + '*/iris/tests/stock/__init__.py', '*/iris/tests/system_test.py', '*/iris/tests/test_analysis.py', '*/iris/tests/test_analysis_calculus.py', diff --git a/lib/iris/tests/unit/analysis/cartography/test_gridcell_angles.py b/lib/iris/tests/unit/analysis/cartography/test_gridcell_angles.py new file mode 100644 index 0000000000..4a1344e83d --- /dev/null +++ b/lib/iris/tests/unit/analysis/cartography/test_gridcell_angles.py @@ -0,0 +1,327 @@ +# (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 . +""" +Unit tests for the function +:func:`iris.analysis.cartography.gridcell_angles`. + +""" +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 numpy as np + +from cf_units import Unit +from iris.cube import Cube +from iris.coords import AuxCoord + +from iris.analysis.cartography import gridcell_angles +from iris.tests.stock import sample_2d_latlons, lat_lon_cube + + +def _2d_multicells_testcube(cellsize_degrees=1.0): + """ + Create a test cube with a grid of X and Y points, where each gridcell + is independent (disjoint), arranged at an angle == the x-coord point. + + """ + # Setup np.linspace arguments to make the coordinate points. + x0, x1, nx = -164, 164, 9 + y0, y1, ny = -75, 75, 7 + + lats = np.linspace(y0, y1, ny, endpoint=True) + lons_angles = np.linspace(x0, x1, nx, endpoint=True) + x_pts_2d, y_pts_2d = np.meshgrid(lons_angles, lats) + + # Make gridcells rectangles surrounding these centrepoints, but also + # tilted at various angles (= same as x-point lons, as that's easy). + + # Calculate centrepoint lons+lats : in radians, and shape (ny, nx, 1). + xangs, yangs = np.deg2rad(x_pts_2d), np.deg2rad(y_pts_2d) + xangs, yangs = [arr[..., None] for arr in (xangs, yangs)] + # Program which corners are up+down on each gridcell axis. + dx_corners = [[[-1, 1, 1, -1]]] + dy_corners = [[[-1, -1, 1, 1]]] + # Calculate the relative offsets in x+y at the 4 corners. + x_ofs_2d = cellsize_degrees * np.cos(xangs) * dx_corners + x_ofs_2d -= cellsize_degrees * np.sin(xangs) * dy_corners + y_ofs_2d = cellsize_degrees * np.cos(xangs) * dy_corners + y_ofs_2d += cellsize_degrees * np.sin(xangs) * dx_corners + # Apply a latitude stretch to make correct angles on the globe. + y_ofs_2d *= np.cos(yangs) + # Make bounds arrays by adding the corner offsets to the centrepoints. + x_bds_2d = x_pts_2d[..., None] + x_ofs_2d + y_bds_2d = y_pts_2d[..., None] + y_ofs_2d + + # Create a cube with these points + bounds in its 'X' and 'Y' coords. + co_x = AuxCoord(points=x_pts_2d, bounds=x_bds_2d, + standard_name='longitude', units='degrees') + co_y = AuxCoord(points=y_pts_2d, bounds=y_bds_2d, + standard_name='latitude', units='degrees') + cube = Cube(np.zeros((ny, nx))) + cube.add_aux_coord(co_x, (0, 1)) + cube.add_aux_coord(co_y, (0, 1)) + return cube + + +class TestGridcellAngles(tests.IrisTest): + def setUp(self): + # Make a small "normal" contiguous-bounded cube to test on. + # This one is regional. + self.standard_regional_cube = sample_2d_latlons( + regional=True, transformed=True) + # Record the standard correct angle answers. + result_cube = gridcell_angles(self.standard_regional_cube) + result_cube.convert_units('degrees') + self.standard_result_cube = result_cube + self.standard_small_cube_results = result_cube.data + + def _check_multiple_orientations_and_latitudes(self, + method='mid-lhs, mid-rhs', + atol_degrees=0.005, + cellsize_degrees=1.0): + + cube = _2d_multicells_testcube(cellsize_degrees=cellsize_degrees) + + # Calculate gridcell angles at each point. + angles_cube = gridcell_angles(cube, cell_angle_boundpoints=method) + + # Check that the results are a close match to the original intended + # gridcell orientation angles. + # NOTE: neither the above gridcell construction nor the calculation + # itself are exact : Errors scale as the square of gridcell sizes. + angles_cube.convert_units('degrees') + angles_calculated = angles_cube.data + + # Note: the gridcell angles **should** just match the longitudes at + # each point + angles_expected = cube.coord('longitude').points + + # Wrap both into standard range for comparison. + angles_calculated = (angles_calculated + 360.) % 360. + angles_expected = (angles_expected + 360.) % 360. + + # Assert (toleranced) equality, and return results. + self.assertArrayAllClose(angles_calculated, angles_expected, + atol=atol_degrees) + + return angles_calculated, angles_expected + + def test_various_orientations_and_locations(self): + self._check_multiple_orientations_and_latitudes() + + def test_result_form(self): + # Check properties of the result cube *other than* the data values. + test_cube = self.standard_regional_cube + result_cube = self.standard_result_cube + self.assertEqual(result_cube.long_name, + 'gridcell_angle_from_true_east') + self.assertEqual(result_cube.units, Unit('degrees')) + self.assertEqual(len(result_cube.coords()), 2) + self.assertEqual(result_cube.coord(axis='x'), + test_cube.coord(axis='x')) + self.assertEqual(result_cube.coord(axis='y'), + test_cube.coord(axis='y')) + + def test_bottom_edge_method(self): + # Get results with the "other" calculation method + check to tolerance. + # A smallish cellsize should yield similar results in both cases. + r1, _ = self._check_multiple_orientations_and_latitudes() + r2, _ = self._check_multiple_orientations_and_latitudes( + method='lower-left, lower-right', + cellsize_degrees=0.1, atol_degrees=0.1) + + # Not *exactly* the same : this checks we tested the 'other' method ! + self.assertFalse(np.allclose(r1, r2)) + # Note: results are a bit different in places. This is acceptable. + self.assertArrayAllClose(r1, r2, atol=0.1) + + def test_bounded_coord_args(self): + # Check that passing the coords gives the same result as the cube. + co_x, co_y = (self.standard_regional_cube.coord(axis=ax) + for ax in ('x', 'y')) + result = gridcell_angles(co_x, co_y) + self.assertArrayAllClose(result.data, + self.standard_small_cube_results) + + def test_coords_radians_args(self): + # Check it still works with coords converted to radians. + co_x, co_y = (self.standard_regional_cube.coord(axis=ax) + for ax in ('x', 'y')) + for coord in (co_x, co_y): + coord.convert_units('radians') + result = gridcell_angles(co_x, co_y) + self.assertArrayAllClose(result.data, + self.standard_small_cube_results) + + def test_bounds_array_args(self): + # Check we can calculate from bounds values alone. + co_x, co_y = (self.standard_regional_cube.coord(axis=ax) + for ax in ('x', 'y')) + # Results drawn from coord bounds should be nearly the same, + # but not exactly, because of the different 'midpoint' values. + result = gridcell_angles(co_x.bounds, co_y.bounds) + self.assertArrayAllClose(result.data, + self.standard_small_cube_results, atol=0.1) + + def test_unbounded_regional_coord_args(self): + # Remove the coord bounds to check points-based calculation. + co_x, co_y = (self.standard_regional_cube.coord(axis=ax) + for ax in ('x', 'y')) + for coord in (co_x, co_y): + coord.bounds = None + result = gridcell_angles(co_x, co_y) + # Note: in this case, we can expect the leftmost and rightmost columns + # to be rubbish, because the data is not global. + # But the rest should match okay. + self.assertArrayAllClose(result.data[:, 1:-1], + self.standard_small_cube_results[:, 1:-1]) + + def test_points_array_args(self): + # Check we can calculate from points arrays alone (no coords). + co_x, co_y = (self.standard_regional_cube.coord(axis=ax) + for ax in ('x', 'y')) + # As previous, the leftmost and rightmost columns are not good. + result = gridcell_angles(co_x.points, co_y.points) + self.assertArrayAllClose(result.data[:, 1:-1], + self.standard_small_cube_results[:, 1:-1]) + + def test_unbounded_global(self): + # For a contiguous global grid, a result based on points, i.e. with the + # bounds removed, should be a reasonable match for the 'ideal' one + # based on the bounds. + + # Make a global cube + calculate ideal bounds-based results. + global_cube = sample_2d_latlons(transformed=True) + result_cube = gridcell_angles(global_cube) + result_cube.convert_units('degrees') + global_cube_results = result_cube.data + + # Check a points-based calculation on the same basic grid. + co_x, co_y = (global_cube.coord(axis=ax) + for ax in ('x', 'y')) + for coord in (co_x, co_y): + coord.bounds = None + result = gridcell_angles(co_x, co_y) + # In this case, the match is actually rather poor (!). + self.assertArrayAllClose(result.data, + global_cube_results, + atol=7.5) + # Leaving off first + last columns again gives a decent result. + self.assertArrayAllClose(result.data[:, 1:-1], + global_cube_results[:, 1:-1]) + + # NOTE: although this looks just as bad as 'test_points_array_args', + # maximum errors there in the end columns are actually > 100 degrees ! + + def test_nonlatlon_coord_system(self): + # Check with points specified in an unexpected coord system. + cube = sample_2d_latlons(regional=True, rotated=True) + result = gridcell_angles(cube) + self.assertArrayAllClose(result.data, + self.standard_small_cube_results) + # Check that the result has transformed (true-latlon) coordinates. + self.assertEqual(len(result.coords()), 2) + x_coord = result.coord(axis='x') + y_coord = result.coord(axis='y') + self.assertEqual(x_coord.shape, cube.shape) + self.assertEqual(y_coord.shape, cube.shape) + self.assertIsNotNone(cube.coord_system) + self.assertIsNone(x_coord.coord_system) + self.assertIsNone(y_coord.coord_system) + + def test_fail_coords_bad_units(self): + # Check error with bad coords units. + co_x, co_y = (self.standard_regional_cube.coord(axis=ax) + for ax in ('x', 'y')) + co_y.units = 'm' + with self.assertRaisesRegexp(ValueError, 'must have angular units'): + gridcell_angles(co_x, co_y) + + def test_fail_nonarraylike(self): + # Check error with bad args. + co_x, co_y = 1, 2 + with self.assertRaisesRegexp(ValueError, + 'must have array shape property'): + gridcell_angles(co_x, co_y) + + def test_fail_non2d_coords(self): + # Check error with bad args. + cube = lat_lon_cube() + with self.assertRaisesRegexp(ValueError, + 'inputs must have 2-dimensional shape'): + gridcell_angles(cube) + + def test_fail_different_shapes(self): + # Check error with mismatched shapes. + co_x, co_y = (self.standard_regional_cube.coord(axis=ax) + for ax in ('x', 'y')) + co_y = co_y[1:] + with self.assertRaisesRegexp(ValueError, 'must have same shape'): + gridcell_angles(co_x, co_y) + + def test_fail_different_coord_system(self): + # Check error with mismatched coord systems. + cube = sample_2d_latlons(regional=True, rotated=True) + cube.coord(axis='x').coord_system = None + with self.assertRaisesRegexp(ValueError, + 'must have same coordinate system'): + gridcell_angles(cube) + + def test_fail_cube_dims(self): + # Check error with mismatched cube dims. + cube = self.standard_regional_cube + # Make 5x6 into 5x5. + cube = cube[:, :-1] + co_x = cube.coord(axis='x') + pts, bds = co_x.points, co_x.bounds + co_new_x = co_x.copy(points=pts.transpose((1, 0)), + bounds=bds.transpose((1, 0, 2))) + cube.remove_coord(co_x) + cube.add_aux_coord(co_new_x, (1, 0)) + with self.assertRaisesRegexp(ValueError, + 'must have the same cube dimensions'): + gridcell_angles(cube) + + def test_fail_coord_noncoord(self): + # Check that passing a coord + an array gives an error. + co_x, co_y = (self.standard_regional_cube.coord(axis=ax) + for ax in ('x', 'y')) + with self.assertRaisesRegexp(ValueError, + 'is a Coordinate, but .* is not'): + gridcell_angles(co_x, co_y.bounds) + + def test_fail_noncoord_coord(self): + # Check that passing an array + a coord gives an error. + co_x, co_y = (self.standard_regional_cube.coord(axis=ax) + for ax in ('x', 'y')) + with self.assertRaisesRegexp(ValueError, + 'is a Coordinate, but .* is not'): + gridcell_angles(co_x.points, co_y) + + def test_fail_bad_method(self): + with self.assertRaisesRegexp(ValueError, + 'unrecognised cell_angle_boundpoints'): + self._check_multiple_orientations_and_latitudes( + method='something_unknown') + + +if __name__ == "__main__": + tests.main() diff --git a/lib/iris/tests/unit/cube/test_Cube.py b/lib/iris/tests/unit/cube/test_Cube.py index 9c96cf6b48..5633417c80 100644 --- a/lib/iris/tests/unit/cube/test_Cube.py +++ b/lib/iris/tests/unit/cube/test_Cube.py @@ -1430,7 +1430,9 @@ def test__masked_scalar_arraymask(self): self._check_copy(cube, cube.copy()) def test__lazy(self): - cube = Cube(as_lazy_data(np.array([1, 0]))) + # Note: multiple chunks added as a workaround suggested to dask#3751, + # which is fixed in dask#3754. + cube = Cube(as_lazy_data(np.array([1, 0]), chunks=(1, 1))) self._check_copy(cube, cube.copy()) diff --git a/lib/iris/tests/unit/plot/test_2d_coords.py b/lib/iris/tests/unit/plot/test_2d_coords.py new file mode 100644 index 0000000000..7397921861 --- /dev/null +++ b/lib/iris/tests/unit/plot/test_2d_coords.py @@ -0,0 +1,167 @@ +# (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 . +"""Unit tests for handling and plotting of 2-dimensional coordinates""" + +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 numpy.ma as ma + +import iris.coords as coords +from iris.tests.stock import simple_2d_w_multidim_coords as cube_2dcoords +from iris.tests.stock import simple_3d_w_multidim_coords as cube3d_2dcoords +from iris.tests.stock import sample_2d_latlons +from iris.tests.stock import make_bounds_discontiguous_at_point + + +if tests.MPL_AVAILABLE: + import iris.plot as iplt + + +def full2d_global(): + return sample_2d_latlons(transformed=True) + + +@tests.skip_data +class Test_2d_coords_plot_defn_bound_mode(tests.IrisTest): + def setUp(self): + self.multidim_cube = cube_2dcoords() + self.overspan_cube = cube3d_2dcoords() + + # latlon_2d is a cube with 2d coords, 4 bounds per point, + # discontiguities in the bounds but masked data at the discontiguities. + self.latlon_2d = full2d_global() + make_bounds_discontiguous_at_point(self.latlon_2d, 2, 2) + + # # Take a latlon cube with 1D coords, broadcast the coords into 2D + # # ones, then add ONE of them back into the cube in place of original: + # single_dims = lat_lon_cube() + # lon = single_dims.coord('longitude') + # lat = single_dims.coord('latitude') + # big_lon, big_lat = testdata.grid_coords_2d_from_1d(lon, lat) + # mixed_dims = single_dims.copy() + # mixed_dims.remove_coord(lon) + # # TODO Fix this coord addition: + # # When adding an aux_coord, the function '_check_multidim_metadata' + # # throws an error as it requires coord.shape to be (1, ) instead of + # # (3, 4) or whatever. + # mixed_dims.add_aux_coord(big_lon) + # + # # mixed_dims is now a cube with 2 1D dim coords and an additional + # # 2D aux coord. + # self.mixed_dims = mixed_dims + + self.mode = coords.BOUND_MODE + + def test_2d_coords_identified(self): + # Test that 2d coords are identified in the plot definition without + # having to be specified in the args without any errors. + cube = self.multidim_cube + defn = iplt._get_plot_defn(cube, mode=self.mode) + self.assertEqual([coord.name() for coord in defn.coords], + ['bar', 'foo']) + + def test_2d_coords_custom_picked(self): + # Test that 2d coords which are specified in the args will be + # accepted without any errors. + cube = self.multidim_cube + defn = iplt._get_plot_defn_custom_coords_picked(cube, ('foo', 'bar'), + self.mode) + self.assertEqual([coord.name() for coord in defn.coords], + ['bar', 'foo']) + + def test_2d_coords_as_integers(self): + # Test that if you pass in 2d coords as args in the form of integers, + # they will still be correctly identified without any errors. + cube = self.multidim_cube + defn = iplt._get_plot_defn_custom_coords_picked(cube, (0, 1), + self.mode) + self.assertEqual([coord for coord in defn.coords], + [1, 0]) + + def test_total_span_check(self): + # Test that an error is raised if a user tries to plot a 2d coord + # against a different coord, making total number of dimensions 3. + cube = self.overspan_cube + with self.assertRaises(ValueError): + iplt._get_plot_defn_custom_coords_picked(cube, ('wibble', 'foo'), + self.mode) + + # def test_2dcoord_with_1dcoord(self): + # # TODO Generate a cube with one 2d coord and one 1d coord + # # TODO Try and plot them against each other + # # TODO Find out where I can put a catch for this (if necessary) + # cube = self.mixed_dims + # with self.assertRaises(ValueError): + # iplt._get_plot_defn_custom_coords_picked( + # cube, + # ('latitude', 'longitude'), + # self.mode) + + def test_map_common_not_enough_bounds(self): + # Test that a lat-lon cube with 2d coords and 2 bounds per point + # throws an error in contiguity checks. + cube = self.multidim_cube + cube.coord('foo').rename('longitude') + cube.coord('bar').rename('latitude') + with self.assertRaises(ValueError): + plot_defn = iplt._get_plot_defn(cube, self.mode) + iplt._map_common('pcolor', None, self.mode, cube, plot_defn) + + def test_map_common_2d(self): + # Test that a cube with 2d coords can be plotted as a map. + cube = self.latlon_2d + # Get necessary variables from _get_plot_defn to check that the test + # case will be accepted by _map_common. + plot_defn = iplt._get_plot_defn(cube, self.mode) + result = iplt._map_common('pcolor', None, self.mode, cube, plot_defn) + self.assertTrue(result) + +# def test_discontiguous_masked(self): +# # Test that a contiguity check will raise a warning (not an error) for +# # discontiguous bounds but appropriately masked data. +# cube = self.latlon_2d +# coord = cube.coord('longitude') +# msg = 'The bounds of the longitude coordinate are not contiguous. ' \ +# 'However, data is masked where the discontiguity occurs so ' \ +# 'plotting anyway.' +# with self.assertWarnsRegexp(msg): +# iplt._check_contiguity_and_bounds(coord, cube.data) + + def test_discontiguous_unmasked(self): + # Check that an error occurs when the contiguity check finds + # discontiguous bounds but unmasked data. + cube = self.latlon_2d + cube.data.mask = ma.nomask + coord = cube.coord('longitude') + with self.assertRaises(ValueError): + iplt._check_contiguity_and_bounds(coord, cube.data) + + def test_draw_2d_from_bounds(self): + # Test this function will not raise an error even with our most + # awkward but supported cube. + cube = self.latlon_2d + result = iplt._draw_2d_from_bounds('pcolormesh', cube) + self.assertTrue(result) + + +if __name__ == '__main__': + tests.main() diff --git a/requirements/core.txt b/requirements/core.txt index c28cdb04a1..997c91c665 100644 --- a/requirements/core.txt +++ b/requirements/core.txt @@ -6,7 +6,7 @@ cartopy cf_units>=2 cftime -dask[array]>=0.17.1 #conda: dask>=0.17.1 +dask[array]==0.18.1 #conda: dask==0.18.1 matplotlib>=2 netcdf4 numpy>=1.14