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"), pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy