Skip to content

Consistent handling of 0-dim in Timedelta arithmetic methods #47390

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jun 21, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 53 additions & 25 deletions pandas/_libs/tslibs/timedeltas.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -765,8 +765,12 @@ def _binary_op_method_timedeltalike(op, name):
# defined by Timestamp methods.

elif is_array(other):
# nd-array like
if other.dtype.kind in ['m', 'M']:
if other.ndim == 0:
# see also: item_from_zerodim
item = cnp.PyArray_ToScalar(cnp.PyArray_DATA(other), other)
return f(self, item)

elif other.dtype.kind in ['m', 'M']:
return op(self.to_timedelta64(), other)
elif other.dtype.kind == 'O':
return np.array([op(self, x) for x in other])
Expand Down Expand Up @@ -943,14 +947,18 @@ cdef _timedelta_from_value_and_reso(int64_t value, NPY_DATETIMEUNIT reso):
td_base = _Timedelta.__new__(Timedelta, milliseconds=int(value))
elif reso == NPY_DATETIMEUNIT.NPY_FR_s:
td_base = _Timedelta.__new__(Timedelta, seconds=int(value))
elif reso == NPY_DATETIMEUNIT.NPY_FR_m:
td_base = _Timedelta.__new__(Timedelta, minutes=int(value))
elif reso == NPY_DATETIMEUNIT.NPY_FR_h:
td_base = _Timedelta.__new__(Timedelta, hours=int(value))
elif reso == NPY_DATETIMEUNIT.NPY_FR_D:
td_base = _Timedelta.__new__(Timedelta, days=int(value))
# Other resolutions are disabled but could potentially be implemented here:
# elif reso == NPY_DATETIMEUNIT.NPY_FR_m:
# td_base = _Timedelta.__new__(Timedelta, minutes=int(value))
# elif reso == NPY_DATETIMEUNIT.NPY_FR_h:
# td_base = _Timedelta.__new__(Timedelta, hours=int(value))
# elif reso == NPY_DATETIMEUNIT.NPY_FR_D:
# td_base = _Timedelta.__new__(Timedelta, days=int(value))
else:
raise NotImplementedError(reso)
raise NotImplementedError(
"Only resolutions 's', 'ms', 'us', 'ns' are supported."
)


td_base.value = value
td_base._is_populated = 0
Expand Down Expand Up @@ -1006,7 +1014,6 @@ cdef class _Timedelta(timedelta):
def __richcmp__(_Timedelta self, object other, int op):
cdef:
_Timedelta ots
int ndim

if isinstance(other, _Timedelta):
ots = other
Expand All @@ -1018,7 +1025,6 @@ cdef class _Timedelta(timedelta):
return op == Py_NE

elif util.is_array(other):
# TODO: watch out for zero-dim
if other.dtype.kind == "m":
return PyObject_RichCompare(self.asm8, other, op)
elif other.dtype.kind == "O":
Expand Down Expand Up @@ -1728,14 +1734,20 @@ class Timedelta(_Timedelta):
)

elif is_array(other):
# ndarray-like
if other.ndim == 0:
# see also: item_from_zerodim
item = cnp.PyArray_ToScalar(cnp.PyArray_DATA(other), other)
return self.__mul__(item)
return other * self.to_timedelta64()

return NotImplemented

__rmul__ = __mul__

def __truediv__(self, other):
cdef:
int64_t new_value

if _should_cast_to_timedelta(other):
# We interpret NaT as timedelta64("NaT")
other = Timedelta(other)
Expand All @@ -1758,6 +1770,10 @@ class Timedelta(_Timedelta):
)

elif is_array(other):
if other.ndim == 0:
# see also: item_from_zerodim
item = cnp.PyArray_ToScalar(cnp.PyArray_DATA(other), other)
return self.__truediv__(item)
return self.to_timedelta64() / other

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

elif is_array(other):
if other.dtype.kind == "O":
if other.ndim == 0:
# see also: item_from_zerodim
item = cnp.PyArray_ToScalar(cnp.PyArray_DATA(other), other)
return self.__rtruediv__(item)
elif other.dtype.kind == "O":
# GH#31869
return np.array([x / self for x in other])

# TODO: if other.dtype.kind == "m" and other.dtype != self.asm8.dtype
# then should disallow for consistency with scalar behavior; requires
# deprecation cycle. (or changing scalar behavior)
return other / self.to_timedelta64()

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

elif is_array(other):
if other.ndim == 0:
# see also: item_from_zerodim
item = cnp.PyArray_ToScalar(cnp.PyArray_DATA(other), other)
return self.__floordiv__(item)

if other.dtype.kind == 'm':
# also timedelta-like
if self._reso != NPY_FR_ns:
Expand Down Expand Up @@ -1838,6 +1867,11 @@ class Timedelta(_Timedelta):
return other.value // self.value

elif is_array(other):
if other.ndim == 0:
# see also: item_from_zerodim
item = cnp.PyArray_ToScalar(cnp.PyArray_DATA(other), other)
return self.__rfloordiv__(item)

