From a3fbc492dfb3f45854c0d1b173148a5c5ef3414f Mon Sep 17 00:00:00 2001 From: Jody Klymak Date: Wed, 7 Aug 2019 16:50:58 -0700 Subject: [PATCH] ENH: add ability to change matplotlib epoch Co-Authored-By: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> --- doc/api/api_changes_3.3/deprecations.rst | 5 + .../2020-04-09-datetime-epoch-change.rst | 22 ++ .../date_precision_and_epochs.py | 155 +++++++++++ lib/matplotlib/axes/_axes.py | 1 - lib/matplotlib/dates.py | 252 +++++++++++------- lib/matplotlib/rcsetup.py | 10 + lib/matplotlib/style/core.py | 2 +- lib/matplotlib/tests/test_axes.py | 41 ++- lib/matplotlib/tests/test_dates.py | 93 ++++--- lib/matplotlib/tests/test_units.py | 5 +- matplotlibrc.template | 5 +- 11 files changed, 438 insertions(+), 153 deletions(-) create mode 100644 doc/users/next_whats_new/2020-04-09-datetime-epoch-change.rst create mode 100644 examples/ticks_and_spines/date_precision_and_epochs.py diff --git a/doc/api/api_changes_3.3/deprecations.rst b/doc/api/api_changes_3.3/deprecations.rst index e655805792db..7d8bf5c27d2d 100644 --- a/doc/api/api_changes_3.3/deprecations.rst +++ b/doc/api/api_changes_3.3/deprecations.rst @@ -491,3 +491,8 @@ experimental and may change in the future. ``testing.compare.make_external_conversion_command`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ... is deprecated. + +`.epoch2num` and `.num2epoch` are deprecated +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +These are unused and can be easily reproduced by other date tools. +`.get_epoch` will return Matplotlib's epoch. diff --git a/doc/users/next_whats_new/2020-04-09-datetime-epoch-change.rst b/doc/users/next_whats_new/2020-04-09-datetime-epoch-change.rst new file mode 100644 index 000000000000..11b1a244bd43 --- /dev/null +++ b/doc/users/next_whats_new/2020-04-09-datetime-epoch-change.rst @@ -0,0 +1,22 @@ +Dates now use a modern epoch +---------------------------- + +Matplotlib converts dates to days since an epoch using `.dates.date2num` (via +`matplotlib.units`). Previously, an epoch of ``0000-12-31T00:00:00`` was used +so that ``0001-01-01`` was converted to 1.0. An epoch so distant in the +past meant that a modern date was not able to preserve microseconds because +2000 years times the 2^(-52) resolution of a 64-bit float gives 14 +microseconds. + +Here we change the default epoch to the more reasonable UNIX default of +``1970-01-01T00:00:00`` which for a modern date has 0.35 microsecond +resolution. (Finer resolution is not possible because we rely on +`datetime.datetime` for the date locators). Access to the epoch is provided +by `~.dates.get_epoch`, and there is a new :rc:`date.epoch` rcParam. The user +may also call `~.dates.set_epoch`, but it must be set *before* any date +conversion or plotting is used. + +If you have data stored as ordinal floats in the old epoch, a simple +conversion (using the new epoch) is:: + + new_ordinal = old_ordinal + mdates.date2num(np.datetime64('0000-12-31')) diff --git a/examples/ticks_and_spines/date_precision_and_epochs.py b/examples/ticks_and_spines/date_precision_and_epochs.py new file mode 100644 index 000000000000..2eaf7b29e5fa --- /dev/null +++ b/examples/ticks_and_spines/date_precision_and_epochs.py @@ -0,0 +1,155 @@ +""" +========================= +Date Precision and Epochs +========================= + +Matplotlib can handle `.datetime` objects and `numpy.datetime64` objects using +a unit converter that recognizes these dates and converts them to floating +point numbers. + +Before Matplotlib 3.3, the default for this conversion returns a float that was +days since "0000-12-31T00:00:00". As of Matplotlib 3.3, the default is +days from "1970-01-01T00:00:00". This allows more resolution for modern +dates. "2020-01-01" with the old epoch converted to 730120, and a 64-bit +floating point number has a resolution of 2^{-52}, or approximately +14 microseconds, so microsecond precision was lost. With the new default +epoch "2020-01-01" is 10957.0, so the achievable resolution is 0.21 +microseconds. + +""" +import datetime +import numpy as np + +import matplotlib +import matplotlib.pyplot as plt +import matplotlib.dates as mdates + + +def _reset_epoch_for_tutorial(): + """ + Users (and downstream libraries) should not use the private method of + resetting the epoch. + """ + mdates._reset_epoch_test_example() + + +############################################################################# +# Datetime +# -------- +# +# Python `.datetime` objects have microsecond resolution, so with the +# old default matplotlib dates could not round-trip full-resolution datetime +# objects. + +old_epoch = '0000-12-31T00:00:00' +new_epoch = '1970-01-01T00:00:00' + +_reset_epoch_for_tutorial() # Don't do this. Just for this tutorial. +mdates.set_epoch(old_epoch) # old epoch (pre MPL 3.3) + +date1 = datetime.datetime(2000, 1, 1, 0, 10, 0, 12, + tzinfo=datetime.timezone.utc) +mdate1 = mdates.date2num(date1) +print('Before Roundtrip: ', date1, 'Matplotlib date:', mdate1) +date2 = mdates.num2date(mdate1) +print('After Roundtrip: ', date2) + +############################################################################# +# Note this is only a round-off error, and there is no problem for +# dates closer to the old epoch: + +date1 = datetime.datetime(10, 1, 1, 0, 10, 0, 12, + tzinfo=datetime.timezone.utc) +mdate1 = mdates.date2num(date1) +print('Before Roundtrip: ', date1, 'Matplotlib date:', mdate1) +date2 = mdates.num2date(mdate1) +print('After Roundtrip: ', date2) + +############################################################################# +# If a user wants to use modern dates at microsecond precision, they +# can change the epoch using `~.set_epoch`. However, the epoch has to be +# set before any date operations to prevent confusion between different +# epochs. Trying to change the epoch later will raise a `RuntimeError`. + +try: + mdates.set_epoch(new_epoch) # this is the new MPL 3.3 default. +except RuntimeError as e: + print('RuntimeError:', str(e)) + +############################################################################# +# For this tutorial, we reset the sentinel using a private method, but users +# should just set the epoch once, if at all. + +_reset_epoch_for_tutorial() # Just being done for this tutorial. +mdates.set_epoch(new_epoch) + +date1 = datetime.datetime(2020, 1, 1, 0, 10, 0, 12, + tzinfo=datetime.timezone.utc) +mdate1 = mdates.date2num(date1) +print('Before Roundtrip: ', date1, 'Matplotlib date:', mdate1) +date2 = mdates.num2date(mdate1) +print('After Roundtrip: ', date2) + +############################################################################# +# datetime64 +# ---------- +# +# `numpy.datetime64` objects have microsecond precision for a much larger +# timespace than `.datetime` objects. However, currently Matplotlib time is +# only converted back to datetime objects, which have microsecond resolution, +# and years that only span 0000 to 9999. + +_reset_epoch_for_tutorial() # Don't do this. Just for this tutorial. +mdates.set_epoch(new_epoch) + +date1 = np.datetime64('2000-01-01T00:10:00.000012') +mdate1 = mdates.date2num(date1) +print('Before Roundtrip: ', date1, 'Matplotlib date:', mdate1) +date2 = mdates.num2date(mdate1) +print('After Roundtrip: ', date2) + +############################################################################# +# Plotting +# -------- +# +# This all of course has an effect on plotting. With the old default epoch +# the times were rounded, leading to jumps in the data: + +_reset_epoch_for_tutorial() # Don't do this. Just for this tutorial. +mdates.set_epoch(old_epoch) + +x = np.arange('2000-01-01T00:00:00.0', '2000-01-01T00:00:00.000100', + dtype='datetime64[us]') +y = np.arange(0, len(x)) +fig, ax = plt.subplots(constrained_layout=True) +ax.plot(x, y) +ax.set_title('Epoch: ' + mdates.get_epoch()) +plt.setp(ax.xaxis.get_majorticklabels(), rotation=40) +plt.show() + +############################################################################# +# For a more recent epoch, the plot is smooth: + +_reset_epoch_for_tutorial() # Don't do this. Just for this tutorial. +mdates.set_epoch(new_epoch) + +fig, ax = plt.subplots(constrained_layout=True) +ax.plot(x, y) +ax.set_title('Epoch: ' + mdates.get_epoch()) +plt.setp(ax.xaxis.get_majorticklabels(), rotation=40) +plt.show() + +_reset_epoch_for_tutorial() # Don't do this. Just for this tutorial. + +############################################################################# +# ------------ +# +# References +# """""""""" +# +# The use of the following functions, methods and classes is shown +# in this example: + +matplotlib.dates.num2date +matplotlib.dates.date2num +matplotlib.dates.set_epoch diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 2218de72646c..f49846cc12f9 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -1809,7 +1809,6 @@ def plot_date(self, x, y, fmt='o', tz=None, xdate=True, ydate=False, self.xaxis_date(tz) if ydate: self.yaxis_date(tz) - ret = self.plot(x, y, fmt, **kwargs) self._request_autoscale_view() diff --git a/lib/matplotlib/dates.py b/lib/matplotlib/dates.py index 23b21d7f20c2..ac0b33bc13c8 100644 --- a/lib/matplotlib/dates.py +++ b/lib/matplotlib/dates.py @@ -6,9 +6,27 @@ Matplotlib date format ---------------------- + Matplotlib represents dates using floating point numbers specifying the number -of days since 0001-01-01 UTC, plus 1. For example, 0001-01-01, 06:00 is 1.25, -not 0.25. Values < 1, i.e. dates before 0001-01-01 UTC, are not supported. +of days since a default epoch of 1970-01-01 UTC; for example, +1970-01-01, 06:00 is the floating point number 0.25. The formatters and +locators require the use of `datetime.datetime` objects, so only dates between +year 0001 and 9999 can be represented. Microsecond precision +is achievable for (approximately) 70 years on either side of the epoch, and +20 microseconds for the rest of the allowable range of dates (year 0001 to +9999). The epoch can be changed at import time via `.dates.set_epoch` or +:rc:`dates.epoch` to other dates if necessary; see +:doc:`/gallery/ticks_and_spines/date_precision_and_epochs` for a discussion. + +.. note:: + + Before Matplotlib 3.3, the epoch was 0000-12-31 which lost modern + microsecond precision and also made the default axis limit of 0 an invalid + datetime. In 3.3 the epoch was changed as above. To convert old + ordinal floats to the new epoch, users can do:: + + new_ordinal = old_ordinal + mdates.date2num(np.datetime64('0000-12-31')) + There are a number of helper functions to convert between :mod:`datetime` objects and Matplotlib dates: @@ -22,14 +40,14 @@ date2num num2date num2timedelta - epoch2num - num2epoch drange + set_epoch + get_epoch .. note:: - Like Python's datetime, Matplotlib uses the Gregorian calendar for all - conversions between dates and floating point numbers. This practice + Like Python's `datetime.datetime`, Matplotlib uses the Gregorian calendar + for all conversions between dates and floating point numbers. This practice is not universal, and calendar differences can cause confusing differences between what Python and Matplotlib give as the number of days since 0001-01-01 and what other software and databases yield. For @@ -150,7 +168,8 @@ import matplotlib.ticker as ticker __all__ = ('datestr2num', 'date2num', 'num2date', 'num2timedelta', 'drange', - 'epoch2num', 'num2epoch', 'mx2num', 'DateFormatter', + 'epoch2num', 'num2epoch', 'mx2num', 'set_epoch', + 'get_epoch', 'DateFormatter', 'ConciseDateFormatter', 'IndexDateFormatter', 'AutoDateFormatter', 'DateLocator', 'RRuleLocator', 'AutoDateLocator', 'YearLocator', 'MonthLocator', 'WeekdayLocator', @@ -201,6 +220,64 @@ def _get_rc_timezone(): MO, TU, WE, TH, FR, SA, SU) WEEKDAYS = (MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY) +# default epoch: passed to np.datetime64... +_epoch = None + + +def _reset_epoch_test_example(): + """ + Reset the Matplotlib date epoch so it can be set again. + + Only for use in tests and examples. + """ + global _epoch + _epoch = None + + +def set_epoch(epoch): + """ + Set the epoch (origin for dates) for datetime calculations. + + The default epoch is :rc:`dates.epoch` (by default 1970-01-01T00:00). + + If microsecond accuracy is desired, the date being plotted needs to be + within approximately 70 years of the epoch. Matplotlib internally + represents dates as days since the epoch, so floating point dynamic + range needs to be within a factor fo 2^52. + + `~.dates.set_epoch` must be called before any dates are converted + (i.e. near the import section) or a RuntimeError will be raised. + + See also :doc:`/gallery/ticks_and_spines/date_precision_and_epochs`. + + Parameters + ---------- + epoch : str + valid UTC date parsable by `numpy.datetime64` (do not include + timezone). + + """ + global _epoch + if _epoch is not None: + raise RuntimeError('set_epoch must be called before dates plotted.') + _epoch = epoch + + +def get_epoch(): + """ + Get the epoch used by `.dates`. + + Returns + ------- + epoch: str + String for the epoch (parsable by `numpy.datetime64`). + """ + global _epoch + + if _epoch is None: + _epoch = matplotlib.rcParams['date.epoch'] + return _epoch + def _to_ordinalf(dt): """ @@ -212,22 +289,9 @@ def _to_ordinalf(dt): tzi = getattr(dt, 'tzinfo', None) if tzi is not None: dt = dt.astimezone(UTC) - tzi = UTC - - base = float(dt.toordinal()) - - # If it's sufficiently datetime-like, it will have a `date()` method - cdate = getattr(dt, 'date', lambda: None)() - if cdate is not None: - # Get a datetime object at midnight UTC - midnight_time = datetime.time(0, tzinfo=tzi) - - rdt = datetime.datetime.combine(cdate, midnight_time) - - # Append the seconds as a fraction of a day - base += (dt - rdt).total_seconds() / SEC_PER_DAY - - return base + dt = dt.replace(tzinfo=None) + dt64 = np.datetime64(dt) + return _dt64_to_ordinalf(dt64) # a version of _to_ordinalf that can operate on numpy arrays @@ -237,19 +301,20 @@ def _to_ordinalf(dt): def _dt64_to_ordinalf(d): """ Convert `numpy.datetime64` or an ndarray of those types to Gregorian - date as UTC float. Roundoff is via float64 precision. Practically: - microseconds for dates between 290301 BC, 294241 AD, milliseconds for - larger dates (see `numpy.datetime64`). Nanoseconds aren't possible - because we do times compared to ``0001-01-01T00:00:00`` (plus one day). + date as UTC float relative to the epoch (see `.get_epoch`). Roundoff + is float64 precision. Practically: microseconds for dates between + 290301 BC, 294241 AD, milliseconds for larger dates + (see `numpy.datetime64`). """ # the "extra" ensures that we at least allow the dynamic range out to # seconds. That should get out to +/-2e11 years. - extra = (d - d.astype('datetime64[s]')).astype('timedelta64[ns]') - t0 = np.datetime64('0001-01-01T00:00:00', 's') - dt = (d.astype('datetime64[s]') - t0).astype(np.float64) + dseconds = d.astype('datetime64[s]') + extra = (d - dseconds).astype('timedelta64[ns]') + t0 = np.datetime64(get_epoch(), 's') + dt = (dseconds - t0).astype(np.float64) dt += extra.astype(np.float64) / 1.0e9 - dt = dt / SEC_PER_DAY + 1.0 + dt = dt / SEC_PER_DAY NaT_int = np.datetime64('NaT').astype(np.int64) d_int = d.astype(np.int64) @@ -271,33 +336,34 @@ def _from_ordinalf(x, tz=None): timezone *tz*, or if *tz* is ``None``, in the timezone specified in :rc:`timezone`. """ + if tz is None: tz = _get_rc_timezone() - ix, remainder = divmod(x, 1) - ix = int(ix) - 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)) - 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 - # `musec_prec`. A value of 20 is appropriate for current dates. - musec_prec = 20 - remainder_musec = int(round(remainder * MUSECONDS_PER_DAY / musec_prec) - * musec_prec) - - # For people trying to plot with full microsecond precision, enable - # an early-year workaround - if x < 30 * 365: - remainder_musec = int(round(remainder * MUSECONDS_PER_DAY)) + dt = (np.datetime64(get_epoch()) + + np.timedelta64(int(np.round(x * MUSECONDS_PER_DAY)), 'us')) + if dt < np.datetime64('0001-01-01') or dt >= np.datetime64('10000-01-01'): + raise ValueError(f'Date ordinal {x} converts to {dt} (using ' + f'epoch {get_epoch()}), but Matplotlib dates must be ' + 'between year 0001 and 9999.') + # convert from datetime64 to datetime: + dt = dt.tolist() + + # datetime64 is always UTC: + dt = dt.replace(tzinfo=dateutil.tz.gettz('UTC')) + # but maybe we are working in a different timezone so move. + dt = dt.astimezone(tz) + # fix round off errors + if np.abs(x) > 70 * 365: + # if x is big, round off to nearest twenty microseconds. + # This avoids floating point roundoff error + ms = round(dt.microsecond / 20) * 20 + if ms == 1000000: + dt = dt.replace(microsecond=0) + datetime.timedelta(seconds=1) + else: + dt = dt.replace(microsecond=ms) - # add hours, minutes, seconds, microseconds - dt += datetime.timedelta(microseconds=remainder_musec) - return dt.astimezone(tz) + return dt # a version of _from_ordinalf that can operate on numpy arrays @@ -343,13 +409,14 @@ def date2num(d): Returns ------- float or sequence of floats - Number of days (fraction part represents hours, minutes, seconds, ms) - since 0001-01-01 00:00:00 UTC, plus one. + Number of days since the epoch. See `.get_epoch` for the + epoch, which can be changed by :rc:`date.epoch` or `.set_epoch`. If + the epoch is "1970-01-01T00:00:00" (default) then noon Jan 1 1970 + ("1970-01-01T12:00:00") returns 0.5. Notes ----- - The addition of one here is a historical artifact. Also, note that the - Gregorian calendar is assumed; this is not universal practice. + The Gregorian calendar is assumed; this is not universal practice. For details see the module docstring. """ if hasattr(d, "values"): @@ -413,7 +480,8 @@ def num2date(x, tz=None): ---------- x : float or sequence of floats Number of days (fraction part represents hours, minutes, seconds) - since 0001-01-01 00:00:00 UTC, plus one. + since the epoch. See `.get_epoch` for the + epoch, which can be changed by :rc:`date.epoch` or `.set_epoch`. tz : str, optional Timezone of *x* (defaults to :rc:`timezone`). @@ -527,11 +595,6 @@ def __init__(self, fmt, tz=None): self.tz = tz def __call__(self, x, pos=0): - if x == 0: - raise ValueError('DateFormatter found a value of x=0, which is ' - 'an illegal date; this usually occurs because ' - 'you have not informed the axis that it is ' - 'plotting dates, e.g., with ax.xaxis_date()') return num2date(x, self.tz).strftime(self.fmt) def set_tzinfo(self, tz): @@ -995,12 +1058,7 @@ 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): @@ -1008,12 +1066,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): @@ -1071,13 +1123,16 @@ def tick_values(self, vmin, vmax): try: start = vmin - delta except (ValueError, OverflowError): - start = _from_ordinalf(1.0) + # cap + start = datetime.datetime(1, 1, 1, 0, 0, 0, + tzinfo=datetime.timezone.utc) try: stop = vmax + delta except (ValueError, OverflowError): - # The magic number! - stop = _from_ordinalf(3652059.9999999) + # cap + stop = datetime.datetime(9999, 12, 31, 23, 59, 59, + tzinfo=datetime.timezone.utc) self.rule.set(dtstart=start, until=stop) @@ -1237,7 +1292,8 @@ def __init__(self, tz=None, minticks=5, maxticks=None, SECONDLY: [1, 5, 10, 15, 30], MICROSECONDLY: [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000, 50000, 100000, 200000, 500000, - 1000000]} + 1000000], + } if interval_multiples: # Swap "3" for "4" in the DAILY list; If we use 3 we get bad # tick loc for months w/ 31 days: 1, 4, ..., 28, 31, 1 @@ -1359,8 +1415,7 @@ def get_locator(self, dmin, dmax): byranges[i] = self._byranges[i] break else: - raise ValueError('No sensible date limit could be found in the ' - 'AutoDateLocator.') + interval = 1 if (freq == YEARLY) and self.interval_multiples: locator = YearLocator(interval, tz=self.tz) @@ -1375,11 +1430,12 @@ def get_locator(self, dmin, dmax): locator = RRuleLocator(rrule, self.tz) else: locator = MicrosecondLocator(interval, tz=self.tz) - if dmin.year > 20 and interval < 1000: + if date2num(dmin) > 70 * 365 and interval < 1000: cbook._warn_external( - 'Plotting microsecond time intervals is not well ' - 'supported; please see the MicrosecondLocator ' - 'documentation for details.') + 'Plotting microsecond time intervals for dates far from ' + f'the epoch (time origin: {get_epoch()}) is not well-' + 'supported. See matplotlib.dates.set_epoch to change the ' + 'epoch.') locator.set_axis(self.axis) @@ -1617,17 +1673,20 @@ class MicrosecondLocator(DateLocator): .. note:: - Due to the floating point representation of time in days since - 0001-01-01 UTC (plus 1), plotting data with microsecond time - resolution does not work well with current dates. + By default, Matplotlib uses a floating point representation of time in + days since the epoch, so plotting data with + microsecond time resolution does not work well for + dates that are far (about 70 years) from the epoch (check with + `~.dates.get_epoch`). - If you want microsecond resolution time plots, it is strongly + If you want sub-microsecond resolution time plots, it is strongly recommended to use floating point seconds, not datetime-like time representation. If you really must use datetime.datetime() or similar and still - need microsecond precision, your only chance is to use very - early years; using year 0001 is recommended. + need microsecond precision, change the time origin via + `.dates.set_epoch` to something closer to the dates being plotted. + See :doc:`/gallery/ticks_and_spines/date_precision_and_epochs`. """ def __init__(self, interval=1, tz=None): @@ -1663,10 +1722,15 @@ def __call__(self): def tick_values(self, vmin, vmax): nmin, nmax = date2num((vmin, vmax)) + t0 = np.floor(nmin) + nmax = nmax - t0 + nmin = nmin - t0 nmin *= MUSECONDS_PER_DAY nmax *= MUSECONDS_PER_DAY + ticks = self._wrapped_locator.tick_values(nmin, nmax) - ticks = [tick / MUSECONDS_PER_DAY for tick in ticks] + + ticks = ticks / MUSECONDS_PER_DAY + t0 return ticks def _get_unit(self): @@ -1678,6 +1742,7 @@ def _get_interval(self): return self._interval +@cbook.deprecated("3.3") def epoch2num(e): """ Convert an epoch or sequence of epochs to the new date format, @@ -1686,6 +1751,7 @@ def epoch2num(e): return EPOCH_OFFSET + np.asarray(e) / SEC_PER_DAY +@cbook.deprecated("3.3") def num2epoch(d): """ Convert days since 0001 to epoch. *d* can be a number or sequence. diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py index 513860988b66..b5550eda8f0e 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -122,6 +122,15 @@ def validate_any(s): validate_anylist = _listify_validator(validate_any) +def validate_date(s): + try: + np.datetime64(s) + return s + except ValueError: + raise RuntimeError('"%s" should be a string that can be parsed by ', + 'numpy.datetime64.' % s) + + @cbook.deprecated("3.2", alternative="os.path.exists") def validate_path_exists(s): """If s is a path, return s, else False""" @@ -1264,6 +1273,7 @@ def _convert_validator_spec(key, conv): 'scatter.marker': ['o', validate_string], 'scatter.edgecolors': ['face', validate_string], + 'date.epoch': ['1970-01-01T00:00', validate_date], # TODO validate that these are valid datetime format strings 'date.autoformatter.year': ['%Y', validate_string], 'date.autoformatter.month': ['%Y-%m', validate_string], diff --git a/lib/matplotlib/style/core.py b/lib/matplotlib/style/core.py index e4da68594388..e17e110a94ee 100644 --- a/lib/matplotlib/style/core.py +++ b/lib/matplotlib/style/core.py @@ -39,7 +39,7 @@ 'webagg.port_retries', 'webagg.open_in_browser', 'backend_fallback', 'toolbar', 'timezone', 'datapath', 'figure.max_open_warning', 'figure.raise_window', 'savefig.directory', 'tk.window_focus', - 'docstring.hardcopy'} + 'docstring.hardcopy', 'date.epoch'} def _remove_blacklisted_style_params(d, warn=True): diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index faf024a02512..874cb8d94bae 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -25,6 +25,7 @@ import matplotlib.markers as mmarkers import matplotlib.patches as mpatches import matplotlib.colors as mcolors +import matplotlib.dates as mdates import matplotlib.ticker as mticker import matplotlib.transforms as mtransforms from numpy.testing import ( @@ -501,35 +502,33 @@ def test_fill_units(): t = units.Epoch("ET", dt=datetime.datetime(2009, 4, 27)) value = 10.0 * units.deg day = units.Duration("ET", 24.0 * 60.0 * 60.0) + dt = np.arange('2009-04-27', '2009-04-29', dtype='datetime64[D]') + dtn = mdates.date2num(dt) fig = plt.figure() # Top-Left ax1 = fig.add_subplot(221) ax1.plot([t], [value], yunits='deg', color='red') - ax1.fill([733525.0, 733525.0, 733526.0, 733526.0], - [0.0, 0.0, 90.0, 0.0], 'b') - + ind = [0, 0, 1, 1] + ax1.fill(dtn[ind], [0.0, 0.0, 90.0, 0.0], 'b') # Top-Right ax2 = fig.add_subplot(222) ax2.plot([t], [value], yunits='deg', color='red') ax2.fill([t, t, t + day, t + day], [0.0, 0.0, 90.0, 0.0], 'b') - # Bottom-Left ax3 = fig.add_subplot(223) ax3.plot([t], [value], yunits='deg', color='red') - ax3.fill([733525.0, 733525.0, 733526.0, 733526.0], + ax3.fill(dtn[ind], [0 * units.deg, 0 * units.deg, 90 * units.deg, 0 * units.deg], 'b') - # Bottom-Right ax4 = fig.add_subplot(224) ax4.plot([t], [value], yunits='deg', color='red') ax4.fill([t, t, t + day, t + day], [0 * units.deg, 0 * units.deg, 90 * units.deg, 0 * units.deg], facecolor="blue") - fig.autofmt_xdate() @@ -559,17 +558,17 @@ def test_single_point(): @image_comparison(['single_date.png'], style='mpl20') def test_single_date(): + # use former defaults to match existing baseline image plt.rcParams['axes.formatter.limits'] = -7, 7 + dt = mdates.date2num(np.datetime64('0000-12-31')) time1 = [721964.0] data1 = [-65.54] - plt.subplot(211) - plt.plot_date(time1, data1, 'o', color='r') - - plt.subplot(212) - plt.plot(time1, data1, 'o', color='r') + fig, ax = plt.subplots(2, 1) + ax[0].plot_date(time1 + dt, data1, 'o', color='r') + ax[1].plot(time1, data1, 'o', color='r') @check_figures_equal(extensions=["png"]) @@ -629,7 +628,7 @@ def test_axvspan_epoch(): ax.set_xlim(t0 - 5.0*dt, tf + 5.0*dt) -@image_comparison(['axhspan_epoch']) +@image_comparison(['axhspan_epoch'], tol=0.02) def test_axhspan_epoch(): import matplotlib.testing.jpl_units as units units.register() @@ -4997,10 +4996,10 @@ def test_broken_barh_empty(): def test_broken_barh_timedelta(): """Check that timedelta works as x, dx pair for this method.""" fig, ax = plt.subplots() - pp = ax.broken_barh([(datetime.datetime(2018, 11, 9, 0, 0, 0), - datetime.timedelta(hours=1))], [1, 2]) - assert pp.get_paths()[0].vertices[0, 0] == 737007.0 - assert pp.get_paths()[0].vertices[2, 0] == 737007.0 + 1 / 24 + d0 = datetime.datetime(2018, 11, 9, 0, 0, 0) + pp = ax.broken_barh([(d0, datetime.timedelta(hours=1))], [1, 2]) + assert pp.get_paths()[0].vertices[0, 0] == mdates.date2num(d0) + assert pp.get_paths()[0].vertices[2, 0] == mdates.date2num(d0) + 1 / 24 def test_pandas_pcolormesh(pd): @@ -5134,7 +5133,7 @@ def test_bar_uint8(): assert patch.xy[0] == x -@image_comparison(['date_timezone_x.png']) +@image_comparison(['date_timezone_x.png'], tol=1.0) def test_date_timezone_x(): # Tests issue 5575 time_index = [datetime.datetime(2016, 2, 22, hour=x, @@ -5169,7 +5168,7 @@ def test_date_timezone_y(): plt.plot_date([3] * 3, time_index, tz='UTC', xdate=False, ydate=True) -@image_comparison(['date_timezone_x_and_y.png']) +@image_comparison(['date_timezone_x_and_y.png'], tol=1.0) def test_date_timezone_x_and_y(): # Tests issue 5575 UTC = datetime.timezone.utc @@ -5957,8 +5956,8 @@ def test_datetime_masked(): fig, ax = plt.subplots() ax.plot(x, m) - # these are the default viewlim - assert ax.get_xlim() == (730120.0, 733773.0) + dt = mdates.date2num(np.datetime64('0000-12-31')) + assert ax.get_xlim() == (730120.0 + dt, 733773.0 + dt) def test_hist_auto_bins(): diff --git a/lib/matplotlib/tests/test_dates.py b/lib/matplotlib/tests/test_dates.py index b9bda2e3666e..b27b730c7919 100644 --- a/lib/matplotlib/tests/test_dates.py +++ b/lib/matplotlib/tests/test_dates.py @@ -1,11 +1,8 @@ import datetime -try: - from contextlib import nullcontext -except ImportError: - from contextlib import ExitStack as nullcontext # Py 3.6. import dateutil.tz import dateutil.rrule +import functools import numpy as np import pytest @@ -163,11 +160,20 @@ def test_too_many_date_ticks(caplog): assert len(caplog.records) > 0 +def _new_epoch_decorator(thefunc): + @functools.wraps(thefunc) + def wrapper(): + mdates._reset_epoch_test_example() + mdates.set_epoch('2000-01-01') + thefunc() + mdates._reset_epoch_test_example() + return wrapper + + @image_comparison(['RRuleLocator_bounds.png']) def test_RRuleLocator(): import matplotlib.testing.jpl_units as units units.register() - # This will cause the RRuleLocator to go out of bounds when it tries # to add padding to the limits, so we make sure it caps at the correct # boundary values. @@ -294,19 +300,7 @@ def test_drange(): assert mdates.num2date(daterange[-1]) == (end - delta) -def test_empty_date_with_year_formatter(): - # exposes sf bug 2861426: - # https://sourceforge.net/tracker/?func=detail&aid=2861426&group_id=80706&atid=560720 - - # update: I am no longer believe this is a bug, as I commented on - # the tracker. The question is now: what to do with this test - - fig, ax = plt.subplots() - ax.xaxis.set_major_formatter(mdates.DateFormatter('%Y')) - with pytest.raises(ValueError): - fig.canvas.draw() - - +@_new_epoch_decorator def test_auto_date_locator(): def _create_auto_date_locator(date1, date2): locator = mdates.AutoDateLocator(interval_multiples=False) @@ -367,18 +361,18 @@ def _create_auto_date_locator(date1, date2): '1990-01-01 00:00:00+00:00', '1990-01-01 00:00:00.000500+00:00', '1990-01-01 00:00:00.001000+00:00', - '1990-01-01 00:00:00.001500+00:00'] + '1990-01-01 00:00:00.001500+00:00', + '1990-01-01 00:00:00.002000+00:00'] ], ) for t_delta, expected in results: d2 = d1 + t_delta locator = _create_auto_date_locator(d1, d2) - with (pytest.warns(UserWarning) if t_delta.microseconds - else nullcontext()): - assert list(map(str, mdates.num2date(locator()))) == expected + assert list(map(str, mdates.num2date(locator()))) == expected +@_new_epoch_decorator def test_auto_date_locator_intmult(): def _create_auto_date_locator(date1, date2): locator = mdates.AutoDateLocator(interval_multiples=True) @@ -443,7 +437,8 @@ def _create_auto_date_locator(date1, date2): '1997-01-01 00:00:00+00:00', '1997-01-01 00:00:00.000500+00:00', '1997-01-01 00:00:00.001000+00:00', - '1997-01-01 00:00:00.001500+00:00'] + '1997-01-01 00:00:00.001500+00:00', + '1997-01-01 00:00:00.002000+00:00'] ], ) @@ -451,9 +446,7 @@ def _create_auto_date_locator(date1, date2): for t_delta, expected in results: d2 = d1 + t_delta locator = _create_auto_date_locator(d1, d2) - with (pytest.warns(UserWarning) if t_delta.microseconds - else nullcontext()): - assert list(map(str, mdates.num2date(locator()))) == expected + assert list(map(str, mdates.num2date(locator()))) == expected def test_concise_formatter(): @@ -740,6 +733,7 @@ def test_date_inverted_limit(): def _test_date2num_dst(date_range, tz_convert): # Timezones + BRUSSELS = dateutil.tz.gettz('Europe/Brussels') UTC = mdates.UTC @@ -752,8 +746,8 @@ def _test_date2num_dst(date_range, tz_convert): 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)] + t0 = 735322.0 + mdates.date2num(np.datetime64('0000-12-31')) + expected_ordinalf = [t0 + (i * interval_days) for i in range(N)] actual_ordinalf = list(mdates.date2num(dt_bxl)) assert actual_ordinalf == expected_ordinalf @@ -878,10 +872,11 @@ def test_yearlocator_pytz(): locator.create_dummy_axis() locator.set_view_interval(mdates.date2num(x[0])-1.0, mdates.date2num(x[-1])+1.0) - - np.testing.assert_allclose([733408.208333, 733773.208333, 734138.208333, - 734503.208333, 734869.208333, - 735234.208333, 735599.208333], locator()) + t = np.array([733408.208333, 733773.208333, 734138.208333, + 734503.208333, 734869.208333, 735234.208333, 735599.208333]) + # convert to new epoch from old... + t = t + mdates.date2num(np.datetime64('0000-12-31')) + np.testing.assert_allclose(t, locator()) expected = ['2009-01-01 00:00:00-05:00', '2010-01-01 00:00:00-05:00', '2011-01-01 00:00:00-05:00', '2012-01-01 00:00:00-05:00', '2013-01-01 00:00:00-05:00', @@ -919,4 +914,36 @@ def test_num2timedelta(x, tdelta): def test_datetime64_in_list(): dt = [np.datetime64('2000-01-01'), np.datetime64('2001-01-01')] dn = mdates.date2num(dt) - np.testing.assert_equal(dn, [730120., 730486.]) + # convert fixed values from old to new epoch + t = (np.array([730120., 730486.]) + + mdates.date2num(np.datetime64('0000-12-31'))) + np.testing.assert_equal(dn, t) + + +def test_change_epoch(): + date = np.datetime64('2000-01-01') + + with pytest.raises(RuntimeError): + # this should fail here because there is a sentinel on the epoch + # if the epoch has been used then it cannot be set. + mdates.set_epoch('0000-01-01') + + # use private method to clear the epoch and allow it to be set... + mdates._reset_epoch_test_example() + mdates.set_epoch('1970-01-01') + dt = (date - np.datetime64('1970-01-01')).astype('datetime64[D]') + dt = dt.astype('int') + np.testing.assert_equal(mdates.date2num(date), float(dt)) + + mdates._reset_epoch_test_example() + mdates.set_epoch('0000-12-31') + np.testing.assert_equal(mdates.date2num(date), 730120.0) + + mdates._reset_epoch_test_example() + mdates.set_epoch('1970-01-01T01:00:00') + np.testing.assert_allclose(mdates.date2num(date), dt - 1./24.) + mdates._reset_epoch_test_example() + mdates.set_epoch('1970-01-01T00:00:00') + np.testing.assert_allclose( + mdates.date2num(np.datetime64('1970-01-01T12:00:00')), + 0.5) diff --git a/lib/matplotlib/tests/test_units.py b/lib/matplotlib/tests/test_units.py index a3420ed0b76d..25396ff0ffe6 100644 --- a/lib/matplotlib/tests/test_units.py +++ b/lib/matplotlib/tests/test_units.py @@ -130,10 +130,9 @@ def test_jpl_bar_units(): x = [0*units.km, 1*units.km, 2*units.km] w = [1*day, 2*day, 3*day] b = units.Epoch("ET", dt=datetime(2009, 4, 25)) - fig, ax = plt.subplots() ax.bar(x, w, bottom=b) - ax.set_ylim([b-1*day, b+w[-1]+1*day]) + ax.set_ylim([b-1*day, b+w[-1]+(1.001)*day]) @image_comparison(['jpl_barh_units.png'], @@ -149,7 +148,7 @@ def test_jpl_barh_units(): fig, ax = plt.subplots() ax.barh(x, w, left=b) - ax.set_xlim([b-1*day, b+w[-1]+1*day]) + ax.set_xlim([b-1*day, b+w[-1]+(1.001)*day]) def test_empty_arrays(): diff --git a/matplotlibrc.template b/matplotlibrc.template index 04b87c9a932b..1034de782723 100644 --- a/matplotlibrc.template +++ b/matplotlibrc.template @@ -352,7 +352,7 @@ #axes.titleweight: normal # font weight of title #axes.titlecolor: auto # color of the axes title, auto falls back to # text.color as default value -#axes.titley: None # position title (axes relative units). None implies auto +#axes.titley: None # position title (axes relative units). None implies auto #axes.titlepad: 6.0 # pad between axes and title in points #axes.labelsize: medium # fontsize of the x any y labels #axes.labelpad: 4.0 # space between label and axis @@ -433,6 +433,9 @@ #date.autoformatter.second: %H:%M:%S #date.autoformatter.microsecond: %M:%S.%f +## The reference date for Matplotlib's internal date representation +## See https://matplotlib.org/examples/ticks_and_spines/date_precision_and_epochs.py +#date.epoch: 1970-01-01T00:00:00 ## *************************************************************************** ## * TICKS *