Skip to content

Commit

Permalink
Adding monthly and yearly arguments to guess_bounds (#6090)
Browse files Browse the repository at this point in the history
* add monthly and some tests

* more monthly tests

* fix broken test

* updated docstrings and removed comment

* adding yearly and tests

* add test for both

* whatsnew and docstring note
  • Loading branch information
HGWright authored Jul 26, 2024
1 parent 4585059 commit 83905e9
Show file tree
Hide file tree
Showing 3 changed files with 251 additions and 21 deletions.
3 changes: 3 additions & 0 deletions docs/src/whatsnew/latest.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
=============
Expand Down
110 changes: 89 additions & 21 deletions lib/iris/coords.py
Original file line number Diff line number Diff line change
Expand Up @@ -2195,14 +2195,20 @@ 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
----------
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
-------
Expand All @@ -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():
Expand All @@ -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
Expand All @@ -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
-----
Expand All @@ -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.
Expand Down
159 changes: 159 additions & 0 deletions lib/iris/tests/unit/coords/test_Coord.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down

0 comments on commit 83905e9

Please sign in to comment.