Skip to content

API: Tick arithmetic respects calendar time #22195

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

Closed
wants to merge 13 commits into from
11 changes: 8 additions & 3 deletions pandas/_libs/tslibs/timestamps.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -268,13 +268,18 @@ cdef class _Timestamp(datetime):
"without freq.")
return Timestamp((self.freq * other).apply(self), freq=self.freq)

elif PyDelta_Check(other) or hasattr(other, 'delta'):
# delta --> offsets.Tick
elif PyDelta_Check(other):
nanos = delta_to_nanoseconds(other)
result = Timestamp(self.value + nanos,
tz=self.tzinfo, freq=self.freq)
return result

elif hasattr(other, 'delta'):
Copy link
Member

Choose a reason for hiding this comment

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

You might be able to import tslibs.offsets._Tick here and make this an isinstance check (thought that might cause a circular import, not sure off the top)

# delta --> offsets.Tick
dst = bool(self.dst())
result = self.tz_localize(None) + other.delta
result = result.tz_localize(self.tz, ambiguous=dst)
if getattr(other, 'normalize', False):
# DateOffset
result = result.normalize()
return result

Expand Down
13 changes: 7 additions & 6 deletions pandas/core/arrays/datetimes.py
Original file line number Diff line number Diff line change
Expand Up @@ -466,22 +466,19 @@ def _sub_datelike_dti(self, other):
return new_values.view('timedelta64[ns]')

def _add_offset(self, offset):
assert not isinstance(offset, Tick)
try:
if self.tz is not None:
values = self.tz_localize(None)
else:
values = self
result = offset.apply_index(values)
if self.tz is not None:
result = result.tz_localize(self.tz)

except NotImplementedError:
warnings.warn("Non-vectorized DateOffset being applied to Series "
"or DatetimeIndex", PerformanceWarning)
result = self.astype('O') + offset

return type(self)(result, freq='infer')
return type(self)(result, freq='infer', tz=self.tz)
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 was changed because if the code goes down the NotImplementedError path, the tz would be dropped.


