Skip to content

Commit b64e9d5

Browse files
jbrockmendeljreback
authored andcommitted
Implement integer array add/sub for datetimelike indexes (pandas-dev#19959)
1 parent f0c2330 commit b64e9d5

File tree

5 files changed

+170
-12
lines changed

5 files changed

+170
-12
lines changed

doc/source/whatsnew/v0.24.0.txt

+7
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ Other Enhancements
2222
Backwards incompatible API changes
2323
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
2424

25+
.. _whatsnew_0240.api.datetimelike:
26+
27+
Datetimelike API Changes
28+
^^^^^^^^^^^^^^^^^^^^^^^^
29+
30+
- For :class:`DatetimeIndex` and :class:`TimedeltaIndex` with non-``None`` ``freq`` attribute, addition or subtraction of integer-dtyped array or ``Index`` will return an object of the same class (:issue:`19959`)
31+
2532
.. _whatsnew_0240.api.other:
2633

2734
Other API Changes

pandas/core/indexes/datetimelike.py

+53-10
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
import numpy as np
1414

15-
from pandas._libs import lib, iNaT, NaT
15+
from pandas._libs import lib, iNaT, NaT, Timedelta
1616
from pandas._libs.tslibs.period import Period
1717
from pandas._libs.tslibs.timedeltas import delta_to_nanoseconds
1818
from pandas._libs.tslibs.timestamps import round_ns
@@ -34,6 +34,7 @@
3434
is_string_dtype,
3535
is_datetime64_dtype,
3636
is_datetime64tz_dtype,
37+
is_datetime64_any_dtype,
3738
is_period_dtype,
3839
is_timedelta64_dtype)
3940
from pandas.core.dtypes.generic import (
@@ -814,6 +815,46 @@ def _addsub_offset_array(self, other, op):
814815
kwargs['freq'] = 'infer'
815816
return self._constructor(res_values, **kwargs)
816817

818+
def _addsub_int_array(self, other, op):
819+
"""
820+
Add or subtract array-like of integers equivalent to applying
821+
`shift` pointwise.
822+
823+
Parameters
824+
----------
825+
other : Index, np.ndarray
826+
integer-dtype
827+
op : {operator.add, operator.sub}
828+
829+
Returns
830+
-------
831+
result : same class as self
832+
"""
833+
assert op in [operator.add, operator.sub]
834+
if is_period_dtype(self):
835+
# easy case for PeriodIndex
836+
if op is operator.sub:
837+
other = -other
838+
res_values = checked_add_with_arr(self.asi8, other,
839+
arr_mask=self._isnan)
840+
res_values = res_values.view('i8')
841+
res_values[self._isnan] = iNaT
842+
return self._from_ordinals(res_values, freq=self.freq)
843+
844+
elif self.freq is None:
845+
# GH#19123
846+
raise NullFrequencyError("Cannot shift with no freq")
847+
848+
elif isinstance(self.freq, Tick):
849+
# easy case where we can convert to timedelta64 operation
850+
td = Timedelta(self.freq)
851+
return op(self, td * other)
852+
853+
# We should only get here with DatetimeIndex; dispatch
854+
# to _addsub_offset_array
855+
assert not is_timedelta64_dtype(self)
856+
return op(self, np.array(other) * self.freq)
857+
817858
@classmethod
818859
def _add_datetimelike_methods(cls):
819860
"""
@@ -822,8 +863,6 @@ def _add_datetimelike_methods(cls):
822863
"""
823864

824865
def __add__(self, other):
825-
from pandas import DateOffset
826-
827866
other = lib.item_from_zerodim(other)
828867
if isinstance(other, (ABCSeries, ABCDataFrame)):
829868
return NotImplemented
@@ -853,9 +892,8 @@ def __add__(self, other):
853892
elif is_datetime64_dtype(other) or is_datetime64tz_dtype(other):
854893
# DatetimeIndex, ndarray[datetime64]
855894
return self._add_datelike(other)
856-
elif is_integer_dtype(other) and self.freq is None:
857-
# GH#19123
858-
raise NullFrequencyError("Cannot shift with no freq")
895+
elif is_integer_dtype(other):
896+
result = self._addsub_int_array(other, operator.add)
859897
elif is_float_dtype(other):
860898
# Explicitly catch invalid dtypes
861899
raise TypeError("cannot add {dtype}-dtype to {cls}"
@@ -915,14 +953,12 @@ def __sub__(self, other):
915953
elif is_datetime64_dtype(other) or is_datetime64tz_dtype(other):
916954
# DatetimeIndex, ndarray[datetime64]
917955
result = self._sub_datelike(other)
956+
elif is_integer_dtype(other):
957+
result = self._addsub_int_array(other, operator.sub)
918958
elif isinstance(other, Index):
919959
raise TypeError("cannot subtract {cls} and {typ}"
920960
.format(cls=type(self).__name__,
921961
typ=type(other).__name__))
922-
elif is_integer_dtype(other) and self.freq is None:
923-
# GH#19123
924-
raise NullFrequencyError("Cannot shift with no freq")
925-
926962
elif is_float_dtype(other):
927963
# Explicitly catch invalid dtypes
928964
raise TypeError("cannot subtract {dtype}-dtype from {cls}"
@@ -948,6 +984,13 @@ def __rsub__(self, other):
948984
# we need to wrap in DatetimeIndex and flip the operation
949985
from pandas import DatetimeIndex
950986
return DatetimeIndex(other) - self
987+
elif (is_datetime64_any_dtype(self) and hasattr(other, 'dtype') and
988+
not is_datetime64_any_dtype(other)):
989+
# GH#19959 datetime - datetime is well-defined as timedelta,
990+
# but any other type - datetime is not well-defined.
991+
raise TypeError("cannot subtract {cls} from {typ}"
992+
.format(cls=type(self).__name__,
993+
typ=type(other).__name__))
951994
return -(self - other)
952995
cls.__rsub__ = __rsub__
953996

pandas/tests/indexes/datetimes/test_arithmetic.py

+46-2
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,49 @@ def test_dti_isub_int(self, tz, one):
367367
rng -= one
368368
tm.assert_index_equal(rng, expected)
369369

370+
# -------------------------------------------------------------
371+
# __add__/__sub__ with integer arrays
372+
373+
@pytest.mark.parametrize('freq', ['H', 'D'])
374+
@pytest.mark.parametrize('box', [np.array, pd.Index])
375+
def test_dti_add_intarray_tick(self, box, freq):
376+
# GH#19959
377+
dti = pd.date_range('2016-01-01', periods=2, freq=freq)
378+
other = box([4, -1])
379+
expected = DatetimeIndex([dti[n] + other[n] for n in range(len(dti))])
380+
result = dti + other
381+
tm.assert_index_equal(result, expected)
382+
result = other + dti
383+
tm.assert_index_equal(result, expected)
384+
385+
@pytest.mark.parametrize('freq', ['W', 'M', 'MS', 'Q'])
386+
@pytest.mark.parametrize('box', [np.array, pd.Index])
387+
def test_dti_add_intarray_non_tick(self, box, freq):
388+
# GH#19959
389+
dti = pd.date_range('2016-01-01', periods=2, freq=freq)
390+
other = box([4, -1])
391+
expected = DatetimeIndex([dti[n] + other[n] for n in range(len(dti))])
392+
with tm.assert_produces_warning(PerformanceWarning):
393+
result = dti + other
394+
tm.assert_index_equal(result, expected)
395+
with tm.assert_produces_warning(PerformanceWarning):
396+
result = other + dti
397+
tm.assert_index_equal(result, expected)
398+
399+
@pytest.mark.parametrize('box', [np.array, pd.Index])
400+
def test_dti_add_intarray_no_freq(self, box):
401+
# GH#19959
402+
dti = pd.DatetimeIndex(['2016-01-01', 'NaT', '2017-04-05 06:07:08'])
403+
other = box([9, 4, -1])
404+
with pytest.raises(NullFrequencyError):
405+
dti + other
406+
with pytest.raises(NullFrequencyError):
407+
other + dti
408+
with pytest.raises(NullFrequencyError):
409+
dti - other
410+
with pytest.raises(TypeError):
411+
other - dti
412+
370413
# -------------------------------------------------------------
371414
# DatetimeIndex.shift is used in integer addition
372415

@@ -528,7 +571,7 @@ def test_dti_sub_tdi(self, tz):
528571
result = dti - tdi.values
529572
tm.assert_index_equal(result, expected)
530573

531-
msg = 'cannot perform __neg__ with this index type:'
574+
msg = 'cannot subtract DatetimeIndex from'
532575
with tm.assert_raises_regex(TypeError, msg):
533576
tdi.values - dti
534577

@@ -553,7 +596,8 @@ def test_dti_isub_tdi(self, tz):
553596
tm.assert_index_equal(result, expected)
554597

555598
msg = '|'.join(['cannot perform __neg__ with this index type:',
556-
'ufunc subtract cannot use operands with types'])
599+
'ufunc subtract cannot use operands with types',
600+
'cannot subtract DatetimeIndex from'])
557601
with tm.assert_raises_regex(TypeError, msg):
558602
tdi.values -= dti
559603

pandas/tests/indexes/period/test_arithmetic.py

+25
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,31 @@ def test_pi_sub_isub_offset(self):
449449
rng -= pd.offsets.MonthEnd(5)
450450
tm.assert_index_equal(rng, expected)
451451

452+
# ---------------------------------------------------------------
453+
# __add__/__sub__ with integer arrays
454+
455+
@pytest.mark.parametrize('box', [np.array, pd.Index])
456+
@pytest.mark.parametrize('op', [operator.add, ops.radd])
457+
def test_pi_add_intarray(self, box, op):
458+
# GH#19959
459+
pi = pd.PeriodIndex([pd.Period('2015Q1'), pd.Period('NaT')])
460+
other = box([4, -1])
461+
result = op(pi, other)
462+
expected = pd.PeriodIndex([pd.Period('2016Q1'), pd.Period('NaT')])
463+
tm.assert_index_equal(result, expected)
464+
465+
@pytest.mark.parametrize('box', [np.array, pd.Index])
466+
def test_pi_sub_intarray(self, box):
467+
# GH#19959
468+
pi = pd.PeriodIndex([pd.Period('2015Q1'), pd.Period('NaT')])
469+
other = box([4, -1])
470+
result = pi - other
471+
expected = pd.PeriodIndex([pd.Period('2014Q1'), pd.Period('NaT')])
472+
tm.assert_index_equal(result, expected)
473+
474+
with pytest.raises(TypeError):
475+
other - pi
476+
452477
# ---------------------------------------------------------------
453478
# Timedelta-like (timedelta, timedelta64, Timedelta, Tick)
454479
# TODO: Some of these are misnomers because of non-Tick DateOffsets

pandas/tests/indexes/timedeltas/test_arithmetic.py

+39
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,45 @@ def test_tdi_isub_int(self, one):
542542
rng -= one
543543
tm.assert_index_equal(rng, expected)
544544

545+
# -------------------------------------------------------------
546+
# __add__/__sub__ with integer arrays
547+
548+
@pytest.mark.parametrize('box', [np.array, pd.Index])
549+
def test_tdi_add_integer_array(self, box):
550+
# GH#19959
551+
rng = timedelta_range('1 days 09:00:00', freq='H', periods=3)
552+
other = box([4, 3, 2])
553+
expected = TimedeltaIndex(['1 day 13:00:00'] * 3)
554+
result = rng + other
555+
tm.assert_index_equal(result, expected)
556+
result = other + rng
557+
tm.assert_index_equal(result, expected)
558+
559+
@pytest.mark.parametrize('box', [np.array, pd.Index])
560+
def test_tdi_sub_integer_array(self, box):
561+
# GH#19959
562+
rng = timedelta_range('9H', freq='H', periods=3)
563+
other = box([4, 3, 2])
564+
expected = TimedeltaIndex(['5H', '7H', '9H'])
565+
result = rng - other
566+
tm.assert_index_equal(result, expected)
567+
result = other - rng
568+
tm.assert_index_equal(result, -expected)
569+
570+
@pytest.mark.parametrize('box', [np.array, pd.Index])
571+
def test_tdi_addsub_integer_array_no_freq(self, box):
572+
# GH#19959
573+
tdi = TimedeltaIndex(['1 Day', 'NaT', '3 Hours'])
574+
other = box([14, -1, 16])
575+
with pytest.raises(NullFrequencyError):
576+
tdi + other
577+
with pytest.raises(NullFrequencyError):
578+
other + tdi
579+
with pytest.raises(NullFrequencyError):
580+
tdi - other
581+
with pytest.raises(NullFrequencyError):
582+
other - tdi
583+
545584
# -------------------------------------------------------------
546585
# Binary operations TimedeltaIndex and timedelta-like
547586

0 commit comments

Comments
 (0)