Skip to content

Commit e27ae70

Browse files
jbrockmendeljreback
authored andcommitted
Fix TimedeltaIndex +/- offset array (#19095)
1 parent 36a71eb commit e27ae70

File tree

4 files changed

+148
-23
lines changed

4 files changed

+148
-23
lines changed

doc/source/whatsnew/v0.23.0.txt

+1-1
Original file line numberDiff line numberDiff line change
@@ -366,7 +366,7 @@ Conversion
366366
- Bug in :class:`WeekOfMonth` and class:`Week` where addition and subtraction did not roll correctly (:issue:`18510`,:issue:`18672`,:issue:`18864`)
367367
- Bug in :meth:`DatetimeIndex.astype` when converting between timezone aware dtypes, and converting from timezone aware to naive (:issue:`18951`)
368368
- Bug in :class:`FY5253` where ``datetime`` addition and subtraction incremented incorrectly for dates on the year-end but not normalized to midnight (:issue:`18854`)
369-
- Bug in :class:`DatetimeIndex` where adding or subtracting an array-like of ``DateOffset`` objects either raised (``np.array``, ``pd.Index``) or broadcast incorrectly (``pd.Series``) (:issue:`18849`)
369+
- Bug in :class:`DatetimeIndex` and :class:`TimedeltaIndex` where adding or subtracting an array-like of ``DateOffset`` objects either raised (``np.array``, ``pd.Index``) or broadcast incorrectly (``pd.Series``) (:issue:`18849`)
370370
- Bug in :class:`Series` floor-division where operating on a scalar ``timedelta`` raises an exception (:issue:`18846`)
371371
- Bug in :class:`FY5253Quarter`, :class:`LastWeekOfMonth` where rollback and rollforward behavior was inconsistent with addition and subtraction behavior (:issue:`18854`)
372372
- Bug in :class:`Index` constructor with ``dtype=CategoricalDtype(...)`` where ``categories`` and ``ordered`` are not maintained (issue:`19032`)

pandas/core/indexes/datetimelike.py

+10-10
Original file line numberDiff line numberDiff line change
@@ -676,20 +676,20 @@ def __add__(self, other):
676676
return NotImplemented
677677
elif is_timedelta64_dtype(other):
678678
return self._add_delta(other)
679+
elif isinstance(other, (DateOffset, timedelta)):
680+
return self._add_delta(other)
681+
elif is_offsetlike(other):
682+
# Array/Index of DateOffset objects
683+
return self._add_offset_array(other)
679684
elif isinstance(self, TimedeltaIndex) and isinstance(other, Index):
680685
if hasattr(other, '_add_delta'):
681686
return other._add_delta(self)
682687
raise TypeError("cannot add TimedeltaIndex and {typ}"
683688
.format(typ=type(other)))
684-
elif isinstance(other, (DateOffset, timedelta)):
685-
return self._add_delta(other)
686689
elif is_integer(other):
687690
return self.shift(other)
688691
elif isinstance(other, (datetime, np.datetime64)):
689692
return self._add_datelike(other)
690-
elif is_offsetlike(other):
691-
# Array/Index of DateOffset objects
692-
return self._add_offset_array(other)
693693
elif isinstance(other, Index):
694694
return self._add_datelike(other)
695695
else: # pragma: no cover
@@ -709,24 +709,24 @@ def __sub__(self, other):
709709
return NotImplemented
710710
elif is_timedelta64_dtype(other):
711711
return self._add_delta(-other)
712+
elif isinstance(other, (DateOffset, timedelta)):
713+
return self._add_delta(-other)
714+
elif is_offsetlike(other):
715+
# Array/Index of DateOffset objects
716+
return self._sub_offset_array(other)
712717
elif isinstance(self, TimedeltaIndex) and isinstance(other, Index):
713718
if not isinstance(other, TimedeltaIndex):
714719
raise TypeError("cannot subtract TimedeltaIndex and {typ}"
715720
.format(typ=type(other).__name__))
716721
return self._add_delta(-other)
717722
elif isinstance(other, DatetimeIndex):
718723
return self._sub_datelike(other)
719-
elif isinstance(other, (DateOffset, timedelta)):
720-
return self._add_delta(-other)
721724
elif is_integer(other):
722725
return self.shift(-other)
723726
elif isinstance(other, (datetime, np.datetime64)):
724727
return self._sub_datelike(other)
725728
elif isinstance(other, Period):
726729
return self._sub_period(other)
727-
elif is_offsetlike(other):
728-
# Array/Index of DateOffset objects
729-
return self._sub_offset_array(other)
730730
elif isinstance(other, Index):
731731
raise TypeError("cannot subtract {typ1} and {typ2}"
732732
.format(typ1=type(self).__name__,

pandas/core/indexes/timedeltas.py

+45-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
""" implement the TimedeltaIndex """
22

33
from datetime import timedelta
4+
import warnings
5+
46
import numpy as np
57
from pandas.core.dtypes.common import (
68
_TD_DTYPE,
@@ -364,8 +366,8 @@ def _add_delta(self, delta):
364366
# update name when delta is index
365367
name = com._maybe_match_name(self, delta)
366368
else:
367-
raise ValueError("cannot add the type {0} to a TimedeltaIndex"
368-
.format(type(delta)))
369+
raise TypeError("cannot add the type {0} to a TimedeltaIndex"
370+
.format(type(delta)))
369371

370372
result = TimedeltaIndex(new_values, freq='infer', name=name)
371373
return result
@@ -414,6 +416,47 @@ def _sub_datelike(self, other):
414416
raise TypeError("cannot subtract a datelike from a TimedeltaIndex")
415417
return DatetimeIndex(result, name=self.name, copy=False)
416418

419+
def _add_offset_array(self, other):
420+
# Array/Index of DateOffset objects
421+
try:
422+
# TimedeltaIndex can only operate with a subset of DateOffset
423+
# subclasses. Incompatible classes will raise AttributeError,
424+
# which we re-raise as TypeError
425+
if isinstance(other, ABCSeries):
426+
return NotImplemented
427+
elif len(other) == 1:
428+
return self + other[0]
429+
else:
430+
from pandas.errors import PerformanceWarning
431+
warnings.warn("Adding/subtracting array of DateOffsets to "
432+
"{} not vectorized".format(type(self)),
433+
PerformanceWarning)
434+
return self.astype('O') + np.array(other)
435+
# TODO: This works for __add__ but loses dtype in __sub__
436+
except AttributeError:
437+
raise TypeError("Cannot add non-tick DateOffset to TimedeltaIndex")
438+
439+
def _sub_offset_array(self, other):
440+
# Array/Index of DateOffset objects
441+
try:
442+
# TimedeltaIndex can only operate with a subset of DateOffset
443+
# subclasses. Incompatible classes will raise AttributeError,
444+
# which we re-raise as TypeError
445+
if isinstance(other, ABCSeries):
446+
return NotImplemented
447+
elif len(other) == 1:
448+
return self - other[0]
449+
else:
450+
from pandas.errors import PerformanceWarning
451+
warnings.warn("Adding/subtracting array of DateOffsets to "
452+
"{} not vectorized".format(type(self)),
453+
PerformanceWarning)
454+
res_values = self.astype('O').values - np.array(other)
455+
return self.__class__(res_values, freq='infer')
456+
except AttributeError:
457+
raise TypeError("Cannot subtrack non-tick DateOffset from"
458+
" TimedeltaIndex")
459+
417460
def _format_native_types(self, na_rep=u('NaT'),
418461
date_format=None, **kwargs):
419462
from pandas.io.formats.format import Timedelta64Formatter

pandas/tests/indexes/timedeltas/test_arithmetic.py

+92-10
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
to_timedelta, timedelta_range, date_range,
1111
Series,
1212
Timestamp, Timedelta)
13+
from pandas.errors import PerformanceWarning
1314

1415

1516
@pytest.fixture(params=[pd.offsets.Hour(2), timedelta(hours=2),
@@ -28,23 +29,104 @@ def freq(request):
2829
class TestTimedeltaIndexArithmetic(object):
2930
_holder = TimedeltaIndex
3031

31-
@pytest.mark.xfail(reason='GH#18824 ufunc add cannot use operands...')
32-
def test_tdi_with_offset_array(self):
32+
@pytest.mark.parametrize('box', [np.array, pd.Index])
33+
def test_tdi_add_offset_array(self, box):
3334
# GH#18849
34-
tdi = pd.TimedeltaIndex(['1 days 00:00:00', '3 days 04:00:00'])
35-
offs = np.array([pd.offsets.Hour(n=1), pd.offsets.Minute(n=-2)])
36-
expected = pd.TimedeltaIndex(['1 days 01:00:00', '3 days 04:02:00'])
35+
tdi = TimedeltaIndex(['1 days 00:00:00', '3 days 04:00:00'])
36+
other = box([pd.offsets.Hour(n=1), pd.offsets.Minute(n=-2)])
3737

38-
res = tdi + offs
38+
expected = TimedeltaIndex([tdi[n] + other[n] for n in range(len(tdi))],
39+
freq='infer')
40+
41+
with tm.assert_produces_warning(PerformanceWarning):
42+
res = tdi + other
3943
tm.assert_index_equal(res, expected)
4044

41-
res2 = offs + tdi
45+
with tm.assert_produces_warning(PerformanceWarning):
46+
res2 = other + tdi
4247
tm.assert_index_equal(res2, expected)
4348

44-
anchored = np.array([pd.offsets.QuarterEnd(),
45-
pd.offsets.Week(weekday=2)])
49+
anchored = box([pd.offsets.QuarterEnd(),
50+
pd.offsets.Week(weekday=2)])
51+
52+
# addition/subtraction ops with anchored offsets should issue
53+
# a PerformanceWarning and _then_ raise a TypeError.
54+
with pytest.raises(TypeError):
55+
with tm.assert_produces_warning(PerformanceWarning):
56+
tdi + anchored
57+
with pytest.raises(TypeError):
58+
with tm.assert_produces_warning(PerformanceWarning):
59+
anchored + tdi
60+
61+
@pytest.mark.parametrize('box', [np.array, pd.Index])
62+
def test_tdi_sub_offset_array(self, box):
63+
# GH#18824
64+
tdi = TimedeltaIndex(['1 days 00:00:00', '3 days 04:00:00'])
65+
other = box([pd.offsets.Hour(n=1), pd.offsets.Minute(n=-2)])
66+
67+
expected = TimedeltaIndex([tdi[n] - other[n] for n in range(len(tdi))],
68+
freq='infer')
69+
70+
with tm.assert_produces_warning(PerformanceWarning):
71+
res = tdi - other
72+
tm.assert_index_equal(res, expected)
73+
74+
anchored = box([pd.offsets.MonthEnd(), pd.offsets.Day(n=2)])
75+
76+
# addition/subtraction ops with anchored offsets should issue
77+
# a PerformanceWarning and _then_ raise a TypeError.
78+
with pytest.raises(TypeError):
79+
with tm.assert_produces_warning(PerformanceWarning):
80+
tdi - anchored
81+
with pytest.raises(TypeError):
82+
with tm.assert_produces_warning(PerformanceWarning):
83+
anchored - tdi
84+
85+
@pytest.mark.parametrize('names', [(None, None, None),
86+
('foo', 'bar', None),
87+
('foo', 'foo', 'foo')])
88+
def test_tdi_with_offset_series(self, names):
89+
# GH#18849
90+
tdi = TimedeltaIndex(['1 days 00:00:00', '3 days 04:00:00'],
91+
name=names[0])
92+
other = Series([pd.offsets.Hour(n=1), pd.offsets.Minute(n=-2)],
93+
name=names[1])
94+
95+
expected_add = Series([tdi[n] + other[n] for n in range(len(tdi))],
96+
name=names[2])
97+
98+
with tm.assert_produces_warning(PerformanceWarning):
99+
res = tdi + other
100+
tm.assert_series_equal(res, expected_add)
101+
102+
with tm.assert_produces_warning(PerformanceWarning):
103+
res2 = other + tdi
104+
tm.assert_series_equal(res2, expected_add)
105+
106+
expected_sub = Series([tdi[n] - other[n] for n in range(len(tdi))],
107+
name=names[2])
108+
109+
with tm.assert_produces_warning(PerformanceWarning):
110+
res3 = tdi - other
111+
tm.assert_series_equal(res3, expected_sub)
112+
113+
anchored = Series([pd.offsets.MonthEnd(), pd.offsets.Day(n=2)],
114+
name=names[1])
115+
116+
# addition/subtraction ops with anchored offsets should issue
117+
# a PerformanceWarning and _then_ raise a TypeError.
118+
with pytest.raises(TypeError):
119+
with tm.assert_produces_warning(PerformanceWarning):
120+
tdi + anchored
121+
with pytest.raises(TypeError):
122+
with tm.assert_produces_warning(PerformanceWarning):
123+
anchored + tdi
124+
with pytest.raises(TypeError):
125+
with tm.assert_produces_warning(PerformanceWarning):
126+
tdi - anchored
46127
with pytest.raises(TypeError):
47-
tdi + anchored
128+
with tm.assert_produces_warning(PerformanceWarning):
129+
anchored - tdi
48130

49131
# TODO: Split by ops, better name
50132
def test_numeric_compat(self):

0 commit comments

Comments
 (0)