Skip to content

WIP/ENH: negative and large datetimes #15148

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

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion lib/matplotlib/axes/_axes.py
Original file line number Diff line number Diff line change
Expand Up @@ -4417,7 +4417,6 @@ def scatter(self, x, y, s=None, c=None, marker=None, cmap=None, norm=None,
linewidths = rcParams['lines.linewidth']

offsets = np.ma.column_stack([x, y])

collection = mcoll.PathCollection(
(path,), scales,
facecolors=colors,
Expand Down
245 changes: 193 additions & 52 deletions lib/matplotlib/dates.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ def _get_rc_timezone():
MIN_PER_HOUR = 60.
SEC_PER_MIN = 60.
MONTHS_PER_YEAR = 12.
DAYS_PER_400Y = 146097

DAYS_PER_WEEK = 7.
DAYS_PER_MONTH = 30.
Expand All @@ -207,6 +208,152 @@ def _get_rc_timezone():
WEEKDAYS = (MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY)


class _datetimey(datetime.datetime):
Copy link
Member Author

Choose a reason for hiding this comment

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

my guess is that this whole class should go in its own file...


def __new__(cls, year, *args, **kwargs):
if year < 1 or year > 9999:
yearoffset = int(np.floor(year / 400) * 400) - 2000
year = year - yearoffset
else:
yearoffset = 0
new = super().__new__(cls, year, *args, **kwargs)
new._yearoffset = yearoffset
return new

def strftime(self, fmt):
year0 = super().year + self._yearoffset
if year0 < 0:
fmt = fmt.replace('%Y', f'{year0:05d}')
else:
fmt = fmt.replace('%Y', f'{year0:04d}')
return super().strftime(fmt)

@property
def year(self):
"""year"""
return super().year + self._yearoffset

@staticmethod
def _datetime_to_datetimey(new, year_offset):
return _datetimey(new.year + year_offset, new.month, new.day,
new.hour, new.minute, new.second, new.microsecond,
new.tzinfo)

@staticmethod
def _ddays(d1, d2):
dt1 = _datetimey._datetimey_to_datetime(d1)
dt2 = _datetimey._datetimey_to_datetime(d2)
ddays = dt1 - dt2
dy = (d1._yearoffset - d2._yearoffset) / 400 * DAYS_PER_400Y
return int(ddays.days + dy)

@staticmethod
def _datetimey_to_datetime(new):
return datetime.datetime(new.year - new._yearoffset, new.month,
new.day, new.hour, new.minute, new.second,
new.microsecond, new.tzinfo)

@staticmethod
def _datetimey_to_datetime_samey0(t1, t2):
dt1 = _datetimey._datetimey_to_datetime(t1)
dt2 = _datetimey._datetimey_to_datetime(t2)
dy = (t2._yearoffset - t1._yearoffset) / 400
if t1._yearoffset < t2._yearoffset:
dt2 = dt2 + dy * datetime.timedelta(days=DAYS_PER_400Y)
else:
dt1 = dt1 + datetime.timedelta(days=dy * DAYS_PER_400Y)

return dt1, dt2

def astimezone(self, tz=None):
dt = _datetimey._datetimey_to_datetime(self)
new = dt.astimezone(tz)
new = self._datetime_to_datetimey(new, self._yearoffset)
return new

def replace(self, *args, **kwargs):
year = kwargs.pop('year', None)
if year is not None:
if year < 1 or year > 9999:
yearoffset = int(np.floor(year / 400) * 400) - 2000
year = year - yearoffset
else:
yearoffset = 0
kwargs['year'] = year
else:
yearoffset = self._yearoffset
new = super().replace(*args, **kwargs)
new._yearoffset = yearoffset
return new

def __add__(self, other):
# other is a timedelta, but can be big...
deltay = int(np.floor(other.years / 400) * 400)
newo = other - relativedelta(years=deltay)
datet = _datetimey._datetimey_to_datetime(self)
try:
newdt = datet + newo
except:
newdt = datet + relativedelta(days=DAYS_PER_400Y) + newo
deltay = deltay - 400

newdty = _datetimey._datetime_to_datetimey(newdt, self._yearoffset
+ deltay)
return newdty

def __sub__(self, other):
if isinstance(other, relativedelta):
return self + -other
return NotImplemented

def __gt__(self, other):
if self.year > other.year:
return True
if self.year < other.year:
return False
datet = _datetimey._datetimey_to_datetime(self)
dateo = _datetimey._datetimey_to_datetime(self)
return datet > dateo

def __lt__(self, other):
if self.year > other.year:
return False
if self.year < other.year:
return True
datet = _datetimey._datetimey_to_datetime(self)
dateo = _datetimey._datetimey_to_datetime(self)
return datet < dateo

def __str__(self):
st0 = super().__str__()[4:]
st0 = f'{self.year:04d}' + st0
return st0

def _to_dt64(self):
dt64 = np.datetime64(_datetimey._datetimey_to_datetime(self))
dt64 = (dt64.astype('datetime64[s]') +
np.timedelta64(int(self._yearoffset / 400)* 146097, 'D'))
return dt64


def _relativedeltay(t1, t2):
"""
relative delta for exteended _datetimey objects...
"""
# a bit of fanciness to try to adjust things for close dates
# that will have a non-year-locator but wrap a 400y boundary...
_yearoffset1 = t1._yearoffset
if t1._yearoffset - t2._yearoffset == 400:
delta = datetime.timedelta(days=DAYS_PER_400Y)
else:
delta = datetime.timedelta(days=0)
dt1 = _datetimey._datetimey_to_datetime(t1) + delta
dt2 = _datetimey._datetimey_to_datetime(t2)
delta = relativedelta(dt1, dt2)
delta = delta + relativedelta(years=_yearoffset1 - t2._yearoffset)
return delta


def _to_ordinalf(dt):
"""
Convert :mod:`datetime` or :mod:`date` to the Gregorian date as UTC float
Expand Down Expand Up @@ -239,6 +386,15 @@ def _to_ordinalf(dt):
_to_ordinalf_np_vectorized = np.vectorize(_to_ordinalf)


def _to_ordinalfy(dt):
datet = _datetimey._datetimey_to_datetime(dt)
base = _to_ordinalf(datet)
base = base + dt._yearoffset / 400 * DAYS_PER_400Y
return base

_to_ordinalfy_np_vectorized = np.vectorize(_to_ordinalfy)


def _dt64_to_ordinalf(d):
"""
Convert `numpy.datetime64` or an ndarray of those types to Gregorian
Expand Down Expand Up @@ -279,14 +435,16 @@ def _from_ordinalf(x, tz=None):
if tz is None:
tz = _get_rc_timezone()

ix, remainder = divmod(x, 1)
ix = int(ix)
i0, remainder = divmod(x, 1)
# remainder is sub-day. i0 is integer days
i0 = int(i0)
year_offset, ix = divmod(i0, DAYS_PER_400Y)
year_offset = year_offset * 400

if ix < 1:
raise ValueError('Cannot convert {} to a date. This often happens if '
'non-datetime values are passed to an axis that '
'expects datetime objects.'.format(ix))
ix = ix + DAYS_PER_400Y
year_offset += 400
dt = datetime.datetime.fromordinal(ix).replace(tzinfo=UTC)

# Since the input date *x* float is unable to preserve microsecond
# precision of time representation in non-antique years, the
# resulting datetime is rounded to the nearest multiple of
Expand All @@ -302,8 +460,10 @@ def _from_ordinalf(x, tz=None):

# add hours, minutes, seconds, microseconds
dt += datetime.timedelta(microseconds=remainder_musec)
return dt.astimezone(tz)
dt = _datetimey._datetime_to_datetimey(dt, year_offset)
dt = dt.astimezone(tz)

return dt

# a version of _from_ordinalf that can operate on numpy arrays
_from_ordinalf_np_vectorized = np.vectorize(_from_ordinalf)
Expand Down Expand Up @@ -426,12 +586,15 @@ def date2num(d):
(isinstance(d, np.ndarray) and
np.issubdtype(d.dtype, np.datetime64))):
return _dt64_to_ordinalf(d)
elif (isinstance(d, _datetimey)):
return _to_ordinalfy(d)
return _to_ordinalf(d)

else:
d = np.asarray(d)
if np.issubdtype(d.dtype, np.datetime64):
return _dt64_to_ordinalf(d)
elif d.size and type(d[0]) == _datetimey:
return _to_ordinalfy_np_vectorized(d)
if not d.size:
return d
return _to_ordinalf_np_vectorized(d)
Expand Down Expand Up @@ -1077,12 +1240,6 @@ def datalim_to_dt(self):
dmin, dmax = self.axis.get_data_interval()
if dmin > dmax:
dmin, dmax = dmax, dmin
if dmin < 1:
raise ValueError('datalim minimum {} is less than 1 and '
'is an invalid Matplotlib date value. This often '
'happens if you pass a non-datetime '
'value to an axis that has datetime units'
.format(dmin))
return num2date(dmin, self.tz), num2date(dmax, self.tz)

def viewlim_to_dt(self):
Expand All @@ -1092,12 +1249,6 @@ def viewlim_to_dt(self):
vmin, vmax = self.axis.get_view_interval()
if vmin > vmax:
vmin, vmax = vmax, vmin
if vmin < 1:
raise ValueError('view limit minimum {} is less than 1 and '
'is an invalid Matplotlib date value. This '
'often happens if you pass a non-datetime '
'value to an axis that has datetime units'
.format(vmin))
return num2date(vmin, self.tz), num2date(vmax, self.tz)

def _get_unit(self):
Expand Down Expand Up @@ -1144,23 +1295,15 @@ def __call__(self):
return self.tick_values(dmin, dmax)

def tick_values(self, vmin, vmax):
delta = relativedelta(vmax, vmin)

# We need to cap at the endpoints of valid datetime
try:
start = vmin - delta
except (ValueError, OverflowError):
start = _from_ordinalf(1.0)

try:
stop = vmax + delta
except (ValueError, OverflowError):
# The magic number!
stop = _from_ordinalf(3652059.9999999)

self.rule.set(dtstart=start, until=stop)

dates = self.rule.between(vmin, vmax, True)
if not isinstance(vmin, _datetimey):
vmin = _datetimey._datetime_to_datetimey(vmin, 0)
if not isinstance(vmax, _datetimey):
vmax = _datetimey._datetime_to_datetimey(vmax, 0)
vmind, vmaxd = _datetimey._datetimey_to_datetime_samey0(vmin, vmax)
self.rule.set(dtstart=vmind, until=vmaxd)
Copy link
Member Author

Choose a reason for hiding this comment

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

Note that in the old code dstart=vmin-(vmax-vmin) for some reason, and dstop = vmax + (vmax - vmin). Expanding the range by a factor of three doesn't make much sense to me. The test that changed (test_DateFormatter), in my opinion, changed for the better. So this is a mildly breaking change, but it gives far more consistent results.

dates = self.rule.between(vmind, vmaxd, inc=True)
dates = [_datetimey._datetime_to_datetimey(date, vmin._yearoffset)
for date in dates]
if len(dates) == 0:
return date2num([vmin, vmax])
return self.raise_if_exceeds(date2num(dates))
Expand Down Expand Up @@ -1202,7 +1345,7 @@ def autoscale(self):
Set the view limits to include the data range.
"""
dmin, dmax = self.datalim_to_dt()
delta = relativedelta(dmax, dmin)
delta = _relativedeltay(dmax, dmin)

# We need to cap at the endpoints of valid datetime
try:
Expand Down Expand Up @@ -1365,25 +1508,24 @@ def autoscale(self):

def get_locator(self, dmin, dmax):
'Pick the best locator based on a distance.'
delta = relativedelta(dmax, dmin)
tdelta = dmax - dmin

ndays = _datetimey._ddays(dmax, dmin)
tdelta = _relativedeltay(dmax, dmin)
# take absolute difference
if dmin > dmax:
delta = -delta
tdelta = -tdelta
# delta = -delta
tdelta = tdelta

# The following uses a mix of calls to relativedelta and timedelta
# The following uses a mix of calls to _relativedeltay and timedelta
# methods because there is incomplete overlap in the functionality of
# these similar functions, and it's best to avoid doing our own math
# whenever possible.
numYears = float(delta.years)
numMonths = numYears * MONTHS_PER_YEAR + delta.months
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(tdelta.total_seconds())
numMicroseconds = np.floor(tdelta.total_seconds() * 1e6)
numYears = float(tdelta.years)
numMonths = numYears * MONTHS_PER_YEAR + tdelta.months
numDays = ndays # Avoids estimates of days/month, days/year
numHours = numDays * HOURS_PER_DAY + tdelta.hours
numMinutes = numHours * MIN_PER_HOUR + tdelta.minutes
numSeconds = numMinutes * 60 + tdelta.seconds
numMicroseconds = numSeconds * 1e6 + tdelta.microseconds

nums = [numYears, numMonths, numDays, numHours, numMinutes,
numSeconds, numMicroseconds]
Expand Down Expand Up @@ -1531,7 +1673,6 @@ def tick_values(self, vmin, vmax):
# look after pytz
if not dt.tzinfo:
dt = self.tz.localize(dt, is_dst=True)

ticks.append(dt)

@cbook.deprecated("3.2")
Expand Down
Binary file not shown.
Loading