diff --git a/pandas/core/arrays/period.py b/pandas/core/arrays/period.py index 2d676f94c6a64..12352c4490f29 100644 --- a/pandas/core/arrays/period.py +++ b/pandas/core/arrays/period.py @@ -14,7 +14,10 @@ import numpy as np -from pandas._libs import algos as libalgos +from pandas._libs import ( + algos as libalgos, + lib, +) from pandas._libs.arrays import NDArrayBacked from pandas._libs.tslibs import ( BaseOffset, @@ -22,7 +25,6 @@ NaTType, Timedelta, astype_overflowsafe, - delta_to_nanoseconds, dt64arr_to_periodarr as c_dt64arr_to_periodarr, get_unit_from_dtype, iNaT, @@ -55,7 +57,6 @@ ) from pandas.core.dtypes.common import ( - TD64NS_DTYPE, ensure_object, is_datetime64_any_dtype, is_datetime64_dtype, @@ -72,7 +73,7 @@ ABCSeries, ABCTimedeltaArray, ) -from pandas.core.dtypes.missing import notna +from pandas.core.dtypes.missing import isna import pandas.core.algorithms as algos from pandas.core.arrays import datetimelike as dtl @@ -764,22 +765,16 @@ def _add_timedeltalike_scalar(self, other): # We cannot add timedelta-like to non-tick PeriodArray raise raise_on_incompatible(self, other) - if notna(other): - # Convert to an integer increment of our own freq, disallowing - # e.g. 30seconds if our freq is minutes. - try: - inc = delta_to_nanoseconds(other, reso=self.freq._reso, round_ok=False) - except ValueError as err: - # "Cannot losslessly convert units" - raise raise_on_incompatible(self, other) from err - - return self._addsub_int_array_or_scalar(inc, operator.add) + if isna(other): + # i.e. np.timedelta64("NaT") + return super()._add_timedeltalike_scalar(other) - return super()._add_timedeltalike_scalar(other) + td = np.asarray(Timedelta(other).asm8) + return self._add_timedelta_arraylike(td) def _add_timedelta_arraylike( self, other: TimedeltaArray | npt.NDArray[np.timedelta64] - ): + ) -> PeriodArray: """ Parameters ---------- @@ -787,7 +782,7 @@ def _add_timedelta_arraylike( Returns ------- - result : ndarray[int64] + PeriodArray """ freq = self.freq if not isinstance(freq, Tick): @@ -803,8 +798,12 @@ def _add_timedelta_arraylike( np.asarray(other), dtype=dtype, copy=False, round_ok=False ) except ValueError as err: - # TODO: not actually a great exception message in this case - raise raise_on_incompatible(self, other) from err + # e.g. if we have minutes freq and try to add 30s + # "Cannot losslessly convert units" + raise IncompatibleFrequency( + "Cannot add/subtract timedelta-like from PeriodArray that is " + "not an integer multiple of the PeriodArray's freq." + ) from err b_mask = np.isnat(delta) @@ -835,31 +834,21 @@ def _check_timedeltalike_freq_compat(self, other): IncompatibleFrequency """ assert isinstance(self.freq, Tick) # checked by calling function - base_nanos = self.freq.base.nanos + + dtype = np.dtype(f"m8[{self.freq._td64_unit}]") if isinstance(other, (timedelta, np.timedelta64, Tick)): - nanos = delta_to_nanoseconds(other) - - elif isinstance(other, np.ndarray): - # numpy timedelta64 array; all entries must be compatible - assert other.dtype.kind == "m" - other = astype_overflowsafe(other, TD64NS_DTYPE, copy=False) - # error: Incompatible types in assignment (expression has type - # "ndarray[Any, dtype[Any]]", variable has type "int") - nanos = other.view("i8") # type: ignore[assignment] + td = np.asarray(Timedelta(other).asm8) else: - # TimedeltaArray/Index - nanos = other.asi8 - - if np.all(nanos % base_nanos == 0): - # nanos being added is an integer multiple of the - # base-frequency to self.freq - delta = nanos // base_nanos - # delta is the integer (or integer-array) number of periods - # by which will be added to self. - return delta - - raise raise_on_incompatible(self, other) + td = np.asarray(other) + + try: + delta = astype_overflowsafe(td, dtype=dtype, copy=False, round_ok=False) + except ValueError as err: + raise raise_on_incompatible(self, other) from err + + delta = delta.view("i8") + return lib.item_from_zerodim(delta) def raise_on_incompatible(left, right): diff --git a/pandas/tests/arithmetic/test_period.py b/pandas/tests/arithmetic/test_period.py index 50f5ab8aee9dd..b03ac26a4b74d 100644 --- a/pandas/tests/arithmetic/test_period.py +++ b/pandas/tests/arithmetic/test_period.py @@ -807,9 +807,13 @@ def test_parr_sub_td64array(self, box_with_array, tdi_freq, pi_freq): elif pi_freq == "D": # Tick, but non-compatible - msg = "Input has different freq=None from PeriodArray" + msg = ( + "Cannot add/subtract timedelta-like from PeriodArray that is " + "not an integer multiple of the PeriodArray's freq." + ) with pytest.raises(IncompatibleFrequency, match=msg): pi - td64obj + with pytest.raises(IncompatibleFrequency, match=msg): pi[0] - td64obj @@ -1107,7 +1111,15 @@ def test_parr_add_sub_timedeltalike_freq_mismatch_daily( rng = period_range("2014-05-01", "2014-05-15", freq="D") rng = tm.box_expected(rng, box_with_array) - msg = "Input has different freq(=.+)? from Period.*?\\(freq=D\\)" + msg = "|".join( + [ + # non-timedelta-like DateOffset + "Input has different freq(=.+)? from Period.*?\\(freq=D\\)", + # timedelta/td64/Timedelta but not a multiple of 24H + "Cannot add/subtract timedelta-like from PeriodArray that is " + "not an integer multiple of the PeriodArray's freq.", + ] + ) with pytest.raises(IncompatibleFrequency, match=msg): rng + other with pytest.raises(IncompatibleFrequency, match=msg): @@ -1134,7 +1146,15 @@ def test_parr_add_timedeltalike_mismatched_freq_hourly( other = not_hourly rng = period_range("2014-01-01 10:00", "2014-01-05 10:00", freq="H") rng = tm.box_expected(rng, box_with_array) - msg = "Input has different freq(=.+)? from Period.*?\\(freq=H\\)" + msg = "|".join( + [ + # non-timedelta-like DateOffset + "Input has different freq(=.+)? from Period.*?\\(freq=H\\)", + # timedelta/td64/Timedelta but not a multiple of 24H + "Cannot add/subtract timedelta-like from PeriodArray that is " + "not an integer multiple of the PeriodArray's freq.", + ] + ) with pytest.raises(IncompatibleFrequency, match=msg): rng + other @@ -1508,17 +1528,17 @@ def test_pi_offset_errors(self): ) ser = Series(idx) - # Series op is applied per Period instance, thus error is raised - # from Period + msg = ( + "Cannot add/subtract timedelta-like from PeriodArray that is not " + "an integer multiple of the PeriodArray's freq" + ) for obj in [idx, ser]: - msg = r"Input has different freq=2H from Period.*?\(freq=D\)" with pytest.raises(IncompatibleFrequency, match=msg): obj + pd.offsets.Hour(2) with pytest.raises(IncompatibleFrequency, match=msg): pd.offsets.Hour(2) + obj - msg = r"Input has different freq=-2H from Period.*?\(freq=D\)" with pytest.raises(IncompatibleFrequency, match=msg): obj - pd.offsets.Hour(2)