Skip to content

Commit b5d6ae3

Browse files
authored
ENH: support Timedelta division with mismatched resos (#48961)
1 parent 159a917 commit b5d6ae3

File tree

3 files changed

+53
-39
lines changed

3 files changed

+53
-39
lines changed

pandas/_libs/tslibs/timedeltas.pxd

+1
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,4 @@ cdef class _Timedelta(timedelta):
2525
cdef _ensure_components(_Timedelta self)
2626
cdef inline bint _compare_mismatched_resos(self, _Timedelta other, op)
2727
cdef _Timedelta _as_reso(self, NPY_DATETIMEUNIT reso, bint round_ok=*)
28+
cpdef _maybe_cast_to_matching_resos(self, _Timedelta other)

pandas/_libs/tslibs/timedeltas.pyx

+25-28
Original file line numberDiff line numberDiff line change
@@ -1537,12 +1537,7 @@ cdef class _Timedelta(timedelta):
15371537
def _as_unit(self, str unit, bint round_ok=True):
15381538
dtype = np.dtype(f"m8[{unit}]")
15391539
reso = get_unit_from_dtype(dtype)
1540-
try:
1541-
return self._as_reso(reso, round_ok=round_ok)
1542-
except OverflowError as err:
1543-
raise OutOfBoundsTimedelta(
1544-
f"Cannot cast {self} to unit='{unit}' without overflow."
1545-
) from err
1540+
return self._as_reso(reso, round_ok=round_ok)
15461541

15471542
@cython.cdivision(False)
15481543
cdef _Timedelta _as_reso(self, NPY_DATETIMEUNIT reso, bint round_ok=True):
@@ -1552,9 +1547,26 @@ cdef class _Timedelta(timedelta):
15521547
if reso == self._reso:
15531548
return self
15541549

1555-
value = convert_reso(self.value, self._reso, reso, round_ok=round_ok)
1550+
try:
1551+
value = convert_reso(self.value, self._reso, reso, round_ok=round_ok)
1552+
except OverflowError as err:
1553+
unit = npy_unit_to_abbrev(reso)
1554+
raise OutOfBoundsTimedelta(
1555+
f"Cannot cast {self} to unit='{unit}' without overflow."
1556+
) from err
1557+
15561558
return type(self)._from_value_and_reso(value, reso=reso)
15571559

1560+
cpdef _maybe_cast_to_matching_resos(self, _Timedelta other):
1561+
"""
1562+
If _resos do not match, cast to the higher resolution, raising on overflow.
1563+
"""
1564+
if self._reso > other._reso:
1565+
other = other._as_reso(self._reso)
1566+
elif self._reso < other._reso:
1567+
self = self._as_reso(other._reso)
1568+
return self, other
1569+
15581570

15591571
# Python front end to C extension type _Timedelta
15601572
# This serves as the box for timedelta64
@@ -1834,11 +1846,7 @@ class Timedelta(_Timedelta):
18341846
if other is NaT:
18351847
return np.nan
18361848
if other._reso != self._reso:
1837-
raise ValueError(
1838-
"division between Timedeltas with mismatched resolutions "
1839-
"are not supported. Explicitly cast to matching resolutions "
1840-
"before dividing."
1841-
)
1849+
self, other = self._maybe_cast_to_matching_resos(other)
18421850
return self.value / float(other.value)
18431851

18441852
elif is_integer_object(other) or is_float_object(other):
@@ -1865,11 +1873,7 @@ class Timedelta(_Timedelta):
18651873
if other is NaT:
18661874
return np.nan
18671875
if self._reso != other._reso:
1868-
raise ValueError(
1869-
"division between Timedeltas with mismatched resolutions "
1870-
"are not supported. Explicitly cast to matching resolutions "
1871-
"before dividing."
1872-
)
1876+
self, other = self._maybe_cast_to_matching_resos(other)
18731877
return float(other.value) / self.value
18741878

18751879
elif is_array(other):
@@ -1897,11 +1901,7 @@ class Timedelta(_Timedelta):
18971901
if other is NaT:
18981902
return np.nan
18991903
if self._reso != other._reso:
1900-
raise ValueError(
1901-
"floordivision between Timedeltas with mismatched resolutions "
1902-
"are not supported. Explicitly cast to matching resolutions "
1903-
"before dividing."
1904-
)
1904+
self, other = self._maybe_cast_to_matching_resos(other)
19051905
return self.value // other.value
19061906

19071907
elif is_integer_object(other) or is_float_object(other):
@@ -1920,6 +1920,7 @@ class Timedelta(_Timedelta):
19201920
if self._reso != NPY_FR_ns:
19211921
raise NotImplementedError
19221922
return _broadcast_floordiv_td64(self.value, other, _floordiv)
1923+
19231924
elif other.dtype.kind in ['i', 'u', 'f']:
19241925
if other.ndim == 0:
19251926
return self // other.item()
@@ -1939,11 +1940,7 @@ class Timedelta(_Timedelta):
19391940
if other is NaT:
19401941
return np.nan
19411942
if self._reso != other._reso:
1942-
raise ValueError(
1943-
"floordivision between Timedeltas with mismatched resolutions "
1944-
"are not supported. Explicitly cast to matching resolutions "
1945-
"before dividing."
1946-
)
1943+
self, other = self._maybe_cast_to_matching_resos(other)
19471944
return other.value // self.value
19481945

19491946
elif is_array(other):
@@ -2029,7 +2026,7 @@ cdef _broadcast_floordiv_td64(
20292026
Parameters
20302027
----------
20312028
value : int64_t; `self.value` from a Timedelta object
2032-
other : object
2029+
other : ndarray[timedelta64[ns]]
20332030
operation : function, either _floordiv or _rfloordiv
20342031
20352032
Returns

pandas/tests/scalar/timedelta/test_timedelta.py

+27-11
Original file line numberDiff line numberDiff line change
@@ -183,14 +183,23 @@ def test_truediv_timedeltalike(self, td):
183183
assert (2.5 * td) / td == 2.5
184184

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

190-
with pytest.raises(ValueError, match=msg):
190+
with pytest.raises(OutOfBoundsTimedelta, match=msg):
191191
# __rtruediv__
192192
other.to_pytimedelta() / td
193193

194+
# if there's no overflow, we cast to the higher reso
195+
left = Timedelta._from_value_and_reso(50, NpyDatetimeUnit.NPY_FR_us.value)
196+
right = Timedelta._from_value_and_reso(50, NpyDatetimeUnit.NPY_FR_ms.value)
197+
result = left / right
198+
assert result == 0.001
199+
200+
result = right / left
201+
assert result == 1000
202+
194203
def test_truediv_numeric(self, td):
195204
assert td / np.nan is NaT
196205

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

209218
other = Timedelta(td.value)
210-
msg = "with mismatched resolutions are not supported"
211-
with pytest.raises(ValueError, match=msg):
219+
msg = "Cannot cast 106752 days 00:00:00 to unit='ns' without overflow"
220+
with pytest.raises(OutOfBoundsTimedelta, match=msg):
212221
td // other
213222

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

227+
# if there's no overflow, we cast to the higher reso
228+
left = Timedelta._from_value_and_reso(50050, NpyDatetimeUnit.NPY_FR_us.value)
229+
right = Timedelta._from_value_and_reso(50, NpyDatetimeUnit.NPY_FR_ms.value)
230+
result = left // right
231+
assert result == 1
232+
result = right // left
233+
assert result == 0
234+
218235
def test_floordiv_numeric(self, td):
219236
assert td // np.nan is NaT
220237

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

261278
other2 = Timedelta(500)
262-
# TODO: should be OutOfBoundsTimedelta
263-
msg = "value too large"
264-
with pytest.raises(OverflowError, match=msg):
279+
msg = "Cannot cast 106752 days 00:00:00 to unit='ns' without overflow"
280+
with pytest.raises(OutOfBoundsTimedelta, match=msg):
265281
td + other2
266-
with pytest.raises(OverflowError, match=msg):
282+
with pytest.raises(OutOfBoundsTimedelta, match=msg):
267283
other2 + td
268-
with pytest.raises(OverflowError, match=msg):
284+
with pytest.raises(OutOfBoundsTimedelta, match=msg):
269285
td - other2
270-
with pytest.raises(OverflowError, match=msg):
286+
with pytest.raises(OutOfBoundsTimedelta, match=msg):
271287
other2 - td
272288

273289
def test_min(self, td):

0 commit comments

Comments
 (0)