Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 0f66e41

Browse files
committed
Properly handle UTC conversion in date2num.
1 parent 0423430 commit 0f66e41

File tree

2 files changed

+89
-12
lines changed

2 files changed

+89
-12
lines changed

lib/matplotlib/dates.py

Lines changed: 13 additions & 12 deletions
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

Lines changed: 76 additions & 0 deletions
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
@@ -20,6 +22,8 @@
2022
import matplotlib.pyplot as plt
2123
import matplotlib.dates as mdates
2224

25+
from numpy import array
26+
2327

2428
@image_comparison(baseline_images=['date_empty'], extensions=['png'])
2529
def test_date_empty():
@@ -355,6 +359,78 @@ def test_date_inverted_limit():
355359
fig.subplots_adjust(left=0.25)
356360

357361

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

0 commit comments

Comments
 (0)