diff --git a/doc/source/whatsnew/v1.6.0.rst b/doc/source/whatsnew/v1.6.0.rst index 07d406ae7d779..fce92f2aabc8f 100644 --- a/doc/source/whatsnew/v1.6.0.rst +++ b/doc/source/whatsnew/v1.6.0.rst @@ -127,6 +127,7 @@ Categorical Datetimelike ^^^^^^^^^^^^ - Bug in :func:`pandas.infer_freq`, raising ``TypeError`` when inferred on :class:`RangeIndex` (:issue:`47084`) +- Bug where adding a :class:`DateOffset` to a :class:`DatetimeIndex` or :class:`Series` over a Daylight Savings Time boundary would produce an incorrect result (:issue:`43784`) - Timedelta diff --git a/pandas/_libs/tslibs/offsets.pyx b/pandas/_libs/tslibs/offsets.pyx index 7be7381bcb4d1..6d6999d11ed81 100644 --- a/pandas/_libs/tslibs/offsets.pyx +++ b/pandas/_libs/tslibs/offsets.pyx @@ -1148,6 +1148,7 @@ cdef class Day(Tick): _td64_unit = "D" _period_dtype_code = PeriodDtypeCode.D _reso = NPY_DATETIMEUNIT.NPY_FR_D + _use_relativedelta = True cdef class Hour(Tick): @@ -1495,6 +1496,7 @@ cdef class BusinessMixin(SingleConstructorOffset): """ Mixin to business types to provide related functions. """ + _use_relativedelta = True cdef readonly: timedelta _offset @@ -2068,6 +2070,7 @@ cdef class WeekOfMonthMixin(SingleConstructorOffset): """ Mixin for methods common to WeekOfMonth and LastWeekOfMonth. """ + _use_relativedelta = True cdef readonly: int weekday, week @@ -2112,6 +2115,7 @@ cdef class YearOffset(SingleConstructorOffset): DateOffset that just needs a month. """ _attributes = tuple(["n", "normalize", "month"]) + _use_relativedelta = True # FIXME(cython#4446): python annotation here gives compile-time errors # _default_month: int @@ -2277,6 +2281,7 @@ cdef class QuarterOffset(SingleConstructorOffset): # FIXME(cython#4446): python annotation here gives compile-time errors # _default_starting_month: int # _from_name_starting_month: int + _use_relativedelta = True cdef readonly: int startingMonth @@ -2448,6 +2453,8 @@ cdef class QuarterBegin(QuarterOffset): # Month-Based Offset Classes cdef class MonthOffset(SingleConstructorOffset): + _use_relativedelta = True + def is_on_offset(self, dt: datetime) -> bool: if self.normalize and not _is_normalized(dt): return False @@ -2548,6 +2555,7 @@ cdef class SemiMonthOffset(SingleConstructorOffset): _default_day_of_month = 15 _min_day_of_month = 2 _attributes = tuple(["n", "normalize", "day_of_month"]) + _use_relativedelta = True cdef readonly: int day_of_month @@ -2750,6 +2758,7 @@ cdef class Week(SingleConstructorOffset): _inc = timedelta(weeks=1) _prefix = "W" _attributes = tuple(["n", "normalize", "weekday"]) + _use_relativedelta = True cdef readonly: object weekday # int or None @@ -3027,6 +3036,7 @@ cdef class LastWeekOfMonth(WeekOfMonthMixin): # Special Offset Classes cdef class FY5253Mixin(SingleConstructorOffset): + _use_relativedelta = True cdef readonly: int startingMonth int weekday @@ -3496,6 +3506,7 @@ cdef class Easter(SingleConstructorOffset): >>> ts + pd.offsets.Easter() Timestamp('2022-04-17 00:00:00') """ + _use_relativedelta = True cpdef __setstate__(self, state): self.n = state.pop("n") diff --git a/pandas/core/arrays/datetimes.py b/pandas/core/arrays/datetimes.py index e96e9b44112d6..5d6941ade2849 100644 --- a/pandas/core/arrays/datetimes.py +++ b/pandas/core/arrays/datetimes.py @@ -694,7 +694,10 @@ def _add_offset(self, offset) -> DatetimeArray: assert not isinstance(offset, Tick) if self.tz is not None: - values = self.tz_localize(None) + if not offset._use_relativedelta: + values = self.tz_convert("utc").tz_localize(None) + else: + values = self.tz_localize(None) else: values = self @@ -716,7 +719,10 @@ def _add_offset(self, offset) -> DatetimeArray: result = DatetimeArray._simple_new(result, dtype=result.dtype) if self.tz is not None: # FIXME: tz_localize with non-nano - result = result.tz_localize(self.tz) + if not offset._use_relativedelta: + result = result.tz_localize("utc").tz_convert(self.tz) + else: + result = result.tz_localize(self.tz) return result diff --git a/pandas/tests/tseries/offsets/test_dst.py b/pandas/tests/tseries/offsets/test_dst.py index 9c6d6a686e9a5..9e61a119994ab 100644 --- a/pandas/tests/tseries/offsets/test_dst.py +++ b/pandas/tests/tseries/offsets/test_dst.py @@ -30,6 +30,8 @@ YearEnd, ) +import pandas as pd +import pandas._testing as tm from pandas.tests.tseries.offsets.test_offsets import get_utc_offset_hours from pandas.util.version import Version @@ -228,3 +230,30 @@ def test_nontick_offset_with_ambiguous_time_error(original_dt, target_dt, offset msg = f"Cannot infer dst time from {target_dt}, try using the 'ambiguous' argument" with pytest.raises(pytz.AmbiguousTimeError, match=msg): localized_dt + offset + + +def test_series_dst_addition(): + # GH#43784 + startdates = pd.Series( + [ + Timestamp("2020-10-25", tz="Europe/Berlin"), + Timestamp("2017-03-12", tz="US/Pacific"), + ] + ) + offset1 = DateOffset(hours=3) + offset2 = DateOffset(days=1) + + expected1 = pd.Series( + [Timestamp("2020-10-25 02:00:00+01:00"), Timestamp("2017-03-12 04:00:00-07:00")] + ) + + expected2 = pd.Series( + [Timestamp("2020-10-26 00:00:00+01:00"), Timestamp("2017-03-13 00:00:00-07:00")] + ) + + result1 = startdates + offset1 + result2 = startdates + offset2 + + tm.assert_series_equal(result1, expected1) + + tm.assert_series_equal(result2, expected2)