diff --git a/docs/src/whatsnew/latest.rst b/docs/src/whatsnew/latest.rst index e04b832e23..bbef181057 100644 --- a/docs/src/whatsnew/latest.rst +++ b/docs/src/whatsnew/latest.rst @@ -45,6 +45,9 @@ This document explains the changes made to Iris for this release grid-mappping syntax -- see : :issue:`3388`. (:issue:`5562`, :pull:`6016`) +#. `@HGWright`_ added the `monthly` and `yearly` options to the + :meth:`~iris.coords.guess_bounds` method. (:issue:`4864`, :pull:`6090`) + 🐛 Bugs Fixed ============= diff --git a/lib/iris/coords.py b/lib/iris/coords.py index a56c13d9af..d2f5b05f89 100644 --- a/lib/iris/coords.py +++ b/lib/iris/coords.py @@ -2195,7 +2195,7 @@ def serialize(x): coord = self.copy(points=points, bounds=bounds) return coord - def _guess_bounds(self, bound_position=0.5): + def _guess_bounds(self, bound_position=0.5, monthly=False, yearly=False): """Return bounds for this coordinate based on its points. Parameters @@ -2203,6 +2203,12 @@ def _guess_bounds(self, bound_position=0.5): bound_position : float, default=0.5 The desired position of the bounds relative to the position of the points. + monthly : bool, default=False + If True, the coordinate must be monthly and bounds are set to the + start and ends of each month. + yearly : bool, default=False + If True, the coordinate must be yearly and bounds are set to the + start and ends of each year. Returns ------- @@ -2225,7 +2231,7 @@ def _guess_bounds(self, bound_position=0.5): if self.ndim != 1: raise iris.exceptions.CoordinateMultiDimError(self) - if self.shape[0] < 2: + if not monthly and self.shape[0] < 2: raise ValueError("Cannot guess bounds for a coordinate of length 1.") if self.has_bounds(): @@ -2234,31 +2240,80 @@ def _guess_bounds(self, bound_position=0.5): "before guessing new ones." ) - if getattr(self, "circular", False): - points = np.empty(self.shape[0] + 2) - points[1:-1] = self.points - direction = 1 if self.points[-1] > self.points[0] else -1 - points[0] = self.points[-1] - (self.units.modulus * direction) - points[-1] = self.points[0] + (self.units.modulus * direction) - diffs = np.diff(points) + if monthly or yearly: + if monthly and yearly: + raise ValueError( + "Cannot guess monthly and yearly bounds simultaneously." + ) + dates = self.units.num2date(self.points) + lower_bounds = [] + upper_bounds = [] + months_and_years = [] + if monthly: + for date in dates: + if date.month == 12: + lyear = date.year + uyear = date.year + 1 + lmonth = 12 + umonth = 1 + else: + lyear = uyear = date.year + lmonth = date.month + umonth = date.month + 1 + date_pair = (date.year, date.month) + if date_pair not in months_and_years: + months_and_years.append(date_pair) + else: + raise ValueError( + "Cannot guess monthly bounds for a coordinate with multiple " + "points in a month." + ) + lower_bounds.append(date.__class__(lyear, lmonth, 1, 0, 0)) + upper_bounds.append(date.__class__(uyear, umonth, 1, 0, 0)) + elif yearly: + for date in dates: + year = date.year + if year not in months_and_years: + months_and_years.append(year) + else: + raise ValueError( + "Cannot guess yearly bounds for a coordinate with multiple " + "points in a year." + ) + lower_bounds.append(date.__class__(date.year, 1, 1, 0, 0)) + upper_bounds.append(date.__class__(date.year + 1, 1, 1, 0, 0)) + bounds = self.units.date2num(np.array([lower_bounds, upper_bounds]).T) + contiguous = np.ma.allclose(bounds[1:, 0], bounds[:-1, 1]) + if not contiguous: + raise ValueError("Cannot guess bounds for a non-contiguous coordinate.") + + # if not monthly or yearly else: - diffs = np.diff(self.points) - diffs = np.insert(diffs, 0, diffs[0]) - diffs = np.append(diffs, diffs[-1]) + if getattr(self, "circular", False): + points = np.empty(self.shape[0] + 2) + points[1:-1] = self.points + direction = 1 if self.points[-1] > self.points[0] else -1 + points[0] = self.points[-1] - (self.units.modulus * direction) + points[-1] = self.points[0] + (self.units.modulus * direction) + diffs = np.diff(points) + else: + diffs = np.diff(self.points) + diffs = np.insert(diffs, 0, diffs[0]) + diffs = np.append(diffs, diffs[-1]) - min_bounds = self.points - diffs[:-1] * bound_position - max_bounds = self.points + diffs[1:] * (1 - bound_position) + min_bounds = self.points - diffs[:-1] * bound_position + max_bounds = self.points + diffs[1:] * (1 - bound_position) - bounds = np.array([min_bounds, max_bounds]).transpose() + bounds = np.array([min_bounds, max_bounds]).transpose() - if self.name() in ("latitude", "grid_latitude") and self.units == "degree": - points = self.points - if (points >= -90).all() and (points <= 90).all(): - np.clip(bounds, -90, 90, out=bounds) + if self.name() in ("latitude", "grid_latitude") and self.units == "degree": + points = self.points + if (points >= -90).all() and (points <= 90).all(): + np.clip(bounds, -90, 90, out=bounds) return bounds - def guess_bounds(self, bound_position=0.5): + def guess_bounds(self, bound_position=0.5, monthly=False, yearly=False): """Add contiguous bounds to a coordinate, calculated from its points. Puts a cell boundary at the specified fraction between each point and @@ -2275,6 +2330,13 @@ def guess_bounds(self, bound_position=0.5): bound_position : float, default=0.5 The desired position of the bounds relative to the position of the points. + monthly : bool, default=False + If True, the coordinate must be monthly and bounds are set to the + start and ends of each month. + yearly : bool, default=False + If True, the coordinate must be yearly and bounds are set to the + start and ends of each year. + Notes ----- @@ -2289,8 +2351,14 @@ def guess_bounds(self, bound_position=0.5): produce unexpected results : In such cases you should assign suitable values directly to the bounds property, instead. + .. note:: + + Monthly and Yearly work differently from the standard case. They + can work for single points but cannot be used together. + + """ - self.bounds = self._guess_bounds(bound_position) + self.bounds = self._guess_bounds(bound_position, monthly, yearly) def intersect(self, other, return_indices=False): """Return a new coordinate from the intersection of two coordinates. diff --git a/lib/iris/tests/unit/coords/test_Coord.py b/lib/iris/tests/unit/coords/test_Coord.py index 3740b17f22..c63261f95c 100644 --- a/lib/iris/tests/unit/coords/test_Coord.py +++ b/lib/iris/tests/unit/coords/test_Coord.py @@ -9,9 +9,11 @@ import iris.tests as tests # isort:skip import collections +from datetime import datetime from unittest import mock import warnings +import cf_units import dask.array as da import numpy as np import pytest @@ -236,6 +238,163 @@ def test_points_inside_bounds_outside_wrong_name_2(self): self.assertArrayEqual(lat.bounds, [[-120, -40], [-40, 35], [35, 105]]) +def test_guess_bounds_monthly_and_yearly(): + units = cf_units.Unit("days since epoch", calendar="gregorian") + points = units.date2num( + [ + datetime(1990, 1, 1), + datetime(1990, 2, 1), + datetime(1990, 3, 1), + ] + ) + coord = iris.coords.AuxCoord(points=points, units=units, standard_name="time") + with pytest.raises( + ValueError, + match="Cannot guess monthly and yearly bounds simultaneously.", + ): + coord.guess_bounds(monthly=True, yearly=True) + + +class Test_Guess_Bounds_Monthly: + def test_monthly_multiple_points_in_month(self): + units = cf_units.Unit("days since epoch", calendar="gregorian") + points = units.date2num( + [ + datetime(1990, 1, 3), + datetime(1990, 1, 28), + datetime(1990, 2, 13), + ] + ) + coord = iris.coords.AuxCoord(points=points, units=units, standard_name="time") + with pytest.raises( + ValueError, + match="Cannot guess monthly bounds for a coordinate with multiple points " + "in a month.", + ): + coord.guess_bounds(monthly=True) + + def test_monthly_non_contiguous(self): + units = cf_units.Unit("days since epoch", calendar="gregorian") + expected = units.date2num( + [ + [datetime(1990, 1, 1), datetime(1990, 2, 1)], + [datetime(1990, 2, 1), datetime(1990, 3, 1)], + [datetime(1990, 5, 1), datetime(1990, 6, 1)], + ] + ) + points = expected.mean(axis=1) + coord = iris.coords.AuxCoord(points=points, units=units, standard_name="time") + with pytest.raises( + ValueError, match="Cannot guess bounds for a non-contiguous coordinate." + ): + coord.guess_bounds(monthly=True) + + def test_monthly_end_of_month(self): + units = cf_units.Unit("days since epoch", calendar="gregorian") + expected = units.date2num( + [ + [datetime(1990, 1, 1), datetime(1990, 2, 1)], + [datetime(1990, 2, 1), datetime(1990, 3, 1)], + [datetime(1990, 3, 1), datetime(1990, 4, 1)], + ] + ) + points = units.date2num( + [ + datetime(1990, 1, 31), + datetime(1990, 2, 28), + datetime(1990, 3, 31), + ] + ) + coord = iris.coords.AuxCoord(points=points, units=units, standard_name="time") + coord.guess_bounds(monthly=True) + dates = units.num2date(coord.bounds) + expected_dates = units.num2date(expected) + np.testing.assert_array_equal(dates, expected_dates) + + def test_monthly_multiple_years(self): + units = cf_units.Unit("days since epoch", calendar="gregorian") + expected = [ + [datetime(1990, 10, 1), datetime(1990, 11, 1)], + [datetime(1990, 11, 1), datetime(1990, 12, 1)], + [datetime(1990, 12, 1), datetime(1991, 1, 1)], + ] + expected_points = units.date2num(expected) + points = expected_points.mean(axis=1) + coord = iris.coords.AuxCoord(points=points, units=units, standard_name="time") + coord.guess_bounds(monthly=True) + dates = units.num2date(coord.bounds) + np.testing.assert_array_equal(dates, expected) + + def test_monthly_single_point(self): + units = cf_units.Unit("days since epoch", calendar="gregorian") + expected = [ + [datetime(1990, 1, 1), datetime(1990, 2, 1)], + ] + expected_points = units.date2num(expected) + points = expected_points.mean(axis=1) + coord = iris.coords.AuxCoord(points=points, units=units, standard_name="time") + coord.guess_bounds(monthly=True) + dates = units.num2date(coord.bounds) + np.testing.assert_array_equal(dates, expected) + + +class Test_Guess_Bounds_Yearly: + def test_yearly_multiple_points_in_year(self): + units = cf_units.Unit("days since epoch", calendar="gregorian") + points = units.date2num( + [ + datetime(1990, 1, 1), + datetime(1990, 2, 1), + datetime(1991, 1, 1), + ] + ) + coord = iris.coords.AuxCoord(points=points, units=units, standard_name="time") + with pytest.raises( + ValueError, + match="Cannot guess yearly bounds for a coordinate with multiple points " + "in a year.", + ): + coord.guess_bounds(yearly=True) + + def test_yearly_non_contiguous(self): + units = cf_units.Unit("days since epoch", calendar="gregorian") + expected = units.date2num( + [ + [datetime(1990, 1, 1), datetime(1990, 1, 1)], + [datetime(1991, 1, 1), datetime(1991, 1, 1)], + [datetime(1994, 1, 1), datetime(1994, 1, 1)], + ] + ) + points = expected.mean(axis=1) + coord = iris.coords.AuxCoord(points=points, units=units, standard_name="time") + with pytest.raises( + ValueError, match="Cannot guess bounds for a non-contiguous coordinate." + ): + coord.guess_bounds(yearly=True) + + def test_yearly_end_of_year(self): + units = cf_units.Unit("days since epoch", calendar="gregorian") + expected = units.date2num( + [ + [datetime(1990, 1, 1), datetime(1991, 1, 1)], + [datetime(1991, 1, 1), datetime(1992, 1, 1)], + [datetime(1992, 1, 1), datetime(1993, 1, 1)], + ] + ) + points = units.date2num( + [ + datetime(1990, 12, 31), + datetime(1991, 12, 31), + datetime(1992, 12, 31), + ] + ) + coord = iris.coords.AuxCoord(points=points, units=units, standard_name="time") + coord.guess_bounds(yearly=True) + dates = units.num2date(coord.bounds) + expected_dates = units.num2date(expected) + np.testing.assert_array_equal(dates, expected_dates) + + class Test_cell(tests.IrisTest): def _mock_coord(self): coord = mock.Mock(