Skip to content

Commit 62e8baf

Browse files
authored
Merge cb47c32 into 0fdedb4
2 parents 0fdedb4 + cb47c32 commit 62e8baf

File tree

4 files changed

+174
-4
lines changed

4 files changed

+174
-4
lines changed

docs/src/whatsnew/latest.rst

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,12 @@ This document explains the changes made to Iris for this release
3030
✨ Features
3131
===========
3232

33-
#. N/A
33+
#. `@trexfeathers`_ added a new :class:`~iris.Future` flag -
34+
``date_microseconds`` - which sets whether Iris should use the new
35+
microsecond-precision units (see :class:`cf_units.Unit`) when the unit
36+
is a time unit. The previous maximum precision was seconds. You should check
37+
your code for new floating point problems if activating this (e.g. when
38+
using the :class:`~iris.Constraint` API). (:pull:`6260`)
3439

3540

3641
🐛 Bugs Fixed
@@ -50,7 +55,10 @@ This document explains the changes made to Iris for this release
5055
🚀 Performance Enhancements
5156
===========================
5257

53-
#. N/A
58+
#. Note that due to the new ``date_microseconds`` :class:`~iris.Future` flag,
59+
the time coordinate categorisation speedup introduced in
60+
:doc:`/whatsnew/3.11` will only be available when
61+
``iris.FUTURE.date_microseconds == True``.
5462

5563

5664
🔥 Deprecations

lib/iris/__init__.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,13 @@ def callback(cube, field, filename):
143143
class Future(threading.local):
144144
"""Run-time configuration controller."""
145145

