diff --git a/dascore/utils/time.py b/dascore/utils/time.py index 6d0dd0dc..7bae6fbe 100644 --- a/dascore/utils/time.py +++ b/dascore/utils/time.py @@ -126,6 +126,11 @@ def _float_to_datetime(num: float | int) -> np.datetime64: def _array_to_datetime64(array: np.ndarray) -> np.datetime64 | np.ndarray: """Convert an array of floating point timestamps to an array of np.datatime64.""" array = np.asarray(array) + # 0-D arrays cannot be indexed or iterated, which the branches below do; + # use the length-one array it stands for and unpack the scalar at the end. + degenerate = array.ndim == 0 + if degenerate: + array = array.reshape(1) nans = pd.isnull(array) # dealing with objects if np.issubdtype(array.dtype, np.dtype(object)): @@ -136,8 +141,6 @@ def _array_to_datetime64(array: np.ndarray) -> np.datetime64 | np.ndarray: # dealing with an array of datetime64 or empty array if np.issubdtype(array.dtype, np.datetime64) or len(array) == 0: out = array.astype("datetime64[ns]") - if not array.shape: # unpack degenerate (0-D) array to a scalar - out = out[()] # dealing with numerical data elif np.issubdtype(array.dtype, np.timedelta64) or np.isreal(array[0]): with np.errstate(divide="ignore", invalid="ignore"): @@ -147,7 +150,7 @@ def _array_to_datetime64(array: np.ndarray) -> np.datetime64 | np.ndarray: out = _float_array_to_ns(array).astype("datetime64[ns]") # fill NaN Back in out[nans] = _NAT_DATETIME64 - return out + return out[0] if degenerate else out @to_datetime64.register(pd.Series) @@ -245,30 +248,33 @@ def _pass_time_delta(time_delta): def _array_to_timedelta64(array: np.ndarray) -> np.timedelta64 | np.ndarray: """Convert an array of floating point durations to np.timedelta64.""" array = np.asarray(array) + # See the note in _array_to_datetime64. + degenerate = array.ndim == 0 + if degenerate: + array = array.reshape(1) # convert pure object arrays into float so sign casting works. if np.issubdtype(array.dtype, np.dtype(object)): array = array.astype(np.float64) if np.issubdtype(array.dtype, np.timedelta64) or len(array) == 0: out = array.astype("timedelta64[ns]") - # unpack degenerate (0-D) array to a scalar - return out[()] if not array.shape else out - # Need to just get the ns form datetime64 + # A datetime becomes its offset from the epoch. The unit has to be + # normalized first, or viewing e.g. datetime64[s] as int64 would label + # its second count as nanoseconds. elif np.issubdtype(array.dtype, np.datetime64): - int_array = array.view(np.int64) - return np.array(int_array).astype("timedelta64[ns]") - - assert np.isreal(array[0]) - invalid = pd.isnull(array) | ~np.isfinite(array) - # Need to make copy to 1) not change original array and 2) handle - # immutable arrays. See #575. - if np.any(invalid): - array = np.array(array) - array[invalid] = 0 - # inf/NaN complain, salience these types of warnings for this block. - with np.errstate(divide="ignore", invalid="ignore"): - out = _float_array_to_ns(array).astype("timedelta64[ns]") - out[invalid] = _NAT_TIMEDELTA64 - return out + out = array.astype("datetime64[ns]").view("timedelta64[ns]") + else: + assert np.isreal(array[0]) + invalid = pd.isnull(array) | ~np.isfinite(array) + # Need to make copy to 1) not change original array and 2) handle + # immutable arrays. See #575. + if np.any(invalid): + array = np.array(array) + array[invalid] = 0 + # inf/NaN complain, salience these types of warnings for this block. + with np.errstate(divide="ignore", invalid="ignore"): + out = _float_array_to_ns(array).astype("timedelta64[ns]") + out[invalid] = _NAT_TIMEDELTA64 + return out[0] if degenerate else out @to_timedelta64.register(pd.Series) diff --git a/tests/test_utils/test_time.py b/tests/test_utils/test_time.py index 5bc147da..255c66fb 100644 --- a/tests/test_utils/test_time.py +++ b/tests/test_utils/test_time.py @@ -416,6 +416,40 @@ def test_immutable_inputs(self): time = to_timedelta64(array_no_nan) assert np.issubdtype(time.dtype, "m8") + @pytest.mark.parametrize("unit", ("D", "s", "ms", "ns")) + def test_datetime_array_is_epoch_offset(self, unit): + """A datetime becomes its offset from the epoch, whatever its unit.""" + value = np.datetime64("2020-01-01", unit) + expected = np.timedelta64(1577836800, "s") + assert to_timedelta64(np.array([value])) == expected + assert to_timedelta64(np.array(value)) == expected + + +class TestDegenerateArrays: + """Tests for 0-D array inputs to the array converters.""" + + values = ( + np.datetime64("2020-01-01", "s"), + np.timedelta64(5, "s"), + 5.0, + 5, + ) + + @pytest.mark.parametrize("func", (to_datetime64, to_timedelta64)) + @pytest.mark.parametrize("value", values) + def test_matches_length_one_array(self, func, value): + """A 0-D array converts like the length-one array it stands for.""" + out = func(np.array(value)) + assert np.shape(out) == () + assert out == func(np.array([value]))[0] + + @pytest.mark.parametrize("func", (to_datetime64, to_timedelta64)) + def test_nan_becomes_nat(self, func): + """A 0-D NaN converts to NaT rather than an epoch value.""" + out = func(np.array(np.nan)) + assert np.shape(out) == () + assert pd.isnull(out) + class TestToInt: """Tests for converting time-like types to ints, or passing through reals."""