Skip to content

Commit 957c36f

Browse files
committed
Properly handle UTC conversion in date2num.
1 parent 0423430 commit 957c36f

File tree

2 files changed

+87
-12
lines changed

2 files changed

+87
-12
lines changed

lib/matplotlib/dates.py

+13-12
Original file line numberDiff line numberDiff line change
@@ -212,23 +212,24 @@ def _to_ordinalf(dt):
212212
days, preserving hours, minutes, seconds and microseconds. Return value
213213
is a :func:`float`.
214214
"""
215-
216-
if hasattr(dt, 'tzinfo') and dt.tzinfo is not None:
217-
delta = dt.tzinfo.utcoffset(dt)
218-
if delta is not None:
219-
dt -= delta
215+
# Convert to UTC
216+
tzi = getattr(dt, 'tzinfo', None)
217+
if tzi is not None:
218+
dt = dt.astimezone(UTC)
219+
tzi = UTC
220220

221221
base = float(dt.toordinal())
222-
if isinstance(dt, datetime.datetime):
223-
# Get a datetime object at midnight in the same time zone as dt.
224-
cdate = dt.date()
225-
midnight_time = datetime.time(0, 0, 0, tzinfo=dt.tzinfo)
222+
223+
# If it's sufficiently datetime-like, it will have a `date()` method
224+
cdate = getattr(dt, 'date', lambda: None)()
225+
if cdate is not None:
226+
# Get a datetime object at midnight UTC
227+
midnight_time = datetime.time(0, tzinfo=tzi)
226228

227229
rdt = datetime.datetime.combine(cdate, midnight_time)
228-
td_remainder = _total_seconds(dt - rdt)
229230

230-
if td_remainder > 0:
231-
base += td_remainder / SEC_PER_DAY
231+
# Append the seconds as a fraction of a day
232+
base += _total_seconds(dt - rdt) / SEC_PER_DAY
232233

233234
return base
234235

lib/matplotlib/tests/test_dates.py

+74
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
import tempfile
1010

1111
import dateutil
12+
import pytz
13+
1214
try:
1315
# mock in python 3.3+
1416
from unittest import mock
@@ -355,6 +357,78 @@ def test_date_inverted_limit():
355357
fig.subplots_adjust(left=0.25)
356358

357359

360+
def test_date2num_dst():
361+
# Test for github issue #3896, but in date2num around DST transitions
362+
# with a timezone-aware pandas date_range object.
363+
364+
class dt_tzaware(datetime.datetime):
365+
"""
366+
This bug specifically occurs because of the normalization behavior of
367+
pandas Timestamp objects, so in order to replicate it, we need a
368+
datetime-like object that applies timezone normalization after
369+
subtraction.
370+
"""
371+
def __sub__(self, other):
372+
r = super(dt_tzaware, self).__sub__(other)
373+
tzinfo = getattr(r, 'tzinfo', None)
374+
375+
if tzinfo is not None:
376+
localizer = getattr(tzinfo, 'normalize', None)
377+
if localizer is not None:
378+
r = tzinfo.normalize(r)
379+
380+
if isinstance(r, datetime.datetime):
381+
r = self.mk_tzaware(r)
382+
383+
return r
384+
385+
def __add__(self, other):
386+
return self.mk_tzaware(super(dt_tzaware, self).__add__(other))
387+
388+
def astimezone(self, tzinfo):
389+
dt = super(dt_tzaware, self).astimezone(tzinfo)
390+
return self.mk_tzaware(dt)
391+
392+
@classmethod
393+
def mk_tzaware(cls, datetime_obj):
394+
kwargs = {}
395+
attrs = ('year',
396+
'month',
397+
'day',
398+
'hour',
399+
'minute',
400+
'second',
401+
'microsecond',
402+
'tzinfo')
403+
404+
for attr in attrs:
405+
val = getattr(datetime_obj, attr, None)
406+
if val is not None:
407+
kwargs[attr] = val
408+
409+
return cls(**kwargs)
410+
411+
# Timezones
412+
BRUSSELS = pytz.timezone('Europe/Brussels')
413+
UTC = pytz.UTC
414+
415+
# Create a list of timezone-aware datetime objects in UTC
416+
# Interval is 0b0.0000011 days, to prevent float rounding issues
417+
dtstart = dt_tzaware(2014, 3, 30, 0, 0, tzinfo=UTC)
418+
interval = datetime.timedelta(minutes=33, seconds=45)
419+
interval_days = 0.0234375 # 2025 / 86400 seconds
420+
N = 8
421+
422+
dt_utc = [dtstart + i * interval for i in range(N)]
423+
dt_bxl = [d.astimezone(BRUSSELS) for d in dt_utc]
424+
425+
expected_ordinalf = [735322.0 + (i * interval_days) for i in range(N)]
426+
427+
actual_ordinalf = list(mdates.date2num(dt_bxl))
428+
429+
assert_equal(actual_ordinalf, expected_ordinalf)
430+
431+
358432
if __name__ == '__main__':
359433
import nose
360434
nose.runmodule(argv=['-s', '--with-doctest'], exit=False)

0 commit comments

Comments
 (0)