diff --git a/doc/whats-new.rst b/doc/whats-new.rst index 755eeef47cf..994fc70339c 100644 --- a/doc/whats-new.rst +++ b/doc/whats-new.rst @@ -71,6 +71,9 @@ Bug fixes By `Benoit Bovy `_. - Fix dask tokenization when opening each node in :py:func:`xarray.open_datatree` (:issue:`10098`, :pull:`10100`). By `Sam Levang `_. +- Improve handling of dtype and NaT when encoding/decoding masked and packaged + datetimes and timedeltas (:issue:`8957`, :pull:`10050`). + By `Kai Mühlbauer `_. Documentation ~~~~~~~~~~~~~ diff --git a/xarray/coding/common.py b/xarray/coding/common.py new file mode 100644 index 00000000000..1b455009668 --- /dev/null +++ b/xarray/coding/common.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +from collections.abc import Callable, Hashable, MutableMapping +from typing import TYPE_CHECKING, Any, Union + +import numpy as np + +from xarray.core import indexing +from xarray.core.variable import Variable +from xarray.namedarray.parallelcompat import get_chunked_array_type +from xarray.namedarray.pycompat import is_chunked_array + +if TYPE_CHECKING: + T_VarTuple = tuple[tuple[Hashable, ...], Any, dict, dict] + T_Name = Union[Hashable, None] + + +class SerializationWarning(RuntimeWarning): + """Warnings about encoding/decoding issues in serialization.""" + + +class VariableCoder: + """Base class for encoding and decoding transformations on variables. + + We use coders for transforming variables between xarray's data model and + a format suitable for serialization. For example, coders apply CF + conventions for how data should be represented in netCDF files. + + Subclasses should implement encode() and decode(), which should satisfy + the identity ``coder.decode(coder.encode(variable)) == variable``. If any + options are necessary, they should be implemented as arguments to the + __init__ method. + + The optional name argument to encode() and decode() exists solely for the + sake of better error messages, and should correspond to the name of + variables in the underlying store. + """ + + def encode(self, variable: Variable, name: T_Name = None) -> Variable: + """Convert an encoded variable to a decoded variable""" + raise NotImplementedError() + + def decode(self, variable: Variable, name: T_Name = None) -> Variable: + """Convert a decoded variable to an encoded variable""" + raise NotImplementedError() + + +class _ElementwiseFunctionArray(indexing.ExplicitlyIndexedNDArrayMixin): + """Lazily computed array holding values of elemwise-function. + + Do not construct this object directly: call lazy_elemwise_func instead. + + Values are computed upon indexing or coercion to a NumPy array. + """ + + def __init__(self, array, func: Callable, dtype: np.typing.DTypeLike): + assert not is_chunked_array(array) + self.array = indexing.as_indexable(array) + self.func = func + self._dtype = dtype + + @property + def dtype(self) -> np.dtype: + return np.dtype(self._dtype) + + def _oindex_get(self, key): + return type(self)(self.array.oindex[key], self.func, self.dtype) + + def _vindex_get(self, key): + return type(self)(self.array.vindex[key], self.func, self.dtype) + + def __getitem__(self, key): + return type(self)(self.array[key], self.func, self.dtype) + + def get_duck_array(self): + return self.func(self.array.get_duck_array()) + + def __repr__(self) -> str: + return f"{type(self).__name__}({self.array!r}, func={self.func!r}, dtype={self.dtype!r})" + + +def lazy_elemwise_func(array, func: Callable, dtype: np.typing.DTypeLike): + """Lazily apply an element-wise function to an array. + Parameters + ---------- + array : any valid value of Variable._data + func : callable + Function to apply to indexed slices of an array. For use with dask, + this should be a pickle-able object. + dtype : coercible to np.dtype + Dtype for the result of this function. + + Returns + ------- + Either a dask.array.Array or _ElementwiseFunctionArray. + """ + if is_chunked_array(array): + chunkmanager = get_chunked_array_type(array) + + return chunkmanager.map_blocks(func, array, dtype=dtype) # type: ignore[arg-type] + else: + return _ElementwiseFunctionArray(array, func, dtype) + + +def safe_setitem(dest, key: Hashable, value, name: T_Name = None): + if key in dest: + var_str = f" on variable {name!r}" if name else "" + raise ValueError( + f"failed to prevent overwriting existing key {key} in attrs{var_str}. " + "This is probably an encoding field used by xarray to describe " + "how a variable is serialized. To proceed, remove this key from " + "the variable's attributes manually." + ) + dest[key] = value + + +def pop_to( + source: MutableMapping, dest: MutableMapping, key: Hashable, name: T_Name = None +) -> Any: + """ + A convenience function which pops a key k from source to dest. + None values are not passed on. If k already exists in dest an + error is raised. + """ + value = source.pop(key, None) + if value is not None: + safe_setitem(dest, key, value, name=name) + return value + + +def unpack_for_encoding(var: Variable) -> T_VarTuple: + return var.dims, var.data, var.attrs.copy(), var.encoding.copy() + + +def unpack_for_decoding(var: Variable) -> T_VarTuple: + return var.dims, var._data, var.attrs.copy(), var.encoding.copy() diff --git a/xarray/coding/times.py b/xarray/coding/times.py index 3319720e26f..e66adc0887c 100644 --- a/xarray/coding/times.py +++ b/xarray/coding/times.py @@ -11,7 +11,7 @@ import pandas as pd from pandas.errors import OutOfBoundsDatetime, OutOfBoundsTimedelta -from xarray.coding.variables import ( +from xarray.coding.common import ( SerializationWarning, VariableCoder, lazy_elemwise_func, @@ -1328,9 +1328,20 @@ def encode(self, variable: Variable, name: T_Name = None) -> Variable: units = encoding.pop("units", None) calendar = encoding.pop("calendar", None) - dtype = encoding.get("dtype", None) + dtype = encoding.pop("dtype", None) + + # in the case of packed data we need to encode into + # float first, the correct dtype will be established + # via CFScaleOffsetCoder/CFMaskCoder + set_dtype_encoding = None + if "add_offset" in encoding or "scale_factor" in encoding: + set_dtype_encoding = dtype + dtype = data.dtype if data.dtype.kind == "f" else "float64" (data, units, calendar) = encode_cf_datetime(data, units, calendar, dtype) + # retain dtype for packed data + if set_dtype_encoding is not None: + safe_setitem(encoding, "dtype", set_dtype_encoding, name=name) safe_setitem(attrs, "units", units, name=name) safe_setitem(attrs, "calendar", calendar, name=name) @@ -1382,9 +1393,22 @@ 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) - data, units = encode_cf_timedelta( - data, encoding.pop("units", None), encoding.get("dtype", None) - ) + dtype = encoding.pop("dtype", None) + + # in the case of packed data we need to encode into + # float first, the correct dtype will be established + # via CFScaleOffsetCoder/CFMaskCoder + set_dtype_encoding = None + if "add_offset" in encoding or "scale_factor" in encoding: + set_dtype_encoding = dtype + dtype = data.dtype if data.dtype.kind == "f" else "float64" + + data, units = encode_cf_timedelta(data, encoding.pop("units", None), dtype) + + # retain dtype for packed data + if set_dtype_encoding is not None: + safe_setitem(encoding, "dtype", set_dtype_encoding, name=name) + safe_setitem(attrs, "units", units, name=name) return Variable(dims, data, attrs, encoding, fastpath=True) diff --git a/xarray/coding/variables.py b/xarray/coding/variables.py index 83112628dbb..1b7bc95e2b4 100644 --- a/xarray/coding/variables.py +++ b/xarray/coding/variables.py @@ -3,87 +3,31 @@ from __future__ import annotations import warnings -from collections.abc import Callable, Hashable, MutableMapping +from collections.abc import Hashable, MutableMapping from functools import partial from typing import TYPE_CHECKING, Any, Union import numpy as np import pandas as pd +from xarray.coding.common import ( + SerializationWarning, + VariableCoder, + lazy_elemwise_func, + pop_to, + safe_setitem, + unpack_for_decoding, + unpack_for_encoding, +) +from xarray.coding.times import CFDatetimeCoder, CFTimedeltaCoder from xarray.core import dtypes, duck_array_ops, indexing from xarray.core.variable import Variable -from xarray.namedarray.parallelcompat import get_chunked_array_type -from xarray.namedarray.pycompat import is_chunked_array if TYPE_CHECKING: T_VarTuple = tuple[tuple[Hashable, ...], Any, dict, dict] T_Name = Union[Hashable, None] -class SerializationWarning(RuntimeWarning): - """Warnings about encoding/decoding issues in serialization.""" - - -class VariableCoder: - """Base class for encoding and decoding transformations on variables. - - We use coders for transforming variables between xarray's data model and - a format suitable for serialization. For example, coders apply CF - conventions for how data should be represented in netCDF files. - - Subclasses should implement encode() and decode(), which should satisfy - the identity ``coder.decode(coder.encode(variable)) == variable``. If any - options are necessary, they should be implemented as arguments to the - __init__ method. - - The optional name argument to encode() and decode() exists solely for the - sake of better error messages, and should correspond to the name of - variables in the underlying store. - """ - - def encode(self, variable: Variable, name: T_Name = None) -> Variable: - """Convert an encoded variable to a decoded variable""" - raise NotImplementedError() - - def decode(self, variable: Variable, name: T_Name = None) -> Variable: - """Convert a decoded variable to an encoded variable""" - raise NotImplementedError() - - -class _ElementwiseFunctionArray(indexing.ExplicitlyIndexedNDArrayMixin): - """Lazily computed array holding values of elemwise-function. - - Do not construct this object directly: call lazy_elemwise_func instead. - - Values are computed upon indexing or coercion to a NumPy array. - """ - - def __init__(self, array, func: Callable, dtype: np.typing.DTypeLike): - assert not is_chunked_array(array) - self.array = indexing.as_indexable(array) - self.func = func - self._dtype = dtype - - @property - def dtype(self) -> np.dtype: - return np.dtype(self._dtype) - - def _oindex_get(self, key): - return type(self)(self.array.oindex[key], self.func, self.dtype) - - def _vindex_get(self, key): - return type(self)(self.array.vindex[key], self.func, self.dtype) - - def __getitem__(self, key): - return type(self)(self.array[key], self.func, self.dtype) - - def get_duck_array(self): - return self.func(self.array.get_duck_array()) - - def __repr__(self) -> str: - return f"{type(self).__name__}({self.array!r}, func={self.func!r}, dtype={self.dtype!r})" - - class NativeEndiannessArray(indexing.ExplicitlyIndexedNDArrayMixin): """Decode arrays on the fly from non-native to native endianness @@ -161,63 +105,6 @@ def __getitem__(self, key) -> np.ndarray: return np.asarray(self.array[key], dtype=self.dtype) -def lazy_elemwise_func(array, func: Callable, dtype: np.typing.DTypeLike): - """Lazily apply an element-wise function to an array. - Parameters - ---------- - array : any valid value of Variable._data - func : callable - Function to apply to indexed slices of an array. For use with dask, - this should be a pickle-able object. - dtype : coercible to np.dtype - Dtype for the result of this function. - - Returns - ------- - Either a dask.array.Array or _ElementwiseFunctionArray. - """ - if is_chunked_array(array): - chunkmanager = get_chunked_array_type(array) - - return chunkmanager.map_blocks(func, array, dtype=dtype) # type: ignore[arg-type] - else: - return _ElementwiseFunctionArray(array, func, dtype) - - -def unpack_for_encoding(var: Variable) -> T_VarTuple: - return var.dims, var.data, var.attrs.copy(), var.encoding.copy() - - -def unpack_for_decoding(var: Variable) -> T_VarTuple: - return var.dims, var._data, var.attrs.copy(), var.encoding.copy() - - -def safe_setitem(dest, key: Hashable, value, name: T_Name = None): - if key in dest: - var_str = f" on variable {name!r}" if name else "" - raise ValueError( - f"failed to prevent overwriting existing key {key} in attrs{var_str}. " - "This is probably an encoding field used by xarray to describe " - "how a variable is serialized. To proceed, remove this key from " - "the variable's attributes manually." - ) - dest[key] = value - - -def pop_to( - source: MutableMapping, dest: MutableMapping, key: Hashable, name: T_Name = None -) -> Any: - """ - A convenience function which pops a key k from source to dest. - None values are not passed on. If k already exists in dest an - error is raised. - """ - value = source.pop(key, None) - if value is not None: - safe_setitem(dest, key, value, name=name) - return value - - def _apply_mask( data: np.ndarray, encoded_fill_values: list, @@ -234,6 +121,8 @@ def _apply_mask( def _is_time_like(units): # test for time-like + # return "datetime" for datetime-like + # return "timedelta" for timedelta-like if units is None: return False time_strings = [ @@ -255,9 +144,9 @@ def _is_time_like(units): _unpack_netcdf_time_units(units) except ValueError: return False - return True + return "datetime" else: - return any(tstr == units for tstr in time_strings) + return "timedelta" if any(tstr == units for tstr in time_strings) else False def _check_fill_values(attrs, name, dtype): @@ -367,6 +256,14 @@ def _encode_unsigned_fill_value( class CFMaskCoder(VariableCoder): """Mask or unmask fill values according to CF conventions.""" + def __init__( + self, + decode_times: bool | CFDatetimeCoder = False, + decode_timedelta: bool | CFTimedeltaCoder = False, + ) -> None: + self.decode_times = decode_times + self.decode_timedelta = decode_timedelta + def encode(self, variable: Variable, name: T_Name = None): dims, data, attrs, encoding = unpack_for_encoding(variable) @@ -393,22 +290,33 @@ def encode(self, variable: Variable, name: T_Name = None): if fv_exists: # Ensure _FillValue is cast to same dtype as data's + # but not for packed data encoding["_FillValue"] = ( _encode_unsigned_fill_value(name, fv, dtype) if has_unsigned - else dtype.type(fv) + else ( + dtype.type(fv) + if "add_offset" not in encoding and "scale_factor" not in encoding + else fv + ) ) fill_value = pop_to(encoding, attrs, "_FillValue", name=name) if mv_exists: # try to use _FillValue, if it exists to align both values # or use missing_value and ensure it's cast to same dtype as data's + # but not for packed data encoding["missing_value"] = attrs.get( "_FillValue", ( _encode_unsigned_fill_value(name, mv, dtype) if has_unsigned - else dtype.type(mv) + else ( + dtype.type(mv) + if "add_offset" not in encoding + and "scale_factor" not in encoding + else mv + ) ), ) fill_value = pop_to(encoding, attrs, "missing_value", name=name) @@ -416,10 +324,21 @@ def encode(self, variable: Variable, name: T_Name = None): # apply fillna if fill_value is not None and not pd.isnull(fill_value): # special case DateTime to properly handle NaT - if _is_time_like(attrs.get("units")) and data.dtype.kind in "iu": - data = duck_array_ops.where( - data != np.iinfo(np.int64).min, data, fill_value - ) + if _is_time_like(attrs.get("units")): + if data.dtype.kind in "iu": + data = duck_array_ops.where( + data != np.iinfo(np.int64).min, data, fill_value + ) + else: + # if we have float data (data was packed prior masking) + # we just fillna + data = duck_array_ops.fillna(data, fill_value) + # but if the fill_value is of integer type + # we need to round and cast + if np.array(fill_value).dtype.kind in "iu": + data = duck_array_ops.astype( + duck_array_ops.around(data), type(fill_value) + ) else: data = duck_array_ops.fillna(data, fill_value) @@ -457,19 +376,28 @@ def decode(self, variable: Variable, name: T_Name = None): ) if encoded_fill_values: - # special case DateTime to properly handle NaT dtype: np.typing.DTypeLike decoded_fill_value: Any - if _is_time_like(attrs.get("units")) and data.dtype.kind in "iu": - dtype, decoded_fill_value = np.int64, np.iinfo(np.int64).min + # in case of packed data we have to decode into float + # in any case + if "scale_factor" in attrs or "add_offset" in attrs: + dtype, decoded_fill_value = ( + _choose_float_dtype(data.dtype, attrs), + np.nan, + ) else: - if "scale_factor" not in attrs and "add_offset" not in attrs: - dtype, decoded_fill_value = dtypes.maybe_promote(data.dtype) + # in case of no-packing special case DateTime/Timedelta to properly + # handle NaT, we need to check if time-like will be decoded + # or not in further processing + is_time_like = _is_time_like(attrs.get("units")) + if ( + (is_time_like == "datetime" and self.decode_times) + or (is_time_like == "timedelta" and self.decode_timedelta) + ) and data.dtype.kind in "iu": + dtype = np.int64 + decoded_fill_value = np.iinfo(np.int64).min else: - dtype, decoded_fill_value = ( - _choose_float_dtype(data.dtype, attrs), - np.nan, - ) + dtype, decoded_fill_value = dtypes.maybe_promote(data.dtype) transform = partial( _apply_mask, @@ -549,6 +477,14 @@ class CFScaleOffsetCoder(VariableCoder): decode_values = encoded_values * scale_factor + add_offset """ + def __init__( + self, + decode_times: bool | CFDatetimeCoder = False, + decode_timedelta: bool | CFTimedeltaCoder = False, + ) -> None: + self.decode_times = decode_times + self.decode_timedelta = decode_timedelta + def encode(self, variable: Variable, name: T_Name = None) -> Variable: dims, data, attrs, encoding = unpack_for_encoding(variable) @@ -578,11 +514,16 @@ def decode(self, variable: Variable, name: T_Name = None) -> Variable: scale_factor = np.asarray(scale_factor).item() if np.ndim(add_offset) > 0: add_offset = np.asarray(add_offset).item() - # if we have a _FillValue/masked_value we already have the wanted + # if we have a _FillValue/masked_value in encoding we already have the wanted # floating point dtype here (via CFMaskCoder), so no check is necessary - # only check in other cases + # only check in other cases and for time-like dtype = data.dtype - if "_FillValue" not in encoding and "missing_value" not in encoding: + is_time_like = _is_time_like(attrs.get("units")) + if ( + ("_FillValue" not in encoding and "missing_value" not in encoding) + or (is_time_like == "datetime" and self.decode_times) + or (is_time_like == "timedelta" and self.decode_timedelta) + ): dtype = _choose_float_dtype(dtype, encoding) transform = partial( diff --git a/xarray/conventions.py b/xarray/conventions.py index f67af95b4ce..071dab43c28 100644 --- a/xarray/conventions.py +++ b/xarray/conventions.py @@ -194,8 +194,12 @@ def decode_cf_variable( if mask_and_scale: for coder in [ - variables.CFMaskCoder(), - variables.CFScaleOffsetCoder(), + variables.CFMaskCoder( + decode_times=decode_times, decode_timedelta=decode_timedelta + ), + variables.CFScaleOffsetCoder( + decode_times=decode_times, decode_timedelta=decode_timedelta + ), ]: var = coder.decode(var, name=name) diff --git a/xarray/tests/test_coding_times.py b/xarray/tests/test_coding_times.py index e6d6764a47b..e736339da1b 100644 --- a/xarray/tests/test_coding_times.py +++ b/xarray/tests/test_coding_times.py @@ -524,6 +524,26 @@ def test_decoded_cf_datetime_array_2d(time_unit: PDDatetimeUnitOptions) -> None: assert_array_equal(np.asarray(result), expected) +@pytest.mark.parametrize("decode_times", [True, False]) +@pytest.mark.parametrize("mask_and_scale", [True, False]) +def test_decode_datetime_mask_and_scale( + decode_times: bool, mask_and_scale: bool +) -> None: + attrs = { + "units": "nanoseconds since 1970-01-01", + "calendar": "proleptic_gregorian", + "_FillValue": np.int16(-1), + "add_offset": 100000.0, + } + encoded = Variable(["time"], np.array([0, -1, 1], "int16"), attrs=attrs) + decoded = conventions.decode_cf_variable( + "foo", encoded, mask_and_scale=mask_and_scale, decode_times=decode_times + ) + result = conventions.encode_cf_variable(decoded, name="foo") + assert_identical(encoded, result) + assert encoded.dtype == result.dtype + + FREQUENCIES_TO_ENCODING_UNITS = { "ns": "nanoseconds", "us": "microseconds", @@ -637,7 +657,9 @@ def test_cf_timedelta_2d() -> None: @pytest.mark.parametrize("encoding_unit", FREQUENCIES_TO_ENCODING_UNITS.values()) -def test_decode_cf_timedelta_time_unit(time_unit, encoding_unit) -> None: +def test_decode_cf_timedelta_time_unit( + time_unit: PDDatetimeUnitOptions, encoding_unit +) -> None: encoded = 1 encoding_unit_as_numpy = _netcdf_to_numpy_timeunit(encoding_unit) if np.timedelta64(1, time_unit) > np.timedelta64(1, encoding_unit_as_numpy): @@ -651,7 +673,9 @@ def test_decode_cf_timedelta_time_unit(time_unit, encoding_unit) -> None: assert result.dtype == expected.dtype -def test_decode_cf_timedelta_time_unit_out_of_bounds(time_unit) -> None: +def test_decode_cf_timedelta_time_unit_out_of_bounds( + time_unit: PDDatetimeUnitOptions, +) -> None: # Define a scale factor that will guarantee overflow with the given # time_unit. scale_factor = np.timedelta64(1, time_unit) // np.timedelta64(1, "ns") @@ -660,7 +684,7 @@ def test_decode_cf_timedelta_time_unit_out_of_bounds(time_unit) -> None: decode_cf_timedelta(encoded, "days", time_unit) -def test_cf_timedelta_roundtrip_large_value(time_unit) -> None: +def test_cf_timedelta_roundtrip_large_value(time_unit: PDDatetimeUnitOptions) -> None: value = np.timedelta64(np.iinfo(np.int64).max, time_unit) encoded, units = encode_cf_timedelta(value) decoded = decode_cf_timedelta(encoded, units, time_unit=time_unit) @@ -982,7 +1006,7 @@ def test_use_cftime_default_standard_calendar_out_of_range( @pytest.mark.parametrize("calendar", _NON_STANDARD_CALENDARS) @pytest.mark.parametrize("units_year", [1500, 2000, 2500]) def test_use_cftime_default_non_standard_calendar( - calendar, units_year, time_unit + calendar, units_year, time_unit: PDDatetimeUnitOptions ) -> None: from cftime import num2date @@ -1433,9 +1457,9 @@ def test_roundtrip_datetime64_nanosecond_precision_warning( ) -> None: # test warning if times can't be serialized faithfully times = [ - np.datetime64("1970-01-01T00:01:00", "ns"), - np.datetime64("NaT"), - np.datetime64("1970-01-02T00:01:00", "ns"), + np.datetime64("1970-01-01T00:01:00", time_unit), + np.datetime64("NaT", time_unit), + np.datetime64("1970-01-02T00:01:00", time_unit), ] units = "days since 1970-01-10T01:01:00" needed_units = "hours" @@ -1624,7 +1648,9 @@ def test_roundtrip_float_times(fill_value, times, units, encoded_values) -> None _ENCODE_DATETIME64_VIA_DASK_TESTS.values(), ids=_ENCODE_DATETIME64_VIA_DASK_TESTS.keys(), ) -def test_encode_cf_datetime_datetime64_via_dask(freq, units, dtype, time_unit) -> None: +def test_encode_cf_datetime_datetime64_via_dask( + freq, units, dtype, time_unit: PDDatetimeUnitOptions +) -> None: import dask.array times_pd = pd.date_range(start="1700", freq=freq, periods=3, unit=time_unit) @@ -1909,6 +1935,21 @@ def test_lazy_decode_timedelta_error() -> None: decoded.load() +@pytest.mark.parametrize("decode_timedelta", [True, False]) +@pytest.mark.parametrize("mask_and_scale", [True, False]) +def test_decode_timedelta_mask_and_scale( + decode_timedelta: bool, mask_and_scale: bool +) -> None: + attrs = {"units": "nanoseconds", "_FillValue": np.int16(-1), "add_offset": 100000.0} + encoded = Variable(["time"], np.array([0, -1, 1], "int16"), attrs=attrs) + decoded = conventions.decode_cf_variable( + "foo", encoded, mask_and_scale=mask_and_scale, decode_timedelta=decode_timedelta + ) + result = conventions.encode_cf_variable(decoded, name="foo") + assert_identical(encoded, result) + assert encoded.dtype == result.dtype + + def test_decode_floating_point_timedelta_no_serialization_warning() -> None: attrs = {"units": "seconds"} encoded = Variable(["time"], [0, 0.1, 0.2], attrs=attrs) diff --git a/xarray/tests/test_conventions.py b/xarray/tests/test_conventions.py index 1d3a8bc809d..961df78154e 100644 --- a/xarray/tests/test_conventions.py +++ b/xarray/tests/test_conventions.py @@ -511,16 +511,13 @@ def test_decode_dask_times(self) -> None: @pytest.mark.parametrize("time_unit", ["s", "ms", "us", "ns"]) def test_decode_cf_time_kwargs(self, time_unit) -> None: - # todo: if we set timedelta attrs "units": "days" - # this errors on the last decode_cf wrt to the lazy_elemwise_func - # trying to convert twice ds = Dataset.from_dict( { "coords": { "timedelta": { "data": np.array([1, 2, 3], dtype="int64"), "dims": "timedelta", - "attrs": {"units": "seconds"}, + "attrs": {"units": "days"}, }, "time": { "data": np.array([1, 2, 3], dtype="int64"),