Skip to content

Commit bedad5f

Browse files
jbrockmendelnoatamir
authored andcommitted
API: Change Timestamp/Timedelta arithmetic to match numpy (pandas-dev#48743)
* API: Change Timestamp/Timedelta arithmetic to match numpy * fix interval test
1 parent a610fbf commit bedad5f

File tree

4 files changed

+101
-92
lines changed

4 files changed

+101
-92
lines changed

pandas/_libs/tslibs/timedeltas.pyx

+6-13
Original file line numberDiff line numberDiff line change
@@ -787,19 +787,12 @@ def _binary_op_method_timedeltalike(op, name):
787787
# e.g. if original other was timedelta64('NaT')
788788
return NaT
789789

790-
# We allow silent casting to the lower resolution if and only
791-
# if it is lossless.
792-
try:
793-
if self._reso < other._reso:
794-
other = (<_Timedelta>other)._as_reso(self._reso, round_ok=False)
795-
elif self._reso > other._reso:
796-
self = (<_Timedelta>self)._as_reso(other._reso, round_ok=False)
797-
except ValueError as err:
798-
raise ValueError(
799-
"Timedelta addition/subtraction with mismatched resolutions is not "
800-
"allowed when casting to the lower resolution would require "
801-
"lossy rounding."
802-
) from err
790+
# Matching numpy, we cast to the higher resolution. Unlike numpy,
791+
# we raise instead of silently overflowing during this casting.
792+
if self._reso < other._reso:
793+
self = (<_Timedelta>self)._as_reso(other._reso, round_ok=True)
794+
elif self._reso > other._reso:
795+
other = (<_Timedelta>other)._as_reso(self._reso, round_ok=True)
803796

804797
res = op(self.value, other.value)
805798
if res == NPY_NAT:

pandas/_libs/tslibs/timestamps.pyx

+23-33
Original file line numberDiff line numberDiff line change
@@ -433,43 +433,40 @@ cdef class _Timestamp(ABCTimestamp):
433433
# TODO: deprecate allowing this? We only get here
434434
# with test_timedelta_add_timestamp_interval
435435
other = np.timedelta64(other.view("i8"), "ns")
436+
other_reso = NPY_DATETIMEUNIT.NPY_FR_ns
436437
elif (
437438
other_reso == NPY_DATETIMEUNIT.NPY_FR_Y or other_reso == NPY_DATETIMEUNIT.NPY_FR_M
438439
):
439440
# TODO: deprecate allowing these? or handle more like the
440441
# corresponding DateOffsets?
441442
# TODO: no tests get here
442443
other = ensure_td64ns(other)
444+
other_reso = NPY_DATETIMEUNIT.NPY_FR_ns
445+
446+
if other_reso > NPY_DATETIMEUNIT.NPY_FR_ns:
447+
# TODO: no tests
448+
other = ensure_td64ns(other)
449+
if other_reso > self._reso:
450+
# Following numpy, we cast to the higher resolution
451+
# test_sub_timedelta64_mismatched_reso
452+
self = (<_Timestamp>self)._as_reso(other_reso)
453+
443454

444455
if isinstance(other, _Timedelta):
445456
# TODO: share this with __sub__, Timedelta.__add__
446-
# We allow silent casting to the lower resolution if and only
447-
# if it is lossless. See also Timestamp.__sub__
448-
# and Timedelta.__add__
449-
try:
450-
if self._reso < other._reso:
451-
other = (<_Timedelta>other)._as_reso(self._reso, round_ok=False)
452-
elif self._reso > other._reso:
453-
self = (<_Timestamp>self)._as_reso(other._reso, round_ok=False)
454-
except ValueError as err:
455-
raise ValueError(
456-
"Timestamp addition with mismatched resolutions is not "
457-
"allowed when casting to the lower resolution would require "
458-
"lossy rounding."
459-
) from err
457+
# Matching numpy, we cast to the higher resolution. Unlike numpy,
458+
# we raise instead of silently overflowing during this casting.
459+
if self._reso < other._reso:
460+
self = (<_Timestamp>self)._as_reso(other._reso, round_ok=True)
461+
elif self._reso > other._reso:
462+
other = (<_Timedelta>other)._as_reso(self._reso, round_ok=True)
460463

461464
try:
462465
nanos = delta_to_nanoseconds(
463466
other, reso=self._reso, round_ok=False
464467
)
465468
except OutOfBoundsTimedelta:
466469
raise
467-
except ValueError as err:
468-
raise ValueError(
469-
"Addition between Timestamp and Timedelta with mismatched "
470-
"resolutions is not allowed when casting to the lower "
471-
"resolution would require lossy rounding."
472-
) from err
473470

474471
try:
475472
new_value = self.value + nanos
@@ -556,19 +553,12 @@ cdef class _Timestamp(ABCTimestamp):
556553
"Cannot subtract tz-naive and tz-aware datetime-like objects."
557554
)
558555

