Skip to content

Commit afaf268

Browse files
authored
Consistent handling of 0-dim in Timedelta arithmetic methods (#47390)
* Consistent handling of 0-dim in Timedelta arithmetic methods * update test
1 parent fd9b2a4 commit afaf268

File tree

6 files changed

+125
-41
lines changed

6 files changed

+125
-41
lines changed

pandas/_libs/tslibs/timedeltas.pyx

Lines changed: 53 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -765,8 +765,12 @@ def _binary_op_method_timedeltalike(op, name):
765765
# defined by Timestamp methods.
766766

767767
elif is_array(other):
768-
# nd-array like
769-
if other.dtype.kind in ['m', 'M']:
768+
if other.ndim == 0:
769+
# see also: item_from_zerodim
770+
item = cnp.PyArray_ToScalar(cnp.PyArray_DATA(other), other)
771+
return f(self, item)
772+
773+
elif other.dtype.kind in ['m', 'M']:
770774
return op(self.to_timedelta64(), other)
771775
elif other.dtype.kind == 'O':
772776
return np.array([op(self, x) for x in other])
@@ -943,14 +947,18 @@ cdef _timedelta_from_value_and_reso(int64_t value, NPY_DATETIMEUNIT reso):
943947
td_base = _Timedelta.__new__(Timedelta, milliseconds=int(value))
944948
elif reso == NPY_DATETIMEUNIT.NPY_FR_s:
945949
td_base = _Timedelta.__new__(Timedelta, seconds=int(value))
946-
elif reso == NPY_DATETIMEUNIT.NPY_FR_m:
947-
td_base = _Timedelta.__new__(Timedelta, minutes=int(value))
948-
elif reso == NPY_DATETIMEUNIT.NPY_FR_h:
949-
td_base = _Timedelta.__new__(Timedelta, hours=int(value))
950-
elif reso == NPY_DATETIMEUNIT.NPY_FR_D:
951-
td_base = _Timedelta.__new__(Timedelta, days=int(value))
950+
# Other resolutions are disabled but could potentially be implemented here:
951+
# elif reso == NPY_DATETIMEUNIT.NPY_FR_m:
952+
# td_base = _Timedelta.__new__(Timedelta, minutes=int(value))
953+
# elif reso == NPY_DATETIMEUNIT.NPY_FR_h:
954+
# td_base = _Timedelta.__new__(Timedelta, hours=int(value))
955+
# elif reso == NPY_DATETIMEUNIT.NPY_FR_D:
956+
# td_base = _Timedelta.__new__(Timedelta, days=int(value))
952957
else:
953-
raise NotImplementedError(reso)
958+
raise NotImplementedError(
959+
"Only resolutions 's', 'ms', 'us', 'ns' are supported."
960+
)
961+
954962

955963
td_base.value = value
956964
td_base._is_populated = 0
@@ -1006,7 +1014,6 @@ cdef class _Timedelta(timedelta):
10061014
def __richcmp__(_Timedelta self, object other, int op):
10071015
cdef:
10081016
_Timedelta ots
1009-
int ndim
10101017

10111018
if isinstance(other, _Timedelta):
10121019
ots = other
@@ -1018,7 +1025,6 @@ cdef class _Timedelta(timedelta):
10181025
return op == Py_NE
10191026

10201027
elif util.is_array(other):
1021-
# TODO: watch out for zero-dim
10221028
if other.dtype.kind == "m":
10231029
return PyObject_RichCompare(self.asm8, other, op)
10241030
elif other.dtype.kind == "O":
@@ -1728,14 +1734,20 @@ class Timedelta(_Timedelta):
17281734
)
17291735

17301736
elif is_array(other):
1731-
# ndarray-like
1737+
if other.ndim == 0:
1738+
# see also: item_from_zerodim
1739+
item = cnp.PyArray_ToScalar(cnp.PyArray_DATA(other), other)
1740+
return self.__mul__(item)
17321741
return other * self.to_timedelta64()
17331742

17341743
return NotImplemented
17351744

17361745
__rmul__ = __mul__
17371746

17381747
def __truediv__(self, other):
1748+
cdef:
1749+
int64_t new_value
1750+
17391751
if _should_cast_to_timedelta(other):
17401752
# We interpret NaT as timedelta64("NaT")
17411753
other = Timedelta(other)
@@ -1758,6 +1770,10 @@ class Timedelta(_Timedelta):
17581770
)
17591771

17601772
elif is_array(other):
1773+
if other.ndim == 0:
1774+
# see also: item_from_zerodim
1775+
item = cnp.PyArray_ToScalar(cnp.PyArray_DATA(other), other)
1776+
return self.__truediv__(item)
17611777
return self.to_timedelta64() / other
17621778

