Skip to content

Commit a16c900

Browse files
committed
Merge branch 'master' of https://github.com/pandas-dev/pandas into dlike_ea
2 parents a83adad + cf11f71 commit a16c900

File tree

6 files changed

+183
-30
lines changed

6 files changed

+183
-30
lines changed

doc/source/whatsnew/v0.24.0.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -789,6 +789,7 @@ Datetimelike
789789
- Bug in :class:`DatetimeIndex` where frequency was being set if original frequency was ``None`` (:issue:`22150`)
790790
- Bug in rounding methods of :class:`DatetimeIndex` (:meth:`~DatetimeIndex.round`, :meth:`~DatetimeIndex.ceil`, :meth:`~DatetimeIndex.floor`) and :class:`Timestamp` (:meth:`~Timestamp.round`, :meth:`~Timestamp.ceil`, :meth:`~Timestamp.floor`) could give rise to loss of precision (:issue:`22591`)
791791
- Bug in :func:`to_datetime` with an :class:`Index` argument that would drop the ``name`` from the result (:issue:`21697`)
792+
- Bug in :class:`PeriodIndex` where adding or subtracting a :class:`timedelta` or :class:`Tick` object produced incorrect results (:issue:`22988`)
792793

793794
Timedelta
794795
^^^^^^^^^

pandas/core/arrays/datetimelike.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,11 @@ def _add_delta_tdi(self, other):
444444
if len(self) != len(other):
445445
raise ValueError("cannot add indices of unequal length")
446446

447+
if isinstance(other, np.ndarray):
448+
# ndarray[timedelta64]; wrap in TimedeltaIndex for op
449+
from pandas import TimedeltaIndex
450+
other = TimedeltaIndex(other)
451+
447452
self_i8 = self.asi8
448453
other_i8 = other.asi8
449454
new_values = checked_add_with_arr(self_i8, other_i8,
@@ -694,11 +699,17 @@ def __add__(self, other):
694699
return self._add_datelike(other)
695700
elif is_integer_dtype(other):
696701
result = self._addsub_int_array(other, operator.add)
697-
elif is_float_dtype(other) or is_period_dtype(other):
702+
elif is_float_dtype(other):
698703
# Explicitly catch invalid dtypes
699704
raise TypeError("cannot add {dtype}-dtype to {cls}"
700705
.format(dtype=other.dtype,
701706
cls=type(self).__name__))
707+
elif is_period_dtype(other):
708+
# if self is a TimedeltaArray and other is a PeriodArray with
709+
# a timedelta-like (i.e. Tick) freq, this operation is valid.
710+
# Defer to the PeriodArray implementation.
711+
# In remaining cases, this will end up raising TypeError.
712+
return NotImplemented
702713
elif is_extension_array_dtype(other):
703714
# Categorical op will raise; defer explicitly
704715
return NotImplemented

pandas/core/arrays/datetimes.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -526,7 +526,7 @@ def _add_delta(self, delta):
526526
Parameters
527527
----------
528528
delta : {timedelta, np.timedelta64, DateOffset,
529-
TimedelaIndex, ndarray[timedelta64]}
529+
TimedeltaIndex, ndarray[timedelta64]}
530530
531531
Returns
532532
-------

pandas/core/arrays/period.py

Lines changed: 101 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# -*- coding: utf-8 -*-
22
from datetime import timedelta
3+
import operator
34
import warnings
45

56
import numpy as np
@@ -17,8 +18,8 @@
1718
from pandas.util._decorators import cache_readonly, deprecate_kwarg, Appender
1819

1920
from pandas.core.dtypes.common import (
20-
is_integer_dtype, is_float_dtype, is_period_dtype,
21-
is_datetime64_dtype)
21+
is_integer_dtype, is_float_dtype, is_period_dtype, is_timedelta64_dtype,
22+
is_datetime64_dtype, _TD_DTYPE)
2223
from pandas.core.dtypes.dtypes import PeriodDtype
2324
from pandas.core.dtypes.generic import ABCSeries
2425
from pandas.core.dtypes.missing import isna
@@ -380,24 +381,54 @@ def _add_offset(self, other):
380381
return self._time_shift(other.n)
381382

382383
def _add_delta_td(self, other):
384+
assert isinstance(self.freq, Tick) # checked by calling function
383385
assert isinstance(other, (timedelta, np.timedelta64, Tick))
384-
nanos = delta_to_nanoseconds(other)
385-
own_offset = frequencies.to_offset(self.freq.rule_code)
386386

