Skip to content

Commit 1b79e2d

Browse files
authored
ENH: consistent add/sub behavior for mixed resolutions (#47394)
* ENH: consistent add/sub behavior for mixed resolutions * test with td64
1 parent 2c947e0 commit 1b79e2d

File tree

4 files changed

+124
-7
lines changed

4 files changed

+124
-7
lines changed

pandas/_libs/tslibs/timedeltas.pyx

+13-2
Original file line numberDiff line numberDiff line change
@@ -791,8 +791,19 @@ def _binary_op_method_timedeltalike(op, name):
791791
# e.g. if original other was timedelta64('NaT')
792792
return NaT
793793

794-
if self._reso != other._reso:
795-
raise NotImplementedError
794+
# We allow silent casting to the lower resolution if and only
795+
# if it is lossless.
796+
try:
797+
if self._reso < other._reso:
798+
other = (<_Timedelta>other)._as_reso(self._reso, round_ok=False)
799+
elif self._reso > other._reso:
800+
self = (<_Timedelta>self)._as_reso(other._reso, round_ok=False)
801+
except ValueError as err:
802+
raise ValueError(
803+
"Timedelta addition/subtraction with mismatched resolutions is not "
804+
"allowed when casting to the lower resolution would require "
805+
"lossy rounding."
806+
) from err
796807

797808
res = op(self.value, other.value)
798809
if res == NPY_NAT:

pandas/_libs/tslibs/timestamps.pyx

+31-5
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ from pandas._libs.tslibs.offsets cimport (
103103
to_offset,
104104
)
105105
from pandas._libs.tslibs.timedeltas cimport (
106+
_Timedelta,
106107
delta_to_nanoseconds,
107108
ensure_td64ns,
108109
is_any_td_scalar,
@@ -384,11 +385,36 @@ cdef class _Timestamp(ABCTimestamp):
384385
# TODO: no tests get here
385386
other = ensure_td64ns(other)
386387

387-
# TODO: what to do with mismatched resos?
388-
# TODO: disallow round_ok
389-
nanos = delta_to_nanoseconds(
390-
other, reso=self._reso, round_ok=True
391-
)
388+
if isinstance(other, _Timedelta):
389+
# TODO: share this with __sub__, Timedelta.__add__
390+
# We allow silent casting to the lower resolution if and only
391+
# if it is lossless. See also Timestamp.__sub__
392+
# and Timedelta.__add__
393+
try:
394+
if self._reso < other._reso:
395+
other = (<_Timedelta>other)._as_reso(self._reso, round_ok=False)
396+
elif self._reso > other._reso:
397+
self = (<_Timestamp>self)._as_reso(other._reso, round_ok=False)
398+
except ValueError as err:
399+
raise ValueError(
400+
"Timestamp addition with mismatched resolutions is not "
401+
"allowed when casting to the lower resolution would require "
402+
"lossy rounding."
403+
) from err
404+
405+
try:
406+
nanos = delta_to_nanoseconds(
407+
other, reso=self._reso, round_ok=False
408+
)
409+
except OutOfBoundsTimedelta:
410+
raise
411+
except ValueError as err:
412+
raise ValueError(
413+
"Addition between Timestamp and Timedelta with mismatched "
414+
"resolutions is not allowed when casting to the lower "
415+
"resolution would require lossy rounding."
416+
) from err
417+
392418
try:
393419
new_value = self.value + nanos
394420
except OverflowError:

pandas/tests/scalar/timedelta/test_timedelta.py

+35
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,41 @@ def test_floordiv_numeric(self, td):
231231
assert res.value == td.value // 2
232232
assert res._reso == td._reso
233233

234+
def test_addsub_mismatched_reso(self, td):
235+
other = Timedelta(days=1) # can losslessly convert to other resos
236+
237+
result = td + other
238+
assert result._reso == td._reso
239+
assert result.days == td.days + 1
240+
241+
result = other + td
242+
assert result._reso == td._reso
243+
assert result.days == td.days + 1
244+
245+
result = td - other
246+
assert result._reso == td._reso
247+
assert result.days == td.days - 1
248+
249+
result = other - td
250+
assert result._reso == td._reso
251+
assert result.days == 1 - td.days
252+
253+
other2 = Timedelta(500) # can't cast losslessly
254+
255+
msg = (
256+
"Timedelta addition/subtraction with mismatched resolutions is "
257+
"not allowed when casting to the lower resolution would require "
258+
"lossy rounding"
259+
)
260+
with pytest.raises(ValueError, match=msg):
261+
td + other2
262+
with pytest.raises(ValueError, match=msg):
263+
other2 + td
264+
with pytest.raises(ValueError, match=msg):
265+
td - other2
266+
with pytest.raises(ValueError, match=msg):
267+
other2 - td
268+
234269

235270
class TestTimedeltaUnaryOps:
236271
def test_invert(self):

pandas/tests/scalar/timestamp/test_timestamp.py

+45
Original file line numberDiff line numberDiff line change
@@ -966,6 +966,51 @@ def test_sub_datetimelike_mismatched_reso(self, ts_tz):
966966
with pytest.raises(ValueError, match=msg):
967967
other - ts2
968968

969+
def test_sub_timedeltalike_mismatched_reso(self, ts_tz):
970+
# case with non-lossy rounding
971+
ts = ts_tz
972+
973+
# choose a unit for `other` that doesn't match ts_tz's;
974+
# this construction ensures we get cases with other._reso < ts._reso
975+
# and cases with other._reso > ts._reso
976+
unit = {
977+
NpyDatetimeUnit.NPY_FR_us.value: "ms",
978+
NpyDatetimeUnit.NPY_FR_ms.value: "s",
979+
NpyDatetimeUnit.NPY_FR_s.value: "us",
980+
}[ts._reso]
981+
other = Timedelta(0)._as_unit(unit)
982+
assert other._reso != ts._reso
983+
984+
result = ts + other
985+
assert isinstance(result, Timestamp)
986+
assert result == ts
987+
assert result._reso == min(ts._reso, other._reso)
988+
989+
result = other + ts
990+
assert isinstance(result, Timestamp)
991+
assert result == ts
992+
assert result._reso == min(ts._reso, other._reso)
993+
994+
msg = "Timestamp addition with mismatched resolutions"
995+
if ts._reso < other._reso:
996+
# Case where rounding is lossy
997+
other2 = other + Timedelta._from_value_and_reso(1, other._reso)
998+
with pytest.raises(ValueError, match=msg):
999+
ts + other2
1000+
with pytest.raises(ValueError, match=msg):
1001+
other2 + ts
1002+
else:
1003+
ts2 = ts + Timedelta._from_value_and_reso(1, ts._reso)
1004+
with pytest.raises(ValueError, match=msg):
1005+
ts2 + other
1006+
with pytest.raises(ValueError, match=msg):
1007+
other + ts2
1008+
1009+
msg = "Addition between Timestamp and Timedelta with mismatched resolutions"
1010+
with pytest.raises(ValueError, match=msg):
1011+
# With a mismatched td64 as opposed to Timedelta
1012+
ts + np.timedelta64(1, "ns")
1013+
9691014

9701015
class TestAsUnit:
9711016
def test_as_unit(self):

0 commit comments

Comments
 (0)