def _sub_datelike(self, other):
# subtract a datetime from myself, yielding a ndarray[timedelta64[ns]]
Expand Down Expand Up @@ -536,8 +533,12 @@ def _add_delta(self, delta):
method (__add__ or __sub__)
"""
from pandas.core.arrays.timedeltas import TimedeltaArrayMixin

if isinstance(delta, (Tick, timedelta, np.timedelta64)):
if isinstance(delta, Tick):
# GH 20633: Ticks behave like offsets now, but cannot change
# this directly in the mixin because it affects Periods and
# Timedeltas
return self._add_offset(delta)
elif isinstance(delta, (timedelta, np.timedelta64)):
new_values = self._add_delta_td(delta)
elif is_timedelta64_dtype(delta):
if not isinstance(delta, TimedeltaArrayMixin):
Expand Down
25 changes: 25 additions & 0 deletions pandas/tests/indexes/datetimes/test_arithmetic.py
Original file line number Diff line number Diff line change
Expand Up @@ -1106,6 +1106,31 @@ def test_dti_add_offset_tzaware(self, tz_aware_fixture):
offset = dates + timedelta(hours=5)
tm.assert_index_equal(offset, expected)

@pytest.mark.parametrize('offset, factor', [
['Day', 1],
['Hour', 24],
['Minute', 24 * 60],
['Second', 24 * 60 * 60],
['Milli', 8.64e7],
['Micro', 8.64e10],
['Nano', 8.64e13]
])
def test_dti_tick_arithmetic_dst_roundtrip(self, offset, factor):
# GH 20633
# Tick classes (e.g. Day) should now respect calendar arithmetic
# Test that calendar day is respected by roundtripping across DST
tick = factor * getattr(pd.offsets, offset)(1)
ts = Timestamp('2016-10-30 00:00:00+0300', tz='Europe/Helsinki')
dti = DatetimeIndex([ts])
expected = Timestamp('2016-10-31 00:00:00+0200', tz='Europe/Helsinki')
expected = DatetimeIndex([expected])

result = dti + tick
tm.assert_index_equal(result, expected)

result = result - tick
tm.assert_index_equal(result, dti)


@pytest.mark.parametrize('klass,assert_func', [
(Series, tm.assert_series_equal),
Expand Down
25 changes: 25 additions & 0 deletions pandas/tests/series/test_arithmetic.py
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,31 @@ def test_ops_series_timedelta(self):
result = pd.tseries.offsets.Day() + ser
tm.assert_series_equal(result, expected)

@pytest.mark.parametrize('offset, factor', [
['Day', 1],
['Hour', 24],
['Minute', 24 * 60],
['Second', 24 * 60 * 60],
['Milli', 8.64e7],
['Micro', 8.64e10],
['Nano', 8.64e13]
])
def test_series_tick_arithmetic_dst_roundtrip(self, offset, factor):
# GH 20633
# Tick classes (e.g. Day) should now respect calendar arithmetic
# Test that calendar day is respected by roundtripping across DST
tick = factor * getattr(pd.offsets, offset)(1)
ts = Timestamp('2016-10-30 00:00:00+0300', tz='Europe/Helsinki')
s = Series([ts])
expected = Timestamp('2016-10-31 00:00:00+0200', tz='Europe/Helsinki')
expected = Series([expected])

result = s + tick
tm.assert_series_equal(result, expected)

result = result - tick
tm.assert_series_equal(result, s)

def test_ops_series_period(self):
# GH 13043
ser = pd.Series([pd.Period('2015-01-01', freq='D'),
Expand Down
26 changes: 25 additions & 1 deletion pandas/tests/tseries/offsets/test_offsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -3035,7 +3035,7 @@ def test_springforward_singular(self):
QuarterEnd: ['11/2/2012', '12/31/2012'],
BQuarterBegin: ['11/2/2012', '12/3/2012'],
BQuarterEnd: ['11/2/2012', '12/31/2012'],
Day: ['11/4/2012', '11/4/2012 23:00']}.items()
Day: ['11/4/2012', '11/5/2012']}.items()

@pytest.mark.parametrize('tup', offset_classes)
def test_all_offset_classes(self, tup):
Expand Down Expand Up @@ -3162,3 +3162,27 @@ def test_last_week_of_month_on_offset():
slow = (ts + offset) - offset == ts
fast = offset.onOffset(ts)
assert fast == slow


@pytest.mark.parametrize('offset, factor', [
['Day', 1],
['Hour', 24],
['Minute', 24 * 60],
['Second', 24 * 60 * 60],
['Milli', 8.64e7],
['Micro', 8.64e10],
['Nano', 8.64e13]
])
def test_tick_dst_arithmetic(offset, factor):
# GH 20633
# Tick classes (e.g. Day) should now respect calendar arithmetic
# Test that calendar day is respected by roundtripping across DST
tick = factor * getattr(offsets, offset)(1)
ts = Timestamp('2016-10-30 00:00:00+0300', tz='Europe/Helsinki')
expected = Timestamp('2016-10-31 00:00:00+0200', tz='Europe/Helsinki')

result = ts + tick
assert result == expected

result = result - tick
assert result == ts
19 changes: 18 additions & 1 deletion pandas/tseries/offsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -2204,7 +2204,6 @@ def delta(self):
def nanos(self):
return delta_to_nanoseconds(self.delta)

# TODO: Should Tick have its own apply_index?
def apply(self, other):
# Timestamp can handle tz and nano sec, thus no need to use apply_wraps
if isinstance(other, Timestamp):
Expand All @@ -2229,6 +2228,24 @@ def apply(self, other):
raise ApplyTypeError('Unhandled type: {type_str}'
.format(type_str=type(other).__name__))

def apply_index(self, idx):
"""
Vectorized apply (addition) of Tick to DatetimeIndex

Parameters
----------
idx : DatetimeIndex

Returns
-------
DatetimeIndex
"""
# TODO: Add a vectorized DatetimeIndex.dst() method
ambiguous = np.array([bool(ts.dst()) if ts is not tslibs.NaT else False
Copy link
Member Author

Choose a reason for hiding this comment

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

I could delay this ambiguous calculation until an AmbiguousTimeError is raised?

for ts in idx])
result = idx.tz_localize(None) + self.delta
return result.tz_localize(idx.tz, ambiguous=ambiguous)

def isAnchored(self):
return False

Expand Down