diff --git a/doc/source/whatsnew/v2.0.0.rst b/doc/source/whatsnew/v2.0.0.rst index 22f6659367683..d798d41aac2db 100644 --- a/doc/source/whatsnew/v2.0.0.rst +++ b/doc/source/whatsnew/v2.0.0.rst @@ -819,6 +819,7 @@ Datetimelike - Bug in :func:`to_datetime` was throwing ``ValueError`` when parsing dates with ISO8601 format where some values were not zero-padded (:issue:`21422`) - Bug in :func:`to_datetime` was giving incorrect results when using ``format='%Y%m%d'`` and ``errors='ignore'`` (:issue:`26493`) - Bug in :func:`to_datetime` was failing to parse date strings ``'today'`` and ``'now'`` if ``format`` was not ISO8601 (:issue:`50359`) +- 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 0bc9751694e9f..7128ee861a281 100644 --- a/pandas/_libs/tslibs/offsets.pyx +++ b/pandas/_libs/tslibs/offsets.pyx @@ -1093,6 +1093,7 @@ cdef class Day(Tick): _td64_unit = "D" _period_dtype_code = PeriodDtypeCode.D _creso = NPY_DATETIMEUNIT.NPY_FR_D + _use_relativedelta = True cdef class Hour(Tick): @@ -1439,6 +1440,7 @@ cdef class BusinessMixin(SingleConstructorOffset): """ Mixin to business types to provide related functions. """ + _use_relativedelta = True cdef readonly: timedelta _offset @@ -2062,6 +2064,7 @@ cdef class WeekOfMonthMixin(SingleConstructorOffset): """ Mixin for methods common to WeekOfMonth and LastWeekOfMonth. """ + _use_relativedelta = True cdef readonly: int weekday, week @@ -2106,6 +2109,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 @@ -2271,6 +2275,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 @@ -2447,6 +2452,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 @@ -2573,6 +2580,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 @@ -2790,6 +2798,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 @@ -3069,6 +3078,7 @@ cdef class LastWeekOfMonth(WeekOfMonthMixin): # Special Offset Classes cdef class FY5253Mixin(SingleConstructorOffset): + _use_relativedelta = True cdef readonly: int startingMonth int weekday @@ -3540,6 +3550,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 608b38765621b..82716490222f7 100644 --- a/pandas/core/arrays/datetimes.py +++ b/pandas/core/arrays/datetimes.py @@ -761,7 +761,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 @@ -782,7 +785,10 @@ def _add_offset(self, offset) -> DatetimeArray: else: result = DatetimeArray._simple_new(result, dtype=result.dtype) if self.tz is not None: - 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 347c91a67ebb5..c2ed5f21c1a46 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.util.version import Version # error: Module has no attribute "__version__" @@ -233,3 +235,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)