Skip to content

Commit 7c4d281

Browse files
committed
Merge branch 'master' of https://github.com/pandas-dev/pandas into dlike8
2 parents 147de57 + cf11f71 commit 7c4d281

File tree

8 files changed

+240
-187
lines changed

8 files changed

+240
-187
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
@@ -399,6 +399,11 @@ def _add_delta_tdi(self, other):
399399
if not len(self) == len(other):
400400
raise ValueError("cannot add indices of unequal length")
401401

402+
if isinstance(other, np.ndarray):
403+
# ndarray[timedelta64]; wrap in TimedeltaIndex for op
404+
from pandas import TimedeltaIndex
405+
other = TimedeltaIndex(other)
406+
402407
self_i8 = self.asi8
403408
other_i8 = other.asi8
404409
new_values = checked_add_with_arr(self_i8, other_i8,
@@ -652,11 +657,17 @@ def __add__(self, other):
652657
return self._add_datelike(other)
653658
elif is_integer_dtype(other):
654659
result = self._addsub_int_array(other, operator.add)
655-
elif is_float_dtype(other) or is_period_dtype(other):
660+
elif is_float_dtype(other):
656661
# Explicitly catch invalid dtypes
657662
raise TypeError("cannot add {dtype}-dtype to {cls}"
658663
.format(dtype=other.dtype,
659664
cls=type(self).__name__))
665+
elif is_period_dtype(other):
666+
# if self is a TimedeltaArray and other is a PeriodArray with
667+
# a timedelta-like (i.e. Tick) freq, this operation is valid.
668+
# Defer to the PeriodArray implementation.
669+
# In remaining cases, this will end up raising TypeError.
670+
return NotImplemented
660671
elif is_extension_array_dtype(other):
661672
# Categorical op will raise; defer explicitly
662673
return NotImplemented

pandas/core/arrays/datetimes.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -497,7 +497,7 @@ def _add_delta(self, delta):
497497
Parameters
498498
----------
499499
delta : {timedelta, np.timedelta64, DateOffset,
500-
TimedelaIndex, ndarray[timedelta64]}
500+
TimedeltaIndex, ndarray[timedelta64]}
501501
502502
Returns
503503
-------

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,9 +18,9 @@
1718
from pandas.util._decorators import (cache_readonly, deprecate_kwarg)
1819

1920
from pandas.core.dtypes.common import (
20-
is_integer_dtype, is_float_dtype, is_period_dtype,
21+
is_integer_dtype, is_float_dtype, is_period_dtype, is_timedelta64_dtype,
2122
is_object_dtype,
22-
is_datetime64_dtype)
23+
is_datetime64_dtype, _TD_DTYPE)
2324
from pandas.core.dtypes.dtypes import PeriodDtype
2425
from pandas.core.dtypes.generic import ABCSeries
2526

@@ -377,24 +378,54 @@ def _add_offset(self, other):
377378
return self._time_shift(other.n)
378379

379380
def _add_delta_td(self, other):
381+
assert isinstance(self.freq, Tick) # checked by calling function
380382
assert isinstance(other, (timedelta, np.timedelta64, Tick))
381-
nanos = delta_to_nanoseconds(other)
382-
own_offset = frequencies.to_offset(self.freq.rule_code)
383383

384-
if isinstance(own_offset, Tick):
385-
offset_nanos = delta_to_nanoseconds(own_offset)
386-
if np.all(nanos % offset_nanos == 0):
387-
return self._time_shift(nanos // offset_nanos)
384+
delta = self._check_timedeltalike_freq_compat(other)
388385

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

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

399430
@deprecate_kwarg(old_arg_name='n', new_arg_name='periods')
400431
def shift(self, periods):
@@ -450,14 +481,9 @@ def _maybe_convert_timedelta(self, other):
450481
other, (timedelta, np.timedelta64, Tick, np.ndarray)):
451482
offset = frequencies.to_offset(self.freq.rule_code)
452483
if isinstance(offset, Tick):
453-
if isinstance(other, np.ndarray):
454-
nanos = np.vectorize(delta_to_nanoseconds)(other)
455-
else:
456-
nanos = delta_to_nanoseconds(other)
457-
offset_nanos = delta_to_nanoseconds(offset)
458-
check = np.all(nanos % offset_nanos == 0)
459-
if check:
460-
return nanos // offset_nanos
484+
# _check_timedeltalike_freq_compat will raise if incompatible
485+
delta = self._check_timedeltalike_freq_compat(other)
486+
return delta
461487
elif isinstance(other, DateOffset):
462488
freqstr = other.rule_code
463489
base = frequencies.get_base_alias(freqstr)
@@ -476,6 +502,58 @@ def _maybe_convert_timedelta(self, other):
476502
raise IncompatibleFrequency(msg.format(cls=type(self).__name__,
477503
freqstr=self.freqstr))
478504

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

480558
PeriodArrayMixin._add_comparison_ops()
481559
PeriodArrayMixin._add_datetimelike_methods()

0 commit comments

Comments
 (0)