Skip to content

BUG: Fix PeriodIndex +/- TimedeltaIndex #23031

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 14 commits into from
Oct 15, 2018
5 changes: 4 additions & 1 deletion pandas/core/arrays/datetimelike.py
Original file line number Diff line number Diff line change
Expand Up @@ -631,11 +631,14 @@ def __add__(self, other):
return self._add_datelike(other)
elif is_integer_dtype(other):
result = self._addsub_int_array(other, operator.add)
elif is_float_dtype(other) or is_period_dtype(other):
elif is_float_dtype(other):
# Explicitly catch invalid dtypes
raise TypeError("cannot add {dtype}-dtype to {cls}"
.format(dtype=other.dtype,
cls=type(self).__name__))
elif is_period_dtype(other):
# PeriodIndex + TimedeltaIndex _sometimes_ is valid
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you clarify?

return NotImplemented
elif is_extension_array_dtype(other):
# Categorical op will raise; defer explicitly
return NotImplemented
Expand Down
45 changes: 29 additions & 16 deletions pandas/core/arrays/period.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from pandas.util._decorators import cache_readonly

from pandas.core.dtypes.common import (
is_integer_dtype, is_float_dtype, is_period_dtype)
is_integer_dtype, is_float_dtype, is_period_dtype, is_timedelta64_dtype)
from pandas.core.dtypes.dtypes import PeriodDtype
from pandas.core.dtypes.generic import ABCSeries

Expand Down Expand Up @@ -316,8 +316,7 @@ def _add_delta_td(self, other):
freqstr=self.freqstr))

def _add_delta(self, other):
ordinal_delta = self._maybe_convert_timedelta(other)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Getting the _maybe_convert_timedelta call out of the arithmetic ops makes everything simpler

return self._time_shift(ordinal_delta)
return self._time_shift(other)

def shift(self, n):
"""
Expand All @@ -335,7 +334,8 @@ def shift(self, n):
return self._time_shift(n)

def _time_shift(self, n):
values = self._ndarray_values + n * self.freq.n
n = self._maybe_convert_timedelta(n)
values = self._ndarray_values + n
if self.hasnans:
values[self._isnan] = iNaT
return self._shallow_copy(values=values)
Expand All @@ -346,7 +346,8 @@ def _maybe_convert_timedelta(self, other):

Parameters
----------
other : timedelta, np.timedelta64, DateOffset, int, np.ndarray
other : timedelta, np.timedelta64, DateOffset, int,
np.ndarray[timedelta64], TimedeltaIndex

Returns
-------
Expand All @@ -357,30 +358,42 @@ def _maybe_convert_timedelta(self, other):
IncompatibleFrequency : if the input cannot be written as a multiple
of self.freq. Note IncompatibleFrequency subclasses ValueError.
"""
if isinstance(
other, (timedelta, np.timedelta64, Tick, np.ndarray)):
offset = frequencies.to_offset(self.freq.rule_code)
if isinstance(offset, Tick):
if isinstance(other, (timedelta, np.timedelta64, Tick)):
base_offset = frequencies.to_offset(self.freq.rule_code)
if isinstance(base_offset, Tick):
other_nanos = delta_to_nanoseconds(other)
base_nanos = delta_to_nanoseconds(base_offset)
check = np.all(other_nanos % base_nanos == 0)
if check:
return other_nanos // base_nanos
elif (isinstance(other, (np.ndarray, DatetimeLikeArrayMixin)) and
is_timedelta64_dtype(other)):
# i.e. TimedeltaArray/TimedeltaIndex
if isinstance(self.freq, Tick):
base_offset = frequencies.to_offset(self.freq.rule_code)
base_nanos = base_offset.nanos
if isinstance(other, np.ndarray):
nanos = np.vectorize(delta_to_nanoseconds)(other)
other_nanos = other.astype('m8[ns]').view('i8')
else:
nanos = delta_to_nanoseconds(other)
offset_nanos = delta_to_nanoseconds(offset)
check = np.all(nanos % offset_nanos == 0)
other_nanos = other.asi8
check = np.all(other_nanos % base_nanos == 0)
if check:
return nanos // offset_nanos
return other_nanos // base_nanos
elif isinstance(other, DateOffset):
freqstr = other.rule_code
base = frequencies.get_base_alias(freqstr)
if base == self.freq.rule_code:
return other.n
return other.n * self.freq.n
msg = DIFFERENT_FREQ_INDEX.format(self.freqstr, other.freqstr)
raise IncompatibleFrequency(msg)
elif lib.is_integer(other):
# integer is passed to .shift via
# _add_datetimelike_methods basically
# but ufunc may pass integer to _add_delta
return other
return other * self.freq.n
elif is_integer_dtype(other) and isinstance(other, np.ndarray):
# TODO: Also need to get Index
return other * self.freq.n

# raise when input doesn't have freq
msg = "Input has different freq from {cls}(freq={freqstr})"
Expand Down
20 changes: 15 additions & 5 deletions pandas/tests/arithmetic/test_period.py
Original file line number Diff line number Diff line change
Expand Up @@ -446,26 +446,36 @@ def test_pi_add_sub_td64_array_non_tick_raises(self):
with pytest.raises(period.IncompatibleFrequency):
tdarr - rng

@pytest.mark.xfail(reason='op with TimedeltaIndex raises, with ndarray OK',
strict=True)
def test_pi_add_sub_td64_array_tick(self):
rng = pd.period_range('1/1/2000', freq='Q', periods=3)
# PeriodIndex + Timedelta-like is allowed only with
# tick-like frequencies
rng = pd.period_range('1/1/2000', freq='90D', periods=3)
tdi = pd.TimedeltaIndex(['-1 Day', '-1 Day', '-1 Day'])
tdarr = tdi.values

expected = rng + tdi
expected = pd.period_range('12/31/1999', freq='90D', periods=3)
result = rng + tdi
tm.assert_index_equal(result, expected)
result = rng + tdarr
tm.assert_index_equal(result, expected)
result = tdi + rng
tm.assert_index_equal(result, expected)
result = tdarr + rng
tm.assert_index_equal(result, expected)

expected = rng - tdi
expected = pd.period_range('1/2/2000', freq='90D', periods=3)

result = rng - tdi
tm.assert_index_equal(result, expected)
result = rng - tdarr
tm.assert_index_equal(result, expected)

with pytest.raises(TypeError):
tdarr - rng

with pytest.raises(TypeError):
tdi - rng

# -----------------------------------------------------------------
# operations with array/Index of DateOffset objects

Expand Down
3 changes: 3 additions & 0 deletions pandas/util/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -805,6 +805,7 @@ def assert_index_equal(left, right, exact='equiv', check_names=True,
Specify object name being compared, internally used to show appropriate
assertion message
"""
__tracebackhide__ = True
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See #22643


def _check_types(l, r, obj='Index'):
if exact:
Expand Down Expand Up @@ -1048,6 +1049,8 @@ def assert_interval_array_equal(left, right, exact='equiv',


def raise_assert_detail(obj, message, left, right, diff=None):
__tracebackhide__ = True

if isinstance(left, np.ndarray):
left = pprint_thing(left)
elif is_categorical_dtype(left):
Expand Down