Skip to content
Closed
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
23 changes: 21 additions & 2 deletions lib/iris/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,13 @@ def callback(cube, field, filename):
class Future(threading.local):
"""Run-time configuration controller."""

def __init__(self, datum_support=False, pandas_ndim=False, save_split_attrs=False):
def __init__(
self,
datum_support=False,
pandas_ndim=False,
save_split_attrs=False,
date_microseconds=False,
):
"""Container for run-time options controls.

To adjust the values simply update the relevant attribute from
Expand All @@ -169,6 +175,13 @@ def __init__(self, datum_support=False, pandas_ndim=False, save_split_attrs=Fals
different ways : "global" ones are saved as dataset attributes, where
possible, while "local" ones are saved as data-variable attributes.
See :func:`iris.fileformats.netcdf.saver.save`.
date_microseconds : bool, default=False
Newer versions of cftime and cf-units support microsecond precision
for dates, compared to the legacy behaviour that only works with
seconds. Enabling microsecond precision will alter core Iris
behaviour, such as when using :class:`~iris.Constraint`, and you
may need to defend against floating point precision issues where
you didn't need to before.

"""
# The flag 'example_future_flag' is provided as a reference for the
Expand All @@ -181,6 +194,7 @@ def __init__(self, datum_support=False, pandas_ndim=False, save_split_attrs=Fals
self.__dict__["datum_support"] = datum_support
self.__dict__["pandas_ndim"] = pandas_ndim
self.__dict__["save_split_attrs"] = save_split_attrs
self.__dict__["date_microseconds"] = date_microseconds

# TODO: next major release: set IrisDeprecation to subclass
# DeprecationWarning instead of UserWarning.
Expand All @@ -189,7 +203,12 @@ def __repr__(self):
# msg = ('Future(example_future_flag={})')
# return msg.format(self.example_future_flag)
msg = "Future(datum_support={}, pandas_ndim={}, save_split_attrs={})"
return msg.format(self.datum_support, self.pandas_ndim, self.save_split_attrs)
return msg.format(
self.datum_support,
self.pandas_ndim,
self.save_split_attrs,
self.date_microseconds,
)

# deprecated_options = {'example_future_flag': 'warning',}
deprecated_options: dict[str, Literal["error", "warning"]] = {}
Expand Down
51 changes: 51 additions & 0 deletions lib/iris/common/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@
from collections import namedtuple
from collections.abc import Iterable, Mapping
from copy import deepcopy
from datetime import timedelta
from functools import lru_cache, wraps
import re
from typing import TYPE_CHECKING, Any
import warnings

import cf_units
import numpy as np
Expand All @@ -21,6 +23,7 @@

if TYPE_CHECKING:
from iris.coords import CellMethod
from .. import FUTURE
from ..config import get_logger
from ._split_attribute_dicts import adjust_for_split_attribute_dictionaries
from .lenient import _LENIENT
Expand Down Expand Up @@ -54,6 +57,54 @@
logger = get_logger(__name__, fmt="[%(cls)s.%(funcName)s]")


_num2date_original = cf_units.Unit.num2date


def _num2date_to_nearest_second(
self,
time_value,
only_use_cftime_datetimes=True,
only_use_python_datetimes=False,
):
# Used to monkey-patch the cf_units.Unit.num2date method to round to the
# nearest second, which was the legacy behaviour. This is under a FUTURE
# flag - users will need to adapt to microsecond precision eventually,
# which may involve floating point issues.
def _round(date):
if date.microsecond == 0:
return date
elif date.microsecond < 500000:
return date - timedelta(microseconds=date.microsecond)
else:
return (
date + timedelta(seconds=1) - timedelta(microseconds=date.microsecond)
)

result = _num2date_original(
self, time_value, only_use_cftime_datetimes, only_use_python_datetimes
)
if FUTURE.date_microseconds is False:
message = (
"You are using legacy date precision for Iris units - max "
"precision is seconds. In future, Iris will use microsecond "
"precision, which may affect core behaviour. To opt-in to the "
"new behaviour, set `iris.FUTURE.date_microseconds = True`."
)
warnings.warn(message, category=FutureWarning)

if hasattr(result, "shape"):
vfunc = np.vectorize(_round)
result = vfunc(result)
else:
result = _round(result)

return result


# See the note in _num2date_to_nearest_second.
cf_units.Unit.num2date = _num2date_to_nearest_second


def hexdigest(item):
"""Calculate a hexadecimal string hash representation of the provided item.

Expand Down
92 changes: 92 additions & 0 deletions lib/iris/tests/unit/common/metadata/test_microsecond_future.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Copyright Iris contributors
#
# This file is part of Iris and is released under the BSD license.
# See LICENSE in the root of the repository for full licensing details.
"""Unit tests for the opt-in FUTURE.date_microseconds behaviour."""

import warnings

import numpy as np
import pytest

from iris import FUTURE
from iris.coords import DimCoord
from iris.tests._shared_utils import assert_array_equal


@pytest.fixture(
params=[0, 1000, 500000],
ids=["no_microseconds", "1_millisecond", "half_second"],
)
def time_coord(request) -> tuple[bool, DimCoord]:
points = np.array([0.0, 1.0, 2.0])
points += request.param / 1e6
return request.param, DimCoord(
points,
"time",
units="seconds since 1970-01-01 00:00:00",
)


@pytest.fixture(
params=[False, True],
ids=["without_future", "with_future"],
)
def future_date_microseconds(request):
FUTURE.date_microseconds = request.param
yield request.param
FUTURE.date_microseconds = False


def test_warning(time_coord, future_date_microseconds):
# Warning should be raised whether the coordinate has microseconds or not.
# Want users to be aware, and opt-in, as early as possible.
n_microseconds, coord = time_coord

def _op():
_ = coord.units.num2date(coord.points)

if future_date_microseconds:
with warnings.catch_warnings():
warnings.simplefilter("error", FutureWarning)
_op()
else:
with pytest.warns(FutureWarning):
_op()


@pytest.mark.parametrize(
"indexing",
(np.s_[0], np.s_[:], np.s_[:, np.newaxis]),
ids=("single", "array", "array_2d"),
)
def test_num2date(time_coord, future_date_microseconds, indexing):
n_microseconds, coord = time_coord
result = coord.units.num2date(coord.points[indexing])

if indexing == np.s_[0]:
assert hasattr(result, "microsecond")
# Convert to iterable for more consistency downstream.
result = [result]
else:
assert hasattr(result, "shape")
assert hasattr(result.flatten()[0], "microsecond")
result = result.flatten()

expected_microseconds = n_microseconds
if not future_date_microseconds:
expected_microseconds = 0

assert all(r.microsecond == expected_microseconds for r in result)


def test_roundup(time_coord, future_date_microseconds):
n_microseconds, coord = time_coord
result = coord.units.num2date(coord.points)

expected_seconds = np.floor(coord.points)
if n_microseconds >= 500000 and not future_date_microseconds:
expected_seconds += 1

result_seconds = np.array([r.second for r in result])
assert_array_equal(result_seconds, expected_seconds)
Loading
Loading