Skip to content

Commit 0a31b36

Browse files
authored
Merge pull request #6262 from pganssle/fix_date2num_dst
FIX: Properly handle UTC conversion in date2num
2 parents 38a32d9 + 3e6e8d1 commit 0a31b36

File tree

2 files changed

+119
-33
lines changed

2 files changed

+119
-33
lines changed

lib/matplotlib/dates.py

+17-33
Original file line numberDiff line numberDiff line change
@@ -212,47 +212,31 @@ 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 += (dt - rdt).total_seconds() / SEC_PER_DAY
232233

233234
return base
234235

235236

236237
# a version of _to_ordinalf that can operate on numpy arrays
237238
_to_ordinalf_np_vectorized = np.vectorize(_to_ordinalf)
238239

239-
try:
240-
# Available as a native method in Python >= 2.7.
241-
_total_seconds = datetime.timedelta.total_seconds
242-
except AttributeError:
243-
def _total_seconds(tdelta):
244-
"""
245-
Alias providing support for datetime.timedelta.total_seconds() function
246-
calls even in Python < 2.7.
247-
248-
The input `tdelta` is a datetime.timedelta object, and returns a float
249-
containing the total number of seconds representing the `tdelta`
250-
duration. For large durations (> 270 on most platforms), this loses
251-
microsecond accuracy.
252-
"""
253-
return (tdelta.microseconds +
254-
(tdelta.seconds + tdelta.days * SEC_PER_DAY) * 1e6) * 1e-6
255-
256240

257241
def _from_ordinalf(x, tz=None):
258242
"""
@@ -432,7 +416,7 @@ def drange(dstart, dend, delta):
432416
"""
433417
f1 = _to_ordinalf(dstart)
434418
f2 = _to_ordinalf(dend)
435-
step = _total_seconds(delta) / SEC_PER_DAY
419+
step = delta.total_seconds() / SEC_PER_DAY
436420

437421
# calculate the difference between dend and dstart in times of delta
438422
num = int(np.ceil((f2 - f1) / step))
@@ -1065,8 +1049,8 @@ def get_locator(self, dmin, dmax):
10651049
numDays = tdelta.days # Avoids estimates of days/month, days/year
10661050
numHours = (numDays * HOURS_PER_DAY) + delta.hours
10671051
numMinutes = (numHours * MIN_PER_HOUR) + delta.minutes
1068-
numSeconds = np.floor(_total_seconds(tdelta))
1069-
numMicroseconds = np.floor(_total_seconds(tdelta) * 1e6)
1052+
numSeconds = np.floor(tdelta.total_seconds())
1053+
numMicroseconds = np.floor(tdelta.total_seconds() * 1e6)
10701054

