Skip to content
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

Implement integer array add/sub for datetimelike indexes #19959

Merged
merged 16 commits into from
May 29, 2018
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
f1147af
implement integer array add/sub for datetimelike indexes
jbrockmendel Mar 2, 2018
39d8ee7
whatsnew, gh references in tests
jbrockmendel Mar 2, 2018
8780466
Merge branch 'master' of https://github.com/pandas-dev/pandas into in…
jbrockmendel Mar 2, 2018
f4e8e01
Merge branch 'master' of https://github.com/pandas-dev/pandas into in…
jbrockmendel Mar 8, 2018
70f359d
add comment on __rsub__
jbrockmendel Mar 8, 2018
79e7575
Merge branch 'master' of https://github.com/pandas-dev/pandas into in…
jbrockmendel Mar 16, 2018
ef6acdb
Merge branch 'master' of https://github.com/pandas-dev/pandas into in…
jbrockmendel Mar 18, 2018
d477304
Merge branch 'master' of https://github.com/pandas-dev/pandas into in…
jbrockmendel Mar 30, 2018
df84dc6
Merge branch 'master' of https://github.com/pandas-dev/pandas into in…
jbrockmendel Apr 5, 2018
e4b6ec8
Merge branch 'master' of https://github.com/pandas-dev/pandas into in…
jbrockmendel Apr 16, 2018
be737e0
unindent because some people have no sense of pizazz
jbrockmendel Apr 16, 2018
650b1ef
Merge branch 'master' of https://github.com/pandas-dev/pandas into in…
jbrockmendel Apr 24, 2018
671a008
Merge branch 'master' of https://github.com/pandas-dev/pandas into in…
jbrockmendel Apr 26, 2018
bcf2419
Merge branch 'master' of https://github.com/pandas-dev/pandas into in…
jbrockmendel May 16, 2018
8cc5270
Merge branch 'master' of https://github.com/pandas-dev/pandas into in…
jbrockmendel May 19, 2018
6cb1b43
Move/fix whatsnew note to 0.24.0
jbrockmendel May 19, 2018
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion doc/source/whatsnew/v0.23.0.txt
Original file line number Diff line number Diff line change
Expand Up @@ -669,7 +669,7 @@ Datetimelike API Changes
- Operations between a :class:`Series` with dtype ``dtype='datetime64[ns]'`` and a :class:`PeriodIndex` will correctly raises ``TypeError`` (:issue:`18850`)
- Subtraction of :class:`Series` with timezone-aware ``dtype='datetime64[ns]'`` with mis-matched timezones will raise ``TypeError`` instead of ``ValueError`` (:issue:`18817`)
- :func:`pandas.merge` provides a more informative error message when trying to merge on timezone-aware and timezone-naive columns (:issue:`15800`)
- For :class:`DatetimeIndex` and :class:`TimedeltaIndex` with ``freq=None``, addition or subtraction of integer-dtyped array or ``Index`` will raise ``NullFrequencyError`` instead of ``TypeError`` (:issue:`19895`)
- For :class:`DatetimeIndex` and :class:`TimedeltaIndex` with ``freq=None``, addition or subtraction of integer-dtyped array or ``Index`` will raise ``NullFrequencyError`` instead of ``TypeError`` if the index ``freq`` attribute is ``None``, and will return an object of the same class otherwise (:issue:`19895`, :issue:`19959`)
Copy link
Contributor

Choose a reason for hiding this comment

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

0.24 (this is an api change right?)

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, just changed.

- :class:`Timestamp` constructor now accepts a `nanosecond` keyword or positional argument (:issue:`18898`)

.. _whatsnew_0230.api.other:
Expand Down
60 changes: 53 additions & 7 deletions pandas/core/indexes/datetimelike.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

import numpy as np

