Skip to content

Commit e800fe1

Browse files
committed
Merge pull request #7966 from sinhrks/period_delta
ENH/BUG: Period and PeriodIndex ops supports timedelta-like
2 parents 81fcbc0 + 8f6ac5b commit e800fe1

File tree

5 files changed

+417
-13
lines changed

5 files changed

+417
-13
lines changed

doc/source/timeseries.rst

+43-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
.. ipython:: python
55
:suppress:
66
7-
from datetime import datetime
7+
from datetime import datetime, timedelta
88
import numpy as np
99
np.random.seed(123456)
1010
from pandas import *
@@ -1098,6 +1098,36 @@ frequency.
10981098
10991099
p - 3
11001100
1101+
If ``Period`` freq is daily or higher (``D``, ``H``, ``T``, ``S``, ``L``, ``U``, ``N``), ``offsets`` and ``timedelta``-like can be added if the result can have same freq. Otherise, ``ValueError`` will be raised.
1102+
1103+
.. ipython:: python
1104+
1105+
p = Period('2014-07-01 09:00', freq='H')
1106+
p + Hour(2)
1107+
p + timedelta(minutes=120)
1108+
p + np.timedelta64(7200, 's')
1109+
1110+
.. code-block:: python
1111+
1112+
In [1]: p + Minute(5)
1113+
Traceback
1114+
...
1115+
ValueError: Input has different freq from Period(freq=H)
1116+
1117+
If ``Period`` has other freqs, only the same ``offsets`` can be added. Otherwise, ``ValueError`` will be raised.
1118+
1119+
.. ipython:: python
1120+
1121+
p = Period('2014-07', freq='M')
1122+
p + MonthEnd(3)
1123+
1124+
.. code-block:: python
1125+
1126+
In [1]: p + MonthBegin(3)
1127+
Traceback
1128+
...
1129+
ValueError: Input has different freq from Period(freq=M)
1130+
11011131
Taking the difference of ``Period`` instances with the same frequency will
11021132
return the number of frequency units between them:
11031133

@@ -1129,6 +1159,18 @@ objects:
11291159
ps = Series(randn(len(prng)), prng)
11301160
ps
11311161
1162+
``PeriodIndex`` supports addition and subtraction as the same rule as ``Period``.
1163+
1164+
.. ipython:: python
1165+
1166+
idx = period_range('2014-07-01 09:00', periods=5, freq='H')
1167+
idx
1168+
idx + Hour(2)
1169+
1170+
idx = period_range('2014-07', periods=5, freq='M')
1171+
idx
1172+
idx + MonthEnd(3)
1173+
11321174
PeriodIndex Partial String Indexing
11331175
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
11341176

doc/source/v0.15.0.txt

+15
Original file line numberDiff line numberDiff line change
@@ -271,10 +271,21 @@ Enhancements
271271

272272

273273

274+
- ``Period`` and ``PeriodIndex`` supports addition/subtraction with ``timedelta``-likes (:issue:`7966`)
274275

276+
If ``Period`` freq is ``D``, ``H``, ``T``, ``S``, ``L``, ``U``, ``N``, ``timedelta``-like can be added if the result can have same freq. Otherwise, only the same ``offsets`` can be added.
275277

278+
.. ipython:: python
276279

280+
idx = pd.period_range('2014-07-01 09:00', periods=5, freq='H')
281+
idx
282+
idx + pd.offsets.Hour(2)
283+
idx + timedelta(minutes=120)
284+
idx + np.timedelta64(7200, 's')
277285

286+
idx = pd.period_range('2014-07', periods=5, freq='M')
287+
idx
288+
idx + pd.offsets.MonthEnd(3)
278289

279290

280291

@@ -415,6 +426,10 @@ Bug Fixes
415426

416427

417428

429+
- ``Period`` and ``PeriodIndex`` addition/subtraction with ``np.timedelta64`` results in incorrect internal representations (:issue:`7740`)
430+
431+
432+
418433

419434

420435

pandas/tests/test_base.py

+119-7
Original file line numberDiff line numberDiff line change
@@ -915,6 +915,8 @@ def test_resolution(self):
915915
self.assertEqual(idx.resolution, expected)
916916

917917
def test_add_iadd(self):
918+
tm._skip_if_not_numpy17_friendly()
919+
918920
# union
919921
rng1 = pd.period_range('1/1/2000', freq='D', periods=5)
920922
other1 = pd.period_range('1/6/2000', freq='D', periods=5)
@@ -968,11 +970,64 @@ def test_add_iadd(self):
968970
tm.assert_index_equal(rng, expected)
969971

