From ff7650f2a5d0c47fe55cbf4f65ff2013f97ca13c Mon Sep 17 00:00:00 2001 From: Spencer Clark Date: Mon, 20 Jan 2025 10:20:48 -0500 Subject: [PATCH 01/12] Allow passing a CFTimedeltaCoder instance to decode_timedelta --- doc/internals/time-coding.rst | 48 ++++++++++++++++++ doc/whats-new.rst | 37 +++++++++----- xarray/backends/api.py | 43 +++++++++++----- xarray/coders.py | 6 +-- xarray/coding/times.py | 48 ++++++++++++++++-- xarray/conventions.py | 20 ++++++-- xarray/tests/test_coding_times.py | 81 ++++++++++++++++++++++++++++++- 7 files changed, 246 insertions(+), 37 deletions(-) diff --git a/doc/internals/time-coding.rst b/doc/internals/time-coding.rst index a7e0d5de23d..1d10a459670 100644 --- a/doc/internals/time-coding.rst +++ b/doc/internals/time-coding.rst @@ -473,3 +473,51 @@ on-disk resolution, if possible. coder = xr.coders.CFDatetimeCoder(time_unit="s") xr.open_dataset("test-datetimes2.nc", decode_times=coder) + +Similar logic applies for decoding timedelta values. The default resolution is +``"ns"``: + +.. ipython:: python + + attrs = {"units": "hours"} + ds = xr.Dataset({"time": ("time", [0, 1, 2, 3], attrs)}) + ds.to_netcdf("test-timedeltas1.nc") + +.. ipython:: python + + xr.open_dataset("test-timedeltas1.nc") + +By default, timedeltas will be decoded to the same resolution as datetimes: + +.. ipython:: python + + coder = xr.coders.CFDatetimeCoder(time_unit="s") + xr.open_dataset("test-timedeltas1.nc", decode_times=coder) + +but if one would like to decode timedeltas to a different resolution, one can +provide a coder specifically for timedeltas to ``decode_timedelta``: + +.. ipython:: python + + timedelta_coder = xr.coders.CFTimedeltaCoder(time_unit="ms") + xr.open_dataset( + "test-timedeltas1.nc", decode_times=coder, decode_timedelta=timedelta_coder + ) + +As with datetimes, if a coarser unit is requested the timedeltas are decoded +into their native on-disk resolution, if possible: + +.. ipython:: python + + attrs = {"units": "milliseconds"} + ds = xr.Dataset({"time": ("time", [0, 1, 2, 3], attrs)}) + ds.to_netcdf("test-timedeltas2.nc") + +.. ipython:: python + + xr.open_dataset("test-timedeltas2.nc") + +.. ipython:: python + + coder = xr.coders.CFDatetimeCoder(time_unit="s") + xr.open_dataset("test-timedeltas2.nc", decode_times=coder) diff --git a/doc/whats-new.rst b/doc/whats-new.rst index fe698bc358b..a8371fbe6a5 100644 --- a/doc/whats-new.rst +++ b/doc/whats-new.rst @@ -19,23 +19,36 @@ What's New v2025.01.2 (unreleased) ----------------------- -This release brings non-nanosecond datetime resolution to xarray. In the -last couple of releases xarray has been prepared for that change. The code had -to be changed and adapted in numerous places, affecting especially the test suite. -The documentation has been updated accordingly and a new internal chapter -on :ref:`internals.timecoding` has been added. - -To make the transition as smooth as possible this is designed to be fully backwards -compatible, keeping the current default of ``'ns'`` resolution on decoding. -To opt-in decoding into other resolutions (``'us'``, ``'ms'`` or ``'s'``) the -new :py:class:`coders.CFDatetimeCoder` is used as parameter to ``decode_times`` -kwarg (see also :ref:`internals.default_timeunit`): +This release brings non-nanosecond datetime and timedelta resolution to xarray. +In the last couple of releases xarray has been prepared for that change. The +code had to be changed and adapted in numerous places, affecting especially the +test suite. The documentation has been updated accordingly and a new internal +chapter on :ref:`internals.timecoding` has been added. + +To make the transition as smooth as possible this is designed to be fully +backwards compatible, keeping the current default of ``'ns'`` resolution on +decoding. To opt-into decoding to other resolutions (``'us'``, ``'ms'`` or +``'s'``) an instance of the newly public :py:class:`coders.CFDatetimeCoder` +class can be passed through the ``decode_times`` keyword argument (see also +:ref:`internals.default_timeunit`): .. code-block:: python coder = xr.coders.CFDatetimeCoder(time_unit="s") ds = xr.open_dataset(filename, decode_times=coder) +Similar control of the resoution of decoded timedeltas can be achieved through +passing a :py:class:`coders.CFTimedeltaCoder` instance to the +``decode_timedelta`` keyword argument: + +.. code-block:: python + + coder = xr.coders.CFTimedeltaCoder(time_unit="s") + ds = xr.open_dataset(filename, decode_timedelta=coder) + +though by default timedeltas will be decoded to the same ``time_unit`` as +datetimes. + There might slight changes when encoding/decoding times as some warning and error messages have been removed or rewritten. Xarray will now also allow non-nanosecond datetimes (with ``'us'``, ``'ms'`` or ``'s'`` resolution) when @@ -50,7 +63,7 @@ eventually be deprecated. New Features ~~~~~~~~~~~~ -- Relax nanosecond datetime restriction in CF time decoding (:issue:`7493`, :pull:`9618`). +- Relax nanosecond datetime / timedelta restriction in CF time decoding (:issue:`7493`, :pull:`9618`, :pull:`9965`). By `Kai Mühlbauer `_ and `Spencer Clark `_. - Improve the error message raised when no key is matching the available variables in a dataset. (:pull:`9943`) By `Jimmy Westling `_. diff --git a/xarray/backends/api.py b/xarray/backends/api.py index 3211b9efbae..d5b40c6eff6 100644 --- a/xarray/backends/api.py +++ b/xarray/backends/api.py @@ -33,7 +33,7 @@ _normalize_path, ) from xarray.backends.locks import _get_scheduler -from xarray.coders import CFDatetimeCoder +from xarray.coders import CFDatetimeCoder, CFTimedeltaCoder from xarray.core import indexing from xarray.core.combine import ( _infer_concat_order_from_positions, @@ -486,7 +486,10 @@ def open_dataset( | CFDatetimeCoder | Mapping[str, bool | CFDatetimeCoder] | None = None, - decode_timedelta: bool | Mapping[str, bool] | None = None, + decode_timedelta: bool + | CFTimedeltaCoder + | Mapping[str, bool | CFTimedeltaCoder] + | None = None, use_cftime: bool | Mapping[str, bool] | None = None, concat_characters: bool | Mapping[str, bool] | None = None, decode_coords: Literal["coordinates", "all"] | bool | None = None, @@ -554,11 +557,14 @@ def open_dataset( Pass a mapping, e.g. ``{"my_variable": False}``, to toggle this feature per-variable individually. This keyword may not be supported by all the backends. - decode_timedelta : bool or dict-like, optional + decode_timedelta : bool, CFTimedeltaCoder, or dict-like, optional If True, decode variables and coordinates with time units in {"days", "hours", "minutes", "seconds", "milliseconds", "microseconds"} into timedelta objects. If False, leave them encoded as numbers. - If None (default), assume the same value of decode_time. + If None (default), assume the same value of ``decode_times``; if + ``decode_times`` is a :py:class:`coders.CFDatetimeCoder` instance, this + takes the form of a :py:class:`coders.CFTimedeltaCoder` instance with a + matching ``time_unit``. Pass a mapping, e.g. ``{"my_variable": False}``, to toggle this feature per-variable individually. This keyword may not be supported by all the backends. @@ -711,7 +717,7 @@ def open_dataarray( | CFDatetimeCoder | Mapping[str, bool | CFDatetimeCoder] | None = None, - decode_timedelta: bool | None = None, + decode_timedelta: bool | CFTimedeltaCoder | None = None, use_cftime: bool | None = None, concat_characters: bool | None = None, decode_coords: Literal["coordinates", "all"] | bool | None = None, @@ -784,7 +790,10 @@ def open_dataarray( If True, decode variables and coordinates with time units in {"days", "hours", "minutes", "seconds", "milliseconds", "microseconds"} into timedelta objects. If False, leave them encoded as numbers. - If None (default), assume the same value of decode_time. + If None (default), assume the same value of ``decode_times``; if + ``decode_times`` is a :py:class:`coders.CFDatetimeCoder` instance, this + takes the form of a :py:class:`coders.CFTimedeltaCoder` instance with a + matching ``time_unit``. This keyword may not be supported by all the backends. use_cftime: bool, optional Only relevant if encoded dates come from a standard calendar @@ -926,7 +935,10 @@ def open_datatree( | CFDatetimeCoder | Mapping[str, bool | CFDatetimeCoder] | None = None, - decode_timedelta: bool | Mapping[str, bool] | None = None, + decode_timedelta: bool + | CFTimedeltaCoder + | Mapping[str, bool | CFTimedeltaCoder] + | None = None, use_cftime: bool | Mapping[str, bool] | None = None, concat_characters: bool | Mapping[str, bool] | None = None, decode_coords: Literal["coordinates", "all"] | bool | None = None, @@ -994,7 +1006,10 @@ def open_datatree( If True, decode variables and coordinates with time units in {"days", "hours", "minutes", "seconds", "milliseconds", "microseconds"} into timedelta objects. If False, leave them encoded as numbers. - If None (default), assume the same value of decode_time. + If None (default), assume the same value of ``decode_times``; if + ``decode_times`` is a :py:class:`coders.CFDatetimeCoder` instance, this + takes the form of a :py:class:`coders.CFTimedeltaCoder` instance with a + matching ``time_unit``. Pass a mapping, e.g. ``{"my_variable": False}``, to toggle this feature per-variable individually. This keyword may not be supported by all the backends. @@ -1149,7 +1164,10 @@ def open_groups( | CFDatetimeCoder | Mapping[str, bool | CFDatetimeCoder] | None = None, - decode_timedelta: bool | Mapping[str, bool] | None = None, + decode_timedelta: bool + | CFTimedeltaCoder + | Mapping[str, bool | CFTimedeltaCoder] + | None = None, use_cftime: bool | Mapping[str, bool] | None = None, concat_characters: bool | Mapping[str, bool] | None = None, decode_coords: Literal["coordinates", "all"] | bool | None = None, @@ -1221,9 +1239,10 @@ def open_groups( If True, decode variables and coordinates with time units in {"days", "hours", "minutes", "seconds", "milliseconds", "microseconds"} into timedelta objects. If False, leave them encoded as numbers. - If None (default), assume the same value of decode_time. - Pass a mapping, e.g. ``{"my_variable": False}``, - to toggle this feature per-variable individually. + If None (default), assume the same value of ``decode_times``; if + ``decode_times`` is a :py:class:`coders.CFDatetimeCoder` instance, this + takes the form of a :py:class:`coders.CFTimedeltaCoder` instance with a + matching ``time_unit``. This keyword may not be supported by all the backends. use_cftime: bool or dict-like, optional Only relevant if encoded dates come from a standard calendar diff --git a/xarray/coders.py b/xarray/coders.py index 238ac714780..4f0a32dc36e 100644 --- a/xarray/coders.py +++ b/xarray/coders.py @@ -3,8 +3,6 @@ "encoding/decoding" process. """ -from xarray.coding.times import CFDatetimeCoder +from xarray.coding.times import CFDatetimeCoder, CFTimedeltaCoder -__all__ = [ - "CFDatetimeCoder", -] +__all__ = ["CFDatetimeCoder", "CFTimedeltaCoder"] diff --git a/xarray/coding/times.py b/xarray/coding/times.py index fd99a55a2a2..6cc09a619fe 100644 --- a/xarray/coding/times.py +++ b/xarray/coding/times.py @@ -327,6 +327,33 @@ def _decode_cf_datetime_dtype( return dtype +def _decode_cf_timedelta_dtype( + data, + units: str, + time_unit: PDDatetimeUnitOptions = "ns", +) -> np.dtype: + # Verify that at least the first and last date can be decoded + # successfully. Otherwise, tracebacks end up swallowed by + # Dataset.__repr__ when users try to view their lazily decoded array. + values = indexing.ImplicitToExplicitIndexingAdapter(indexing.as_indexable(data)) + example_value = np.concatenate( + [first_n_items(values, 1) or [0], last_item(values) or [0]] + ) + + try: + result = decode_cf_timedelta(example_value, units, time_unit) + except Exception as err: + message = ( + f"unable to decode timedelta units {units!r}. Try opening your " + f"dataset with decode_timedelta=False." + ) + raise ValueError(message) from err + else: + dtype = result.dtype + + return dtype + + def _decode_datetime_with_cftime( num_dates: np.ndarray, units: str, calendar: str ) -> np.ndarray: @@ -1343,6 +1370,20 @@ def decode(self, variable: Variable, name: T_Name = None) -> Variable: class CFTimedeltaCoder(VariableCoder): + """Coder for CF Timedelta coding. + + Parameters + ---------- + time_unit : PDDatetimeUnitOptions + Target resolution when decoding timedeltas. Defaults to "ns". + """ + + def __init__( + self, + time_unit: PDDatetimeUnitOptions = "ns", + ) -> None: + self.time_unit = time_unit + def encode(self, variable: Variable, name: T_Name = None) -> Variable: if np.issubdtype(variable.data.dtype, np.timedelta64): dims, data, attrs, encoding = unpack_for_encoding(variable) @@ -1362,9 +1403,10 @@ def decode(self, variable: Variable, name: T_Name = None) -> Variable: dims, data, attrs, encoding = unpack_for_decoding(variable) units = pop_to(attrs, encoding, "units") - transform = partial(decode_cf_timedelta, units=units) - # todo: check, if we can relax this one here, too - dtype = np.dtype("timedelta64[ns]") + dtype = _decode_cf_timedelta_dtype(data, units, self.time_unit) + transform = partial( + decode_cf_timedelta, units=units, time_unit=self.time_unit + ) data = lazy_elemwise_func(data, transform, dtype=dtype) return Variable(dims, data, attrs, encoding, fastpath=True) diff --git a/xarray/conventions.py b/xarray/conventions.py index 485c9ac0c71..00e5762987b 100644 --- a/xarray/conventions.py +++ b/xarray/conventions.py @@ -7,7 +7,7 @@ import numpy as np -from xarray.coders import CFDatetimeCoder +from xarray.coders import CFDatetimeCoder, CFTimedeltaCoder from xarray.coding import strings, times, variables from xarray.coding.variables import SerializationWarning, pop_to from xarray.core import indexing @@ -114,7 +114,7 @@ def decode_cf_variable( decode_endianness: bool = True, stack_char_dim: bool = True, use_cftime: bool | None = None, - decode_timedelta: bool | None = None, + decode_timedelta: bool | CFTimedeltaCoder | None = None, ) -> Variable: """ Decodes a variable which may hold CF encoded information. @@ -158,6 +158,8 @@ def decode_cf_variable( .. deprecated:: 2025.01.1 Please pass a :py:class:`coders.CFDatetimeCoder` instance initialized with ``use_cftime`` to the ``decode_times`` kwarg instead. + decode_timedelta : None, bool, or CFTimedeltaCoder + Decode cf timedeltas ("hours") to np.timedelta64. Returns ------- @@ -171,7 +173,10 @@ def decode_cf_variable( original_dtype = var.dtype if decode_timedelta is None: - decode_timedelta = True if decode_times else False + if isinstance(decode_times, CFDatetimeCoder): + decode_timedelta = CFTimedeltaCoder(time_unit=decode_times.time_unit) + else: + decode_timedelta = True if decode_times else False if concat_characters: if stack_char_dim: @@ -193,7 +198,9 @@ def decode_cf_variable( var = coder.decode(var, name=name) if decode_timedelta: - var = times.CFTimedeltaCoder().decode(var, name=name) + if not isinstance(decode_timedelta, CFTimedeltaCoder): + decode_timedelta = CFTimedeltaCoder() + var = decode_timedelta.decode(var, name=name) if decode_times: # remove checks after end of deprecation cycle if not isinstance(decode_times, CFDatetimeCoder): @@ -335,7 +342,10 @@ def decode_cf_variables( decode_coords: bool | Literal["coordinates", "all"] = True, drop_variables: T_DropVariables = None, use_cftime: bool | Mapping[str, bool] | None = None, - decode_timedelta: bool | Mapping[str, bool] | None = None, + decode_timedelta: bool + | CFTimedeltaCoder + | Mapping[str, bool | CFTimedeltaCoder] + | None = None, ) -> tuple[T_Variables, T_Attrs, set[Hashable]]: """ Decode several CF encoded variables. diff --git a/xarray/tests/test_coding_times.py b/xarray/tests/test_coding_times.py index 44c0157f1b2..7080a631852 100644 --- a/xarray/tests/test_coding_times.py +++ b/xarray/tests/test_coding_times.py @@ -19,7 +19,7 @@ date_range, decode_cf, ) -from xarray.coders import CFDatetimeCoder +from xarray.coders import CFDatetimeCoder, CFTimedeltaCoder from xarray.coding.times import _STANDARD_CALENDARS as _STANDARD_CALENDARS_UNSORTED from xarray.coding.times import ( _encode_datetime_with_cftime, @@ -1829,3 +1829,82 @@ def test_encode_cf_timedelta_casting_overflow_error(use_dask, dtype) -> None: with pytest.raises(OverflowError, match="Not possible"): encoded = conventions.encode_cf_variable(variable) encoded.compute() + + +_DECODE_TIMEDELTA_TESTS = { + "default": (True, None, np.dtype("timedelta64[ns]")), + "decode_timdelta=False": (True, False, np.dtype("int64")), + "inherit-time_unit-from-decode_times": ( + CFDatetimeCoder(time_unit="s"), + None, + np.dtype("timedelta64[s]"), + ), + "set-time_unit-via-CFTimedeltaCoder-decode_times=True": ( + True, + CFTimedeltaCoder(time_unit="s"), + np.dtype("timedelta64[s]"), + ), + "set-time_unit-via-CFTimedeltaCoder-decode_times=False": ( + False, + CFTimedeltaCoder(time_unit="s"), + np.dtype("timedelta64[s]"), + ), + "override-time_unit-from-decode_times": ( + CFDatetimeCoder(time_unit="ns"), + CFTimedeltaCoder(time_unit="s"), + np.dtype("timedelta64[s]"), + ), +} + + +@pytest.mark.parametrize( + ("decode_times", "decode_timedelta", "expected_dtype"), + list(_DECODE_TIMEDELTA_TESTS.values()), + ids=list(_DECODE_TIMEDELTA_TESTS.keys()), +) +def test_decode_timedelta(decode_times, decode_timedelta, expected_dtype) -> None: + timedeltas = pd.timedelta_range(0, freq="d", periods=3) + var = Variable(["time"], timedeltas) + encoded = conventions.encode_cf_variable(var) + decoded = conventions.decode_cf_variable( + "foo", encoded, decode_times=decode_times, decode_timedelta=decode_timedelta + ) + if decode_timedelta is False: + assert_equal(encoded, decoded) + else: + assert_equal(var, decoded) + assert decoded.dtype == expected_dtype + + +def test_decode_timedelta_error() -> None: + attrs = {"units": "seconds"} + encoded = Variable(["time"], [np.iinfo(np.int64).max, 1], attrs=attrs) + with pytest.raises(ValueError, match="unable"): + conventions.decode_cf_variable( + "foo", encoded, decode_timedelta=CFTimedeltaCoder(time_unit="ms") + ) + + +def test_lazy_decode_timedelta_unexpected_dtype() -> None: + attrs = {"units": "seconds"} + encoded = Variable(["time"], [0, 0.5, 1], attrs=attrs) + decoded = conventions.decode_cf_variable( + "foo", encoded, decode_timedelta=CFTimedeltaCoder(time_unit="s") + ) + + expected_dtype_upon_lazy_decoding = np.dtype("timedelta64[s]") + assert decoded.dtype == expected_dtype_upon_lazy_decoding + + expected_dtype_upon_loading = np.dtype("timedelta64[ms]") + with pytest.warns(SerializationWarning, match="Can't decode floating"): + assert decoded.load().dtype == expected_dtype_upon_loading + + +def test_lazy_decode_timedelta_error() -> None: + attrs = {"units": "seconds"} + encoded = Variable(["time"], [0, np.iinfo(np.int64).max, 1], attrs=attrs) + decoded = conventions.decode_cf_variable( + "foo", encoded, decode_timedelta=CFTimedeltaCoder(time_unit="ms") + ) + with pytest.raises(OutOfBoundsTimedelta, match="overflow"): + decoded.load() From 72d1d1c3d3b1c9eef092df797491981a6ffe43b7 Mon Sep 17 00:00:00 2001 From: Spencer Clark Date: Mon, 20 Jan 2025 15:08:33 -0500 Subject: [PATCH 02/12] Updates based on @kmuehlbauer's branch https://github.com/kmuehlbauer/xarray/tree/split-out-coders --- xarray/coding/times.py | 29 +---------------------------- xarray/conventions.py | 4 ++-- xarray/tests/test_coding_times.py | 9 --------- xarray/tests/test_conventions.py | 2 +- 4 files changed, 4 insertions(+), 40 deletions(-) diff --git a/xarray/coding/times.py b/xarray/coding/times.py index 6cc09a619fe..67d90cc325b 100644 --- a/xarray/coding/times.py +++ b/xarray/coding/times.py @@ -327,33 +327,6 @@ def _decode_cf_datetime_dtype( return dtype -def _decode_cf_timedelta_dtype( - data, - units: str, - time_unit: PDDatetimeUnitOptions = "ns", -) -> np.dtype: - # Verify that at least the first and last date can be decoded - # successfully. Otherwise, tracebacks end up swallowed by - # Dataset.__repr__ when users try to view their lazily decoded array. - values = indexing.ImplicitToExplicitIndexingAdapter(indexing.as_indexable(data)) - example_value = np.concatenate( - [first_n_items(values, 1) or [0], last_item(values) or [0]] - ) - - try: - result = decode_cf_timedelta(example_value, units, time_unit) - except Exception as err: - message = ( - f"unable to decode timedelta units {units!r}. Try opening your " - f"dataset with decode_timedelta=False." - ) - raise ValueError(message) from err - else: - dtype = result.dtype - - return dtype - - def _decode_datetime_with_cftime( num_dates: np.ndarray, units: str, calendar: str ) -> np.ndarray: @@ -1403,7 +1376,7 @@ def decode(self, variable: Variable, name: T_Name = None) -> Variable: dims, data, attrs, encoding = unpack_for_decoding(variable) units = pop_to(attrs, encoding, "units") - dtype = _decode_cf_timedelta_dtype(data, units, self.time_unit) + dtype = np.dtype(f"timedelta64[{self.time_unit}]") transform = partial( decode_cf_timedelta, units=units, time_unit=self.time_unit ) diff --git a/xarray/conventions.py b/xarray/conventions.py index 00e5762987b..e6b0bcb67e7 100644 --- a/xarray/conventions.py +++ b/xarray/conventions.py @@ -8,7 +8,7 @@ import numpy as np from xarray.coders import CFDatetimeCoder, CFTimedeltaCoder -from xarray.coding import strings, times, variables +from xarray.coding import strings, variables from xarray.coding.variables import SerializationWarning, pop_to from xarray.core import indexing from xarray.core.common import ( @@ -90,7 +90,7 @@ def encode_cf_variable( for coder in [ CFDatetimeCoder(), - times.CFTimedeltaCoder(), + CFTimedeltaCoder(), variables.CFScaleOffsetCoder(), variables.CFMaskCoder(), variables.NativeEnumCoder(), diff --git a/xarray/tests/test_coding_times.py b/xarray/tests/test_coding_times.py index 7080a631852..06e1a2dcb55 100644 --- a/xarray/tests/test_coding_times.py +++ b/xarray/tests/test_coding_times.py @@ -1876,15 +1876,6 @@ def test_decode_timedelta(decode_times, decode_timedelta, expected_dtype) -> Non assert decoded.dtype == expected_dtype -def test_decode_timedelta_error() -> None: - attrs = {"units": "seconds"} - encoded = Variable(["time"], [np.iinfo(np.int64).max, 1], attrs=attrs) - with pytest.raises(ValueError, match="unable"): - conventions.decode_cf_variable( - "foo", encoded, decode_timedelta=CFTimedeltaCoder(time_unit="ms") - ) - - def test_lazy_decode_timedelta_unexpected_dtype() -> None: attrs = {"units": "seconds"} encoded = Variable(["time"], [0, 0.5, 1], attrs=attrs) diff --git a/xarray/tests/test_conventions.py b/xarray/tests/test_conventions.py index 346ad1c908b..acefd4d44e8 100644 --- a/xarray/tests/test_conventions.py +++ b/xarray/tests/test_conventions.py @@ -538,7 +538,7 @@ def test_decode_cf_time_kwargs(self, time_unit) -> None: dsc = conventions.decode_cf( ds, decode_times=CFDatetimeCoder(time_unit=time_unit) ) - assert dsc.timedelta.dtype == np.dtype("m8[ns]") + assert dsc.timedelta.dtype == np.dtype(f"m8[{time_unit}]") assert dsc.time.dtype == np.dtype(f"M8[{time_unit}]") dsc = conventions.decode_cf(ds, decode_times=False) assert dsc.timedelta.dtype == np.dtype("int64") From 714e17d80448e19de54f0e9f94666865414880a3 Mon Sep 17 00:00:00 2001 From: Spencer Clark Date: Mon, 20 Jan 2025 15:36:01 -0500 Subject: [PATCH 03/12] Increment what's new PR number --- doc/whats-new.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/whats-new.rst b/doc/whats-new.rst index a8371fbe6a5..28a60c07877 100644 --- a/doc/whats-new.rst +++ b/doc/whats-new.rst @@ -63,7 +63,7 @@ eventually be deprecated. New Features ~~~~~~~~~~~~ -- Relax nanosecond datetime / timedelta restriction in CF time decoding (:issue:`7493`, :pull:`9618`, :pull:`9965`). +- Relax nanosecond datetime / timedelta restriction in CF time decoding (:issue:`7493`, :pull:`9618`, :pull:`9966`). By `Kai Mühlbauer `_ and `Spencer Clark `_. - Improve the error message raised when no key is matching the available variables in a dataset. (:pull:`9943`) By `Jimmy Westling `_. From 3a96c8a9992ddb39a1737e51b0e407d6f6f76139 Mon Sep 17 00:00:00 2001 From: Spencer Clark Date: Wed, 29 Jan 2025 07:22:01 -0500 Subject: [PATCH 04/12] Add FutureWarning for change in decode_timedelta behavior --- doc/whats-new.rst | 6 ++++ xarray/coding/times.py | 9 ++++++ xarray/conventions.py | 9 ++++++ xarray/tests/test_backends.py | 14 ++++++++-- xarray/tests/test_coding_times.py | 46 +++++++++++++++++++++++-------- xarray/tests/test_conventions.py | 18 ++++++++++-- 6 files changed, 86 insertions(+), 16 deletions(-) diff --git a/doc/whats-new.rst b/doc/whats-new.rst index 28a60c07877..053a00721b4 100644 --- a/doc/whats-new.rst +++ b/doc/whats-new.rst @@ -74,6 +74,12 @@ Breaking changes Deprecations ~~~~~~~~~~~~ +- In a future version of xarray decoding of variables into + :py:class:`numpy.timedelta64` values will be disabled by default. To silence + warnings associated with this, set ``decode_timedelta`` to ``True``, + ``False``, or a :py:class:`coders.CFTimedeltaCoder` instance when opening + data (:issue:`1621`, :pull:`9966`). By `Spencer Clark + `_. Bug fixes diff --git a/xarray/coding/times.py b/xarray/coding/times.py index 67d90cc325b..ad5e8653e2a 100644 --- a/xarray/coding/times.py +++ b/xarray/coding/times.py @@ -1356,6 +1356,7 @@ def __init__( time_unit: PDDatetimeUnitOptions = "ns", ) -> None: self.time_unit = time_unit + self._emit_decode_timedelta_future_warning = False def encode(self, variable: Variable, name: T_Name = None) -> Variable: if np.issubdtype(variable.data.dtype, np.timedelta64): @@ -1373,6 +1374,14 @@ def encode(self, variable: Variable, name: T_Name = None) -> Variable: def decode(self, variable: Variable, name: T_Name = None) -> Variable: units = variable.attrs.get("units", None) if isinstance(units, str) and units in TIME_UNITS: + if self._emit_decode_timedelta_future_warning: + emit_user_level_warning( + "In a future version of xarray decode_timedelta will " + "default to False rather than None. To silence this " + "warning, set decode_timedelta to True, False, or a " + "'CFTimedeltaCoder' instance.", + FutureWarning, + ) dims, data, attrs, encoding = unpack_for_decoding(variable) units = pop_to(attrs, encoding, "units") diff --git a/xarray/conventions.py b/xarray/conventions.py index e6b0bcb67e7..9fb34842d7f 100644 --- a/xarray/conventions.py +++ b/xarray/conventions.py @@ -1,6 +1,7 @@ from __future__ import annotations import itertools +import warnings from collections import defaultdict from collections.abc import Hashable, Iterable, Mapping, MutableMapping from typing import TYPE_CHECKING, Any, Literal, TypeVar, Union @@ -172,6 +173,7 @@ def decode_cf_variable( original_dtype = var.dtype + decode_timedelta_was_none = decode_timedelta is None if decode_timedelta is None: if isinstance(decode_times, CFDatetimeCoder): decode_timedelta = CFTimedeltaCoder(time_unit=decode_times.time_unit) @@ -200,6 +202,9 @@ def decode_cf_variable( if decode_timedelta: if not isinstance(decode_timedelta, CFTimedeltaCoder): decode_timedelta = CFTimedeltaCoder() + decode_timedelta._emit_decode_timedelta_future_warning = ( + decode_timedelta_was_none + ) var = decode_timedelta.decode(var, name=name) if decode_times: # remove checks after end of deprecation cycle @@ -352,6 +357,10 @@ def decode_cf_variables( See: decode_cf_variable """ + # Only emit once instance of the decode_timedelta default change + # FutureWarning. This can be removed once this change is made. + warnings.filterwarnings("once", "decode_timedelta", FutureWarning) + dimensions_used_by = defaultdict(list) for v in variables.values(): for d in v.dims: diff --git a/xarray/tests/test_backends.py b/xarray/tests/test_backends.py index 72078da11b9..5d8aa293434 100644 --- a/xarray/tests/test_backends.py +++ b/xarray/tests/test_backends.py @@ -49,7 +49,7 @@ from xarray.backends.pydap_ import PydapDataStore from xarray.backends.scipy_ import ScipyBackendEntrypoint from xarray.backends.zarr import ZarrStore -from xarray.coders import CFDatetimeCoder +from xarray.coders import CFDatetimeCoder, CFTimedeltaCoder from xarray.coding.cftime_offsets import cftime_range from xarray.coding.strings import check_vlen_dtype, create_vlen_dtype from xarray.coding.variables import SerializationWarning @@ -639,7 +639,9 @@ def test_roundtrip_timedelta_data(self) -> None: # to support large ranges time_deltas = pd.to_timedelta(["1h", "2h", "NaT"]).as_unit("s") # type: ignore[arg-type, unused-ignore] expected = Dataset({"td": ("td", time_deltas), "td0": time_deltas[0]}) - with self.roundtrip(expected) as actual: + with self.roundtrip( + expected, open_kwargs={"decode_timedelta": CFTimedeltaCoder(time_unit="ns")} + ) as actual: assert_identical(expected, actual) def test_roundtrip_float64_data(self) -> None: @@ -3267,7 +3269,13 @@ def test_attributes(self, obj) -> None: def test_chunked_datetime64_or_timedelta64(self, dtype) -> None: # Generalized from @malmans2's test in PR #8253 original = create_test_data().astype(dtype).chunk(1) - with self.roundtrip(original, open_kwargs={"chunks": {}}) as actual: + with self.roundtrip( + original, + open_kwargs={ + "chunks": {}, + "decode_timedelta": CFTimedeltaCoder(time_unit="ns"), + }, + ) as actual: for name, actual_var in actual.variables.items(): assert original[name].chunks == actual_var.chunks assert original.chunks == actual.chunks diff --git a/xarray/tests/test_coding_times.py b/xarray/tests/test_coding_times.py index 06e1a2dcb55..67a36c8fef2 100644 --- a/xarray/tests/test_coding_times.py +++ b/xarray/tests/test_coding_times.py @@ -1542,7 +1542,10 @@ def test_roundtrip_timedelta64_nanosecond_precision( encoded_var = conventions.encode_cf_variable(var) decoded_var = conventions.decode_cf_variable( - "foo", encoded_var, decode_times=CFDatetimeCoder(time_unit=time_unit) + "foo", + encoded_var, + decode_times=CFDatetimeCoder(time_unit=time_unit), + decode_timedelta=CFTimedeltaCoder(time_unit=time_unit), ) assert_identical(var, decoded_var) @@ -1569,7 +1572,9 @@ def test_roundtrip_timedelta64_nanosecond_precision_warning() -> None: assert encoded_var.dtype == np.int64 assert encoded_var.attrs["units"] == needed_units assert encoded_var.attrs["_FillValue"] == 20 - decoded_var = conventions.decode_cf_variable("foo", encoded_var) + decoded_var = conventions.decode_cf_variable( + "foo", encoded_var, decode_timedelta=CFTimedeltaCoder(time_unit="ns") + ) assert_identical(var, decoded_var) assert decoded_var.encoding["dtype"] == np.int64 @@ -1617,7 +1622,9 @@ def test_roundtrip_float_times(fill_value, times, units, encoded_values) -> None assert encoded_var.attrs["units"] == units assert encoded_var.attrs["_FillValue"] == fill_value - decoded_var = conventions.decode_cf_variable("foo", encoded_var) + decoded_var = conventions.decode_cf_variable( + "foo", encoded_var, decode_timedelta=CFTimedeltaCoder(time_unit="ns") + ) assert_identical(var, decoded_var) assert decoded_var.encoding["units"] == units assert decoded_var.encoding["_FillValue"] == fill_value @@ -1808,7 +1815,9 @@ def test_encode_cf_timedelta_casting_value_error(use_dask) -> None: with pytest.warns(UserWarning, match="Timedeltas can't be serialized"): encoded = conventions.encode_cf_variable(variable) assert encoded.attrs["units"] == "hours" - decoded = conventions.decode_cf_variable("name", encoded) + decoded = conventions.decode_cf_variable( + "name", encoded, decode_timedelta=CFTimedeltaCoder(time_unit="ns") + ) assert_equal(variable, decoded) else: with pytest.raises(ValueError, match="Not possible"): @@ -1832,43 +1841,58 @@ def test_encode_cf_timedelta_casting_overflow_error(use_dask, dtype) -> None: _DECODE_TIMEDELTA_TESTS = { - "default": (True, None, np.dtype("timedelta64[ns]")), - "decode_timdelta=False": (True, False, np.dtype("int64")), + "default": (True, None, np.dtype("timedelta64[ns]"), True), + "decode_timdelta=False": (True, False, np.dtype("int64"), False), "inherit-time_unit-from-decode_times": ( CFDatetimeCoder(time_unit="s"), None, np.dtype("timedelta64[s]"), + True, ), "set-time_unit-via-CFTimedeltaCoder-decode_times=True": ( True, CFTimedeltaCoder(time_unit="s"), np.dtype("timedelta64[s]"), + False, ), "set-time_unit-via-CFTimedeltaCoder-decode_times=False": ( False, CFTimedeltaCoder(time_unit="s"), np.dtype("timedelta64[s]"), + False, ), "override-time_unit-from-decode_times": ( CFDatetimeCoder(time_unit="ns"), CFTimedeltaCoder(time_unit="s"), np.dtype("timedelta64[s]"), + False, ), } @pytest.mark.parametrize( - ("decode_times", "decode_timedelta", "expected_dtype"), + ("decode_times", "decode_timedelta", "expected_dtype", "warns"), list(_DECODE_TIMEDELTA_TESTS.values()), ids=list(_DECODE_TIMEDELTA_TESTS.keys()), ) -def test_decode_timedelta(decode_times, decode_timedelta, expected_dtype) -> None: +def test_decode_timedelta( + decode_times, decode_timedelta, expected_dtype, warns +) -> None: timedeltas = pd.timedelta_range(0, freq="d", periods=3) var = Variable(["time"], timedeltas) encoded = conventions.encode_cf_variable(var) - decoded = conventions.decode_cf_variable( - "foo", encoded, decode_times=decode_times, decode_timedelta=decode_timedelta - ) + if warns: + with pytest.warns(FutureWarning, match="decode_timedelta"): + decoded = conventions.decode_cf_variable( + "foo", + encoded, + decode_times=decode_times, + decode_timedelta=decode_timedelta, + ) + else: + decoded = conventions.decode_cf_variable( + "foo", encoded, decode_times=decode_times, decode_timedelta=decode_timedelta + ) if decode_timedelta is False: assert_equal(encoded, decoded) else: diff --git a/xarray/tests/test_conventions.py b/xarray/tests/test_conventions.py index acefd4d44e8..8d3827fac54 100644 --- a/xarray/tests/test_conventions.py +++ b/xarray/tests/test_conventions.py @@ -18,7 +18,7 @@ ) from xarray.backends.common import WritableCFDataStore from xarray.backends.memory import InMemoryDataStore -from xarray.coders import CFDatetimeCoder +from xarray.coders import CFDatetimeCoder, CFTimedeltaCoder from xarray.conventions import decode_cf from xarray.testing import assert_identical from xarray.tests import ( @@ -536,7 +536,9 @@ def test_decode_cf_time_kwargs(self, time_unit) -> None: ) dsc = conventions.decode_cf( - ds, decode_times=CFDatetimeCoder(time_unit=time_unit) + ds, + decode_times=CFDatetimeCoder(time_unit=time_unit), + decode_timedelta=CFTimedeltaCoder(time_unit=time_unit), ) assert dsc.timedelta.dtype == np.dtype(f"m8[{time_unit}]") assert dsc.time.dtype == np.dtype(f"M8[{time_unit}]") @@ -655,3 +657,15 @@ def test_encode_cf_variable_with_vlen_dtype() -> None: encoded_v = conventions.encode_cf_variable(v) assert encoded_v.data.dtype.kind == "O" assert coding.strings.check_vlen_dtype(encoded_v.data.dtype) is str + + +def test_decode_cf_variables_decode_timedelta_warning() -> None: + v = Variable(["time"], [1, 2], attrs={"units": "seconds"}) + variables = {"a": v} + + with warnings.catch_warnings(): + warnings.filterwarnings("error", "decode_timedelta", FutureWarning) + conventions.decode_cf_variables(variables, {}, decode_timedelta=True) + + with pytest.warns(FutureWarning, match="decode_timedelta"): + conventions.decode_cf_variables(variables, {}) From e67f2e449dbf89d6fbe4fc6d9c17199b6315be54 Mon Sep 17 00:00:00 2001 From: Spencer Clark Date: Wed, 29 Jan 2025 07:22:52 -0500 Subject: [PATCH 05/12] Include a note about opting out of timedelta decoding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Kai Mühlbauer --- doc/internals/time-coding.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/doc/internals/time-coding.rst b/doc/internals/time-coding.rst index 1d10a459670..674c6f99859 100644 --- a/doc/internals/time-coding.rst +++ b/doc/internals/time-coding.rst @@ -521,3 +521,9 @@ into their native on-disk resolution, if possible: coder = xr.coders.CFDatetimeCoder(time_unit="s") xr.open_dataset("test-timedeltas2.nc", decode_times=coder) + +To opt-out of timedelta decoding (see issue `Undesired decoding to timedelta64 `_) pass ``False`` to ``decode_timedelta``: + +.. ipython:: python + xr.open_dataset("test-timedeltas2.nc", decode_times=False) + From 6ffca3aafba21c7bf460a0cece4ace2d82646ab0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 29 Jan 2025 12:24:23 +0000 Subject: [PATCH 06/12] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- doc/internals/time-coding.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/internals/time-coding.rst b/doc/internals/time-coding.rst index 674c6f99859..a28e87b26e9 100644 --- a/doc/internals/time-coding.rst +++ b/doc/internals/time-coding.rst @@ -521,9 +521,9 @@ into their native on-disk resolution, if possible: coder = xr.coders.CFDatetimeCoder(time_unit="s") xr.open_dataset("test-timedeltas2.nc", decode_times=coder) - + To opt-out of timedelta decoding (see issue `Undesired decoding to timedelta64 `_) pass ``False`` to ``decode_timedelta``: .. ipython:: python - xr.open_dataset("test-timedeltas2.nc", decode_times=False) + xr.open_dataset("test-timedeltas2.nc", decode_times=False) From 763fde287f3c6f8e589b6589dbe9aca6101d92ce Mon Sep 17 00:00:00 2001 From: Spencer Clark Date: Wed, 29 Jan 2025 07:39:32 -0500 Subject: [PATCH 07/12] Fix typing --- doc/internals/time-coding.rst | 4 ++-- xarray/conventions.py | 14 ++++++++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/doc/internals/time-coding.rst b/doc/internals/time-coding.rst index 674c6f99859..a28e87b26e9 100644 --- a/doc/internals/time-coding.rst +++ b/doc/internals/time-coding.rst @@ -521,9 +521,9 @@ into their native on-disk resolution, if possible: coder = xr.coders.CFDatetimeCoder(time_unit="s") xr.open_dataset("test-timedeltas2.nc", decode_times=coder) - + To opt-out of timedelta decoding (see issue `Undesired decoding to timedelta64 `_) pass ``False`` to ``decode_timedelta``: .. ipython:: python - xr.open_dataset("test-timedeltas2.nc", decode_times=False) + xr.open_dataset("test-timedeltas2.nc", decode_times=False) diff --git a/xarray/conventions.py b/xarray/conventions.py index 9fb34842d7f..5e3c072a778 100644 --- a/xarray/conventions.py +++ b/xarray/conventions.py @@ -491,7 +491,10 @@ def decode_cf( decode_coords: bool | Literal["coordinates", "all"] = True, drop_variables: T_DropVariables = None, use_cftime: bool | None = None, - decode_timedelta: bool | None = None, + decode_timedelta: bool + | CFTimedeltaCoder + | Mapping[str, bool | CFTimedeltaCoder] + | None = None, ) -> Dataset: """Decode the given Dataset or Datastore according to CF conventions into a new Dataset. @@ -535,11 +538,14 @@ def decode_cf( .. deprecated:: 2025.01.1 Please pass a :py:class:`coders.CFDatetimeCoder` instance initialized with ``use_cftime`` to the ``decode_times`` kwarg instead. - decode_timedelta : bool, optional - If True, decode variables and coordinates with time units in + decode_timedelta : bool | CFTimedeltaCoder | Mapping[str, bool | CFTimedeltaCoder], optional + If True or :py:class:`CFTimedeltaCoder`, decode variables and + coordinates with time units in {"days", "hours", "minutes", "seconds", "milliseconds", "microseconds"} into timedelta objects. If False, leave them encoded as numbers. - If None (default), assume the same value of decode_time. + If None (default), assume the same behavior as decode_times. The + resolution of the decoded timedeltas can be configured with the + ``time_unit`` argument in the :py:class:`CFTimedeltaCoder` passed. Returns ------- From 5f20901e980666767174e256dd4cd7ed2cc10174 Mon Sep 17 00:00:00 2001 From: Spencer Clark Date: Wed, 29 Jan 2025 07:42:53 -0500 Subject: [PATCH 08/12] Fix typo --- xarray/conventions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/xarray/conventions.py b/xarray/conventions.py index 5e3c072a778..f67af95b4ce 100644 --- a/xarray/conventions.py +++ b/xarray/conventions.py @@ -357,7 +357,7 @@ def decode_cf_variables( See: decode_cf_variable """ - # Only emit once instance of the decode_timedelta default change + # Only emit one instance of the decode_timedelta default change # FutureWarning. This can be removed once this change is made. warnings.filterwarnings("once", "decode_timedelta", FutureWarning) From cdcc53ec12942666dbfdcb7bec61eb5675369962 Mon Sep 17 00:00:00 2001 From: Spencer Clark Date: Wed, 29 Jan 2025 08:11:34 -0500 Subject: [PATCH 09/12] Fix doc build --- doc/internals/time-coding.rst | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/doc/internals/time-coding.rst b/doc/internals/time-coding.rst index a28e87b26e9..57f43fb571f 100644 --- a/doc/internals/time-coding.rst +++ b/doc/internals/time-coding.rst @@ -1,6 +1,8 @@ .. ipython:: python :suppress: + import warnings + import numpy as np import pandas as pd import xarray as xr @@ -11,6 +13,7 @@ int64_min = np.iinfo("int64").min + 1 uint64_max = np.iinfo("uint64").max + warnings.filterwarnings("ignore", FutureWarning, "decode_timedelta") .. _internals.timecoding: Time Coding @@ -526,4 +529,8 @@ To opt-out of timedelta decoding (see issue `Undesired decoding to timedelta64 < .. ipython:: python - xr.open_dataset("test-timedeltas2.nc", decode_times=False) + xr.open_dataset("test-timedeltas2.nc", decode_timedelta=False) + +.. note:: + Note that in the future the default value of ``decode_timedelta`` will be + ``False`` rather than ``None``. From 921a74f577fb1c2feb1f66814f7f375cd3ee5144 Mon Sep 17 00:00:00 2001 From: Spencer Clark Date: Wed, 29 Jan 2025 08:21:30 -0500 Subject: [PATCH 10/12] Fix order of arguments in filterwarnings --- doc/internals/time-coding.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/internals/time-coding.rst b/doc/internals/time-coding.rst index 57f43fb571f..397903c39c6 100644 --- a/doc/internals/time-coding.rst +++ b/doc/internals/time-coding.rst @@ -13,7 +13,7 @@ int64_min = np.iinfo("int64").min + 1 uint64_max = np.iinfo("uint64").max - warnings.filterwarnings("ignore", FutureWarning, "decode_timedelta") + warnings.filterwarnings("ignore", "decode_timedelta", FutureWarning) .. _internals.timecoding: Time Coding From 36ab5fdc3fb17bd7415c6492b92f2219bff2c460 Mon Sep 17 00:00:00 2001 From: Spencer Clark Date: Wed, 29 Jan 2025 08:56:53 -0500 Subject: [PATCH 11/12] Switch to :okwarning: --- doc/internals/time-coding.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/internals/time-coding.rst b/doc/internals/time-coding.rst index 57f43fb571f..7710928d47e 100644 --- a/doc/internals/time-coding.rst +++ b/doc/internals/time-coding.rst @@ -1,8 +1,6 @@ .. ipython:: python :suppress: - import warnings - import numpy as np import pandas as pd import xarray as xr @@ -13,7 +11,6 @@ int64_min = np.iinfo("int64").min + 1 uint64_max = np.iinfo("uint64").max - warnings.filterwarnings("ignore", FutureWarning, "decode_timedelta") .. _internals.timecoding: Time Coding @@ -487,12 +484,14 @@ Similar logic applies for decoding timedelta values. The default resolution is ds.to_netcdf("test-timedeltas1.nc") .. ipython:: python + :okwarning: xr.open_dataset("test-timedeltas1.nc") By default, timedeltas will be decoded to the same resolution as datetimes: .. ipython:: python + :okwarning: coder = xr.coders.CFDatetimeCoder(time_unit="s") xr.open_dataset("test-timedeltas1.nc", decode_times=coder) @@ -517,6 +516,7 @@ into their native on-disk resolution, if possible: ds.to_netcdf("test-timedeltas2.nc") .. ipython:: python + :okwarning: xr.open_dataset("test-timedeltas2.nc") From 3e9f91eaacd973f67e8bd6b49e1ccedf2e9c91d4 Mon Sep 17 00:00:00 2001 From: Spencer Clark Date: Wed, 29 Jan 2025 09:02:53 -0500 Subject: [PATCH 12/12] Fix missing :okwarning: --- doc/internals/time-coding.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/internals/time-coding.rst b/doc/internals/time-coding.rst index 7710928d47e..442b749a73b 100644 --- a/doc/internals/time-coding.rst +++ b/doc/internals/time-coding.rst @@ -521,6 +521,7 @@ into their native on-disk resolution, if possible: xr.open_dataset("test-timedeltas2.nc") .. ipython:: python + :okwarning: coder = xr.coders.CFDatetimeCoder(time_unit="s") xr.open_dataset("test-timedeltas2.nc", decode_times=coder)