Skip to content

Commit

Permalink
Merge branch 'master' into pjordan-make-gradient-consider-distance
Browse files Browse the repository at this point in the history
* master:
  Make difference module handle circular cubes (metoppv#2016)

# Conflicts:
#	improver/utilities/spatial.py
#	improver_tests/utilities/test_DifferenceBetweenAdjacentGridSquares.py
  • Loading branch information
MoseleyS committed Jul 19, 2024
2 parents 980fd97 + 1d63757 commit 12df67b
Show file tree
Hide file tree
Showing 2 changed files with 145 additions and 32 deletions.
83 changes: 67 additions & 16 deletions improver/utilities/spatial.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
""" Provides support utilities."""

import copy
import warnings
from typing import List, Optional, Tuple, Union
from abc import ABC, abstractmethod

Expand All @@ -16,14 +17,16 @@
from numpy.ma import MaskedArray
from cf_units import Unit
import iris
from iris.coords import Coord, DimCoord, AuxCoord, CellMethod
from iris.coord_systems import GeogCS
from iris.coords import AuxCoord, CellMethod, Coord, DimCoord
from iris.cube import Cube, CubeList
from iris.coord_systems import (
CoordSystem,
GeogCS,
)

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
Expand Down Expand Up @@ -453,9 +456,44 @@ def _get_max_x_axis_value(cube):
return 2 * np.pi * axis.coord_system.semi_major_axis # Planet circumference

@staticmethod
def _axis_wraps_around_meridian(axis: Coord, cube: Cube):
def _axis_wraps_around_meridian(axis: Coord, cube: Cube) -> bool:
"""Returns true if the cube is 'circular' with the given axis wrapping around, i.e. if there
is a smooth transition between 180 degrees and -180 degrees on the axis.
Args:
axis:
Axis to check for circularity.
cube:
The cube to which the axis belongs.
Returns:
True if the axis wraps around the meridian; false otherwise.
"""
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.
Expand All @@ -475,12 +513,11 @@ def _update_metadata(diff_cube: Cube, coord_name: str, cube_name: str) -> None:
diff_cube.attributes["form_of_difference"] = "forward_difference"
diff_cube.rename("difference_of_" + cube_name)

def create_difference_cube(self,
cube: Cube, coord_name: str, diff_along_axis: ndarray
def create_difference_cube(
self, cube: Cube, coord_name: str, diff_along_axis: ndarray
) -> Cube:
"""
Put the difference array into a cube with the appropriate
metadata.
Put the difference array into a cube with the appropriate metadata.
Args:
cube:
Expand All @@ -499,9 +536,18 @@ def create_difference_cube(self,
points = axis.points
mean_points = (points[1:] + points[:-1]) / 2
if self._axis_wraps_around_meridian(axis, cube):
max_value = self._get_max_x_axis_value(cube)
extra_mean_point = np.mean([points[-1], (points[0] + max_value)]) % max_value
mean_points = np.hstack([mean_points, extra_mean_point])
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."
)
else:
max_value = self._get_max_x_axis_value(cube)
extra_mean_point = np.mean([points[-1], (points[0] + max_value)]) % max_value
mean_points = np.hstack([mean_points, extra_mean_point])

# Copy cube metadata and coordinates into a new cube.
# Create a new coordinate for the coordinate along which the
Expand Down Expand Up @@ -541,10 +587,17 @@ def calculate_difference(self, cube: Cube, coord_name: str) -> ndarray:
diff_axis_number = cube.coord_dims(coord_name)[0]
diff_along_axis = np.diff(cube.data, axis=diff_axis_number)
if self._axis_wraps_around_meridian(diff_axis, cube):
first_column = cube.data[:, :1]
last_column = cube.data[:,- 1:]
wrap_around_diff = np.diff(np.hstack([last_column, first_column]), axis=diff_axis_number)
diff_along_axis = np.hstack([diff_along_axis, wrap_around_diff]) # Todo: order is wrong.
# Get wrap-around difference:
first_column = np.take(cube.data, indices=0, axis=diff_axis_number)
last_column = np.take(cube.data, indices=-1, axis=diff_axis_number)
wrap_around_diff = first_column - last_column
# Apply wrap-around difference vector to diff array:
if diff_axis_number == 0:
diff_along_axis = np.vstack([diff_along_axis, wrap_around_diff])
elif diff_axis_number == 1:
diff_along_axis = np.hstack(
[diff_along_axis, wrap_around_diff.reshape([-1, 1])]
)
return diff_along_axis

def process(self, cube: Cube) -> Tuple[Cube, Cube]:
Expand All @@ -567,9 +620,7 @@ def process(self, cube: Cube) -> Tuple[Cube, Cube]:
for axis in ["x", "y"]:
coord_name = cube.coord(axis=axis).name()
difference = self.calculate_difference(cube, coord_name)
diff_cube = self.create_difference_cube(
cube, coord_name, difference
)
diff_cube = self.create_difference_cube(cube, coord_name, difference)
self._update_metadata(diff_cube, coord_name, cube.name())
diffs.append(diff_cube)
return tuple(diffs)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,19 +54,52 @@ def test_x_dimension(self):
self.assertArrayEqual(result.data, diff_array)

def test_x_dimension_for_circular_latlon_cube(self):
"""Test differences calculated along the x dimension."""
"""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]])
test_cube_x_grid_spacing = 120
test_cube = set_up_variable_cube(test_cube_data, "wind_speed", "m s-1", "latlon", x_grid_spacing=test_cube_x_grid_spacing)
test_cube = set_up_variable_cube(
test_cube_data,
"latlon",
x_grid_spacing=test_cube_x_grid_spacing,
name="wind_speed",
units="m s-1",
)
test_cube.coord(axis="x").circular = True
diff_array = np.array([[1, 1, -2], [2, 2, -4], [5, 5, -10]])
expected_x_coords = np.array([-60, 60, 180]) # Original data at [-120, 0, 120], therefore differences are at [-60, 60, 180].
expected_diff_array = np.array([[1, 1, -2], [2, 2, -4], [5, 5, -10]])
expected_x_coords = np.array(
[-60, 60, 180]
) # Original data are at [-120, 0, 120], therefore differences are at [-60, 60, 180].
result = self.plugin.create_difference_cube(
test_cube, "longitude", diff_array
test_cube, "longitude", expected_diff_array
)
self.assertIsInstance(result, Cube)
self.assertArrayAlmostEqual(result.coord(axis="x").points, expected_x_coords)
self.assertArrayEqual(result.data, diff_array)
self.assertArrayEqual(result.data, expected_diff_array)