10711055
nums = [numYears, numMonths, numDays, numHours, numMinutes,
10721056
numSeconds, numMicroseconds]
@@ -1406,7 +1390,7 @@ def _close_to_dt(d1, d2, epsilon=5):
14061390
Assert that datetimes *d1* and *d2* are within *epsilon* microseconds.
14071391
"""
14081392
delta = d2 - d1
1409-
mus = abs(_total_seconds(delta) * 1e6)
1393+
mus = abs(delta.total_seconds() * 1e6)
14101394
assert mus < epsilon
14111395

14121396

lib/matplotlib/tests/test_dates.py

+102
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,15 @@
99
import tempfile
1010

1111
import dateutil
12+
import pytz
13+
1214
try:
1315
# mock in python 3.3+
1416
from unittest import mock
1517
except ImportError:
1618
import mock
1719
from nose.tools import assert_raises, assert_equal
20+
from nose.plugins.skip import SkipTest
1821

1922
from matplotlib.testing.decorators import image_comparison, cleanup
2023
import matplotlib.pyplot as plt
@@ -355,6 +358,105 @@ def test_date_inverted_limit():
355358
fig.subplots_adjust(left=0.25)
356359

357360

361+
def _test_date2num_dst(date_range, tz_convert):
362+
# Timezones
363+
BRUSSELS = pytz.timezone('Europe/Brussels')
364+
UTC = pytz.UTC
365+
366+
# Create a list of timezone-aware datetime objects in UTC
367+
# Interval is 0b0.0000011 days, to prevent float rounding issues
368+
dtstart = datetime.datetime(2014, 3, 30, 0, 0, tzinfo=UTC)
369+
interval = datetime.timedelta(minutes=33, seconds=45)
370+
interval_days = 0.0234375 # 2025 / 86400 seconds
371+
N = 8
372+
373+
dt_utc = date_range(start=dtstart, freq=interval, periods=N)
374+
dt_bxl = tz_convert(dt_utc, BRUSSELS)
375+
376+
expected_ordinalf = [735322.0 + (i * interval_days) for i in range(N)]
377+
actual_ordinalf = list(mdates.date2num(dt_bxl))
378+
379+
assert_equal(actual_ordinalf, expected_ordinalf)
380+
381+
382+
def test_date2num_dst():
383+
# Test for github issue #3896, but in date2num around DST transitions
384+
# with a timezone-aware pandas date_range object.
385+
386+
class dt_tzaware(datetime.datetime):
387+
"""
388+
This bug specifically occurs because of the normalization behavior of
389+
pandas Timestamp objects, so in order to replicate it, we need a
390+
datetime-like object that applies timezone normalization after
391+
subtraction.
392+
"""
393+
def __sub__(self, other):
394+
r = super(dt_tzaware, self).__sub__(other)
395+
tzinfo = getattr(r, 'tzinfo', None)
396+
397+
if tzinfo is not None:
398+
localizer = getattr(tzinfo, 'normalize', None)
399+
if localizer is not None:
400+
r = tzinfo.normalize(r)
401+
402+
if isinstance(r, datetime.datetime):
403+
r = self.mk_tzaware(r)
404+
405+
return r
406+
407+
def __add__(self, other):
408+
return self.mk_tzaware(super(dt_tzaware, self).__add__(other))
409+
410+
def astimezone(self, tzinfo):
411+
dt = super(dt_tzaware, self).astimezone(tzinfo)
412+
return self.mk_tzaware(dt)
413+
414+
@classmethod
415+
def mk_tzaware(cls, datetime_obj):
416+
kwargs = {}
417+
attrs = ('year',
418+
'month',
419+
'day',
420+
'hour',
421+
'minute',
422+
'second',
423+
'microsecond',
424+
'tzinfo')
425+
426+
for attr in attrs:
427+
val = getattr(datetime_obj, attr, None)
428+
if val is not None:
429+
kwargs[attr] = val
430+
431+
return cls(**kwargs)
432+
433+
# Define a date_range function similar to pandas.date_range
434+
def date_range(start, freq, periods):
435+
dtstart = dt_tzaware.mk_tzaware(start)
436+
437+
return [dtstart + (i * freq) for i in range(periods)]
438+
439+
# Define a tz_convert function that converts a list to a new time zone.
440+
def tz_convert(dt_list, tzinfo):
441+
return [d.astimezone(tzinfo) for d in dt_list]
442+
443+
_test_date2num_dst(date_range, tz_convert)
444+
445+
446+
def test_date2num_dst_pandas():
447+
# Test for github issue #3896, but in date2num around DST transitions
448+
# with a timezone-aware pandas date_range object.
449+
try:
450+
import pandas as pd
451+
except ImportError:
452+
raise SkipTest('pandas not installed')
453+
454+
def tz_convert(*args):
455+
return pd.DatetimeIndex.tz_convert(*args).astype(datetime.datetime)
456+
457+
_test_date2num_dst(pd.date_range, tz_convert)
458+
459+
358460
if __name__ == '__main__':
359461
import nose
360462
nose.runmodule(argv=['-s', '--with-doctest'], exit=False)

0 commit comments

Comments
 (0)