970972
# offset
971-
for delta in [pd.offsets.Hour(2), timedelta(hours=2)]:
972-
rng = pd.period_range('2000-01-01', '2000-02-01')
973-
with tm.assertRaisesRegexp(TypeError, 'unsupported operand type\(s\)'):
973+
# DateOffset
974+
rng = pd.period_range('2014', '2024', freq='A')
975+
result = rng + pd.offsets.YearEnd(5)
976+
expected = pd.period_range('2019', '2029', freq='A')
977+
tm.assert_index_equal(result, expected)
978+
rng += pd.offsets.YearEnd(5)
979+
tm.assert_index_equal(rng, expected)
980+
981+
for o in [pd.offsets.YearBegin(2), pd.offsets.MonthBegin(1), pd.offsets.Minute(),
982+
np.timedelta64(365, 'D'), timedelta(365)]:
983+
with tm.assertRaisesRegexp(ValueError, 'Input has different freq from Period'):
984+
rng + o
985+
986+
rng = pd.period_range('2014-01', '2016-12', freq='M')
987+
result = rng + pd.offsets.MonthEnd(5)
988+
expected = pd.period_range('2014-06', '2017-05', freq='M')
989+
tm.assert_index_equal(result, expected)
990+
rng += pd.offsets.MonthEnd(5)
991+
tm.assert_index_equal(rng, expected)
992+
993+
for o in [pd.offsets.YearBegin(2), pd.offsets.MonthBegin(1), pd.offsets.Minute(),
994+
np.timedelta64(365, 'D'), timedelta(365)]:
995+
rng = pd.period_range('2014-01', '2016-12', freq='M')
996+
with tm.assertRaisesRegexp(ValueError, 'Input has different freq from Period'):
997+
rng + o
998+
999+
# Tick
1000+
offsets = [pd.offsets.Day(3), timedelta(days=3), np.timedelta64(3, 'D'),
1001+
pd.offsets.Hour(72), timedelta(minutes=60*24*3), np.timedelta64(72, 'h')]
1002+
for delta in offsets:
1003+
rng = pd.period_range('2014-05-01', '2014-05-15', freq='D')
1004+
result = rng + delta
1005+
expected = pd.period_range('2014-05-04', '2014-05-18', freq='D')
1006+
tm.assert_index_equal(result, expected)
1007+
rng += delta
1008+
tm.assert_index_equal(rng, expected)
1009+
1010+
for o in [pd.offsets.YearBegin(2), pd.offsets.MonthBegin(1), pd.offsets.Minute(),
1011+
np.timedelta64(4, 'h'), timedelta(hours=23)]:
1012+
rng = pd.period_range('2014-05-01', '2014-05-15', freq='D')
1013+
with tm.assertRaisesRegexp(ValueError, 'Input has different freq from Period'):
1014+
rng + o
1015+
1016+
offsets = [pd.offsets.Hour(2), timedelta(hours=2), np.timedelta64(2, 'h'),
1017+
pd.offsets.Minute(120), timedelta(minutes=120), np.timedelta64(120, 'm')]
1018+
for delta in offsets:
1019+
rng = pd.period_range('2014-01-01 10:00', '2014-01-05 10:00', freq='H')
1020+
result = rng + delta
1021+
expected = pd.period_range('2014-01-01 12:00', '2014-01-05 12:00', freq='H')
1022+
tm.assert_index_equal(result, expected)
1023+
rng += delta
1024+
tm.assert_index_equal(rng, expected)
1025+
1026+
for delta in [pd.offsets.YearBegin(2), timedelta(minutes=30), np.timedelta64(30, 's')]:
1027+
rng = pd.period_range('2014-01-01 10:00', '2014-01-05 10:00', freq='H')
1028+
with tm.assertRaisesRegexp(ValueError, 'Input has different freq from Period'):
9741029
result = rng + delta
975-
with tm.assertRaisesRegexp(TypeError, 'unsupported operand type\(s\)'):
1030+
with tm.assertRaisesRegexp(ValueError, 'Input has different freq from Period'):
9761031
rng += delta
9771032

9781033
# int
@@ -984,6 +1039,8 @@ def test_add_iadd(self):
9841039
tm.assert_index_equal(rng, expected)
9851040

