diff --git a/improver/generate_ancillaries/generate_orographic_smoothing_coefficients.py b/improver/generate_ancillaries/generate_orographic_smoothing_coefficients.py index fff32541c0..904f74a02a 100644 --- a/improver/generate_ancillaries/generate_orographic_smoothing_coefficients.py +++ b/improver/generate_ancillaries/generate_orographic_smoothing_coefficients.py @@ -253,7 +253,7 @@ def process(self, cube: Cube, mask: Optional[Cube] = None) -> CubeList: """ This creates the smoothing_coefficient cubes. It returns one for the x direction and one for the y direction. It uses the - DifferenceBetweenAdjacentGridSquares plugin to calculate an average + GradientBetweenAdjacentGridSquares plugin to calculate an average gradient across each grid square. These gradients are then used to calculate "smoothing_coefficient" arrays that are normalised between a user-specified max and min. diff --git a/improver/utilities/spatial.py b/improver/utilities/spatial.py index 3c98dd660b..5655e8e05d 100644 --- a/improver/utilities/spatial.py +++ b/improver/utilities/spatial.py @@ -5,7 +5,7 @@ """ Provides support utilities.""" import copy -import warnings +from abc import ABC, abstractmethod from typing import List, Optional, Tuple, Union import cartopy.crs as ccrs @@ -14,20 +14,21 @@ import numpy as np from cartopy.crs import CRS from cf_units import Unit -from iris.coord_systems import GeogCS -from iris.coords import AuxCoord, CellMethod, Coord +from iris.coord_systems import CoordSystem, GeogCS +from iris.coords import AuxCoord, CellMethod, Coord, DimCoord from iris.cube import Cube, CubeList from numpy import ndarray from numpy.ma import MaskedArray from scipy.ndimage.filters import maximum_filter -from scipy.stats import circmean from improver import BasePlugin, PostProcessingPlugin from improver.metadata.amend import update_diagnostic_name from improver.metadata.constants import FLOAT_DTYPE -from improver.metadata.constants.attributes import MANDATORY_ATTRIBUTE_DEFAULTS from improver.metadata.probabilistic import in_vicinity_name_format, is_probability -from improver.metadata.utilities import create_new_diagnostic_cube +from improver.metadata.utilities import ( + create_new_diagnostic_cube, + generate_mandatory_attributes, +) from improver.utilities.cube_checker import check_cube_coordinates, spatial_coords_match from improver.utilities.cube_manipulation import enforce_coordinate_ordering @@ -126,9 +127,8 @@ def distance_to_number_of_grid_cells( d_error = f"Distance of {distance}m" if distance <= 0: raise ValueError(f"Please specify a positive distance in metres. {d_error}") - # calculate grid spacing along chosen axis - grid_spacing_metres = calculate_grid_spacing(cube, "metres", axis=axis) + grid_spacing_metres = calculate_grid_spacing(cube, "m", axis=axis) grid_cells = distance / abs(grid_spacing_metres) if return_int: @@ -160,10 +160,336 @@ def number_of_grid_cells_to_distance(cube: Cube, grid_points: int) -> float: return radius_in_metres +class BaseDistanceCalculator(ABC): + """Base class for distance calculators for cubes with different coordinate systems/axis types""" + + def __init__(self, cube: Cube): + """ + Args: + cube: + Cube for which the distances will be calculated. + """ + self.cube = cube + self.x_separations_axis, self.y_separation_axis = self.get_difference_axes() + + @staticmethod + def build_distances_cube(distances: ndarray, dims: List[Coord], axis: str) -> Cube: + """ + Constructs an output cube with units of metres. + Args: + distances: + Data array containing calculated distances with which to populate the output cube. + dims: + Coordinate axes for the output cube. Must match the shape of distances. + axis: + The axis along which distances have been calculated. + """ + return Cube( + distances, + long_name=f"{axis}_distance_between_grid_points", + units="m", + dim_coords_and_dims=dims, + ) + + @staticmethod + def get_midpoints(axis: Coord) -> np.ndarray: + """ + Returns the midpoints along the supplied axis. If the axis is circular, the difference + between the last and first point is included with the assumption that this is in units of + degrees. + """ + points = axis.points + + if axis.circular: + points = np.hstack((points, 360 + points[0])) + mean_points = (points[1:] + points[:-1]) / 2 + + return mean_points.astype(axis.dtype) + + def get_difference_axes(self) -> Tuple[DimCoord, DimCoord]: + """Derives and returns the x and y coords for a cube of differences along one axis""" + input_cube_x_axis = self.cube.coord(axis="x") + input_cube_y_axis = self.cube.coord(axis="y") + distance_cube_x_axis = input_cube_x_axis.copy( + points=self.get_midpoints(input_cube_x_axis) + ) + distance_cube_y_axis = input_cube_y_axis.copy( + points=self.get_midpoints(input_cube_y_axis) + ) + return distance_cube_x_axis, distance_cube_y_axis + + @abstractmethod + def _get_x_distances(self) -> Cube: + """ + Abstract method for calculating distances along the x axis of the input cube. + The resulting cube shall have two dimensions as the result may be a function of position + along the y axis. + """ + + @abstractmethod + def _get_y_distances(self) -> Cube: + """ + Abstract method for calculating distances along the y axis of the input cube. + The resulting cube shall have two dimensions. + """ + + def get_distances(self) -> Tuple[Cube, Cube]: + """ + Calculates and returns the distances between grid points calculated along the cube's + x and y axis. + + Returns: + - 2D Cube of x-axis distances. + - 2D Cube of y-axis distances. + """ + return self._get_x_distances(), self._get_y_distances() + + +class LatLonCubeDistanceCalculator(BaseDistanceCalculator): + """ + Distance calculator for cubes using a Geographic Coordinate system. + Assumes that latitude and longitude are given in degrees, and that the origin is at the + intersection of the equator and the prime meridian. + Distances are calculated assuming a spherical earth, resulting in a < 0.15% error when compared + with the full haversine formula. + """ + + def __init__(self, cube: Cube): + super().__init__(cube) + self.lats, self.longs = self._get_cube_latlon_points() + self.sphere_radius = cube.coord(axis="x").coord_system.semi_major_axis + + def _get_cube_latlon_points(self) -> Tuple[ndarray, ndarray]: + """ + Extracts the y-axis and x-axis grid points used by a cube + with a geographic coordinate system. + + Returns: + - latitude points used by the cube's grid (in degrees). + - longitude points used by the cube's grid (in degrees). + Raises: + ValueError: Input cube does not use geographic coordinates, and/or + uses units other than degrees. + """ + if ( + self.cube.coord(axis="x").units == "degrees" + and self.cube.coord(axis="y").units == "degrees" + ): + longs = self.cube.coord(axis="x").points + lats = self.cube.coord(axis="y").points + return lats, longs + + raise ValueError( + "Cannot parse spatial axes of the cube provided. " + "Expected lat-long cube with units of degrees." + ) + + def _get_x_distances(self) -> Cube: + """ + Calculates the x-axis distances between adjacent grid points of a cube which uses + Geographic coordinates. + + Returns: + A 2D cube containing the x-axis distances between adjacent grid points of the input + cube in metres. As the earth is an oblate spheroid, the x-axis distances vary as + a function of the y-axis. + If the x-axis is marked as being circular, the distance between the last and first + points is included in the output. + x-axis coord positions are shifted to the mid-point of each pair. + """ + lats_as_col = np.expand_dims(self.lats, axis=1) + + if self.cube.coord(axis="x").circular: + longs = np.hstack([self.longs, 360 + self.longs[0]]) + else: + longs = self.longs + lon_diffs = np.diff(longs) + + x_distances = ( + self.sphere_radius * np.cos(np.deg2rad(lats_as_col)) * np.deg2rad(lon_diffs) + ) + + dims = [(self.cube.coord(axis="y"), 0), (self.x_separations_axis, 1)] + return self.build_distances_cube(x_distances, dims, "x") + + def _get_y_distances(self) -> Cube: + """ + Calculates the y-axis distances between adjacent grid points of a cube which uses + Geographic coordinates. + + Returns: + A 2D cube containing the y-axis distances between adjacent grid points of the input + cube in metres. + y-axis coord positions are shifted to the mid-point of each pair. + """ + lat_diffs = np.diff(self.lats) + + y_distances = self.sphere_radius * np.deg2rad(lat_diffs) + + y_distances_grid = np.tile(np.expand_dims(y_distances, axis=1), len(self.longs)) + dims = [(self.y_separation_axis, 0), (self.cube.coord(axis="x"), 1)] + return self.build_distances_cube(y_distances_grid, dims, "y") + + +class ProjectionCubeDistanceCalculator(BaseDistanceCalculator): + """ + Distance calculator for cubes using a projected coordinate system. + Assumes that x and y coordinates can be expressed in metres. + Distances are calculated assuming an equal-area projection. + """ + + def __init__(self, cube: Cube): + """ + Args: + cube: + Cube for which the distances will be calculated. + Raises: + NotImplementedError: + If the x-axis is marked as being circular. + """ + if cube.coord(axis="x").circular: + raise NotImplementedError( + "Cannot calculate distances between bounding points of a circular projected " + "coordinate." + ) + super().__init__(cube) + + def _get_x_distances(self) -> Cube: + """ + Calculates the x-axis distances between adjacent grid points of a cube which uses + Equal Area coordinates. + + Returns: + A 2D cube containing the x-axis distances between the grid points of the input + cube in metres. + x-axis coord positions are shifted to the mid-point of each pair. + """ + x_distances = calculate_grid_spacing(self.cube, axis="x", units="m") + data = np.full( + (self.cube.shape[0], len(self.x_separations_axis.points)), x_distances + ) + dims = [ + (self.cube.coord("projection_y_coordinate"), 0), + (self.x_separations_axis, 1), + ] + return self.build_distances_cube(data, dims, "x") + + def _get_y_distances(self) -> Cube: + """ + Calculates the y-axis distances between adjacent grid points of a cube which uses + Equal Area coordinates. + + Returns: + A 2D cube containing the y-axis distances between the grid points of the input + cube in metres. + y-axis coord positions are shifted to the mid-point of each pair. + """ + y_grid_spacing = calculate_grid_spacing(self.cube, axis="y", units="m") + data = np.full( + (len(self.y_separation_axis.points), self.cube.data.shape[1]), + y_grid_spacing, + ) + dims = [ + (self.y_separation_axis, 0), + (self.cube.coord("projection_x_coordinate"), 1), + ] + return self.build_distances_cube(data, dims, "y") + + +class DistanceBetweenGridSquares(BasePlugin): + """ + Calculates the distances between adjacent grid squares within a cube. + The distances are calculated along the x and y axes individually. + Returned distances are in metres. + The class can handle cubes with either Geographic (lat-long) or Equal Area projections. + For lat-lon cubes, the distances are calculated assuming a spherical earth. + This causes a < 0.15% error compared with the full haversine formula. + """ + + def _select_distance_calculator(self, cube: Cube): + """ + Chooses which distance calculator class to apply based on the cube's spatial coordinates. + + Args: + cube: + Cube for which the distances will be calculated. + Raises: + ValueError: Cube does not have enough information from which to calculate distances + or uses an unsupported coordinate system. + """ + if self._cube_xy_dimensions_are_distances(cube): + self.distance_calculator = ProjectionCubeDistanceCalculator(cube) + elif self._get_cube_spatial_type(cube) == GeogCS: + self.distance_calculator = LatLonCubeDistanceCalculator(cube) + else: + raise ValueError( + "Unsupported cube coordinate system or insufficent information to " + "calculate cube distances. Cube must either have coordinates for the " + "x and y axis with distance units, or use the Geographic (GeogCS) " + "coordinate system. For cubes with x and y dimensions expressed as angles, " + "distance between points cannot be calculated without a coordinate system." + ) + + @staticmethod + def _get_cube_spatial_type(cube: Cube) -> CoordSystem: + """ + Finds the coordinate system used by a cube. + + Args: + cube: + Cube to find the coordinate system of. + + Returns: + The coordinate system of the cube as an Iris Coordinate System. + """ + coord_system = cube.coord_system() + return type(coord_system) + + @staticmethod + def _cube_xy_dimensions_are_distances(cube: Cube) -> bool: + """ + Returns true if the given cube has coordinates mapping to the x and y axes with units + measuring distance (as opposed to angular separation) and false otherwise. + Args: + cube: + The iris cube to evaluate. + + Returns: + Boolean representing whether the cube has x and y axes defined in a distance unit. + """ + try: + cube.coord(axis="x").convert_units("m") + cube.coord(axis="y").convert_units("m") + return True + except ( + TypeError, + ValueError, + iris.exceptions.UnitConversionError, + iris.exceptions.CoordinateNotFoundError, + ): + return False + + def process(self, cube: Cube) -> Tuple[Cube, Cube]: + """ + Calculate the distances between grid points along the x and y axes + and return the result in separate cubes. + + Args: + cube: + Cube for which the distances will be calculated. + + Returns: + - Cube of x-axis distances. + - Cube of y-axis distances. + """ + self._select_distance_calculator(cube) + return self.distance_calculator.get_distances() + + class DifferenceBetweenAdjacentGridSquares(BasePlugin): """ Calculate the difference between adjacent grid squares within - a cube. The difference is calculated along the x and y axis + a cube. The difference is calculated along the x and y axes individually. """ @@ -183,29 +509,6 @@ def _axis_wraps_around_meridian(axis: Coord, cube: Cube) -> bool: """ return axis.circular and axis == cube.coord(axis="x") - @staticmethod - def _get_wrap_around_mean_point(points: ndarray) -> float: - """ - Calculates the midpoint between the two x coordinate points nearest the meridian. - - args: - points: - The x coordinate points of the cube. - - returns: - The x value of the midpoint between the two x coordinate points nearest the meridian. - """ - # The values of max and min azimuth doesn't matter as long as there is 360 degrees - # between them. - min_azimuth = -180 - max_azimuth = 180 - extra_mean_point = circmean([points[-1], points[0]], max_azimuth, min_azimuth) - extra_mean_point = np.round(extra_mean_point, 4) - if extra_mean_point < points[-1]: - # Ensures that the longitudinal coordinate is monotonically increasing - extra_mean_point += 360 - return extra_mean_point - @staticmethod def _update_metadata(diff_cube: Cube, coord_name: str, cube_name: str) -> None: """Rename cube, add attribute and cell method to describe difference. @@ -246,19 +549,14 @@ def create_difference_cube( """ axis = cube.coord(coord_name) points = axis.points - mean_points = (points[1:] + points[:-1]) / 2 if self._axis_wraps_around_meridian(axis, cube): + points = np.hstack((points, 360 + points[0])) if type(axis.coord_system) != GeogCS: - warnings.warn( - "DifferenceBetweenAdjacentGridSquares does not fully support cubes with " - "circular x-axis that do not use a geographic (i.e. latlon) coordinate system. " - "Such cubes will be handled as if they were not circular, meaning that the " - "differences cube returned will have one fewer points along the specified axis" - "than the input cube." + raise NotImplementedError( + "DifferenceBetweenAdjacentGridSquares does not support cubes with " + "circular x-axis that do not use a geographic (i.e. latlon) coordinate system." ) - else: - extra_mean_point = self._get_wrap_around_mean_point(points) - mean_points = np.hstack([mean_points, extra_mean_point]) + mean_points = (points[1:] + points[:-1]) / 2 # Copy cube metadata and coordinates into a new cube. # Create a new coordinate for the coordinate along which the @@ -337,7 +635,7 @@ def process(self, cube: Cube) -> Tuple[Cube, Cube]: return tuple(diffs) -class GradientBetweenAdjacentGridSquares(BasePlugin): +class GradientBetweenAdjacentGridSquares(PostProcessingPlugin): """Calculate the gradient between adjacent grid squares within a cube. The gradient is calculated along the x and y axis @@ -349,67 +647,47 @@ def __init__(self, regrid: bool = False) -> None: Args: regrid: If True, the gradient cube is regridded to match the spatial - dimensions of the input cube. If False, the length of the - spatial dimensions of the gradient cube are one less than for - the input cube. + dimensions of the input cube. If False, the two output gradient cubes will have + different spatial coords such that the coord matching the gradient axis will + represent the midpoint of the input cube and will have one fewer points. + If the x-axis is marked as circular, the gradient between the last and first points + is also included. """ self.regrid = regrid @staticmethod - def _create_output_cube( - gradient: ndarray, diff: Cube, cube: Cube, axis: str - ) -> Cube: + def _create_output_cube(gradient: Cube, name: str) -> Cube: """ - Create the output gradient cube. + Create the output gradient cube, inheriting all metadata from source, but discarding + the "form_of_difference" attribute. Args: gradient: Gradient values used in the data array of the resulting cube. - diff: - Cube containing differences along the x or y axis - cube: - Cube with correct output dimensions - axis: - Short-hand reference for the x or y coordinate, as allowed by - iris.util.guess_coord_axis. + name: + Name to apply to the output cube. Returns: A cube of the gradients in the coordinate direction specified. """ + attributes = gradient.attributes + attributes.pop("form_of_difference") grad_cube = create_new_diagnostic_cube( - "gradient_of_" + cube.name(), - cube.units / diff.coord(axis=axis).units, - diff, - MANDATORY_ATTRIBUTE_DEFAULTS, - data=gradient, + name, + gradient.units, + gradient, + generate_mandatory_attributes([gradient]), + optional_attributes=attributes, + data=gradient.data, ) return grad_cube - @staticmethod - def _gradient_from_diff(diff: Cube, axis: str) -> ndarray: - """ - Calculate the gradient along the x or y axis from differences between - adjacent grid squares. - - Args: - diff: - Cube containing differences along the x or y axis - axis: - Short-hand reference for the x or y coordinate, as allowed by - iris.util.guess_coord_axis. - - Returns: - Array of the gradients in the coordinate direction specified. - """ - grid_spacing = np.diff(diff.coord(axis=axis).points)[0] - gradient = diff.data / grid_spacing - return gradient - def process(self, cube: Cube) -> Tuple[Cube, Cube]: """ Calculate the gradient along the x and y axes and return the result in separate cubes. The difference along each axis is - calculated using numpy.diff. + calculated using numpy.diff. This is then divided by the distance + between grid points along the same axis to get the gradient. Args: cube: @@ -417,15 +695,16 @@ def process(self, cube: Cube) -> Tuple[Cube, Cube]: Returns: - Cube after the gradients have been calculated along the - x axis. + x-axis. - Cube after the gradients have been calculated along the - y axis. + y-axis. """ gradients = [] diffs = DifferenceBetweenAdjacentGridSquares()(cube) - for axis, diff in zip(["x", "y"], diffs): - gradient = self._gradient_from_diff(diff, axis) - grad_cube = self._create_output_cube(gradient, diff, cube, axis) + distances = DistanceBetweenGridSquares()(cube) + for diff, distance in zip(diffs, distances): + gradient = diff / distance + grad_cube = self._create_output_cube(gradient, "gradient_of_" + cube.name()) if self.regrid: grad_cube = grad_cube.regrid(cube, iris.analysis.Linear()) gradients.append(grad_cube) diff --git a/improver_tests/utilities/spatial/__init__.py b/improver_tests/utilities/spatial/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/improver_tests/utilities/test_DifferenceBetweenAdjacentGridSquares.py b/improver_tests/utilities/spatial/test_DifferenceBetweenAdjacentGridSquares.py similarity index 90% rename from improver_tests/utilities/test_DifferenceBetweenAdjacentGridSquares.py rename to improver_tests/utilities/spatial/test_DifferenceBetweenAdjacentGridSquares.py index 4ff663261e..6db261d072 100644 --- a/improver_tests/utilities/test_DifferenceBetweenAdjacentGridSquares.py +++ b/improver_tests/utilities/spatial/test_DifferenceBetweenAdjacentGridSquares.py @@ -8,6 +8,7 @@ import iris import numpy as np +import pytest from iris.coords import CellMethod from iris.cube import Cube from iris.tests import IrisTest @@ -30,8 +31,8 @@ def setUp(self): ) self.plugin = DifferenceBetweenAdjacentGridSquares() - def test_y_dimension(self): - """Test differences calculated along the y dimension.""" + def test_y_dimension_equalarea(self): + """Test differences calculated along the y dimension, equalarea grid.""" points = self.cube.coord(axis="y").points expected_y_coords = (points[1:] + points[:-1]) / 2 result = self.plugin.create_difference_cube( @@ -41,8 +42,8 @@ def test_y_dimension(self): self.assertArrayAlmostEqual(result.coord(axis="y").points, expected_y_coords) self.assertArrayEqual(result.data, self.diff_in_y_array) - def test_x_dimension(self): - """Test differences calculated along the x dimension.""" + def test_x_dimension_equalarea(self): + """Test differences calculated along the x dimension, equalarea grid.""" diff_array = np.array([[1, 1], [2, 2], [5, 5]]) points = self.cube.coord(axis="x").points expected_x_coords = (points[1:] + points[:-1]) / 2 @@ -53,6 +54,19 @@ def test_x_dimension(self): self.assertArrayAlmostEqual(result.coord(axis="x").points, expected_x_coords) self.assertArrayEqual(result.data, diff_array) + def test_x_dimension_equalarea_circular(self): + """Test differences calculated along the x dimension when x is circular, equalarea grid.""" + diff_array = np.array([[1, 1], [2, 2], [5, 5]]) + self.cube.coord(axis="x").circular = True + with pytest.raises( + NotImplementedError, + match="DifferenceBetweenAdjacentGridSquares does not support cubes with circular " + "x-axis that do not use a geographic", + ): + self.plugin.create_difference_cube( + self.cube, "projection_x_coordinate", diff_array + ) + def test_x_dimension_for_circular_latlon_cube(self): """Test differences calculated along the x dimension for a cube which is circular in x.""" test_cube_data = np.array([[1, 2, 3], [2, 4, 6], [5, 10, 15]]) @@ -258,8 +272,13 @@ def test_3d_cube(self): self.assertArrayEqual(result[1].data, expected_y) def test_circular_non_geographic_cube_raises_approprate_exception(self): + """Check for error and message with projection coord and circular x axis""" self.cube.coord(axis="x").circular = True - with self.assertRaises(ValueError): + with self.assertRaisesRegex( + NotImplementedError, + "DifferenceBetweenAdjacentGridSquares does not support cubes with " + r"circular x-axis that do not use a geographic \(i.e. latlon\) coordinate system.", + ): self.plugin.process(self.cube) diff --git a/improver_tests/utilities/spatial/test_DistanceBetweenGridSquares.py b/improver_tests/utilities/spatial/test_DistanceBetweenGridSquares.py new file mode 100644 index 0000000000..7bf4f77ccd --- /dev/null +++ b/improver_tests/utilities/spatial/test_DistanceBetweenGridSquares.py @@ -0,0 +1,291 @@ +# (C) Crown copyright, Met Office. All rights reserved. +# +# This file is part of IMPROVER and is released under a BSD 3-Clause license. +# See LICENSE in the root of the repository for full licensing details. +""" Tests of DifferenceBetweenAdjacentGridSquares plugin, which encompasses all paths through +LatLonCubeDistanceCalculator, ProjectionCubeDistanceCalculator and BaseDistanceCalculator.""" +from typing import Tuple + +import numpy as np +import pytest +from iris.coord_systems import CoordSystem, GeogCS, TransverseMercator +from iris.coords import DimCoord +from iris.cube import Cube +from iris.tests import IrisTest + +from improver.synthetic_data.set_up_test_cubes import set_up_variable_cube +from improver.utilities.spatial import DistanceBetweenGridSquares + +EARTH_RADIUS = 6371229.0 # metres + +TEST_LATITUDES = np.array([0, 10, 20]) +# Distance covered when travelling 10 degrees north/south: +Y_GRID_SPACING = 1111949 # Metres + +DISTANCE_PER_DEGREE_AT_EQUATOR = 111319.49079327357 +DISTANCE_PER_DEGREE_AT_10_DEGREES_NORTH = 109639.32210546243 +DISTANCE_PER_DEGREE_AT_20_DEGREES_NORTH = 104646.93093328059 + +ONE_DEGREE_DISTANCE_AT_TEST_LATITUDES = np.array( + [ + DISTANCE_PER_DEGREE_AT_EQUATOR, + DISTANCE_PER_DEGREE_AT_10_DEGREES_NORTH, + DISTANCE_PER_DEGREE_AT_20_DEGREES_NORTH, + ] +).reshape((3, 1)) + +TRANSVERSE_MERCATOR_GRID_SPACING = 2000.0 # Metres + + +def make_equalarea_test_cube(shape, grid_spacing, units="metres"): + """Creates a cube using the Lambert Azimuthal Equal Area projection for testing""" + data = np.ones(shape, dtype=np.float32) + cube = set_up_variable_cube( + data, + spatial_grid="equalarea", + x_grid_spacing=grid_spacing, + y_grid_spacing=grid_spacing, + ) + cube.coord("projection_x_coordinate").convert_units(units) + cube.coord("projection_y_coordinate").convert_units(units) + return cube + + +def make_test_cube( + shape: Tuple[int, int], + coordinate_system: CoordSystem, + x_axis_values: np.ndarray, + y_axis_values: np.ndarray, +) -> Cube: + """Creates an example cube for use as test input that can have unequal spatial coordinates.""" + example_data = np.ones(shape, dtype=np.float32) + cube = set_up_variable_cube( + example_data, + spatial_grid="latlon" if type(coordinate_system) == GeogCS else "equalarea", + ) + cube.replace_coord(cube.coord(axis="x").copy(x_axis_values)) + cube.replace_coord(cube.coord(axis="y").copy(y_axis_values)) + return cube + + +def make_transverse_mercator_test_cube(shape: Tuple[int, int]) -> Cube: + """ + Data are on a 2 km Transverse Mercator grid with an inverted y-axis, + located in the UK. + """ + # UKPP projection + transvers_mercator_coord_system = TransverseMercator( + latitude_of_projection_origin=49.0, + longitude_of_central_meridian=-2.0, + false_easting=400000.0, + false_northing=-100000.0, + scale_factor_at_central_meridian=0.9996013045310974, + ellipsoid=GeogCS(semi_major_axis=6377563.396, semi_minor_axis=6356256.91), + ) + xo = 400000.0 + yo = 0.0 + y_points = TRANSVERSE_MERCATOR_GRID_SPACING * (shape[0] - np.arange(shape[0])) + yo + x_points = TRANSVERSE_MERCATOR_GRID_SPACING * np.arange(shape[1]) + xo + return make_test_cube(shape, transvers_mercator_coord_system, x_points, y_points) + + +def make_latlon_test_cube( + shape: Tuple[int, int], latitudes: np.ndarray, longitudes: np.ndarray +) -> Cube: + """Creates a cube using the Geographic coordinate system with its origin at the + intersecton of the equator and the prime meridian.""" + return make_test_cube(shape, GeogCS(EARTH_RADIUS), longitudes, latitudes) + + +@pytest.mark.parametrize( + "longitudes, is_circular", + ( + ([0, 10, 20], False), + ([0, 5, 10], False), + ([0, 11, 22], False), + ([0, 60, 120], False), + ([0, 120, 240], False), + ([0, 120, 240], True), + ([0, 60, 120, 180, 240, 300], False), + ([0, 60, 120, 180, 240, 300], True), + ([0, 10], False), + ([0, 20], False), + ([-20, 20], False), + ([0, 30, 60, 90, 120], False), + ([0, 120, 180, 300], False), + ([0, 120, 180, 300], True), + ), +) +def test_latlon_cube(longitudes, is_circular): + """Basic test for a cube using a geographic coordinate system.""" + input_cube = make_latlon_test_cube( + (len(TEST_LATITUDES), len(longitudes)), TEST_LATITUDES, longitudes + ) + expected_y_distances = np.full( + (len(TEST_LATITUDES) - 1, len(longitudes)), Y_GRID_SPACING + ) + expected_longitudes = longitudes.copy() + if is_circular: + input_cube.coord(axis="x").circular = True + expected_longitudes.append(360 + longitudes[0]) + expected_x_distances = ( + np.diff(expected_longitudes) * ONE_DEGREE_DISTANCE_AT_TEST_LATITUDES + ) + ( + calculated_x_distances_cube, + calculated_y_distances_cube, + ) = DistanceBetweenGridSquares()(input_cube) + for result, expected in zip( + (calculated_x_distances_cube, calculated_y_distances_cube), + (expected_x_distances, expected_y_distances), + ): + assert result.units == "m" + np.testing.assert_allclose( + result.data, expected.data, rtol=15e-4, atol=0 + ) # Allowing 0.15% error for spherical earth approximation. + + +def test_equalarea_cube(): + """Basic test for a non-circular cube using a Lambert Azimuthal Equal Area projection""" + spacing = 1000 + input_cube = make_equalarea_test_cube((3, 3), grid_spacing=spacing) + expected_x_distances_cube_shape = (3, 2) + + expected_x_distances = np.full(expected_x_distances_cube_shape, spacing) + expected_y_distances = np.full((2, 3), spacing) + ( + calculated_x_distances_cube, + calculated_y_distances_cube, + ) = DistanceBetweenGridSquares()(input_cube) + for result, expected in zip( + (calculated_x_distances_cube, calculated_y_distances_cube), + (expected_x_distances, expected_y_distances), + ): + assert result.units == "m" + np.testing.assert_allclose(result.data, expected.data, rtol=2e-5, atol=0) + + +def test_equalarea_circular_cube_error(): + """Test for error with circular cube using a Lambert Azimuthal Equal Area projection""" + input_cube = make_equalarea_test_cube((3, 3), grid_spacing=1000) + input_cube.coord(axis="x").circular = True + with pytest.raises( + NotImplementedError, match="Cannot calculate distances between bounding points" + ): + DistanceBetweenGridSquares()(input_cube) + + +def test_equalarea_cube_nonstandard_units(): + """ + Test for a cube using a Lambert Azimuthal Equal Area projection with units of + kilometers for its x and y axes. + """ + input_cube = make_equalarea_test_cube((3, 3), grid_spacing=10, units="km") + expected_x_distances = np.full((3, 2), 10) + expected_y_distances = np.full((2, 3), 10) + ( + calculated_x_distances_cube, + calculated_y_distances_cube, + ) = DistanceBetweenGridSquares()(input_cube) + for result, expected in zip( + (calculated_x_distances_cube, calculated_y_distances_cube), + (expected_x_distances, expected_y_distances), + ): + assert result.units == "m" + np.testing.assert_allclose(result.data, expected.data, rtol=2e-5, atol=0) + + +def test_transverse_mercator_cube(): + """Test for a cube using a Transverse Mercator projection""" + input_cube = make_transverse_mercator_test_cube((3, 2)) + expected_x_distances = np.array( + [ + [TRANSVERSE_MERCATOR_GRID_SPACING], + [TRANSVERSE_MERCATOR_GRID_SPACING], + [TRANSVERSE_MERCATOR_GRID_SPACING], + ] + ) + expected_y_distances = np.full((2, 2), TRANSVERSE_MERCATOR_GRID_SPACING) + ( + calculated_x_distances_cube, + calculated_y_distances_cube, + ) = DistanceBetweenGridSquares()(input_cube) + for result, expected in zip( + (calculated_x_distances_cube, calculated_y_distances_cube), + (expected_x_distances, expected_y_distances), + ): + assert result.units == "m" + np.testing.assert_allclose( + result.data, expected.data, rtol=15e-4, atol=0 + ) # Allowing 0.15% error for spherical earth approximation. + + +def test_distance_cube_with_no_coordinate_system(): + """ + Test for a cube with no specified coordinate system but known distances between + adjacent grid points + """ + data = np.ones((3, 3)) + x_coord = DimCoord(np.arange(3), "projection_x_coordinate", units="km") + y_coord = DimCoord(np.arange(3), "projection_y_coordinate", units="km") + input_cube = Cube( + data, + long_name="topography", + units="m", + dim_coords_and_dims=[(y_coord, 0), (x_coord, 1)], + ) + expected_x_distances = np.full((3, 2), 1000) + expected_y_distances = np.full((2, 3), 1000) + ( + calculated_x_distances_cube, + calculated_y_distances_cube, + ) = DistanceBetweenGridSquares()(input_cube) + for result, expected in zip( + (calculated_x_distances_cube, calculated_y_distances_cube), + (expected_x_distances, expected_y_distances), + ): + assert result.units == "m" + np.testing.assert_allclose(result.data, expected.data, rtol=2e-5, atol=0) + + +def test_degrees_cube_with_no_coordinate_system_information(): + """ + Tests that a cube which does not contain enough information to determine distances + between grid points is handled appropriately. + """ + input_cube = make_test_cube( + shape=(3, 3), + coordinate_system=GeogCS(EARTH_RADIUS), + x_axis_values=np.arange(3), + y_axis_values=np.arange(3), + ) + input_cube.coord(axis="x").coord_system = None + input_cube.coord(axis="y").coord_system = None + with IrisTest().assertRaisesRegex( + expected_exception=ValueError, + expected_regex="Unsupported cube coordinate system.*", + ): + DistanceBetweenGridSquares()(input_cube) + + +def test_latlon_cube_with_no_coordinate_units_error(): + """ + Tests that a cube with latlon projection that cannot be coerced into units of degrees + is handled correctly. + """ + input_cube = make_test_cube( + shape=(3, 3), + coordinate_system=GeogCS(EARTH_RADIUS), + x_axis_values=np.arange(3), + y_axis_values=np.arange(3), + ) + input_cube.coord(axis="x").units = "1" + input_cube.coord(axis="y").units = "1" + with pytest.raises( + ValueError, + match=( + "Cannot parse spatial axes of the cube provided. " + "Expected lat-long cube with units of degrees." + ), + ): + DistanceBetweenGridSquares()(input_cube) diff --git a/improver_tests/utilities/spatial/test_GradientBetweenAdjacentGridSquares.py b/improver_tests/utilities/spatial/test_GradientBetweenAdjacentGridSquares.py new file mode 100644 index 0000000000..1378f9073c --- /dev/null +++ b/improver_tests/utilities/spatial/test_GradientBetweenAdjacentGridSquares.py @@ -0,0 +1,168 @@ +# (C) Crown copyright, Met Office. All rights reserved. +# +# This file is part of IMPROVER and is released under a BSD 3-Clause license. +# See LICENSE in the root of the repository for full licensing details. +""" Tests of GradientBetweenAdjacentGridSquares plugin.""" + +import numpy as np +import pytest + +from improver.metadata.constants.attributes import MANDATORY_ATTRIBUTE_DEFAULTS +from improver.synthetic_data.set_up_test_cubes import set_up_variable_cube +from improver.utilities.spatial import GradientBetweenAdjacentGridSquares + +EQUAL_AREA_GRID_SPACING = 2000 # Metres +# Distances covered when travelling degrees north-south or east-west: +DISTANCE_PER_DEGREE_AT_EQUATOR = 111198.9234485458 +DISTANCE_PER_DEGREE_AT_10_DEGREES_NORTH = 109509.56193873892 + +# Values cycle from east to west (sine wave). Values also decrease from equator to pole. +INPUT_DATA = ( + np.array( + [[0, 100, 0, -100], [0, 200, 0, -200], [0, 100, 0, -100]], dtype=np.float32 + ), +) +# The standard calculations return these values (last column of x omitted for non-circular data). +# These are not a cosine wave because we don't have sufficient data points. +EXPECTED_DATA = ( + np.array( + [ + [0.05, -0.05, -0.05, 0.05], + [0.1, -0.1, -0.1, 0.1], + [0.05, -0.05, -0.05, 0.05], + ], + dtype=np.float32, + ), + np.array([[0.0, 0.05, 0.0, -0.05], [0.0, -0.05, 0.0, 0.05]], dtype=np.float32), +) +# These data are expected when the above are regridded back to the source grid and the source +# data are not circular. +REGRIDDED_DATA = ( + np.array( + [[0.1, 0, -0.05, -0.05], [0.2, 0, -0.1, -0.1], [0.1, 0, -0.05, -0.05]], + dtype=np.float32, + ), + np.array( + [[0.0, 0.1, 0.0, -0.1], [0.0, 0.0, 0.0, 0.0], [0.0, -0.1, 0.0, 0.1]], + dtype=np.float32, + ), +) +# These data are expected when the EXPECTED_DATA are regridded back to the source grid and the +# source data are circular, which gives us a cosine wave. +CIRCULAR_DATA = ( + np.array( + [[0.05, 0, -0.05, 0.0], [0.1, 0, -0.1, 0.0], [0.05, 0, -0.05, 0.0]], + dtype=np.float32, + ), + np.array( + [[0.0, 0.1, 0.0, -0.1], [0.0, 0.0, 0.0, 0.0], [0.0, -0.1, 0.0, 0.1]], + dtype=np.float32, + ), +) + + +@pytest.mark.parametrize( + "projected, circular, regrid, example_x, example_y", + ( + (False, False, False, EXPECTED_DATA[0], EXPECTED_DATA[1]), + (False, False, True, REGRIDDED_DATA[0], REGRIDDED_DATA[1]), + (False, True, False, EXPECTED_DATA[0], EXPECTED_DATA[1]), + (False, True, True, CIRCULAR_DATA[0], CIRCULAR_DATA[1]), + (True, False, False, EXPECTED_DATA[0], EXPECTED_DATA[1]), + (True, False, True, REGRIDDED_DATA[0], REGRIDDED_DATA[1]), + ), +) +def test_data(projected, circular, regrid, example_x, example_y, data=INPUT_DATA[0]): + """Tests that the plugin produces the expected data for valid projections""" + x_grid_spacing = EQUAL_AREA_GRID_SPACING if projected else 90 + cube = set_up_variable_cube( + data, + spatial_grid="equalarea" if projected else "latlon", + x_grid_spacing=x_grid_spacing, + ) + expected_x = example_x.copy() + expected_y = example_y.copy() + if circular: + cube.coord(axis="x").circular = True + elif not regrid: # Drop final column + expected_x = expected_x[..., :-1] + if not projected: # Adjust expected values to represent 10 degrees rather than 2km + expected_x[0::2] /= ( + x_grid_spacing + * DISTANCE_PER_DEGREE_AT_10_DEGREES_NORTH + / EQUAL_AREA_GRID_SPACING + ) + expected_x[1] /= ( + x_grid_spacing * DISTANCE_PER_DEGREE_AT_EQUATOR / EQUAL_AREA_GRID_SPACING + ) + expected_y /= 10 * DISTANCE_PER_DEGREE_AT_EQUATOR / EQUAL_AREA_GRID_SPACING + plugin = GradientBetweenAdjacentGridSquares(regrid=regrid) + result_x, result_y = plugin(cube) + assert np.allclose(expected_x, result_x.data) + assert np.allclose(expected_y, result_y.data) + + +# By default, projected cubes have a spacing of 2000, and 10 for latlon. +# Coords are centred on zero. +projected_y_coord_points = [-1000, 1000] +projected_x_coord_points = [-2000, 0, 2000] +latlon_y_coord_points = [-5, 5] +latlon_x_coord_points = [-10, 0, 10, 180] + + +@pytest.mark.parametrize( + "projected, circular, expected_x_points, expected_y_points", + ( + (False, False, latlon_x_coord_points[:-1], latlon_y_coord_points), + (False, True, latlon_x_coord_points, latlon_y_coord_points), + (True, False, projected_x_coord_points, projected_y_coord_points), + ), +) +@pytest.mark.parametrize("data", (INPUT_DATA[0],)) +@pytest.mark.parametrize("regrid", (True, False)) +def test_metadata( + data, projected, circular, expected_x_points, expected_y_points, regrid +): + """Tests that the plugin produces cubes with the right metadata""" + cube = set_up_variable_cube( + data, + spatial_grid="equalarea" if projected else "latlon", + standard_grid_metadata="gl_det", + attributes=MANDATORY_ATTRIBUTE_DEFAULTS, + ) + if circular: + cube.coord(axis="x").circular = True + expected_x_coord = cube.coord(axis="x").copy(expected_x_points) + expected_y_coord = cube.coord(axis="y").copy(expected_y_points) + plugin = GradientBetweenAdjacentGridSquares(regrid=regrid) + result_x, result_y = plugin(cube) + for result in (result_x, result_y): + assert result.name() == "gradient_of_air_temperature" + assert result.units == "K m-1" + assert result.attributes == cube.attributes + if regrid: + # In regrid mode, we expect the original spatial coords + for axis in "xy": + assert result_x.coord(axis=axis) == cube.coord(axis=axis) + assert result_y.coord(axis=axis) == cube.coord(axis=axis) + else: + # Regrid=False => expected coords apply to one coord of one result + # (the one that the gradient has been calculated along) + assert result_x.coord(axis="y") == cube.coord(axis="y") + assert result_y.coord(axis="x") == cube.coord(axis="x") + assert result_x.coord(axis="x") == expected_x_coord + assert result_y.coord(axis="y") == expected_y_coord + + +@pytest.mark.parametrize("regrid", (True, False)) +def test_error(regrid, data=INPUT_DATA[0], projected=True, circular=True): + """Tests that an error is raised if a projected cube has a circular x coordinate. + The content of the error is checked in the tests for the class that raises it.""" + cube = set_up_variable_cube( + data, spatial_grid="equalarea" if projected else "latlon" + ) + if circular: + cube.coord(axis="x").circular = True + plugin = GradientBetweenAdjacentGridSquares(regrid=regrid) + with pytest.raises(NotImplementedError): + plugin(cube) diff --git a/improver_tests/utilities/test_spatial.py b/improver_tests/utilities/spatial/test_spatial.py similarity index 100% rename from improver_tests/utilities/test_spatial.py rename to improver_tests/utilities/spatial/test_spatial.py diff --git a/improver_tests/utilities/test_GradientBetweenAdjacentGridSquares.py b/improver_tests/utilities/test_GradientBetweenAdjacentGridSquares.py deleted file mode 100644 index 7887b77ceb..0000000000 --- a/improver_tests/utilities/test_GradientBetweenAdjacentGridSquares.py +++ /dev/null @@ -1,69 +0,0 @@ -# (C) Crown copyright, Met Office. All rights reserved. -# -# This file is part of IMPROVER and is released under a BSD 3-Clause license. -# See LICENSE in the root of the repository for full licensing details. -""" Tests of GradientBetweenAdjacentGridSquares plugin.""" - -import numpy as np -import pytest -from iris.cube import Cube - -from improver.metadata.constants.attributes import MANDATORY_ATTRIBUTE_DEFAULTS -from improver.synthetic_data.set_up_test_cubes import set_up_variable_cube -from improver.utilities.spatial import GradientBetweenAdjacentGridSquares - - -@pytest.fixture(name="wind_speed") -def wind_speed_fixture() -> Cube: - """Wind speed in m/s""" - data = np.array([[0, 1, 2], [2, 3, 4], [4, 5, 6]], dtype=np.float32) - cube = set_up_variable_cube( - data, name="wind_speed", units="m s^-1", spatial_grid="equalarea" - ) - for axis in ["x", "y"]: - cube.coord(axis=axis).points = np.array([0, 1, 2], dtype=np.float32) - return cube - - -@pytest.fixture(name="make_expected") -def make_expected_fixture() -> callable: - """Factory as fixture for generating a cube of varying size.""" - - def _make_expected(shape, value) -> Cube: - """Create a cube filled with data of a specific shape and value.""" - data = np.full(shape, value, dtype=np.float32) - cube = set_up_variable_cube( - data, - name="gradient_of_wind_speed", - units="s^-1", - spatial_grid="equalarea", - attributes=MANDATORY_ATTRIBUTE_DEFAULTS, - ) - for index, axis in enumerate(["y", "x"]): - cube.coord(axis=axis).points = np.array( - np.arange(shape[index]), dtype=np.float32 - ) - return cube - - return _make_expected - - -@pytest.mark.parametrize( - "grid", - ( - {"regrid": False, "xshape": (3, 2), "yshape": (2, 3)}, - {"regrid": True, "xshape": (3, 3), "yshape": (3, 3)}, - ), -) -def test_gradient(wind_speed, make_expected, grid): - """Check calculating the gradient with and without regridding""" - expected_x = make_expected(grid["xshape"], 1) - expected_y = make_expected(grid["yshape"], 2) - gradient_x, gradient_y = GradientBetweenAdjacentGridSquares(regrid=grid["regrid"])( - wind_speed - ) - for result, expected in zip((gradient_x, gradient_y), (expected_x, expected_y)): - assert result.name() == expected.name() - assert result.attributes == expected.attributes - assert result.units == expected.units - np.testing.assert_allclose(result.data, expected.data, rtol=1e-5, atol=1e-8)