17631779
return NotImplemented
@@ -1777,9 +1793,17 @@ class Timedelta(_Timedelta):
17771793
return float(other.value) / self.value
17781794

17791795
elif is_array(other):
1780-
if other.dtype.kind == "O":
1796+
if other.ndim == 0:
1797+
# see also: item_from_zerodim
1798+
item = cnp.PyArray_ToScalar(cnp.PyArray_DATA(other), other)
1799+
return self.__rtruediv__(item)
1800+
elif other.dtype.kind == "O":
17811801
# GH#31869
17821802
return np.array([x / self for x in other])
1803+
1804+
# TODO: if other.dtype.kind == "m" and other.dtype != self.asm8.dtype
1805+
# then should disallow for consistency with scalar behavior; requires
1806+
# deprecation cycle. (or changing scalar behavior)
17831807
return other / self.to_timedelta64()
17841808

17851809
return NotImplemented
@@ -1806,6 +1830,11 @@ class Timedelta(_Timedelta):
18061830
return type(self)._from_value_and_reso(self.value // other, self._reso)
18071831

18081832
elif is_array(other):
1833+
if other.ndim == 0:
1834+
# see also: item_from_zerodim
1835+
item = cnp.PyArray_ToScalar(cnp.PyArray_DATA(other), other)
1836+
return self.__floordiv__(item)
1837+
18091838
if other.dtype.kind == 'm':
18101839
# also timedelta-like
18111840
if self._reso != NPY_FR_ns:
@@ -1838,6 +1867,11 @@ class Timedelta(_Timedelta):
18381867
return other.value // self.value
18391868

18401869
elif is_array(other):
1870+
if other.ndim == 0:
1871+
# see also: item_from_zerodim
1872+
item = cnp.PyArray_ToScalar(cnp.PyArray_DATA(other), other)
1873+
return self.__rfloordiv__(item)
1874+
18411875
if other.dtype.kind == 'm':
18421876
# also timedelta-like
18431877
if self._reso != NPY_FR_ns:
@@ -1923,23 +1957,17 @@ cdef _broadcast_floordiv_td64(
19231957
result : varies based on `other`
19241958
"""
19251959
# assumes other.dtype.kind == 'm', i.e. other is timedelta-like
1960+
# assumes other.ndim != 0
19261961

19271962
# We need to watch out for np.timedelta64('NaT').
19281963
mask = other.view('i8') == NPY_NAT
19291964

1930-
if other.ndim == 0:
1931-
if mask:
1932-
return np.nan
1933-
1934-
return operation(value, other.astype('m8[ns]', copy=False).astype('i8'))
1935-
1936-
else:
1937-
res = operation(value, other.astype('m8[ns]', copy=False).astype('i8'))
1965+
res = operation(value, other.astype('m8[ns]', copy=False).astype('i8'))
19381966

1939-
if mask.any():
1940-
res = res.astype('f8')
1941-
res[mask] = np.nan
1942-
return res
1967+
if mask.any():
1968+
res = res.astype('f8')
1969+
res[mask] = np.nan
1970+
return res
19431971

19441972

19451973
# resolution in ns

pandas/_libs/tslibs/timestamps.pyx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,11 @@ cdef class _Timestamp(ABCTimestamp):
215215
if value == NPY_NAT:
216216
return NaT
217217

218+
if reso < NPY_DATETIMEUNIT.NPY_FR_s or reso > NPY_DATETIMEUNIT.NPY_FR_ns:
219+
raise NotImplementedError(
220+
"Only resolutions 's', 'ms', 'us', 'ns' are supported."
221+
)
222+
218223
obj.value = value
219224
pandas_datetime_to_datetimestruct(value, reso, &obj.dts)
220225
maybe_localize_tso(obj, tz, reso)

pandas/tests/indexes/timedeltas/test_indexing.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
TimedeltaIndex,
1515
Timestamp,
1616
notna,
17+
offsets,
1718
timedelta_range,
1819
to_timedelta,
1920
)
@@ -346,3 +347,14 @@ def test_contains_nonunique(self):
346347
):
347348
idx = TimedeltaIndex(vals)
348349
assert idx[0] in idx
350+
351+
def test_contains(self):
352+
# Checking for any NaT-like objects
353+
# GH#13603
354+
td = to_timedelta(range(5), unit="d") + offsets.Hour(1)
355+
for v in [NaT, None, float("nan"), np.nan]:
356+
assert not (v in td)
357+
358+
td = to_timedelta([NaT])
359+
for v in [NaT, None, float("nan"), np.nan]:
360+
assert v in td

pandas/tests/scalar/timedelta/test_arithmetic.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,26 @@ def test_td_add_sub_dt64_ndarray(self):
318318
tm.assert_numpy_array_equal(-td + other, expected)
319319
tm.assert_numpy_array_equal(other - td, expected)
320320

321+
def test_td_add_sub_ndarray_0d(self):
322+
td = Timedelta("1 day")
323+
other = np.array(td.asm8)
324+
325+
result = td + other
326+
assert isinstance(result, Timedelta)
327+
assert result == 2 * td
328+
329+
result = other + td
330+
assert isinstance(result, Timedelta)
331+
assert result == 2 * td
332+
333+
result = other - td
334+
assert isinstance(result, Timedelta)
335+
assert result == 0 * td
336+
337+
result = td - other
338+
assert isinstance(result, Timedelta)
339+
assert result == 0 * td
340+
321341

322342
class TestTimedeltaMultiplicationDivision:
323343
"""
@@ -395,6 +415,20 @@ def test_td_mul_numeric_ndarray(self):
395415
result = other * td
396416
tm.assert_numpy_array_equal(result, expected)
397417

418+
def test_td_mul_numeric_ndarray_0d(self):
419+
td = Timedelta("1 day")
420+
other = np.array(2)
421+
assert other.ndim == 0
422+
expected = Timedelta("2 days")
423+
424+
res = td * other
425+
assert type(res) is Timedelta
426+
assert res == expected
427+
428+
res = other * td
429+
assert type(res) is Timedelta
430+
assert res == expected
431+
398432
def test_td_mul_td64_ndarray_invalid(self):
399433
td = Timedelta("1 day")
400434
other = np.array([Timedelta("2 Days").to_timedelta64()])
@@ -484,6 +518,14 @@ def test_td_div_td64_ndarray(self):
484518
result = other / td
485519
tm.assert_numpy_array_equal(result, expected * 4)
486520

521+
def test_td_div_ndarray_0d(self):
522+
td = Timedelta("1 day")
523+
524+
other = np.array(1)
525+
res = td / other
526+
assert isinstance(res, Timedelta)
527+
assert res == td
528+
487529
# ---------------------------------------------------------------
488530
# Timedelta.__rdiv__
489531

@@ -539,6 +581,13 @@ def test_td_rdiv_ndarray(self):
539581
with pytest.raises(TypeError, match=msg):
540582
arr / td
541583

584+
def test_td_rdiv_ndarray_0d(self):
585+
td = Timedelta(10, unit="d")
586+
587+
arr = np.array(td.asm8)
588+
589+
assert arr / td == 1
590+
542591
# ---------------------------------------------------------------
543592
# Timedelta.__floordiv__
544593

pandas/tests/scalar/timedelta/test_timedelta.py

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -84,15 +84,15 @@ def test_as_unit_rounding(self):
8484

8585
def test_as_unit_non_nano(self):
8686
# case where we are going neither to nor from nano
87-
td = Timedelta(days=1)._as_unit("D")
87+
td = Timedelta(days=1)._as_unit("ms")
8888
assert td.days == 1
89-
assert td.value == 1
89+
assert td.value == 86_400_000
9090
assert td.components.days == 1
9191
assert td._d == 1
9292
assert td.total_seconds() == 86400
9393

94-
res = td._as_unit("h")
95-
assert res.value == 24
94+
res = td._as_unit("us")
95+
assert res.value == 86_400_000_000
9696
assert res.components.days == 1
9797
assert res.components.hours == 0
9898
assert res._d == 1
@@ -677,17 +677,6 @@ def test_round_non_nano(self, unit):
677677
assert res == Timedelta("1 days 02:35:00")
678678
assert res._reso == td._reso
679679

680-
def test_contains(self):
681-
# Checking for any NaT-like objects
682-
# GH 13603
683-
td = to_timedelta(range(5), unit="d") + offsets.Hour(1)
684-
for v in [NaT, None, float("nan"), np.nan]:
685-
assert not (v in td)
686-
687-
td = to_timedelta([NaT])
688-
for v in [NaT, None, float("nan"), np.nan]:
689-
assert v in td
690-
691680
def test_identity(self):
692681

693682
td = Timedelta(10, unit="d")

pandas/tests/tslibs/test_timedeltas.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,5 +127,6 @@ def test_ints_to_pytimedelta_unsupported(unit):
127127

128128
with pytest.raises(NotImplementedError, match=r"\d{1,2}"):
129129
ints_to_pytimedelta(arr, box=False)
130-
with pytest.raises(NotImplementedError, match=r"\d{1,2}"):
130+
msg = "Only resolutions 's', 'ms', 'us', 'ns' are supported"
131+
with pytest.raises(NotImplementedError, match=msg):
131132
ints_to_pytimedelta(arr, box=True)

0 commit comments

Comments
 (0)