387-
if isinstance(own_offset, Tick):
388-
offset_nanos = delta_to_nanoseconds(own_offset)
389-
if np.all(nanos % offset_nanos == 0):
390-
return self._time_shift(nanos // offset_nanos)
387+
delta = self._check_timedeltalike_freq_compat(other)
391388

392-
# raise when input doesn't have freq
393-
raise IncompatibleFrequency("Input has different freq from "
394-
"{cls}(freq={freqstr})"
395-
.format(cls=type(self).__name__,
396-
freqstr=self.freqstr))
389+
# Note: when calling parent class's _add_delta_td, it will call
390+
# delta_to_nanoseconds(delta). Because delta here is an integer,
391+
# delta_to_nanoseconds will return it unchanged.
392+
return DatetimeLikeArrayMixin._add_delta_td(self, delta)
393+
394+
def _add_delta_tdi(self, other):
395+
assert isinstance(self.freq, Tick) # checked by calling function
396+
397+
delta = self._check_timedeltalike_freq_compat(other)
398+
return self._addsub_int_array(delta, operator.add)
397399

398400
def _add_delta(self, other):
399-
ordinal_delta = self._maybe_convert_timedelta(other)
400-
return self._time_shift(ordinal_delta)
401+
"""
402+
Add a timedelta-like, Tick, or TimedeltaIndex-like object
403+
to self.
404+
405+
Parameters
406+
----------
407+
other : {timedelta, np.timedelta64, Tick,
408+
TimedeltaIndex, ndarray[timedelta64]}
409+
410+
Returns
411+
-------
412+
result : same type as self
413+
"""
414+
if not isinstance(self.freq, Tick):
415+
# We cannot add timedelta-like to non-tick PeriodArray
416+
raise IncompatibleFrequency("Input has different freq from "
417+
"{cls}(freq={freqstr})"
418+
.format(cls=type(self).__name__,
419+
freqstr=self.freqstr))
420+
421+
# TODO: standardize across datetimelike subclasses whether to return
422+
# i8 view or _shallow_copy
423+
if isinstance(other, (Tick, timedelta, np.timedelta64)):
424+
new_values = self._add_delta_td(other)
425+
return self._shallow_copy(new_values)
426+
elif is_timedelta64_dtype(other):
427+
# ndarray[timedelta64] or TimedeltaArray/index
428+
new_values = self._add_delta_tdi(other)
429+
return self._shallow_copy(new_values)
430+
else: # pragma: no cover
431+
raise TypeError(type(other).__name__)
401432

402433
@deprecate_kwarg(old_arg_name='n', new_arg_name='periods')
403434
def shift(self, periods):
@@ -453,14 +484,9 @@ def _maybe_convert_timedelta(self, other):
453484
other, (timedelta, np.timedelta64, Tick, np.ndarray)):
454485
offset = frequencies.to_offset(self.freq.rule_code)
455486
if isinstance(offset, Tick):
456-
if isinstance(other, np.ndarray):
457-
nanos = np.vectorize(delta_to_nanoseconds)(other)
458-
else:
459-
nanos = delta_to_nanoseconds(other)
460-
offset_nanos = delta_to_nanoseconds(offset)
461-
check = np.all(nanos % offset_nanos == 0)
462-
if check:
463-
return nanos // offset_nanos
487+
# _check_timedeltalike_freq_compat will raise if incompatible
488+
delta = self._check_timedeltalike_freq_compat(other)
489+
return delta
464490
elif isinstance(other, DateOffset):
465491
freqstr = other.rule_code
466492
base = frequencies.get_base_alias(freqstr)
@@ -479,6 +505,58 @@ def _maybe_convert_timedelta(self, other):
479505
raise IncompatibleFrequency(msg.format(cls=type(self).__name__,
480506
freqstr=self.freqstr))
481507

508+
def _check_timedeltalike_freq_compat(self, other):
509+
"""
510+
Arithmetic operations with timedelta-like scalars or array `other`
511+
are only valid if `other` is an integer multiple of `self.freq`.
512+
If the operation is valid, find that integer multiple. Otherwise,
513+
raise because the operation is invalid.
514+
515+
Parameters
516+
----------
517+
other : timedelta, np.timedelta64, Tick,
518+
ndarray[timedelta64], TimedeltaArray, TimedeltaIndex
519+
520+
Returns
521+
-------
522+
multiple : int or ndarray[int64]
523+
524+
Raises
525+
------
526+
IncompatibleFrequency
527+
"""
528+
assert isinstance(self.freq, Tick) # checked by calling function
529+
own_offset = frequencies.to_offset(self.freq.rule_code)
530+
base_nanos = delta_to_nanoseconds(own_offset)
531+
532+
if isinstance(other, (timedelta, np.timedelta64, Tick)):
533+
nanos = delta_to_nanoseconds(other)
534+
535+
elif isinstance(other, np.ndarray):
536+
# numpy timedelta64 array; all entries must be compatible
537+
assert other.dtype.kind == 'm'
538+
if other.dtype != _TD_DTYPE:
539+
# i.e. non-nano unit
540+
# TODO: disallow unit-less timedelta64
541+
other = other.astype(_TD_DTYPE)
542+
nanos = other.view('i8')
543+
else:
544+
# TimedeltaArray/Index
545+
nanos = other.asi8
546+
547+
if np.all(nanos % base_nanos == 0):
548+
# nanos being added is an integer multiple of the
549+
# base-frequency to self.freq
550+
delta = nanos // base_nanos
551+
# delta is the integer (or integer-array) number of periods
552+
# by which will be added to self.
553+
return delta
554+
555+
raise IncompatibleFrequency("Input has different freq from "
556+
"{cls}(freq={freqstr})"
557+
.format(cls=type(self).__name__,
558+
freqstr=self.freqstr))
559+
482560