def test_x_dimension_for_circular_latlon_cube_360_degree_coord(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]])
test_cube_x_grid_spacing = 120
test_cube = set_up_variable_cube(
test_cube_data,
"latlon",
x_grid_spacing=test_cube_x_grid_spacing,
name="wind_speed",
units="m s-1",
)
test_cube.coord(axis="x").bounds = [[0, 120], [120, 240], [240, 360]]
test_cube.coord(axis="x").points = [60, 120, 300]
test_cube.coord(axis="x").circular = True
expected_diff_array = np.array([[1, 1, -2], [2, 2, -4], [5, 5, -10]])
expected_x_coords = np.array(
[90, 210, 360]
) # Original data are at [60, 120, 300], therefore differences are at [90, 210, 360].
result = self.plugin.create_difference_cube(
test_cube, "longitude", expected_diff_array
)
self.assertIsInstance(result, Cube)
self.assertArrayAlmostEqual(result.coord(axis="x").points, expected_x_coords)
self.assertArrayEqual(result.data, expected_diff_array)

# TOdo: FAILS FOR equal area cubes. Is this a problem? Which projections is it worth coding against?

Expand All @@ -82,20 +115,40 @@ def test_othercoords(self):


class Test_calculate_difference(IrisTest):

"""Test the calculate_difference method."""

def setUp(self):
"""Set up cube."""
data = np.array([[1, 2, 3], [2, 4, 6], [5, 10, 15]])
data = np.array([[1, 2, 3, 4], [2, 4, 6, 8], [5, 10, 15, 20]])
self.cube = set_up_variable_cube(
data, name="wind_speed", units="m s-1", spatial_grid="equalarea",
data, "equalarea", name="wind_speed", units="m s-1",
)
self.plugin = DifferenceBetweenAdjacentGridSquares()