146-
def __init__(self, datum_support=False, pandas_ndim=False, save_split_attrs=False):
146+
def __init__(
147+
self,
148+
datum_support=False,
149+
pandas_ndim=False,
150+
save_split_attrs=False,
151+
date_microseconds=False,
152+
):
147153
"""Container for run-time options controls.
148154
149155
To adjust the values simply update the relevant attribute from
@@ -169,6 +175,13 @@ def __init__(self, datum_support=False, pandas_ndim=False, save_split_attrs=Fals
169175
different ways : "global" ones are saved as dataset attributes, where
170176
possible, while "local" ones are saved as data-variable attributes.
171177
See :func:`iris.fileformats.netcdf.saver.save`.
178+
date_microseconds : bool, default=False
179+
Newer versions of cftime and cf-units support microsecond precision
180+
for dates, compared to the legacy behaviour that only works with
181+
seconds. Enabling microsecond precision will alter core Iris
182+
behaviour, such as when using :class:`~iris.Constraint`, and you
183+
may need to defend against floating point precision issues where
184+
you didn't need to before.
172185
173186
"""
174187
# The flag 'example_future_flag' is provided as a reference for the
@@ -181,6 +194,7 @@ def __init__(self, datum_support=False, pandas_ndim=False, save_split_attrs=Fals
181194
self.__dict__["datum_support"] = datum_support
182195
self.__dict__["pandas_ndim"] = pandas_ndim
183196
self.__dict__["save_split_attrs"] = save_split_attrs
197+
self.__dict__["date_microseconds"] = date_microseconds
184198

185199
# TODO: next major release: set IrisDeprecation to subclass
186200
# DeprecationWarning instead of UserWarning.
@@ -189,7 +203,12 @@ def __repr__(self):
189203
# msg = ('Future(example_future_flag={})')
190204
# return msg.format(self.example_future_flag)
191205
msg = "Future(datum_support={}, pandas_ndim={}, save_split_attrs={})"
192-
return msg.format(self.datum_support, self.pandas_ndim, self.save_split_attrs)
206+
return msg.format(
207+
self.datum_support,
208+
self.pandas_ndim,
209+
self.save_split_attrs,
210+
self.date_microseconds,
211+
)
193212

194213
# deprecated_options = {'example_future_flag': 'warning',}
195214
deprecated_options: dict[str, Literal["error", "warning"]] = {}

lib/iris/common/metadata.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@
1010
from collections import namedtuple
1111
from collections.abc import Iterable, Mapping
1212
from copy import deepcopy
13+
from datetime import timedelta
1314
from functools import lru_cache, wraps
1415
import re
1516
from typing import TYPE_CHECKING, Any
17+
import warnings
1618

1719
import cf_units
1820
import numpy as np
@@ -21,6 +23,7 @@
2123

2224
if TYPE_CHECKING:
2325
from iris.coords import CellMethod
26+
from .. import FUTURE
2427
from ..config import get_logger
2528
from ._split_attribute_dicts import adjust_for_split_attribute_dictionaries
2629
from .lenient import _LENIENT
@@ -54,6 +57,54 @@
5457
logger = get_logger(__name__, fmt="[%(cls)s.%(funcName)s]")
5558

5659

60+
_num2date_original = cf_units.Unit.num2date
61+
62+
63+
def _num2date_to_nearest_second(
64+
self,
65+
time_value,
66+
only_use_cftime_datetimes=True,
67+
only_use_python_datetimes=False,
68+
):
69+
# Used to monkey-patch the cf_units.Unit.num2date method to round to the
70+
# nearest second, which was the legacy behaviour. This is under a FUTURE
71+
# flag - users will need to adapt to microsecond precision eventually,
72+
# which may involve floating point issues.
73+
def _round(date):
74+
if date.microsecond == 0:
75+
return date
76+
elif date.microsecond < 500000:
77+
return date - timedelta(microseconds=date.microsecond)
78+
else:
79+
return (
80+
date + timedelta(seconds=1) - timedelta(microseconds=date.microsecond)
81+
)
82+
83+
result = _num2date_original(
84+
self, time_value, only_use_cftime_datetimes, only_use_python_datetimes
85+
)
86+
if FUTURE.date_microseconds is False:
87+
message = (
88+
"You are using legacy date precision for Iris units - max "
89+
"precision is seconds. In future, Iris will use microsecond "
90+
"precision, which may affect core behaviour. To opt-in to the "
91+
"new behaviour, set `iris.FUTURE.date_microseconds = True`."
92+
)
93+
warnings.warn(message, category=FutureWarning)
94+
95+
if hasattr(result, "shape"):
96+
vfunc = np.vectorize(_round)
97+
result = vfunc(result)
98+
else:
99+
result = _round(result)
100+
101+
return result
102+
103+
104+
# See the note in _num2date_to_nearest_second.
105+
cf_units.Unit.num2date = _num2date_to_nearest_second
106+
107+
57108
def hexdigest(item):
58109
"""Calculate a hexadecimal string hash representation of the provided item.
59110
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# Copyright Iris contributors
2+
#
3+
# This file is part of Iris and is released under the BSD license.
4+
# See LICENSE in the root of the repository for full licensing details.
5+
"""Unit tests for the opt-in FUTURE.date_microseconds behaviour."""
6+
7+
import warnings
8+
9+
import numpy as np
10+
import pytest
11+
12+
from iris import FUTURE
13+
from iris.coords import DimCoord
14+
from iris.tests._shared_utils import assert_array_equal
15+
16+
17+
@pytest.fixture(
18+
params=[0, 1000, 500000],
19+
ids=["no_microseconds", "1_millisecond", "half_second"],
20+
)
21+
def time_coord(request) -> tuple[bool, DimCoord]:
22+
points = np.array([0.0, 1.0, 2.0])
23+
points += request.param / 1e6
24+
return request.param, DimCoord(
25+
points,
26+
"time",
27+
units="seconds since 1970-01-01 00:00:00",
28+
)
29+
30+
31+
@pytest.fixture(
32+
params=[False, True],
33+
ids=["without_future", "with_future"],
34+
)
35+
def future_date_microseconds(request):
36+
FUTURE.date_microseconds = request.param
37+
yield request.param
38+
FUTURE.date_microseconds = False
39+
40+
41+
def test_warning(time_coord, future_date_microseconds):
42+
# Warning should be raised whether the coordinate has microseconds or not.
43+
# Want users to be aware, and opt-in, as early as possible.
44+
n_microseconds, coord = time_coord
45+
46+
def _op():
47+
_ = coord.units.num2date(coord.points)
48+
49+
if future_date_microseconds:
50+
with warnings.catch_warnings():
51+
warnings.simplefilter("error", FutureWarning)
52+
_op()
53+
else:
54+
with pytest.warns(FutureWarning):
55+
_op()
56+
57+
58+
@pytest.mark.parametrize(
59+
"indexing",
60+
(np.s_[0], np.s_[:], np.s_[:, np.newaxis]),
61+
ids=("single", "array", "array_2d"),
62+
)
63+
def test_num2date(time_coord, future_date_microseconds, indexing):
64+
n_microseconds, coord = time_coord
65+
result = coord.units.num2date(coord.points[indexing])
66+
67+
if indexing == np.s_[0]:
68+
assert hasattr(result, "microsecond")
69+
# Convert to iterable for more consistency downstream.
70+
result = [result]
71+
else:
72+
assert hasattr(result, "shape")
73+
assert hasattr(result.flatten()[0], "microsecond")
74+
result = result.flatten()
75+
76+
expected_microseconds = n_microseconds
77+
if not future_date_microseconds:
78+
expected_microseconds = 0
79+
80+
assert all(r.microsecond == expected_microseconds for r in result)
81+
82+
83+
def test_roundup(time_coord, future_date_microseconds):
84+
n_microseconds, coord = time_coord
85+
result = coord.units.num2date(coord.points)
86+
87+
expected_seconds = np.floor(coord.points)
88+
if n_microseconds >= 500000 and not future_date_microseconds:
89+
expected_seconds += 1
90+
91+
result_seconds = np.array([r.second for r in result])
92+
assert_array_equal(result_seconds, expected_seconds)

0 commit comments

Comments
 (0)