Skip to content

Commit e989126

Browse files
committed
Merge branch 'dst-date-offset' of https://github.com/adamgreenhall/pandas into adamgreenhall-dst-date-offset
Conflicts: doc/source/v0.15.0.txt
2 parents b999a8a + 2a42334 commit e989126

File tree

3 files changed

+171
-4
lines changed

3 files changed

+171
-4
lines changed

doc/source/v0.15.0.txt

+2
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,8 @@ Bug Fixes
620620

621621
- Bug in ``DataFrame.to_latex`` formatting when columns or index is a ``MultiIndex`` (:issue:`7982`).
622622

623+
- Bug in ``DateOffset`` around Daylight Savings Time produces unexpected results (:issue:`5175`).
624+
623625

624626

625627

pandas/tseries/offsets.py

+41-4
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,12 @@ def __add__(date):
152152
"""
153153
_cacheable = False
154154
_normalize_cache = True
155+
_kwds_use_relativedelta = (
156+
'years', 'months', 'weeks', 'days',
157+
'year', 'month', 'week', 'day', 'weekday',
158+
'hour', 'minute', 'second', 'microsecond'
159+
)
160+
_use_relativedelta = False
155161

156162
# default for prior pickles
157163
normalize = False
@@ -160,21 +166,52 @@ def __init__(self, n=1, normalize=False, **kwds):
160166
self.n = int(n)
161167
self.normalize = normalize
162168
self.kwds = kwds
163-
if len(kwds) > 0:
164-
self._offset = relativedelta(**kwds)
169+
self._offset, self._use_relativedelta = self._determine_offset()
170+
171+
def _determine_offset(self):
172+
# timedelta is used for sub-daily plural offsets and all singular offsets
173+
# relativedelta is used for plural offsets of daily length or more
174+
# nanosecond(s) are handled by apply_wraps
175+
kwds_no_nanos = dict(
176+
(k, v) for k, v in self.kwds.items()
177+
if k not in ('nanosecond', 'nanoseconds')
178+
)
179+
use_relativedelta = False
180+
181+
if len(kwds_no_nanos) > 0:
182+
if any(k in self._kwds_use_relativedelta for k in kwds_no_nanos):
183+
use_relativedelta = True
184+
offset = relativedelta(**kwds_no_nanos)
185+
else:
186+
# sub-daily offset - use timedelta (tz-aware)
187+
offset = timedelta(**kwds_no_nanos)
165188
else:
166-
self._offset = timedelta(1)
189+
offset = timedelta(1)
190+
return offset, use_relativedelta
167191

168192
@apply_wraps
169193
def apply(self, other):
194+
if self._use_relativedelta:
195+
other = as_datetime(other)
196+
170197
if len(self.kwds) > 0:
198+
tzinfo = getattr(other, 'tzinfo', None)
199+
if tzinfo is not None and self._use_relativedelta:
200+
# perform calculation in UTC
201+
other = other.replace(tzinfo=None)
202+
171203
if self.n > 0:
172204
for i in range(self.n):
173205
other = other + self._offset
174206
else:
175207
for i in range(-self.n):
176208
other = other - self._offset
177-
return other
209+
210+
if tzinfo is not None and self._use_relativedelta:
211+
# bring tz back from UTC calculation
212+
other = tslib._localize_pydatetime(other, tzinfo)
213+
214+
return as_timestamp(other)
178215
else:
179216
return other + timedelta(self.n)
180217

pandas/tseries/tests/test_offsets.py

+128
Original file line numberDiff line numberDiff line change
@@ -3104,6 +3104,134 @@ def test_str_for_named_is_name(self):
31043104
self.assertEqual(str(offset), name)
31053105

31063106

3107+
def get_utc_offset_hours(ts):
3108+
# take a Timestamp and compute total hours of utc offset
3109+
o = ts.utcoffset()
3110+
return (o.days * 24 * 3600 + o.seconds) / 3600.0
3111+
3112+
3113+
class TestDST(tm.TestCase):
3114+
"""
3115+
test DateOffset additions over Daylight Savings Time
3116+
"""
3117+
# one microsecond before the DST transition
3118+
ts_pre_fallback = "2013-11-03 01:59:59.999999"
3119+
ts_pre_springfwd = "2013-03-10 01:59:59.999999"
3120+
3121+
# test both basic names and dateutil timezones
3122+
timezone_utc_offsets = {
3123+
'US/Eastern': dict(
3124+
utc_offset_daylight=-4,
3125+
utc_offset_standard=-5,
3126+
),
3127+
'dateutil/US/Pacific': dict(
3128+
utc_offset_daylight=-7,
3129+
utc_offset_standard=-8,
3130+
)
3131+
}
3132+
valid_date_offsets_singular = [
3133+
'weekday', 'day', 'hour', 'minute', 'second', 'microsecond'
3134+
]
3135+
valid_date_offsets_plural = [
3136+
'weeks', 'days',
3137+
'hours', 'minutes', 'seconds',
3138+
'milliseconds', 'microseconds'
3139+
]
3140+
3141+
def _test_all_offsets(self, n, **kwds):
3142+
valid_offsets = self.valid_date_offsets_plural if n > 1 \
3143+
else self.valid_date_offsets_singular
3144+
3145+
for name in valid_offsets:
3146+
self._test_offset(offset_name=name, offset_n=n, **kwds)
3147+
3148+
def _test_offset(self, offset_name, offset_n, tstart, expected_utc_offset):
3149+
offset = DateOffset(**{offset_name: offset_n})
3150+
t = tstart + offset
3151+
if expected_utc_offset is not None:
3152+
self.assertTrue(get_utc_offset_hours(t) == expected_utc_offset)
3153+
3154+
if offset_name == 'weeks':
3155+
# dates should match
3156+
self.assertTrue(
3157+
t.date() ==
3158+
timedelta(days=7 * offset.kwds['weeks']) + tstart.date()
3159+
)
3160+
# expect the same day of week, hour of day, minute, second, ...
3161+
self.assertTrue(
3162+
t.dayofweek == tstart.dayofweek and
3163+
t.hour == tstart.hour and
3164+
t.minute == tstart.minute and
3165+
t.second == tstart.second
3166+
)
3167+
elif offset_name == 'days':
3168+
# dates should match
3169+
self.assertTrue(timedelta(offset.kwds['days']) + tstart.date() == t.date())
3170+
# expect the same hour of day, minute, second, ...
3171+
self.assertTrue(
3172+
t.hour == tstart.hour and
3173+
t.minute == tstart.minute and
3174+
t.second == tstart.second
3175+
)
3176+
elif offset_name in self.valid_date_offsets_singular:
3177+
# expect the signular offset value to match between tstart and t
3178+
datepart_offset = getattr(t, offset_name if offset_name != 'weekday' else 'dayofweek')
3179+
self.assertTrue(datepart_offset == offset.kwds[offset_name])
3180+
else:
3181+
# the offset should be the same as if it was done in UTC
3182+
self.assertTrue(
3183+
t == (tstart.tz_convert('UTC') + offset).tz_convert('US/Pacific')
3184+
)
3185+
3186+
def _make_timestamp(self, string, hrs_offset, tz):
3187+
offset_string = '{hrs:02d}00'.format(hrs=hrs_offset) if hrs_offset >= 0 else \
3188+
'-{hrs:02d}00'.format(hrs=-1 * hrs_offset)
3189+
return Timestamp(string + offset_string).tz_convert(tz)
3190+
3191+
def test_fallback_plural(self):
3192+
"""test moving from daylight savings to standard time"""
3193+
for tz, utc_offsets in self.timezone_utc_offsets.items():
3194+
hrs_pre = utc_offsets['utc_offset_daylight']
3195+
hrs_post = utc_offsets['utc_offset_standard']
3196+
self._test_all_offsets(
3197+
n=3,
3198+
tstart=self._make_timestamp(self.ts_pre_fallback, hrs_pre, tz),
3199+
expected_utc_offset=hrs_post
3200+
)
3201+
3202+
def test_springforward_plural(self):
3203+
"""test moving from standard to daylight savings"""
3204+
for tz, utc_offsets in self.timezone_utc_offsets.items():
3205+
hrs_pre = utc_offsets['utc_offset_standard']
3206+
hrs_post = utc_offsets['utc_offset_daylight']
3207+
self._test_all_offsets(
3208+
n=3,
3209+
tstart=self._make_timestamp(self.ts_pre_springfwd, hrs_pre, tz),
3210+
expected_utc_offset=hrs_post
3211+
)
3212+
3213+
def test_fallback_singular(self):
3214+
# in the case of signular offsets, we dont neccesarily know which utc offset
3215+
# the new Timestamp will wind up in (the tz for 1 month may be different from 1 second)
3216+
# so we don't specify an expected_utc_offset
3217+
for tz, utc_offsets in self.timezone_utc_offsets.items():
3218+
hrs_pre = utc_offsets['utc_offset_standard']
3219+
self._test_all_offsets(
3220+
n=1,
3221+
tstart=self._make_timestamp(self.ts_pre_fallback, hrs_pre, tz),
3222+
expected_utc_offset=None
3223+
)
3224+
3225+
def test_springforward_singular(self):
3226+
for tz, utc_offsets in self.timezone_utc_offsets.items():
3227+
hrs_pre = utc_offsets['utc_offset_standard']
3228+
self._test_all_offsets(
3229+
n=1,
3230+
tstart=self._make_timestamp(self.ts_pre_springfwd, hrs_pre, tz),
3231+
expected_utc_offset=None
3232+
)
3233+
3234+
31073235
if __name__ == '__main__':
31083236
nose.runmodule(argv=[__file__, '-vvs', '-x', '--pdb', '--pdb-failure'],
31093237
exit=False)

0 commit comments

Comments
 (0)