9861041
def test_sub_isub(self):
1042+
tm._skip_if_not_numpy17_friendly()
1043+
9871044
# diff
9881045
rng1 = pd.period_range('1/1/2000', freq='D', periods=5)
9891046
other1 = pd.period_range('1/6/2000', freq='D', periods=5)
@@ -1027,10 +1084,65 @@ def test_sub_isub(self):
10271084
tm.assert_index_equal(rng, expected)
10281085

10291086
# offset
1030-
for delta in [pd.offsets.Hour(2), timedelta(hours=2)]:
1031-
with tm.assertRaisesRegexp(TypeError, 'unsupported operand type\(s\)'):
1087+
# DateOffset
1088+
rng = pd.period_range('2014', '2024', freq='A')
1089+
result = rng - pd.offsets.YearEnd(5)
1090+
expected = pd.period_range('2009', '2019', freq='A')
1091+
tm.assert_index_equal(result, expected)
1092+
rng -= pd.offsets.YearEnd(5)
1093+
tm.assert_index_equal(rng, expected)
1094+
1095+
for o in [pd.offsets.YearBegin(2), pd.offsets.MonthBegin(1), pd.offsets.Minute(),
1096+
np.timedelta64(365, 'D'), timedelta(365)]:
1097+
rng = pd.period_range('2014', '2024', freq='A')
1098+
with tm.assertRaisesRegexp(ValueError, 'Input has different freq from Period'):
1099+
rng - o
1100+
1101+
rng = pd.period_range('2014-01', '2016-12', freq='M')
1102+
result = rng - pd.offsets.MonthEnd(5)
1103+
expected = pd.period_range('2013-08', '2016-07', freq='M')
1104+
tm.assert_index_equal(result, expected)
1105+
rng -= pd.offsets.MonthEnd(5)
1106+
tm.assert_index_equal(rng, expected)
1107+
1108+
for o in [pd.offsets.YearBegin(2), pd.offsets.MonthBegin(1), pd.offsets.Minute(),
1109+
np.timedelta64(365, 'D'), timedelta(365)]:
1110+
rng = pd.period_range('2014-01', '2016-12', freq='M')
1111+
with tm.assertRaisesRegexp(ValueError, 'Input has different freq from Period'):
1112+
rng - o
1113+
1114+
# Tick
1115+
offsets = [pd.offsets.Day(3), timedelta(days=3), np.timedelta64(3, 'D'),
1116+
pd.offsets.Hour(72), timedelta(minutes=60*24*3), np.timedelta64(72, 'h')]
1117+
for delta in offsets:
1118+
rng = pd.period_range('2014-05-01', '2014-05-15', freq='D')
1119+
result = rng - delta
1120+
expected = pd.period_range('2014-04-28', '2014-05-12', freq='D')
1121+
tm.assert_index_equal(result, expected)
1122+
rng -= delta
1123+
tm.assert_index_equal(rng, expected)
1124+
1125+
for o in [pd.offsets.YearBegin(2), pd.offsets.MonthBegin(1), pd.offsets.Minute(),
1126+
np.timedelta64(4, 'h'), timedelta(hours=23)]:
1127+
rng = pd.period_range('2014-05-01', '2014-05-15', freq='D')
1128+
with tm.assertRaisesRegexp(ValueError, 'Input has different freq from Period'):
1129+
rng - o
1130+
1131+
offsets = [pd.offsets.Hour(2), timedelta(hours=2), np.timedelta64(2, 'h'),
1132+
pd.offsets.Minute(120), timedelta(minutes=120), np.timedelta64(120, 'm')]
1133+
for delta in offsets:
1134+
rng = pd.period_range('2014-01-01 10:00', '2014-01-05 10:00', freq='H')
1135+
result = rng - delta
1136+
expected = pd.period_range('2014-01-01 08:00', '2014-01-05 08:00', freq='H')
1137+
tm.assert_index_equal(result, expected)
1138+
rng -= delta
1139+
tm.assert_index_equal(rng, expected)
1140+
1141+
for delta in [pd.offsets.YearBegin(2), timedelta(minutes=30), np.timedelta64(30, 's')]:
1142+
rng = pd.period_range('2014-01-01 10:00', '2014-01-05 10:00', freq='H')
1143+
with tm.assertRaisesRegexp(ValueError, 'Input has different freq from Period'):
10321144
result = rng + delta
1033-
with tm.assertRaisesRegexp(TypeError, 'unsupported operand type\(s\)'):
1145+
with tm.assertRaisesRegexp(ValueError, 'Input has different freq from Period'):
10341146
rng += delta
10351147

