Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Properly handle UTC conversion in date2num. #6262

Merged
merged 3 commits into from Jul 26, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
50 changes: 17 additions & 33 deletions lib/matplotlib/dates.py
Expand Up @@ -212,47 +212,31 @@ def _to_ordinalf(dt):
days, preserving hours, minutes, seconds and microseconds. Return value
is a :func:`float`.
"""

if hasattr(dt, 'tzinfo') and dt.tzinfo is not None:
delta = dt.tzinfo.utcoffset(dt)
if delta is not None:
dt -= delta
# Convert to UTC
tzi = getattr(dt, 'tzinfo', None)
if tzi is not None:
dt = dt.astimezone(UTC)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think Python 2.7 datetime.date has the astimezone method.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

date also doesn't have tzinfo, so the astimezone line will never fire.

Though, actually, datetime.datetime.timetz() returns an object that has tzinfo and not astimezone. I'm not really sure how to deal with that, though. Are time objects an acceptable input here? I think the offset returned will be None if you try and get the utcoffset of a non-fixed-offset bare time object.

tzi = UTC

base = float(dt.toordinal())
if isinstance(dt, datetime.datetime):
# Get a datetime object at midnight in the same time zone as dt.
cdate = dt.date()
midnight_time = datetime.time(0, 0, 0, tzinfo=dt.tzinfo)

# If it's sufficiently datetime-like, it will have a `date()` method
cdate = getattr(dt, 'date', lambda: None)()
if cdate is not None:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on the line above, I don't think cdate will ever be None--it can return None.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it is calling the attribute it obtains before assigning to cdate.

On Mon, May 16, 2016 at 3:29 PM, Eric Firing notifications@github.com
wrote:

In lib/matplotlib/dates.py
#6262 (comment):

 base = float(dt.toordinal())
  • if isinstance(dt, datetime.datetime):
  •    # Get a datetime object at midnight in the same time zone as dt.
    
  •    cdate = dt.date()
    
  •    midnight_time = datetime.time(0, 0, 0, tzinfo=dt.tzinfo)
    
  • If it's sufficiently datetime-like, it will have a date() method

  • cdate = getattr(dt, 'date', lambda: None)()
  • if cdate is not None:

Based on the line above, I don't think cdate will ever be None--it can
return None.


You are receiving this because you are subscribed to this thread.
Reply to this email directly or view it on GitHub
https://github.com/matplotlib/matplotlib/pull/6262/files/3e6e8d1324e4aac4c0cf08dcffab451e1ee5f525#r63410423

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can be more explicit about this if you'd prefer.

# Get a datetime object at midnight UTC
midnight_time = datetime.time(0, tzinfo=tzi)

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

if td_remainder > 0:
base += td_remainder / SEC_PER_DAY
# Append the seconds as a fraction of a day
base += (dt - rdt).total_seconds() / SEC_PER_DAY

return base


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

try:
# Available as a native method in Python >= 2.7.
_total_seconds = datetime.timedelta.total_seconds
except AttributeError:
def _total_seconds(tdelta):
"""
Alias providing support for datetime.timedelta.total_seconds() function
calls even in Python < 2.7.

The input `tdelta` is a datetime.timedelta object, and returns a float
containing the total number of seconds representing the `tdelta`
duration. For large durations (> 270 on most platforms), this loses
microsecond accuracy.
"""
return (tdelta.microseconds +
(tdelta.seconds + tdelta.days * SEC_PER_DAY) * 1e6) * 1e-6


def _from_ordinalf(x, tz=None):
"""
Expand Down Expand Up @@ -432,7 +416,7 @@ def drange(dstart, dend, delta):
"""
f1 = _to_ordinalf(dstart)
f2 = _to_ordinalf(dend)
step = _total_seconds(delta) / SEC_PER_DAY
step = delta.total_seconds() / SEC_PER_DAY

# calculate the difference between dend and dstart in times of delta
num = int(np.ceil((f2 - f1) / step))
Expand Down Expand Up @@ -1061,8 +1045,8 @@ def get_locator(self, dmin, dmax):
numDays = tdelta.days # Avoids estimates of days/month, days/year
numHours = (numDays * HOURS_PER_DAY) + delta.hours
numMinutes = (numHours * MIN_PER_HOUR) + delta.minutes
numSeconds = np.floor(_total_seconds(tdelta))
numMicroseconds = np.floor(_total_seconds(tdelta) * 1e6)
numSeconds = np.floor(tdelta.total_seconds())
numMicroseconds = np.floor(tdelta.total_seconds() * 1e6)

nums = [numYears, numMonths, numDays, numHours, numMinutes,
numSeconds, numMicroseconds]
Expand Down Expand Up @@ -1402,7 +1386,7 @@ def _close_to_dt(d1, d2, epsilon=5):
Assert that datetimes *d1* and *d2* are within *epsilon* microseconds.
"""
delta = d2 - d1
mus = abs(_total_seconds(delta) * 1e6)
mus = abs(delta.total_seconds() * 1e6)
assert mus < epsilon