if other.dtype.kind == 'm':
# also timedelta-like
if self._reso != NPY_FR_ns:
Expand Down Expand Up @@ -1923,23 +1957,17 @@ cdef _broadcast_floordiv_td64(
result : varies based on `other`
"""
# assumes other.dtype.kind == 'm', i.e. other is timedelta-like
# assumes other.ndim != 0

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

if other.ndim == 0:
if mask:
return np.nan

return operation(value, other.astype('m8[ns]', copy=False).astype('i8'))

else:
res = operation(value, other.astype('m8[ns]', copy=False).astype('i8'))
res = operation(value, other.astype('m8[ns]', copy=False).astype('i8'))

if mask.any():
res = res.astype('f8')
res[mask] = np.nan
return res
if mask.any():
res = res.astype('f8')
res[mask] = np.nan
return res


# resolution in ns
Expand Down
5 changes: 5 additions & 0 deletions pandas/_libs/tslibs/timestamps.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,11 @@ cdef class _Timestamp(ABCTimestamp):
if value == NPY_NAT:
return NaT

if reso < NPY_DATETIMEUNIT.NPY_FR_s or reso > NPY_DATETIMEUNIT.NPY_FR_ns:
raise NotImplementedError(
"Only resolutions 's', 'ms', 'us', 'ns' are supported."
)

obj.value = value
pandas_datetime_to_datetimestruct(value, reso, &obj.dts)
maybe_localize_tso(obj, tz, reso)
Expand Down
12 changes: 12 additions & 0 deletions pandas/tests/indexes/timedeltas/test_indexing.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
TimedeltaIndex,
Timestamp,
notna,
offsets,
timedelta_range,
to_timedelta,
)
Expand Down Expand Up @@ -346,3 +347,14 @@ def test_contains_nonunique(self):
):
idx = TimedeltaIndex(vals)
assert idx[0] in idx

def test_contains(self):
# Checking for any NaT-like objects
# GH#13603
td = to_timedelta(range(5), unit="d") + offsets.Hour(1)
for v in [NaT, None, float("nan"), np.nan]:
assert not (v in td)

td = to_timedelta([NaT])
for v in [NaT, None, float("nan"), np.nan]:
assert v in td
49 changes: 49 additions & 0 deletions pandas/tests/scalar/timedelta/test_arithmetic.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,26 @@ def test_td_add_sub_dt64_ndarray(self):
tm.assert_numpy_array_equal(-td + other, expected)
tm.assert_numpy_array_equal(other - td, expected)

def test_td_add_sub_ndarray_0d(self):
td = Timedelta("1 day")
other = np.array(td.asm8)

result = td + other
assert isinstance(result, Timedelta)
assert result == 2 * td

result = other + td
assert isinstance(result, Timedelta)
assert result == 2 * td

result = other - td
assert isinstance(result, Timedelta)
assert result == 0 * td

result = td - other
assert isinstance(result, Timedelta)
assert result == 0 * td


class TestTimedeltaMultiplicationDivision:
"""
Expand Down Expand Up @@ -395,6 +415,20 @@ def test_td_mul_numeric_ndarray(self):
result = other * td
tm.assert_numpy_array_equal(result, expected)

def test_td_mul_numeric_ndarray_0d(self):
td = Timedelta("1 day")
other = np.array(2)
assert other.ndim == 0
expected = Timedelta("2 days")

res = td * other
assert type(res) is Timedelta
assert res == expected

res = other * td
assert type(res) is Timedelta
assert res == expected

def test_td_mul_td64_ndarray_invalid(self):
td = Timedelta("1 day")
other = np.array([Timedelta("2 Days").to_timedelta64()])
Expand Down Expand Up @@ -484,6 +518,14 @@ def test_td_div_td64_ndarray(self):
result = other / td
tm.assert_numpy_array_equal(result, expected * 4)

def test_td_div_ndarray_0d(self):
td = Timedelta("1 day")

other = np.array(1)
res = td / other
assert isinstance(res, Timedelta)
assert res == td

# ---------------------------------------------------------------
# Timedelta.__rdiv__

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

def test_td_rdiv_ndarray_0d(self):
td = Timedelta(10, unit="d")

arr = np.array(td.asm8)

assert arr / td == 1

# ---------------------------------------------------------------
# Timedelta.__floordiv__

Expand Down
19 changes: 4 additions & 15 deletions pandas/tests/scalar/timedelta/test_timedelta.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,15 +84,15 @@ def test_as_unit_rounding(self):

def test_as_unit_non_nano(self):
# case where we are going neither to nor from nano
td = Timedelta(days=1)._as_unit("D")
td = Timedelta(days=1)._as_unit("ms")
assert td.days == 1
assert td.value == 1
assert td.value == 86_400_000
assert td.components.days == 1
assert td._d == 1
assert td.total_seconds() == 86400

res = td._as_unit("h")
assert res.value == 24
res = td._as_unit("us")
assert res.value == 86_400_000_000
assert res.components.days == 1
assert res.components.hours == 0
assert res._d == 1
Expand Down Expand Up @@ -677,17 +677,6 @@ def test_round_non_nano(self, unit):
assert res == Timedelta("1 days 02:35:00")
assert res._reso == td._reso

def test_contains(self):
# Checking for any NaT-like objects
# GH 13603
td = to_timedelta(range(5), unit="d") + offsets.Hour(1)
for v in [NaT, None, float("nan"), np.nan]:
assert not (v in td)

td = to_timedelta([NaT])
for v in [NaT, None, float("nan"), np.nan]:
assert v in td

def test_identity(self):

td = Timedelta(10, unit="d")
Expand Down
3 changes: 2 additions & 1 deletion pandas/tests/tslibs/test_timedeltas.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,5 +127,6 @@ def test_ints_to_pytimedelta_unsupported(unit):

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