Skip to content

Commit f1147af

Browse files
committed
implement integer array add/sub for datetimelike indexes
1 parent 6ef4be3 commit f1147af

File tree

4 files changed

+158
-9
lines changed

4 files changed

+158
-9
lines changed

pandas/core/indexes/datetimelike.py

Lines changed: 53 additions & 7 deletions
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 (
@@ -48,6 +49,7 @@
4849
from pandas.util._decorators import Appender, cache_readonly
4950
import pandas.core.dtypes.concat as _concat
5051
import pandas.tseries.frequencies as frequencies
52+
from pandas.tseries.offsets import Tick
5153

5254
import pandas.core.indexes.base as ibase
5355
_index_doc_kwargs = dict(ibase._index_doc_kwargs)
@@ -694,6 +696,47 @@ def _addsub_offset_array(self, other, op):
694696
kwargs['freq'] = 'infer'
695697
return self._constructor(res_values, **kwargs)
696698

699+
def _addsub_int_array(self, other, op):
700+
"""
701+
Add or subtract array-like of integers equivalent to applying
702+
`shift` pointwise.
703+
704+
Parameters
705+
----------
706+
other : Index, np.ndarray
707+
integer-dtype
708+
op : {operator.add, operator.sub}
709+
710+
Returns
711+
-------
712+
result : same class as self
713+
"""
714+
assert op in [operator.add, operator.sub]
715+
if is_period_dtype(self):
716+
# easy case for PeriodIndex
717+
if op is operator.sub:
718+
other = -other
719+
res_values = checked_add_with_arr(self.asi8, other,
720+
arr_mask=self._isnan)
721+
res_values = res_values.view('i8')
722+
res_values[self._isnan] = iNaT
723+
return self._from_ordinals(res_values, freq=self.freq)
724+
725+
elif self.freq is None:
726+
# GH#19123
727+
raise NullFrequencyError("Cannot shift with no freq")
728+
729+
elif isinstance(self.freq, Tick):
730+
# easy case where we can convert to timedelta64 operation
731+
td = Timedelta(self.freq)
732+
return op(self, td * other)
733+
734+
else:
735+
# We should only get here with DatetimeIndex; dispatch
736+
# to _addsub_offset_array
737+
assert not is_timedelta64_dtype(self)
738+
return op(self, np.array(other) * self.freq)
739+
697740
@classmethod
698741
def _add_datetimelike_methods(cls):
699742
"""
@@ -730,9 +773,8 @@ def __add__(self, other):
730773
elif is_datetime64_dtype(other) or is_datetime64tz_dtype(other):
731774
# DatetimeIndex, ndarray[datetime64]
732775
return self._add_datelike(other)
733-
elif is_integer_dtype(other) and self.freq is None:
734-
# GH#19123
735-
raise NullFrequencyError("Cannot shift with no freq")
776+
elif is_integer_dtype(other):
777+
result = self._addsub_int_array(other, operator.add)
736778
else: # pragma: no cover
737779
return NotImplemented
738780

@@ -783,13 +825,12 @@ def __sub__(self, other):
783825
elif is_datetime64_dtype(other) or is_datetime64tz_dtype(other):
784826
# DatetimeIndex, ndarray[datetime64]
785827
result = self._sub_datelike(other)
828+
elif is_integer_dtype(other):
829+
result = self._addsub_int_array(other, operator.sub)
786830
elif isinstance(other, Index):
787831
raise TypeError("cannot subtract {cls} and {typ}"
788832
.format(cls=type(self).__name__,
789833
typ=type(other).__name__))
790-
elif is_integer_dtype(other) and self.freq is None:
791-
# GH#19123
792-
raise NullFrequencyError("Cannot shift with no freq")
793834
else: # pragma: no cover
794835
return NotImplemented
795836

@@ -810,6 +851,11 @@ def __rsub__(self, other):
810851
# we need to wrap in DatetimeIndex and flip the operation
811852
from pandas import DatetimeIndex
812853
return DatetimeIndex(other) - self
854+
elif (is_datetime64_any_dtype(self) and hasattr(other, 'dtype') and
855+
not is_datetime64_any_dtype(other)):
856+
raise TypeError("cannot subtract {cls} from {typ}"
857+
.format(cls=type(self).__name__,
858+
typ=type(other).__name__))
813859
return -(self - other)
814860
cls.__rsub__ = __rsub__
815861

pandas/tests/indexes/datetimes/test_arithmetic.py

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,46 @@ def test_dti_isub_int(self, tz, one):
355355
rng -= one
356356
tm.assert_index_equal(rng, expected)
357357

358+
# -------------------------------------------------------------
359+
# __add__/__sub__ with integer arrays
360+
361+
@pytest.mark.parametrize('freq', ['H', 'D'])
362+
@pytest.mark.parametrize('box', [np.array, pd.Index])
363+
def test_dti_add_intarray_tick(self, box, freq):
364+
dti = pd.date_range('2016-01-01', periods=2, freq=freq)
365+
other = box([4, -1])
366+
expected = DatetimeIndex([dti[n] + other[n] for n in range(len(dti))])
367+
result = dti + other
368+
tm.assert_index_equal(result, expected)
369+
result = other + dti
370+
tm.assert_index_equal(result, expected)
371+
372+
@pytest.mark.parametrize('freq', ['W', 'M', 'MS', 'Q'])
373+
@pytest.mark.parametrize('box', [np.array, pd.Index])
374+
def test_dti_add_intarray_non_tick(self, box, freq):
375+
dti = pd.date_range('2016-01-01', periods=2, freq=freq)
376+
other = box([4, -1])
377+
expected = DatetimeIndex([dti[n] + other[n] for n in range(len(dti))])
378+
with tm.assert_produces_warning(PerformanceWarning):
379+
result = dti + other
380+
tm.assert_index_equal(result, expected)
381+
with tm.assert_produces_warning(PerformanceWarning):
382+
result = other + dti
383+
tm.assert_index_equal(result, expected)
384+
385+
@pytest.mark.parametrize('box', [np.array, pd.Index])
386+
def test_dti_add_intarray_no_freq(self, box):
387+
dti = pd.DatetimeIndex(['2016-01-01', 'NaT', '2017-04-05 06:07:08'])
388+
other = box([9, 4, -1])
389+
with pytest.raises(NullFrequencyError):
390+
dti + other
391+
with pytest.raises(NullFrequencyError):
392+
other + dti
393+
with pytest.raises(NullFrequencyError):
394+
dti - other
395+
with pytest.raises(TypeError):
396+
other - dti
397+
358398
# -------------------------------------------------------------
359399
# DatetimeIndex.shift is used in integer addition
360400

@@ -516,7 +556,7 @@ def test_dti_sub_tdi(self, tz):
516556
result = dti - tdi.values
517557
tm.assert_index_equal(result, expected)
518558

519-
msg = 'cannot perform __neg__ with this index type:'
559+
msg = 'cannot subtract DatetimeIndex from'
520560
with tm.assert_raises_regex(TypeError, msg):
521561
tdi.values - dti
522562

@@ -541,7 +581,8 @@ def test_dti_isub_tdi(self, tz):
541581
tm.assert_index_equal(result, expected)
542582

543583
msg = '|'.join(['cannot perform __neg__ with this index type:',
544-
'ufunc subtract cannot use operands with types'])
584+
'ufunc subtract cannot use operands with types',
585+
'cannot subtract DatetimeIndex from'])
545586
with tm.assert_raises_regex(TypeError, msg):
546587
tdi.values -= dti
547588

pandas/tests/indexes/period/test_arithmetic.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# -*- coding: utf-8 -*-
22
from datetime import timedelta
3+
import operator
4+
35
import pytest
46
import numpy as np
57

@@ -9,6 +11,7 @@
911
period_range, Period, PeriodIndex,
1012
_np_version_under1p10)
1113
import pandas.core.indexes.period as period
14+
from pandas.core import ops
1215
from pandas.errors import PerformanceWarning
1316

1417

@@ -434,6 +437,29 @@ def test_pi_sub_isub_offset(self):
434437
rng -= pd.offsets.MonthEnd(5)
435438
tm.assert_index_equal(rng, expected)
436439

440+
# ---------------------------------------------------------------
441+
# __add__/__sub__ with integer arrays
442+
443+
@pytest.mark.parametrize('box', [np.array, pd.Index])
444+
@pytest.mark.parametrize('op', [operator.add, ops.radd])
445+
def test_pi_add_intarray(self, box, op):
446+
pi = pd.PeriodIndex([pd.Period('2015Q1'), pd.Period('NaT')])
447+
other = box([4, -1])
448+
result = op(pi, other)
449+
expected = pd.PeriodIndex([pd.Period('2016Q1'), pd.Period('NaT')])
450+
tm.assert_index_equal(result, expected)
451+
452+
@pytest.mark.parametrize('box', [np.array, pd.Index])
453+
def test_pi_sub_intarray(self, box):
454+
pi = pd.PeriodIndex([pd.Period('2015Q1'), pd.Period('NaT')])
455+
other = box([4, -1])
456+
result = pi - other
457+
expected = pd.PeriodIndex([pd.Period('2014Q1'), pd.Period('NaT')])
458+
tm.assert_index_equal(result, expected)
459+
460+
with pytest.raises(TypeError):
461+
other - pi
462+
437463
# ---------------------------------------------------------------
438464
# Timedelta-like (timedelta, timedelta64, Timedelta, Tick)
439465
# TODO: Some of these are misnomers because of non-Tick DateOffsets

pandas/tests/indexes/timedeltas/test_arithmetic.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -530,6 +530,42 @@ def test_tdi_isub_int(self, one):
530530
rng -= one
531531
tm.assert_index_equal(rng, expected)
532532

533+
# -------------------------------------------------------------
534+
# __add__/__sub__ with integer arrays
535+
536+
@pytest.mark.parametrize('box', [np.array, pd.Index])
537+
def test_tdi_add_integer_array(self, box):
538+
rng = timedelta_range('1 days 09:00:00', freq='H', periods=3)
539+
other = box([4, 3, 2])
540+
expected = TimedeltaIndex(['1 day 13:00:00'] * 3)
541+
result = rng + other
542+
tm.assert_index_equal(result, expected)
543+
result = other + rng
544+
tm.assert_index_equal(result, expected)
545+
546+
@pytest.mark.parametrize('box', [np.array, pd.Index])
547+
def test_tdi_sub_integer_array(self, box):
548+
rng = timedelta_range('9H', freq='H', periods=3)
549+
other = box([4, 3, 2])
550+
expected = TimedeltaIndex(['5H', '7H', '9H'])
551+
result = rng - other
552+
tm.assert_index_equal(result, expected)
553+
result = other - rng
554+
tm.assert_index_equal(result, -expected)
555+
556+
@pytest.mark.parametrize('box', [np.array, pd.Index])
557+
def test_tdi_addsub_integer_array_no_freq(self, box):
558+
tdi = TimedeltaIndex(['1 Day', 'NaT', '3 Hours'])
559+
other = box([14, -1, 16])
560+
with pytest.raises(NullFrequencyError):
561+
tdi + other
562+
with pytest.raises(NullFrequencyError):
563+
other + tdi
564+
with pytest.raises(NullFrequencyError):
565+
tdi - other
566+
with pytest.raises(NullFrequencyError):
567+
other - tdi
568+
533569
# -------------------------------------------------------------
534570
# Binary operations TimedeltaIndex and timedelta-like
535571

0 commit comments

Comments
 (0)