559-
# We allow silent casting to the lower resolution if and only
560-
# if it is lossless.
561-
try:
562-
if self._reso < other._reso:
563-
other = (<_Timestamp>other)._as_reso(self._reso, round_ok=False)
564-
elif self._reso > other._reso:
565-
self = (<_Timestamp>self)._as_reso(other._reso, round_ok=False)
566-
except ValueError as err:
567-
raise ValueError(
568-
"Timestamp subtraction with mismatched resolutions is not "
569-
"allowed when casting to the lower resolution would require "
570-
"lossy rounding."
571-
) from err
556+
# Matching numpy, we cast to the higher resolution. Unlike numpy,
557+
# we raise instead of silently overflowing during this casting.
558+
if self._reso < other._reso:
559+
self = (<_Timestamp>self)._as_reso(other._reso, round_ok=False)
560+
elif self._reso > other._reso:
561+
other = (<_Timestamp>other)._as_reso(self._reso, round_ok=False)
572562

573563
# scalar Timestamp/datetime - Timestamp/datetime -> yields a
574564
# Timedelta

pandas/tests/scalar/timedelta/test_timedelta.py

+15-16
Original file line numberDiff line numberDiff line change
@@ -237,38 +237,37 @@ def test_floordiv_numeric(self, td):
237237
assert res._reso == td._reso
238238

239239
def test_addsub_mismatched_reso(self, td):
240-
other = Timedelta(days=1) # can losslessly convert to other resos
240+
# need to cast to since td is out of bounds for ns, so
241+
# so we would raise OverflowError without casting
242+
other = Timedelta(days=1)._as_unit("us")
241243

244+
# td is out of bounds for ns
242245
result = td + other
243-
assert result._reso == td._reso
246+
assert result._reso == other._reso
244247
assert result.days == td.days + 1
245248

246249
result = other + td
247-
assert result._reso == td._reso
250+
assert result._reso == other._reso
248251
assert result.days == td.days + 1
249252

250253
result = td - other
251-
assert result._reso == td._reso
254+
assert result._reso == other._reso
252255
assert result.days == td.days - 1
253256

254257
result = other - td
255-
assert result._reso == td._reso
258+
assert result._reso == other._reso
256259
assert result.days == 1 - td.days
257260

258-
other2 = Timedelta(500) # can't cast losslessly
259-
260-
msg = (
261-
"Timedelta addition/subtraction with mismatched resolutions is "
262-
"not allowed when casting to the lower resolution would require "
263-
"lossy rounding"
264-
)
265-
with pytest.raises(ValueError, match=msg):
261+
other2 = Timedelta(500)
262+
# TODO: should be OutOfBoundsTimedelta
263+
msg = "value too large"
264+
with pytest.raises(OverflowError, match=msg):
266265
td + other2
267-
with pytest.raises(ValueError, match=msg):
266+
with pytest.raises(OverflowError, match=msg):
268267
other2 + td
269-
with pytest.raises(ValueError, match=msg):
268+
with pytest.raises(OverflowError, match=msg):
270269
td - other2
271-
with pytest.raises(ValueError, match=msg):
270+
with pytest.raises(OverflowError, match=msg):
272271
other2 - td
273272

274273
def test_min(self, td):

pandas/tests/scalar/timestamp/test_timestamp.py

+57-30
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@
1818
utc,
1919
)
2020

21-
from pandas._libs.tslibs.dtypes import NpyDatetimeUnit
21+
from pandas._libs.tslibs.dtypes import (
22+
NpyDatetimeUnit,
23+
npy_unit_to_abbrev,
24+
)
2225
from pandas._libs.tslibs.timezones import (
2326
dateutil_gettz as gettz,
2427
get_timezone,
@@ -884,22 +887,29 @@ def test_to_period(self, dt64, ts):
884887
)
885888
def test_addsub_timedeltalike_non_nano(self, dt64, ts, td):
886889

890+
if isinstance(td, Timedelta):
891+
# td._reso is ns
892+
exp_reso = td._reso
893+
else:
894+
# effective td._reso is s
895+
exp_reso = ts._reso
896+
887897
result = ts - td
888898
expected = Timestamp(dt64) - td
889899
assert isinstance(result, Timestamp)
890-
assert result._reso == ts._reso
900+
assert result._reso == exp_reso
891901
assert result == expected
892902

