Skip to content

Commit 83905e9

Browse files
authored
Adding monthly and yearly arguments to guess_bounds (#6090)
* 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
1 parent 4585059 commit 83905e9

File tree

3 files changed

+251
-21
lines changed

3 files changed

+251
-21
lines changed

docs/src/whatsnew/latest.rst

+3
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ This document explains the changes made to Iris for this release
4545
grid-mappping syntax -- see : :issue:`3388`.
4646
(:issue:`5562`, :pull:`6016`)
4747

48+
#. `@HGWright`_ added the `monthly` and `yearly` options to the
49+
:meth:`~iris.coords.guess_bounds` method. (:issue:`4864`, :pull:`6090`)
50+
4851

4952
🐛 Bugs Fixed
5053
=============

lib/iris/coords.py

+89-21
Original file line numberDiff line numberDiff line change
@@ -2195,14 +2195,20 @@ def serialize(x):
21952195
coord = self.copy(points=points, bounds=bounds)
21962196
return coord
21972197

2198-
def _guess_bounds(self, bound_position=0.5):
2198+
def _guess_bounds(self, bound_position=0.5, monthly=False, yearly=False):
21992199
"""Return bounds for this coordinate based on its points.
22002200
22012201
Parameters
22022202
----------
22032203
bound_position : float, default=0.5
22042204
The desired position of the bounds relative to the position
22052205
of the points.
2206+
monthly : bool, default=False
2207+
If True, the coordinate must be monthly and bounds are set to the
2208+
start and ends of each month.
2209+
yearly : bool, default=False
2210+
If True, the coordinate must be yearly and bounds are set to the
2211+
start and ends of each year.
22062212
22072213
Returns
22082214
-------
@@ -2225,7 +2231,7 @@ def _guess_bounds(self, bound_position=0.5):
22252231
if self.ndim != 1:
22262232
raise iris.exceptions.CoordinateMultiDimError(self)
22272233

2228-
if self.shape[0] < 2:
2234+
if not monthly and self.shape[0] < 2:
22292235
raise ValueError("Cannot guess bounds for a coordinate of length 1.")
22302236

22312237
if self.has_bounds():
@@ -2234,31 +2240,80 @@ def _guess_bounds(self, bound_position=0.5):
22342240
"before guessing new ones."
22352241
)
22362242

2237-
if getattr(self, "circular", False):
2238-
points = np.empty(self.shape[0] + 2)
2239-
points[1:-1] = self.points
2240-
direction = 1 if self.points[-1] > self.points[0] else -1
2241-
points[0] = self.points[-1] - (self.units.modulus * direction)
2242-
points[-1] = self.points[0] + (self.units.modulus * direction)
2243-
diffs = np.diff(points)
2243+
if monthly or yearly:
2244+
if monthly and yearly:
2245+
raise ValueError(
2246+
"Cannot guess monthly and yearly bounds simultaneously."
2247+
)
2248+
dates = self.units.num2date(self.points)
2249+
lower_bounds = []
2250+
upper_bounds = []
2251+
months_and_years = []
2252+
if monthly:
2253+
for date in dates:
2254+
if date.month == 12:
2255+
lyear = date.year
2256+
uyear = date.year + 1
2257+
lmonth = 12
2258+
umonth = 1
2259+
else:
2260+
lyear = uyear = date.year
2261+
lmonth = date.month
2262+
umonth = date.month + 1
2263+
date_pair = (date.year, date.month)
2264+
if date_pair not in months_and_years:
2265+
months_and_years.append(date_pair)
2266+
else:
2267+
raise ValueError(
2268+
"Cannot guess monthly bounds for a coordinate with multiple "
2269+
"points in a month."
2270+
)
2271+
lower_bounds.append(date.__class__(lyear, lmonth, 1, 0, 0))
2272+
upper_bounds.append(date.__class__(uyear, umonth, 1, 0, 0))
2273+
elif yearly:
2274+
for date in dates:
2275+
year = date.year
2276+
if year not in months_and_years:
2277+
months_and_years.append(year)
2278+
else:
2279+
raise ValueError(
2280+
"Cannot guess yearly bounds for a coordinate with multiple "
2281+
"points in a year."
2282+
)
2283+
lower_bounds.append(date.__class__(date.year, 1, 1, 0, 0))
2284+
upper_bounds.append(date.__class__(date.year + 1, 1, 1, 0, 0))
2285+
bounds = self.units.date2num(np.array([lower_bounds, upper_bounds]).T)
2286+
contiguous = np.ma.allclose(bounds[1:, 0], bounds[:-1, 1])
2287+
if not contiguous:
2288+
raise ValueError("Cannot guess bounds for a non-contiguous coordinate.")
2289+
2290+
# if not monthly or yearly
22442291
else:
2245-
diffs = np.diff(self.points)
2246-
diffs = np.insert(diffs, 0, diffs[0])
2247-
diffs = np.append(diffs, diffs[-1])
2292+
if getattr(self, "circular", False):
2293+
points = np.empty(self.shape[0] + 2)
2294+
points[1:-1] = self.points
2295+
direction = 1 if self.points[-1] > self.points[0] else -1
2296+
points[0] = self.points[-1] - (self.units.modulus * direction)
2297+
points[-1] = self.points[0] + (self.units.modulus * direction)
2298+
diffs = np.diff(points)
2299+
else:
2300+
diffs = np.diff(self.points)
2301+
diffs = np.insert(diffs, 0, diffs[0])
2302+
diffs = np.append(diffs, diffs[-1])
22482303

2249-
min_bounds = self.points - diffs[:-1] * bound_position
2250-
max_bounds = self.points + diffs[1:] * (1 - bound_position)
2304+
min_bounds = self.points - diffs[:-1] * bound_position
2305+
max_bounds = self.points + diffs[1:] * (1 - bound_position)
22512306

2252-
bounds = np.array([min_bounds, max_bounds]).transpose()
2307+
bounds = np.array([min_bounds, max_bounds]).transpose()
22532308

2254-
if self.name() in ("latitude", "grid_latitude") and self.units == "degree":
2255-
points = self.points
2256-
if (points >= -90).all() and (points <= 90).all():
2257-
np.clip(bounds, -90, 90, out=bounds)
2309+
if self.name() in ("latitude", "grid_latitude") and self.units == "degree":
2310+
points = self.points
2311+
if (points >= -90).all() and (points <= 90).all():
2312+
np.clip(bounds, -90, 90, out=bounds)
22582313

22592314
return bounds
22602315

2261-
def guess_bounds(self, bound_position=0.5):
2316+
def guess_bounds(self, bound_position=0.5, monthly=False, yearly=False):
22622317
"""Add contiguous bounds to a coordinate, calculated from its points.
22632318
22642319
Puts a cell boundary at the specified fraction between each point and
@@ -2275,6 +2330,13 @@ def guess_bounds(self, bound_position=0.5):
22752330
bound_position : float, default=0.5
22762331
The desired position of the bounds relative to the position
22772332
of the points.
2333+
monthly : bool, default=False
2334+
If True, the coordinate must be monthly and bounds are set to the
2335+
start and ends of each month.
2336+
yearly : bool, default=False
2337+
If True, the coordinate must be yearly and bounds are set to the
2338+
start and ends of each year.
2339+
22782340
22792341
Notes
22802342
-----
@@ -2289,8 +2351,14 @@ def guess_bounds(self, bound_position=0.5):
22892351
produce unexpected results : In such cases you should assign
22902352
suitable values directly to the bounds property, instead.
22912353
2354+
.. note::
2355+
2356+
Monthly and Yearly work differently from the standard case. They
2357+
can work for single points but cannot be used together.
2358+
2359+
22922360
"""
2293-
self.bounds = self._guess_bounds(bound_position)
2361+
self.bounds = self._guess_bounds(bound_position, monthly, yearly)
22942362

22952363
def intersect(self, other, return_indices=False):
22962364
"""Return a new coordinate from the intersection of two coordinates.

lib/iris/tests/unit/coords/test_Coord.py

+159
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@
99
import iris.tests as tests # isort:skip
1010

1111
import collections
12+
from datetime import datetime
1213
from unittest import mock
1314
import warnings
1415

16+
import cf_units
1517
import dask.array as da
1618
import numpy as np
1719
import pytest
@@ -236,6 +238,163 @@ def test_points_inside_bounds_outside_wrong_name_2(self):
236238
self.assertArrayEqual(lat.bounds, [[-120, -40], [-40, 35], [35, 105]])
237239

238240

241+
def test_guess_bounds_monthly_and_yearly():
242+
units = cf_units.Unit("days since epoch", calendar="gregorian")
243+
points = units.date2num(
244+
[
245+
datetime(1990, 1, 1),
246+
datetime(1990, 2, 1),
247+
datetime(1990, 3, 1),
248+
]
249+
)
250+
coord = iris.coords.AuxCoord(points=points, units=units, standard_name="time")
251+
with pytest.raises(
252+
ValueError,
253+
match="Cannot guess monthly and yearly bounds simultaneously.",
254+
):
255+
coord.guess_bounds(monthly=True, yearly=True)
256+
257+
258+
class Test_Guess_Bounds_Monthly:
259+
def test_monthly_multiple_points_in_month(self):
260+
units = cf_units.Unit("days since epoch", calendar="gregorian")
261+
points = units.date2num(
262+
[
263+
datetime(1990, 1, 3),
264+
datetime(1990, 1, 28),
265+
datetime(1990, 2, 13),
266+
]
267+
)
268+
coord = iris.coords.AuxCoord(points=points, units=units, standard_name="time")
269+
with pytest.raises(
270+
ValueError,
271+
match="Cannot guess monthly bounds for a coordinate with multiple points "
272+
"in a month.",
273+
):
274+
coord.guess_bounds(monthly=True)
275+
276+
def test_monthly_non_contiguous(self):
277+
units = cf_units.Unit("days since epoch", calendar="gregorian")
278+
expected = units.date2num(
279+
[
280+
[datetime(1990, 1, 1), datetime(1990, 2, 1)],
281+
[datetime(1990, 2, 1), datetime(1990, 3, 1)],
282+
[datetime(1990, 5, 1), datetime(1990, 6, 1)],
283+
]
284+
)
285+
points = expected.mean(axis=1)
286+
coord = iris.coords.AuxCoord(points=points, units=units, standard_name="time")
287+
with pytest.raises(
288+
ValueError, match="Cannot guess bounds for a non-contiguous coordinate."
289+
):
290+
coord.guess_bounds(monthly=True)
291+
292+
def test_monthly_end_of_month(self):
293+
units = cf_units.Unit("days since epoch", calendar="gregorian")
294+
expected = units.date2num(
295+
[
296+
[datetime(1990, 1, 1), datetime(1990, 2, 1)],
297+
[datetime(1990, 2, 1), datetime(1990, 3, 1)],
298+
[datetime(1990, 3, 1), datetime(1990, 4, 1)],
299+
]
300+
)
301+
points = units.date2num(
302+
[
303+
datetime(1990, 1, 31),
304+
datetime(1990, 2, 28),
305+
datetime(1990, 3, 31),
306+
]
307+
)
308+
coord = iris.coords.AuxCoord(points=points, units=units, standard_name="time")
309+
coord.guess_bounds(monthly=True)
310+
dates = units.num2date(coord.bounds)
311+
expected_dates = units.num2date(expected)
312+
np.testing.assert_array_equal(dates, expected_dates)
313+
314+
def test_monthly_multiple_years(self):
315+
units = cf_units.Unit("days since epoch", calendar="gregorian")
316+
expected = [
317+
[datetime(1990, 10, 1), datetime(1990, 11, 1)],
318+
[datetime(1990, 11, 1), datetime(1990, 12, 1)],
319+
[datetime(1990, 12, 1), datetime(1991, 1, 1)],
320+
]
321+
expected_points = units.date2num(expected)
322+
points = expected_points.mean(axis=1)
323+
coord = iris.coords.AuxCoord(points=points, units=units, standard_name="time")
324+
coord.guess_bounds(monthly=True)
325+
dates = units.num2date(coord.bounds)
326+
np.testing.assert_array_equal(dates, expected)
327+
328+
def test_monthly_single_point(self):
329+
units = cf_units.Unit("days since epoch", calendar="gregorian")
330+
expected = [
331+
[datetime(1990, 1, 1), datetime(1990, 2, 1)],
332+
]
333+
expected_points = units.date2num(expected)
334+
points = expected_points.mean(axis=1)
335+
coord = iris.coords.AuxCoord(points=points, units=units, standard_name="time")
336+
coord.guess_bounds(monthly=True)
337+
dates = units.num2date(coord.bounds)
338+
np.testing.assert_array_equal(dates, expected)
339+
340+
341+
class Test_Guess_Bounds_Yearly:
342+
def test_yearly_multiple_points_in_year(self):
343+
units = cf_units.Unit("days since epoch", calendar="gregorian")
344+
points = units.date2num(
345+
[
346+
datetime(1990, 1, 1),
347+
datetime(1990, 2, 1),
348+
datetime(1991, 1, 1),
349+
]
350+
)
351+
coord = iris.coords.AuxCoord(points=points, units=units, standard_name="time")
352+
with pytest.raises(
353+
ValueError,
354+
match="Cannot guess yearly bounds for a coordinate with multiple points "
355+
"in a year.",
356+
):
357+
coord.guess_bounds(yearly=True)
358+
359+
def test_yearly_non_contiguous(self):
360+
units = cf_units.Unit("days since epoch", calendar="gregorian")
361+
expected = units.date2num(
362+
[
363+
[datetime(1990, 1, 1), datetime(1990, 1, 1)],
364+
[datetime(1991, 1, 1), datetime(1991, 1, 1)],
365+
[datetime(1994, 1, 1), datetime(1994, 1, 1)],
366+
]
367+
)
368+
points = expected.mean(axis=1)
369+
coord = iris.coords.AuxCoord(points=points, units=units, standard_name="time")
370+
with pytest.raises(
371+
ValueError, match="Cannot guess bounds for a non-contiguous coordinate."
372+
):
373+
coord.guess_bounds(yearly=True)
374+
375+
def test_yearly_end_of_year(self):
376+
units = cf_units.Unit("days since epoch", calendar="gregorian")
377+
expected = units.date2num(
378+
[
379+
[datetime(1990, 1, 1), datetime(1991, 1, 1)],
380+
[datetime(1991, 1, 1), datetime(1992, 1, 1)],
381+
[datetime(1992, 1, 1), datetime(1993, 1, 1)],
382+
]
383+
)
384+
points = units.date2num(
385+
[
386+
datetime(1990, 12, 31),
387+
datetime(1991, 12, 31),
388+
datetime(1992, 12, 31),
389+
]
390+
)
391+
coord = iris.coords.AuxCoord(points=points, units=units, standard_name="time")
392+
coord.guess_bounds(yearly=True)
393+
dates = units.num2date(coord.bounds)
394+
expected_dates = units.num2date(expected)
395+
np.testing.assert_array_equal(dates, expected_dates)
396+
397+
239398
class Test_cell(tests.IrisTest):
240399
def _mock_coord(self):
241400
coord = mock.Mock(

0 commit comments

Comments
 (0)