Expand Down
102 changes: 102 additions & 0 deletions lib/matplotlib/tests/test_dates.py
Expand Up @@ -9,12 +9,15 @@
import tempfile

import dateutil
import pytz

try:
# mock in python 3.3+
from unittest import mock
except ImportError:
import mock
from nose.tools import assert_raises, assert_equal
from nose.plugins.skip import SkipTest

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


def _test_date2num_dst(date_range, tz_convert):
# Timezones
BRUSSELS = pytz.timezone('Europe/Brussels')
UTC = pytz.UTC

# Create a list of timezone-aware datetime objects in UTC
# Interval is 0b0.0000011 days, to prevent float rounding issues
dtstart = datetime.datetime(2014, 3, 30, 0, 0, tzinfo=UTC)
interval = datetime.timedelta(minutes=33, seconds=45)
interval_days = 0.0234375 # 2025 / 86400 seconds
N = 8

dt_utc = date_range(start=dtstart, freq=interval, periods=N)
dt_bxl = tz_convert(dt_utc, BRUSSELS)

expected_ordinalf = [735322.0 + (i * interval_days) for i in range(N)]
actual_ordinalf = list(mdates.date2num(dt_bxl))

assert_equal(actual_ordinalf, expected_ordinalf)


def test_date2num_dst():
# Test for github issue #3896, but in date2num around DST transitions
# with a timezone-aware pandas date_range object.

class dt_tzaware(datetime.datetime):
"""
This bug specifically occurs because of the normalization behavior of
pandas Timestamp objects, so in order to replicate it, we need a
datetime-like object that applies timezone normalization after
subtraction.
"""
def __sub__(self, other):
r = super(dt_tzaware, self).__sub__(other)
tzinfo = getattr(r, 'tzinfo', None)

if tzinfo is not None:
localizer = getattr(tzinfo, 'normalize', None)
if localizer is not None:
r = tzinfo.normalize(r)

if isinstance(r, datetime.datetime):
r = self.mk_tzaware(r)

return r

def __add__(self, other):
return self.mk_tzaware(super(dt_tzaware, self).__add__(other))

def astimezone(self, tzinfo):
dt = super(dt_tzaware, self).astimezone(tzinfo)
return self.mk_tzaware(dt)

@classmethod
def mk_tzaware(cls, datetime_obj):
kwargs = {}
attrs = ('year',
'month',
'day',
'hour',
'minute',
'second',
'microsecond',
'tzinfo')

for attr in attrs:
val = getattr(datetime_obj, attr, None)
if val is not None:
kwargs[attr] = val

return cls(**kwargs)

# Define a date_range function similar to pandas.date_range
def date_range(start, freq, periods):
dtstart = dt_tzaware.mk_tzaware(start)

return [dtstart + (i * freq) for i in range(periods)]

# Define a tz_convert function that converts a list to a new time zone.
def tz_convert(dt_list, tzinfo):
return [d.astimezone(tzinfo) for d in dt_list]

_test_date2num_dst(date_range, tz_convert)


def test_date2num_dst_pandas():
# Test for github issue #3896, but in date2num around DST transitions
# with a timezone-aware pandas date_range object.
try:
import pandas as pd
except ImportError:
raise SkipTest('pandas not installed')

def tz_convert(*args):
return pd.DatetimeIndex.tz_convert(*args).astype(datetime.datetime)

_test_date2num_dst(pd.date_range, tz_convert)


if __name__ == '__main__':
import nose
nose.runmodule(argv=['-s', '--with-doctest'], exit=False)