from pandas._libs import lib, iNaT, NaT
from pandas._libs import lib, iNaT, NaT, Timedelta
from pandas._libs.tslibs.period import Period
from pandas._libs.tslibs.timedeltas import delta_to_nanoseconds
from pandas._libs.tslibs.timestamps import round_ns
Expand All @@ -34,6 +34,7 @@
is_string_dtype,
is_datetime64_dtype,
is_datetime64tz_dtype,
is_datetime64_any_dtype,
is_period_dtype,
is_timedelta64_dtype)
from pandas.core.dtypes.generic import (
Expand All @@ -48,6 +49,7 @@
from pandas.util._decorators import Appender, cache_readonly
import pandas.core.dtypes.concat as _concat
import pandas.tseries.frequencies as frequencies
from pandas.tseries.offsets import Tick

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

def _addsub_int_array(self, other, op):
"""
Add or subtract array-like of integers equivalent to applying
`shift` pointwise.

Parameters
----------
other : Index, np.ndarray
integer-dtype
op : {operator.add, operator.sub}

Returns
-------
result : same class as self
"""
assert op in [operator.add, operator.sub]
if is_period_dtype(self):
# easy case for PeriodIndex
if op is operator.sub:
other = -other
res_values = checked_add_with_arr(self.asi8, other,
arr_mask=self._isnan)
res_values = res_values.view('i8')
res_values[self._isnan] = iNaT
return self._from_ordinals(res_values, freq=self.freq)

elif self.freq is None:
# GH#19123
raise NullFrequencyError("Cannot shift with no freq")

elif isinstance(self.freq, Tick):
# easy case where we can convert to timedelta64 operation
td = Timedelta(self.freq)
return op(self, td * other)

else:
Copy link
Contributor

Choose a reason for hiding this comment

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

no need for an else

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 address this comment.

Copy link
Member Author

Choose a reason for hiding this comment

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

This fell through the cracks; just pushed an update.

# We should only get here with DatetimeIndex; dispatch
# to _addsub_offset_array
assert not is_timedelta64_dtype(self)
return op(self, np.array(other) * self.freq)

@classmethod
def _add_datetimelike_methods(cls):
"""
Expand Down Expand Up @@ -730,9 +773,8 @@ def __add__(self, other):
elif is_datetime64_dtype(other) or is_datetime64tz_dtype(other):
# DatetimeIndex, ndarray[datetime64]
return self._add_datelike(other)
elif is_integer_dtype(other) and self.freq is None:
# GH#19123
raise NullFrequencyError("Cannot shift with no freq")
elif is_integer_dtype(other):
result = self._addsub_int_array(other, operator.add)
else: # pragma: no cover
return NotImplemented

Expand Down Expand Up @@ -783,13 +825,12 @@ def __sub__(self, other):
elif is_datetime64_dtype(other) or is_datetime64tz_dtype(other):
# DatetimeIndex, ndarray[datetime64]
result = self._sub_datelike(other)
elif is_integer_dtype(other):
result = self._addsub_int_array(other, operator.sub)
elif isinstance(other, Index):
raise TypeError("cannot subtract {cls} and {typ}"
.format(cls=type(self).__name__,
typ=type(other).__name__))
elif is_integer_dtype(other) and self.freq is None:
# GH#19123
raise NullFrequencyError("Cannot shift with no freq")
else: # pragma: no cover
return NotImplemented

Expand All @@ -810,6 +851,11 @@ def __rsub__(self, other):
# we need to wrap in DatetimeIndex and flip the operation
from pandas import DatetimeIndex
return DatetimeIndex(other) - self
elif (is_datetime64_any_dtype(self) and hasattr(other, 'dtype') and
not is_datetime64_any_dtype(other)):
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 add a comment on why this is

Copy link
Member

Choose a reason for hiding this comment

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

Addressed, but GitHub UI not surfacing.

raise TypeError("cannot subtract {cls} from {typ}"
.format(cls=type(self).__name__,
typ=type(other).__name__))
return -(self - other)
cls.__rsub__ = __rsub__

Expand Down
48 changes: 46 additions & 2 deletions pandas/tests/indexes/datetimes/test_arithmetic.py
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,49 @@ def test_dti_isub_int(self, tz, one):
rng -= one
tm.assert_index_equal(rng, expected)

# -------------------------------------------------------------
# __add__/__sub__ with integer arrays

@pytest.mark.parametrize('freq', ['H', 'D'])
@pytest.mark.parametrize('box', [np.array, pd.Index])
def test_dti_add_intarray_tick(self, box, freq):
Copy link
Contributor

Choose a reason for hiding this comment

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

we must have some tests that do index arithmetic with integers, either move them here if they are not redundant or remove them.

Copy link
Member Author

Choose a reason for hiding this comment

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

There are tests for integers, but nothing ATM for integer-arrays.

# GH#19959
dti = pd.date_range('2016-01-01', periods=2, freq=freq)
other = box([4, -1])
expected = DatetimeIndex([dti[n] + other[n] for n in range(len(dti))])
result = dti + other
tm.assert_index_equal(result, expected)
result = other + dti
tm.assert_index_equal(result, expected)

@pytest.mark.parametrize('freq', ['W', 'M', 'MS', 'Q'])
@pytest.mark.parametrize('box', [np.array, pd.Index])
def test_dti_add_intarray_non_tick(self, box, freq):
# GH#19959
dti = pd.date_range('2016-01-01', periods=2, freq=freq)
other = box([4, -1])
expected = DatetimeIndex([dti[n] + other[n] for n in range(len(dti))])
with tm.assert_produces_warning(PerformanceWarning):
result = dti + other
tm.assert_index_equal(result, expected)
with tm.assert_produces_warning(PerformanceWarning):
result = other + dti
tm.assert_index_equal(result, expected)

@pytest.mark.parametrize('box', [np.array, pd.Index])
def test_dti_add_intarray_no_freq(self, box):
# GH#19959
dti = pd.DatetimeIndex(['2016-01-01', 'NaT', '2017-04-05 06:07:08'])
other = box([9, 4, -1])
with pytest.raises(NullFrequencyError):
dti + other
with pytest.raises(NullFrequencyError):
other + dti
with pytest.raises(NullFrequencyError):
dti - other
with pytest.raises(TypeError):
other - dti

# -------------------------------------------------------------
# DatetimeIndex.shift is used in integer addition

Expand Down Expand Up @@ -516,7 +559,7 @@ def test_dti_sub_tdi(self, tz):
result = dti - tdi.values
tm.assert_index_equal(result, expected)

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

Expand All @@ -541,7 +584,8 @@ def test_dti_isub_tdi(self, tz):
tm.assert_index_equal(result, expected)

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

Expand Down
28 changes: 28 additions & 0 deletions pandas/tests/indexes/period/test_arithmetic.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# -*- coding: utf-8 -*-
from datetime import timedelta
import operator

import pytest
import numpy as np

Expand All @@ -9,6 +11,7 @@
period_range, Period, PeriodIndex,
_np_version_under1p10)
import pandas.core.indexes.period as period
from pandas.core import ops
from pandas.errors import PerformanceWarning


Expand Down Expand Up @@ -434,6 +437,31 @@ def test_pi_sub_isub_offset(self):
rng -= pd.offsets.MonthEnd(5)
tm.assert_index_equal(rng, expected)

# ---------------------------------------------------------------
# __add__/__sub__ with integer arrays

@pytest.mark.parametrize('box', [np.array, pd.Index])
Copy link
Contributor

Choose a reason for hiding this comment

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

same comment as above

@pytest.mark.parametrize('op', [operator.add, ops.radd])
def test_pi_add_intarray(self, box, op):
# GH#19959
pi = pd.PeriodIndex([pd.Period('2015Q1'), pd.Period('NaT')])
other = box([4, -1])
result = op(pi, other)
expected = pd.PeriodIndex([pd.Period('2016Q1'), pd.Period('NaT')])
tm.assert_index_equal(result, expected)

@pytest.mark.parametrize('box', [np.array, pd.Index])
def test_pi_sub_intarray(self, box):
# GH#19959
pi = pd.PeriodIndex([pd.Period('2015Q1'), pd.Period('NaT')])
other = box([4, -1])
result = pi - other
expected = pd.PeriodIndex([pd.Period('2014Q1'), pd.Period('NaT')])
tm.assert_index_equal(result, expected)

with pytest.raises(TypeError):
other - pi

# ---------------------------------------------------------------
# Timedelta-like (timedelta, timedelta64, Timedelta, Tick)
# TODO: Some of these are misnomers because of non-Tick DateOffsets
Expand Down
39 changes: 39 additions & 0 deletions pandas/tests/indexes/timedeltas/test_arithmetic.py
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,45 @@ def test_tdi_isub_int(self, one):
rng -= one
tm.assert_index_equal(rng, expected)

# -------------------------------------------------------------
# __add__/__sub__ with integer arrays

@pytest.mark.parametrize('box', [np.array, pd.Index])
def test_tdi_add_integer_array(self, box):
# GH#19959
rng = timedelta_range('1 days 09:00:00', freq='H', periods=3)
other = box([4, 3, 2])
expected = TimedeltaIndex(['1 day 13:00:00'] * 3)
result = rng + other
tm.assert_index_equal(result, expected)
result = other + rng
tm.assert_index_equal(result, expected)

@pytest.mark.parametrize('box', [np.array, pd.Index])
def test_tdi_sub_integer_array(self, box):
# GH#19959
rng = timedelta_range('9H', freq='H', periods=3)
other = box([4, 3, 2])
expected = TimedeltaIndex(['5H', '7H', '9H'])
result = rng - other
tm.assert_index_equal(result, expected)
result = other - rng
tm.assert_index_equal(result, -expected)

@pytest.mark.parametrize('box', [np.array, pd.Index])
def test_tdi_addsub_integer_array_no_freq(self, box):
# GH#19959
tdi = TimedeltaIndex(['1 Day', 'NaT', '3 Hours'])
other = box([14, -1, 16])
with pytest.raises(NullFrequencyError):
tdi + other
with pytest.raises(NullFrequencyError):
other + tdi
with pytest.raises(NullFrequencyError):
tdi - other
with pytest.raises(NullFrequencyError):
other - tdi

# -------------------------------------------------------------
# Binary operations TimedeltaIndex and timedelta-like

Expand Down