893903
result = ts + td
894904
expected = Timestamp(dt64) + td
895905
assert isinstance(result, Timestamp)
896-
assert result._reso == ts._reso
906+
assert result._reso == exp_reso
897907
assert result == expected
898908

899909
result = td + ts
900910
expected = td + Timestamp(dt64)
901911
assert isinstance(result, Timestamp)
902-
assert result._reso == ts._reso
912+
assert result._reso == exp_reso
903913
assert result == expected
904914

905915
def test_addsub_offset(self, ts_tz):
@@ -944,27 +954,35 @@ def test_sub_datetimelike_mismatched_reso(self, ts_tz):
944954
result = ts - other
945955
assert isinstance(result, Timedelta)
946956
assert result.value == 0
947-
assert result._reso == min(ts._reso, other._reso)
957+
assert result._reso == max(ts._reso, other._reso)
948958

949959
result = other - ts
950960
assert isinstance(result, Timedelta)
951961
assert result.value == 0
952-
assert result._reso == min(ts._reso, other._reso)
962+
assert result._reso == max(ts._reso, other._reso)
953963

954-
msg = "Timestamp subtraction with mismatched resolutions"
955964
if ts._reso < other._reso:
956965
# Case where rounding is lossy
957966
other2 = other + Timedelta._from_value_and_reso(1, other._reso)
958-
with pytest.raises(ValueError, match=msg):
959-
ts - other2
960-
with pytest.raises(ValueError, match=msg):
961-
other2 - ts
967+
exp = ts._as_unit(npy_unit_to_abbrev(other._reso)) - other2
968+
969+
res = ts - other2
970+
assert res == exp
971+
assert res._reso == max(ts._reso, other._reso)
972+
973+
res = other2 - ts
974+
assert res == -exp
975+
assert res._reso == max(ts._reso, other._reso)
962976
else:
963977
ts2 = ts + Timedelta._from_value_and_reso(1, ts._reso)
964-
with pytest.raises(ValueError, match=msg):
965-
ts2 - other
966-
with pytest.raises(ValueError, match=msg):
967-
other - ts2
978+
exp = ts2 - other._as_unit(npy_unit_to_abbrev(ts2._reso))
979+
980+
res = ts2 - other
981+
assert res == exp
982+
assert res._reso == max(ts._reso, other._reso)
983+
res = other - ts2
984+
assert res == -exp
985+
assert res._reso == max(ts._reso, other._reso)
968986

969987
def test_sub_timedeltalike_mismatched_reso(self, ts_tz):
970988
# case with non-lossy rounding
@@ -984,32 +1002,41 @@ def test_sub_timedeltalike_mismatched_reso(self, ts_tz):
9841002
result = ts + other
9851003
assert isinstance(result, Timestamp)
9861004
assert result == ts
987-
assert result._reso == min(ts._reso, other._reso)
1005+
assert result._reso == max(ts._reso, other._reso)
9881006

9891007
result = other + ts
9901008
assert isinstance(result, Timestamp)
9911009
assert result == ts
992-
assert result._reso == min(ts._reso, other._reso)
1010+
assert result._reso == max(ts._reso, other._reso)
9931011

994-
msg = "Timestamp addition with mismatched resolutions"
9951012
if ts._reso < other._reso:
9961013
# Case where rounding is lossy
9971014
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
1015+
exp = ts._as_unit(npy_unit_to_abbrev(other._reso)) + other2
1016+
res = ts + other2
1017+
assert res == exp
1018+
assert res._reso == max(ts._reso, other._reso)
1019+
res = other2 + ts
1020+
assert res == exp
1021+
assert res._reso == max(ts._reso, other._reso)
10021022
else:
10031023
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
1024+
exp = ts2 + other._as_unit(npy_unit_to_abbrev(ts2._reso))
10081025

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")
1026+
res = ts2 + other
1027+
assert res == exp
1028+
assert res._reso == max(ts._reso, other._reso)
1029+
res = other + ts2
1030+
assert res == exp
1031+
assert res._reso == max(ts._reso, other._reso)
1032+
1033+
def test_sub_timedelta64_mismatched_reso(self, ts_tz):
1034+
ts = ts_tz
1035+
1036+
res = ts + np.timedelta64(1, "ns")
1037+
exp = ts._as_unit("ns") + np.timedelta64(1, "ns")
1038+
assert exp == res
1039+
assert exp._reso == NpyDatetimeUnit.NPY_FR_ns.value
10131040

10141041
def test_min(self, ts):
10151042
assert ts.min <= ts

0 commit comments

Comments
 (0)