Skip to content

ENH: support Timedelta division with mismatched resos #48961

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 1 commit into from
Oct 6, 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
1 change: 1 addition & 0 deletions pandas/_libs/tslibs/timedeltas.pxd
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ cdef class _Timedelta(timedelta):
cdef _ensure_components(_Timedelta self)
cdef inline bint _compare_mismatched_resos(self, _Timedelta other, op)
cdef _Timedelta _as_reso(self, NPY_DATETIMEUNIT reso, bint round_ok=*)
cpdef _maybe_cast_to_matching_resos(self, _Timedelta other)
53 changes: 25 additions & 28 deletions pandas/_libs/tslibs/timedeltas.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -1530,12 +1530,7 @@ cdef class _Timedelta(timedelta):
def _as_unit(self, str unit, bint round_ok=True):
dtype = np.dtype(f"m8[{unit}]")
reso = get_unit_from_dtype(dtype)
try:
return self._as_reso(reso, round_ok=round_ok)
except OverflowError as err:
raise OutOfBoundsTimedelta(
f"Cannot cast {self} to unit='{unit}' without overflow."
) from err
return self._as_reso(reso, round_ok=round_ok)

@cython.cdivision(False)
cdef _Timedelta _as_reso(self, NPY_DATETIMEUNIT reso, bint round_ok=True):
Expand All @@ -1545,9 +1540,26 @@ cdef class _Timedelta(timedelta):
if reso == self._reso:
return self

value = convert_reso(self.value, self._reso, reso, round_ok=round_ok)
try:
value = convert_reso(self.value, self._reso, reso, round_ok=round_ok)
except OverflowError as err:
unit = npy_unit_to_abbrev(reso)
raise OutOfBoundsTimedelta(
f"Cannot cast {self} to unit='{unit}' without overflow."
) from err

return type(self)._from_value_and_reso(value, reso=reso)

cpdef _maybe_cast_to_matching_resos(self, _Timedelta other):
"""
If _resos do not match, cast to the higher resolution, raising on overflow.
"""
if self._reso > other._reso:
other = other._as_reso(self._reso)
elif self._reso < other._reso:
self = self._as_reso(other._reso)
return self, other


# Python front end to C extension type _Timedelta
# This serves as the box for timedelta64
Expand Down Expand Up @@ -1827,11 +1839,7 @@ class Timedelta(_Timedelta):
if other is NaT:
return np.nan
if other._reso != self._reso:
raise ValueError(
"division between Timedeltas with mismatched resolutions "
"are not supported. Explicitly cast to matching resolutions "
"before dividing."
)
self, other = self._maybe_cast_to_matching_resos(other)
return self.value / float(other.value)

elif is_integer_object(other) or is_float_object(other):
Expand All @@ -1858,11 +1866,7 @@ class Timedelta(_Timedelta):
if other is NaT:
return np.nan
if self._reso != other._reso:
raise ValueError(
"division between Timedeltas with mismatched resolutions "
"are not supported. Explicitly cast to matching resolutions "
"before dividing."
)
self, other = self._maybe_cast_to_matching_resos(other)
return float(other.value) / self.value

elif is_array(other):
Expand Down Expand Up @@ -1890,11 +1894,7 @@ class Timedelta(_Timedelta):
if other is NaT:
return np.nan
if self._reso != other._reso:
raise ValueError(
"floordivision between Timedeltas with mismatched resolutions "
"are not supported. Explicitly cast to matching resolutions "
"before dividing."
)
self, other = self._maybe_cast_to_matching_resos(other)
return self.value // other.value

elif is_integer_object(other) or is_float_object(other):
Expand All @@ -1913,6 +1913,7 @@ class Timedelta(_Timedelta):
if self._reso != NPY_FR_ns:
raise NotImplementedError
return _broadcast_floordiv_td64(self.value, other, _floordiv)

elif other.dtype.kind in ['i', 'u', 'f']:
if other.ndim == 0:
return self // other.item()
Expand All @@ -1932,11 +1933,7 @@ class Timedelta(_Timedelta):
if other is NaT:
return np.nan
if self._reso != other._reso:
raise ValueError(
"floordivision between Timedeltas with mismatched resolutions "
"are not supported. Explicitly cast to matching resolutions "
"before dividing."
)
self, other = self._maybe_cast_to_matching_resos(other)
return other.value // self.value

elif is_array(other):
Expand Down Expand Up @@ -2022,7 +2019,7 @@ cdef _broadcast_floordiv_td64(
Parameters
----------
value : int64_t; `self.value` from a Timedelta object
other : object
other : ndarray[timedelta64[ns]]
operation : function, either _floordiv or _rfloordiv

Returns
Expand Down
38 changes: 27 additions & 11 deletions pandas/tests/scalar/timedelta/test_timedelta.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,14 +183,23 @@ def test_truediv_timedeltalike(self, td):
assert (2.5 * td) / td == 2.5

other = Timedelta(td.value)
msg = "with mismatched resolutions are not supported"
with pytest.raises(ValueError, match=msg):
msg = "Cannot cast 106752 days 00:00:00 to unit='ns' without overflow"
with pytest.raises(OutOfBoundsTimedelta, match=msg):
td / other

with pytest.raises(ValueError, match=msg):
with pytest.raises(OutOfBoundsTimedelta, match=msg):
# __rtruediv__
other.to_pytimedelta() / td

# if there's no overflow, we cast to the higher reso
left = Timedelta._from_value_and_reso(50, NpyDatetimeUnit.NPY_FR_us.value)
right = Timedelta._from_value_and_reso(50, NpyDatetimeUnit.NPY_FR_ms.value)
result = left / right
assert result == 0.001

result = right / left
assert result == 1000

def test_truediv_numeric(self, td):
assert td / np.nan is NaT

Expand All @@ -207,14 +216,22 @@ def test_floordiv_timedeltalike(self, td):
assert (2.5 * td) // td == 2

other = Timedelta(td.value)
msg = "with mismatched resolutions are not supported"
with pytest.raises(ValueError, match=msg):
msg = "Cannot cast 106752 days 00:00:00 to unit='ns' without overflow"
with pytest.raises(OutOfBoundsTimedelta, match=msg):
td // other

with pytest.raises(ValueError, match=msg):
# __rfloordiv__
other.to_pytimedelta() // td

# if there's no overflow, we cast to the higher reso
left = Timedelta._from_value_and_reso(50050, NpyDatetimeUnit.NPY_FR_us.value)
right = Timedelta._from_value_and_reso(50, NpyDatetimeUnit.NPY_FR_ms.value)
result = left // right
assert result == 1
result = right // left
assert result == 0

def test_floordiv_numeric(self, td):
assert td // np.nan is NaT

Expand Down Expand Up @@ -259,15 +276,14 @@ def test_addsub_mismatched_reso(self, td):
assert result.days == 1 - td.days

other2 = Timedelta(500)
# TODO: should be OutOfBoundsTimedelta
msg = "value too large"
with pytest.raises(OverflowError, match=msg):
msg = "Cannot cast 106752 days 00:00:00 to unit='ns' without overflow"
with pytest.raises(OutOfBoundsTimedelta, match=msg):
td + other2
with pytest.raises(OverflowError, match=msg):
with pytest.raises(OutOfBoundsTimedelta, match=msg):
other2 + td
with pytest.raises(OverflowError, match=msg):
with pytest.raises(OutOfBoundsTimedelta, match=msg):
td - other2
with pytest.raises(OverflowError, match=msg):
with pytest.raises(OutOfBoundsTimedelta, match=msg):
other2 - td

def test_min(self, td):
Expand Down