def test_x_dimension(self):
"""Test differences calculated along the x dimension."""
expected = np.array([[1, 1], [2, 2], [5, 5]])
expected = np.array([[1, 1, 1], [2, 2, 2], [5, 5, 5]])
result = self.plugin.calculate_difference(
self.cube, self.cube.coord(axis="x").name()
)
self.assertIsInstance(result, np.ndarray)
self.assertArrayEqual(result, expected)

def test_x_dimension_wraps_around_meridian(self):
"""Test differences calculated along the x dimension for a cube which is circular in x."""
self.cube.coord(axis="x").circular = True
expected = np.array([[1, 1, 1, -3], [2, 2, 2, -6], [5, 5, 5, -15]])
result = self.plugin.calculate_difference(
self.cube, self.cube.coord(axis="x").name()
)
self.assertIsInstance(result, np.ndarray)
self.assertArrayEqual(result, expected)

def test_x_dimension_wraps_around_meridian_cube_axes_flipped(self):
"""Test differences calculated along the x dimension for a cube which is circular in x."""
self.cube.coord(axis="x").circular = True
self.cube.transpose()
expected = np.array([[1, 1, 1, -3], [2, 2, 2, -6], [5, 5, 5, -15]]).transpose()
result = self.plugin.calculate_difference(
self.cube, self.cube.coord(axis="x").name()
)
Expand All @@ -114,7 +167,7 @@ def test_x_dimension_wraps_around_meridian(self):

def test_y_dimension(self):
"""Test differences calculated along the y dimension."""
expected = np.array([[1, 2, 3], [3, 6, 9]])
expected = np.array([[1, 2, 3, 4], [3, 6, 9, 12]])
result = self.plugin.calculate_difference(
self.cube, self.cube.coord(axis="y").name()
)
Expand All @@ -123,9 +176,11 @@ def test_y_dimension(self):

def test_missing_data(self):
"""Test that the result is as expected when data is missing."""
data = np.array([[1, 2, 3], [np.nan, 4, 6], [5, 10, 15]], dtype=np.float32)
data = np.array(
[[1, 2, 3, 4], [np.nan, 4, 6, 8], [5, 10, 15, 20]], dtype=np.float32
)
self.cube.data = data
expected = np.array([[np.nan, 2, 3], [np.nan, 6, 9]])
expected = np.array([[np.nan, 2, 3, 4], [np.nan, 6, 9, 12]])
result = self.plugin.calculate_difference(
self.cube, self.cube.coord(axis="y").name()
)
Expand All @@ -135,10 +190,13 @@ def test_missing_data(self):
def test_masked_data(self):
"""Test that the result is as expected when data is masked."""
data = ma.array(
[[1, 2, 3], [2, 4, 6], [5, 10, 15]], mask=[[0, 0, 0], [1, 0, 0], [0, 0, 0]]
[[1, 2, 3, 4], [2, 4, 6, 8], [5, 10, 15, 20]],
mask=[[0, 0, 0, 0], [1, 0, 0, 0], [0, 0, 0, 0]],
)
self.cube.data = data
expected = ma.array([[1, 2, 3], [3, 6, 9]], mask=[[1, 0, 0], [1, 0, 0]])
expected = ma.array(
[[1, 2, 3, 4], [3, 6, 9, 12]], mask=[[1, 0, 0, 0], [1, 0, 0, 0]]
)
result = self.plugin.calculate_difference(
self.cube, self.cube.coord(axis="y").name()
)
Expand All @@ -148,7 +206,6 @@ def test_masked_data(self):


class Test_process(IrisTest):

"""Test the process method."""

def setUp(self):
Expand Down Expand Up @@ -212,6 +269,11 @@ def test_3d_cube(self):
self.assertIsInstance(result[1], iris.cube.Cube)
self.assertArrayEqual(result[1].data, expected_y)

def test_circular_non_geographic_cube_raises_approprate_exception(self):
self.cube.coord(axis="x").circular = True
with self.assertRaises(ValueError):
self.plugin.process(self.cube)


if __name__ == "__main__":
unittest.main()

0 comments on commit 12df67b

Please sign in to comment.