Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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