From 5855a1c84a0060dbb9d6656889a1b07aef05db16 Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Tue, 4 Aug 2026 10:16:46 +0200 Subject: [PATCH 1/2] Convert 0-D arrays in the time converters to_datetime64 and to_timedelta64 raised "TypeError: len() of unsized object" for any 0-D array whose dtype missed the first branch: a 0-D float, int, or the other time type. Only the matching time type worked, because that branch returns before the length check. Both converters now reshape a 0-D input to the length-one array it stands for and unpack the scalar back out, so every rank takes the same path. _array_to_timedelta64's early returns become an if/elif/else to give it a single exit to unpack at. --- dascore/utils/time.py | 52 +++++++++++++++++++++-------------- tests/test_utils/test_time.py | 26 ++++++++++++++++++ 2 files changed, 58 insertions(+), 20 deletions(-) diff --git a/dascore/utils/time.py b/dascore/utils/time.py index 6d0dd0dc6..ba7212e63 100644 --- a/dascore/utils/time.py +++ b/dascore/utils/time.py @@ -21,6 +21,17 @@ _EPOCH_DATETIME64 = np.datetime64(0, "ns") +def _is_degenerate(array) -> bool: + """ + Return True for a 0-D array. + + The array converters index and iterate their input, which a 0-D array + supports for neither. Callers reshape it to the length-one array it + stands for, then unpack the scalar back out. + """ + return array.ndim == 0 + + def _float_array_to_ns(array): """Convert seconds as floats to signed integer nanoseconds.""" # Integer inputs must be widened first; the default integer is only @@ -126,6 +137,9 @@ 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) + degenerate = _is_degenerate(array) + if degenerate: + array = array.reshape(1) nans = pd.isnull(array) # dealing with objects if np.issubdtype(array.dtype, np.dtype(object)): @@ -136,8 +150,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 +159,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 +257,30 @@ 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) + degenerate = _is_degenerate(array) + 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 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 = np.array(array.view(np.int64)).astype("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 5bc147da0..78e0df25f 100644 --- a/tests/test_utils/test_time.py +++ b/tests/test_utils/test_time.py @@ -417,6 +417,32 @@ def test_immutable_inputs(self): assert np.issubdtype(time.dtype, "m8") +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.""" From 0e6fd465c030728b8496ba1c53cd94f204eb5fb0 Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Tue, 4 Aug 2026 11:23:56 +0200 Subject: [PATCH 2/2] Normalize the unit before reinterpreting a datetime as a duration to_timedelta64 of a datetime64 array viewed its raw integer and labeled the result nanoseconds, so only an array already in ns was right: a datetime64[D] of 2020-01-01 came back as 18262 ns rather than 18262 days. Casting to ns first makes every unit give the same epoch offset. Also inlines the degenerate check and drops the helper, per review. --- dascore/utils/time.py | 24 +++++++++--------------- tests/test_utils/test_time.py | 8 ++++++++ 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/dascore/utils/time.py b/dascore/utils/time.py index ba7212e63..7bae6fbe5 100644 --- a/dascore/utils/time.py +++ b/dascore/utils/time.py @@ -21,17 +21,6 @@ _EPOCH_DATETIME64 = np.datetime64(0, "ns") -def _is_degenerate(array) -> bool: - """ - Return True for a 0-D array. - - The array converters index and iterate their input, which a 0-D array - supports for neither. Callers reshape it to the length-one array it - stands for, then unpack the scalar back out. - """ - return array.ndim == 0 - - def _float_array_to_ns(array): """Convert seconds as floats to signed integer nanoseconds.""" # Integer inputs must be widened first; the default integer is only @@ -137,7 +126,9 @@ 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) - degenerate = _is_degenerate(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) @@ -257,7 +248,8 @@ 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) - degenerate = _is_degenerate(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. @@ -265,9 +257,11 @@ def _array_to_timedelta64(array: np.ndarray) -> np.timedelta64 | np.ndarray: array = array.astype(np.float64) if np.issubdtype(array.dtype, np.timedelta64) or len(array) == 0: out = array.astype("timedelta64[ns]") - # 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): - out = np.array(array.view(np.int64)).astype("timedelta64[ns]") + out = array.astype("datetime64[ns]").view("timedelta64[ns]") else: assert np.isreal(array[0]) invalid = pd.isnull(array) | ~np.isfinite(array) diff --git a/tests/test_utils/test_time.py b/tests/test_utils/test_time.py index 78e0df25f..255c66fb6 100644 --- a/tests/test_utils/test_time.py +++ b/tests/test_utils/test_time.py @@ -416,6 +416,14 @@ 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."""