10361148
# int

pandas/tseries/period.py

+54-4
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# pylint: disable=E1101,E1103,W0232
22
import operator
33

4-
from datetime import datetime, date
4+
from datetime import datetime, date, timedelta
55
import numpy as np
66
from pandas.core.base import PandasObject
77

@@ -10,6 +10,7 @@
1010
from pandas.tseries.index import DatetimeIndex, Int64Index, Index
1111
from pandas.core.base import DatetimeIndexOpsMixin
1212
from pandas.tseries.tools import parse_time_string
13+
import pandas.tseries.offsets as offsets
1314

1415
import pandas.core.common as com
1516
from pandas.core.common import (isnull, _INT64_DTYPE, _maybe_box,
@@ -169,8 +170,37 @@ def __ne__(self, other):
169170
def __hash__(self):
170171
return hash((self.ordinal, self.freq))
171172

173+
def _add_delta(self, other):
174+
if isinstance(other, (timedelta, np.timedelta64, offsets.Tick)):
175+
offset = frequencies.to_offset(self.freq)
176+
if isinstance(offset, offsets.Tick):
177+
nanos = tslib._delta_to_nanoseconds(other)
178+
offset_nanos = tslib._delta_to_nanoseconds(offset)
179+
180+
if nanos % offset_nanos == 0:
181+
if self.ordinal == tslib.iNaT:
182+
ordinal = self.ordinal
183+
else:
184+
ordinal = self.ordinal + (nanos // offset_nanos)
185+
return Period(ordinal=ordinal, freq=self.freq)
186+
elif isinstance(other, offsets.DateOffset):
187+
freqstr = frequencies.get_standard_freq(other)
188+
base = frequencies.get_base_alias(freqstr)
189+
190+
if base == self.freq:
191+
if self.ordinal == tslib.iNaT:
192+
ordinal = self.ordinal
193+
else:
194+
ordinal = self.ordinal + other.n
195+
return Period(ordinal=ordinal, freq=self.freq)
196+
197+
raise ValueError("Input has different freq from Period(freq={0})".format(self.freq))
198+
172199
def __add__(self, other):
173-
if com.is_integer(other):
200+
if isinstance(other, (timedelta, np.timedelta64,
201+
offsets.Tick, offsets.DateOffset)):
202+
return self._add_delta(other)
203+
elif com.is_integer(other):
174204
if self.ordinal == tslib.iNaT:
175205
ordinal = self.ordinal
176206
else:
@@ -180,13 +210,17 @@ def __add__(self, other):
180210
return NotImplemented
181211

182212
def __sub__(self, other):
183-
if com.is_integer(other):
213+
if isinstance(other, (timedelta, np.timedelta64,
214+
offsets.Tick, offsets.DateOffset)):
215+
neg_other = -other
216+
return self + neg_other
217+
elif com.is_integer(other):
184218
if self.ordinal == tslib.iNaT:
185219
ordinal = self.ordinal
186220
else:
187221
ordinal = self.ordinal - other
188222
return Period(ordinal=ordinal, freq=self.freq)
189-
if isinstance(other, Period):
223+
elif isinstance(other, Period):
190224
if other.freq != self.freq:
191225
raise ValueError("Cannot do arithmetic with "
192226
"non-conforming periods")
@@ -862,6 +896,22 @@ def to_timestamp(self, freq=None, how='start'):
862896
new_data = tslib.periodarr_to_dt64arr(new_data.values, base)
863897
return DatetimeIndex(new_data, freq='infer', name=self.name)
864898

899+
def _add_delta(self, other):
900+
if isinstance(other, (timedelta, np.timedelta64, offsets.Tick)):
901+
offset = frequencies.to_offset(self.freq)
902+
if isinstance(offset, offsets.Tick):
903+
nanos = tslib._delta_to_nanoseconds(other)
904+
offset_nanos = tslib._delta_to_nanoseconds(offset)
905+
if nanos % offset_nanos == 0:
906+
return self.shift(nanos // offset_nanos)
907+
elif isinstance(other, offsets.DateOffset):
908+
freqstr = frequencies.get_standard_freq(other)
909+
base = frequencies.get_base_alias(freqstr)
910+
911+
if base == self.freq:
912+
return self.shift(other.n)
913+
raise ValueError("Input has different freq from PeriodIndex(freq={0})".format(self.freq))
914+
865915
def shift(self, n):
866916
"""
867917
Specialized shift which produces an PeriodIndex

0 commit comments

Comments
 (0)