483561
PeriodArrayMixin._add_comparison_ops()
484562
PeriodArrayMixin._add_datetimelike_methods()

pandas/tests/arithmetic/test_period.py

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -446,26 +446,36 @@ def test_pi_add_sub_td64_array_non_tick_raises(self):
446446
with pytest.raises(period.IncompatibleFrequency):
447447
tdarr - rng
448448

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

456-
expected = rng + tdi
456+
expected = pd.period_range('12/31/1999', freq='90D', periods=3)
457+
result = rng + tdi
458+
tm.assert_index_equal(result, expected)
457459
result = rng + tdarr
458460
tm.assert_index_equal(result, expected)
461+
result = tdi + rng
462+
tm.assert_index_equal(result, expected)
459463
result = tdarr + rng
460464
tm.assert_index_equal(result, expected)
461465

462-
expected = rng - tdi
466+
expected = pd.period_range('1/2/2000', freq='90D', periods=3)
467+
468+
result = rng - tdi
469+
tm.assert_index_equal(result, expected)
463470
result = rng - tdarr
464471
tm.assert_index_equal(result, expected)
465472

466473
with pytest.raises(TypeError):
467474
tdarr - rng
468475

476+
with pytest.raises(TypeError):
477+
tdi - rng
478+
469479
# -----------------------------------------------------------------
470480
# operations with array/Index of DateOffset objects
471481

@@ -596,6 +606,56 @@ def test_pi_sub_intarray(self, box):
596606
# Timedelta-like (timedelta, timedelta64, Timedelta, Tick)
597607
# TODO: Some of these are misnomers because of non-Tick DateOffsets
598608

609+
def test_pi_add_timedeltalike_minute_gt1(self, three_days):
610+
# GH#23031 adding a time-delta-like offset to a PeriodArray that has
611+
# minute frequency with n != 1. A more general case is tested below
612+
# in test_pi_add_timedeltalike_tick_gt1, but here we write out the
613+
# expected result more explicitly.
614+
other = three_days
615+
rng = pd.period_range('2014-05-01', periods=3, freq='2D')
616+
617+
expected = pd.PeriodIndex(['2014-05-04', '2014-05-06', '2014-05-08'],
618+
freq='2D')
619+
620+
result = rng + other
621+
tm.assert_index_equal(result, expected)
622+
623+
result = other + rng
624+
tm.assert_index_equal(result, expected)
625+
626+
# subtraction
627+
expected = pd.PeriodIndex(['2014-04-28', '2014-04-30', '2014-05-02'],
628+
freq='2D')
629+
result = rng - other
630+
tm.assert_index_equal(result, expected)
631+
632+
with pytest.raises(TypeError):
633+
other - rng
634+
635+
@pytest.mark.parametrize('freqstr', ['5ns', '5us', '5ms',
636+
'5s', '5T', '5h', '5d'])
637+
def test_pi_add_timedeltalike_tick_gt1(self, three_days, freqstr):
638+
# GH#23031 adding a time-delta-like offset to a PeriodArray that has
639+
# tick-like frequency with n != 1
640+
other = three_days
641+
rng = pd.period_range('2014-05-01', periods=6, freq=freqstr)
642+
643+
expected = pd.period_range(rng[0] + other, periods=6, freq=freqstr)
644+
645+
result = rng + other
646+
tm.assert_index_equal(result, expected)
647+
648+
result = other + rng
649+
tm.assert_index_equal(result, expected)
650+
651+
# subtraction
652+
expected = pd.period_range(rng[0] - other, periods=6, freq=freqstr)
653+
result = rng - other
654+
tm.assert_index_equal(result, expected)
655+
656+
with pytest.raises(TypeError):
657+
other - rng
658+
599659
def test_pi_add_iadd_timedeltalike_daily(self, three_days):
600660
# Tick
601661
other = three_days

pandas/util/testing.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -805,6 +805,7 @@ def assert_index_equal(left, right, exact='equiv', check_names=True,
805805
Specify object name being compared, internally used to show appropriate
806806
assertion message
807807
"""
808+
__tracebackhide__ = True
808809

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

10491050

10501051
def raise_assert_detail(obj, message, left, right, diff=None):
1052+
__tracebackhide__ = True
1053+
10511054
if isinstance(left, np.ndarray):
10521055
left = pprint_thing(left)
10531056
elif is_categorical_dtype(left):

0 commit comments

Comments
 (0)