"""
.. currentmodule:: timple.timedelta
Formatters, locators and converters
===================================
Timple timedelta format
-----------------------
Timple represents timedeltas using floating point numbers.
A value of 1.0 corresponds to a timedelta of 1 day.
There are two helper functions for converting between timedelta-like
values and Timple's floating point timedeltas.
.. autosummary::
:nosignatures:
timedelta2num
num2timedelta
A wide range of specific and general purpose timedelta tick locators and
formatters are provided in this module.
You should check Matplotlib's documentation for general information on tick
locators and formatters at :mod:`matplotlib.ticker`.
These tickers and locators are described below.
Timedelta tickers
-----------------
The available date tickers are:
* :class:`FixedTimedeltaLocator`: Locate microseconds, seconds, minutes, hours
or days (the 'base unit') in fixed intervals.
Tick locations will always be multiples of the selected interval.
E.g. if the interval is 15 and the base unit 'seconds', the locator will
pick 0, 15, 30, 45 seconds as tick locations::
loc = FixedTimedeltaLocator(base_unit='seconds', interval='15')
* :class:`AutoTimedeltaLocator`: On autoscale, this class picks the best base
unit (e.g. 'minutes') and the best interval to set the view limits and the
tick locations.
Tick locations will always be a multiple of the chosen interval.
Timedelta formatters
--------------------
The available date formatters are:
* :class:`AutoTimedeltaFormatter`: attempts to figure out the best format to
use. This is most useful when used with the `AutoTimedeltaLocator`.
* :class:`ConciseTimedeltaFormatter`: also attempts to figure out the best
format to use, and to make the format as compact as possible while still
having complete date information. The formatter will make use of axis
offsets to shorten the length of the tick label when possible.
This is most useful when used with the `AutoTimedeltaLocator`.
* :class:`TimedeltaFormatter` : use custom timedelta format strings and a
custom axis offset.
Timedelta format strings
------------------------
Timple uses format strings to define the format of the tick labels and axis
offset.
The format strings for timedeltas defined here are similar to
`datetime.datetime.strftime` format strings but they are **not** the
same and **not** compatible.
+--------------+---------------------------+----------------------------------+
| Directive | Meaning | Example |
+==============+===========================+==================================+
| ``%d`` | The number of days | 0, 1, 2, ... |
+--------------+---------------------------+----------------------------------+
| ``%h`` | Hours up to one day | 00 ... 23 |
| | (with zero-padding) | |
+--------------+---------------------------+----------------------------------+
| ``%H`` | Total number of hours | 0, 1, 2, .... 50, 51, ... |
+--------------+---------------------------+----------------------------------+
| ``%m`` | Minutes up to one hour | 00 ... 59 |
| | (with zero-padding) | |
+--------------+---------------------------+----------------------------------+
| ``%M`` | Total number of minutes | 0, 1, 2, ..... 100, 101, ... |
+--------------+---------------------------+----------------------------------+
| ``%s`` | Seconds up to one minute | 00 ... 59 |
| | (with zero-padding) | |
+--------------+---------------------------+----------------------------------+
| ``%S`` | Total number of seconds | 0, 1, 2, ..... 100, 101, ... |
+--------------+---------------------------+----------------------------------+
| ``%ms`` | Milliseconds up to one | 000 ... 999 |
| | second | |
| | (with zero-padding) | |
+--------------+---------------------------+----------------------------------+
| ``%us`` | Microseconds up to one | 000 ... 999 |
| | millisecond | |
| | (with zero-padding) | |
+--------------+---------------------------+----------------------------------+
| ``%day`` | The string 'day' with | 'day' or 'days' |
| | correct plural | |
+--------------+---------------------------+----------------------------------+
The following two functions can be used to format timedelta values with a
format string:
.. autosummary::
:nosignatures:
strftimedelta
strftdnum
String formatting examples::
>>> import datetime
>>> fmt = "%d %day, %h:%m"
>>> td = datetime.timedelta(days=10, hours=6, minutes=14)
>>> strftimedelta(td, fmt)
10 days, 06:14
2.5 days as days and hours::
>>> fmt = "%d %day and %h:00"
>>> td = datetime.timedelta(days=2, hours=12)
>>> strftimedelta(td, fmt)
2 days and 12:00
2.5 days as hours only::
>>> fmt = "%H:00"
>>> td = datetime.timedelta(days=2, hours=12)
>>> strftimedelta(td, fmt)
60:00
Seconds with millisecond and microseconds as decimals::
>>> fmt = "%S.%ms%us seconds"
>>> td = datetime.timedelta(seconds=2, milliseconds=351, microseconds=16)
>>> strftimedelta(td, fmt)
2.351016 seconds
Timedelta converters
--------------------
Timple provides two timedelta converters which can be registered through
Matplotlib's unit conversion interface (see `matplotlib.units`):
.. autosummary::
:nosignatures:
TimedeltaConverter
ConciseTimedeltaConverter
Usually you don't need to interact with these converters.
When enabling Timple, one of them is automatically registered with Matplotlib.
(see :mod:`timple.timple`)
The only difference between these converters is the default formatter that is
used. `ConciseTimdeltaConverter` will use the `ConsciseTimedeltaFormatter` by
default while `TimedeltaConverter` will use `AutoTimedeltaFormatter`.
API Reference
-------------
"""
import datetime
import string
import math
import re
import numpy as np
import matplotlib as mpl
from matplotlib import ticker, units
try:
# only available for matplotlib version >= 3.4.0
from matplotlib.dates import _wrap_in_tex
except ImportError:
def _wrap_in_tex(text):
p = r'([a-zA-Z]+)'
ret_text = re.sub(p, r'}$\1$\\mathdefault{', text)
# Braces ensure dashes are not spaced like binary operators.
ret_text = '$\\mathdefault{' + ret_text.replace('-', '{-}') + '}$'
ret_text = ret_text.replace('$\\mathdefault{}$', '')
return ret_text
__all__ = ('num2timedelta', 'timedelta2num',
'TimedeltaFormatter', 'ConciseTimedeltaFormatter',
'AutoTimedeltaFormatter',
'TimedeltaLocator', 'AutoTimedeltaLocator', 'FixedTimedeltaLocator',
'TimedeltaConverter', 'ConciseTimedeltaConverter')
"""
Time-related constants.
"""
HOURS_PER_DAY = 24.
MIN_PER_HOUR = 60.
SEC_PER_MIN = 60.
MINUTES_PER_DAY = MIN_PER_HOUR * HOURS_PER_DAY
SEC_PER_HOUR = SEC_PER_MIN * MIN_PER_HOUR
SEC_PER_DAY = SEC_PER_HOUR * HOURS_PER_DAY
MUSECONDS_PER_DAY = 1e6 * SEC_PER_DAY
def _td64_to_ordinalf(d):
"""
Convert `numpy.timedelta64` or an ndarray of those types to a number of
days as float. Roundoff is float64 precision. Practically: microseconds
for up to 292271 years, milliseconds for larger time spans.
(see `numpy.timedelta64`).
"""
# the "extra" ensures that we at least allow the dynamic range out to
# seconds. That should get out to +/-2e11 years.
dseconds = d.astype('timedelta64[s]')
extra = (d - dseconds).astype('timedelta64[ns]')
dt = dseconds.astype(np.float64)
dt += extra.astype(np.float64) / 1.0e9
dt = dt / SEC_PER_DAY
NaT_int = np.timedelta64('NaT').astype(np.int64)
d_int = d.astype(np.int64)
try:
dt[d_int == NaT_int] = np.nan
except TypeError:
if d_int == NaT_int:
dt = np.nan
return dt
[docs]def timedelta2num(t):
"""
Convert timedelta objects to Timple's timedeltas.
Parameters
----------
t : `datetime.timedelta`, `numpy.timedelta64` or `pandas.Timedelta`
or sequences of these
Returns
-------
float or sequence of floats
Number of days
"""
if hasattr(t, "values"):
# this unpacks pandas series or dataframes...
t = t.values
# make an iterable, but save state to unpack later:
iterable = np.iterable(t)
if not iterable:
t = [t]
t = np.asarray(t)
if not t.size:
# deals with an empty array...
return t.astype('float64')
if hasattr(t.take(0), 'value'):
# elements are pandas objects; temporarily convert data to numbers
# pandas nat is defined as the minimum value of int64,
# replace all 'min int' values with the string 'nat' and convert the
# array to the dtype of the first non-nat value
values = np.asarray([x.value for x in t], dtype='object')
nat_mask = (np.iinfo('int64').min == values)
if not all(nat_mask):
_ttype = t[~nat_mask].take(0).to_numpy().dtype
else:
_ttype = 'timedelta64[us]' # default in case of all NaT
t = np.where(nat_mask, 'nat', values).astype(_ttype)
# convert to datetime64 or timedelta64 arrays, if not already:
if not np.issubdtype(t.dtype, np.timedelta64):
t = t.astype('timedelta64[us]')
t = _td64_to_ordinalf(t)
return t if iterable else t[0]
_ordinalf_to_timedelta_np_vectorized = np.vectorize(
lambda x: datetime.timedelta(days=x), otypes="O")
[docs]def num2timedelta(x):
"""
Convert number of days to a `~datetime.timedelta` object.
If *x* is a sequence, a sequence of `~datetime.timedelta` objects will
be returned.
Parameters
----------
x : float, sequence of floats
Number of days. The fraction part represents hours, minutes, seconds.
Returns
-------
`datetime.timedelta` or list[`datetime.timedelta`]
"""
return _ordinalf_to_timedelta_np_vectorized(x).tolist()
class _TimedeltaFormatTemplate(string.Template):
# formatting template for datetime-like formatter strings
delimiter = '%'
def strftimedelta(td, fmt_str):
"""
Return a string representing a timedelta, controlled by an explicit
format string.
Arguments
---------
td : datetime.timedelta
fmt_str : str
format string
"""
# *_t values are not partially consumed by there next larger unit
# e.g. for timedelta(days=1.5): d=1, h=12, H=36
s_t = td.total_seconds()
sign = '-' if s_t < 0 else ''
s_t = abs(s_t)
d, s = divmod(s_t, SEC_PER_DAY)
m_t, s = divmod(s, SEC_PER_MIN)
h, m = divmod(m_t, MIN_PER_HOUR)
h_t, _ = divmod(s_t, SEC_PER_HOUR)
us = td.microseconds
ms, us = divmod(us, 1e3)
# create correctly zero padded string for substitution
# last one is a special for correct day(s) plural
values = {'d': int(d),
'H': int(h_t),
'M': int(m_t),
'S': int(s_t),
'h': '{:02d}'.format(int(h)),
'm': '{:02d}'.format(int(m)),
's': '{:02d}'.format(int(s)),
'ms': '{:03d}'.format(int(ms)),
'us': '{:03d}'.format(int(us)),
'day': 'day' if d == 1 else 'days'}
try:
result = _TimedeltaFormatTemplate(fmt_str).substitute(**values)
except KeyError:
raise ValueError(f"Invalid format string '{fmt_str}' for timedelta")
return sign + result
def strftdnum(td_num, fmt_str):
"""
Return a string representing a float based timedelta,
controlled by an explicit format string.
Arguments
---------
td_num : float
timedelta in timple float representation
fmt_str : str
format string
"""
td = num2timedelta(td_num)
return strftimedelta(td, fmt_str)
[docs]class TimedeltaLocator(ticker.MultipleLocator):
"""
Determines the tick locations when plotting timedeltas.
This class is subclassed by other Locators and
is not meant to be used on its own.
Attributes
----------
base_units : list
list of all supported base units
By default those are::
self.base_units = ['days',
'hours',
'minutes',
'seconds',
'microseconds']
base_factors : dict
mapping of base units to conversion factors to convert from the
default day representation to hours, seconds, ...
"""
def __init__(self):
super().__init__()
self.base_factors = {'days': 1,
'hours': HOURS_PER_DAY,
'minutes': MINUTES_PER_DAY,
'seconds': SEC_PER_DAY,
'microseconds': MUSECONDS_PER_DAY}
# don't rely on order of dict
self.base_units = ['days',
'hours',
'minutes',
'seconds',
'microseconds'] # mind docstring for fixed locator
[docs] def datalim_to_td(self):
"""Convert axis data interval to timedelta objects."""
tmin, tmax = self.axis.get_data_interval()
if tmin > tmax:
tmin, tmax = tmax, tmin
return num2timedelta(tmin), num2timedelta(tmax)
[docs] def viewlim_to_td(self):
"""Convert the view interval to timedelta objects."""
tmin, tmax = self.axis.get_view_interval()
if tmin > tmax:
tmin, tmax = tmax, tmin
return num2timedelta(tmin), num2timedelta(tmax)
def _create_locator(self, base, interval):
"""
Create an instance of :class:`ticker.MultipleLocator` using base unit
and interval
Parameters
----------
base : {'days', 'hours', 'minutes', 'seconds', 'microseconds'}
interval : int or float
Returns
-------
instance of :class:`matplotlib.ticker.MultipleLocator`
"""
factor = self.base_factors[base]
locator = ticker.MultipleLocator(base=interval/factor)
locator.set_axis(self.axis)
if self.axis is not None:
self.axis.set_view_interval(*self.axis.get_view_interval())
self.axis.set_data_interval(*self.axis.get_data_interval())
return locator
def _get_unit(self):
"""
Return how many days a unit of the locator is; used for
intelligent autoscaling.
"""
return 1
def _get_interval(self):
"""
Return the number of units for each tick.
"""
return 1
[docs] def nonsingular(self, vmin, vmax):
"""
Given the proposed upper and lower extent, adjust the range
if it is too close to being singular (i.e. a range of ~0).
"""
if not np.isfinite(vmin) or not np.isfinite(vmax):
# Except if there is no data, then use 1 day - 2 days as default.
return (timedelta2num(datetime.timedelta(days=1)),
timedelta2num(datetime.timedelta(days=2)))
if vmax < vmin:
vmin, vmax = vmax, vmin
unit = self._get_unit()
interval = self._get_interval()
if abs(vmax - vmin) < 1e-6:
vmin -= 2 * unit * interval
vmax += 2 * unit * interval
return vmin, vmax
[docs]class FixedTimedeltaLocator(TimedeltaLocator):
"""
Make ticks in an interval of the base unit.
Examples::
# Ticks every 2 days
locator = TimedeltaLocatorManual('days', 2)
# Ticks every 20 seconds
locator = TimedeltaLocatorManual('seconds', 20)
Parameters
----------
base_unit: {'days', 'hours', 'minutes', 'seconds', 'microseconds'}
interval: `int` or `float`
"""
def __init__(self, base_unit, interval):
super().__init__()
if base_unit not in self.base_units:
raise ValueError(f"base must be one of {self.base_units}")
self.base = base_unit
self.interval = interval
self._freq = 1 / self.base_factors[base_unit]
[docs] def __call__(self):
# docstring inherited
locator = self._create_locator(self.base, self.interval)
return locator()
[docs] def tick_values(self, vmin, vmax):
return self._create_locator(self.base, self.interval)\
.tick_values(vmin, vmax)
def _get_unit(self):
return self._freq
[docs] def nonsingular(self, vmin, vmax):
if not np.isfinite(vmin) or not np.isfinite(vmax):
# Except if there is no data, then use 1 day - 2 days as default.
return (timedelta2num(datetime.timedelta(days=1)),
timedelta2num(datetime.timedelta(days=2)))
if vmax < vmin:
vmin, vmax = vmax, vmin
unit = self._get_unit()
interval = self._get_interval()
# factor adjusts unit from days to hours, seconds, ... if necessary
factor = self.base_factors[self.base]
if abs(vmax - vmin) < 1e-6 / factor:
vmin -= 2 * unit * interval / factor
vmax += 2 * unit * interval / factor
return vmin, vmax
[docs]class AutoTimedeltaLocator(TimedeltaLocator):
"""
This class automatically finds the best base unit and interval for setting
view limits and tick locations.
Parameters
----------
minticks : int
The minimum number of ticks desired; controls whether ticks occur
daily, hourly, etc.
maxticks : dict or int
The maximum number of ticks desired; controls the interval between
ticks (ticking every other, every 3, etc.). For fine-grained
control, this can be a dictionary mapping individual base units
('days', 'hours', etc.) to their own maximum
number of ticks. This can be used to keep the number of ticks
appropriate to the format chosen in `AutoDateFormatter`. Any
frequency not specified in this dictionary is given a default
value.
Attributes
----------
intervald : dict
Mapping of tick frequencies to multiples allowed for that ticking.
The default is ::
self.intervald = {
'days': [1, 2, 5, 10, 20, 25, 50, 100, 200, 500, 1000, 2000,
5000, 10000, 20000, 50000, 100000, 200000, 500000,
1000000],
'hours': [1, 2, 3, 4, 6, 8, 12],
'minutes': [1, 2, 3, 5, 10, 15, 20, 30],
'seconds': [1, 2, 3, 5, 10, 15, 20, 30],
'microseconds': [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000,
2000, 5000, 10000, 20000, 50000, 100000,
200000, 500000, 1000000],
}
The interval is used to specify multiples that are appropriate for
the frequency of ticking. For instance, every 12 hours is sensible
for hourly ticks, but for minutes/seconds, 15 or 30 make sense.
When customizing, you should only modify the values for the existing
keys. You should not add or delete entries.
Example for forcing ticks every 3 hours::
locator = AutoTimedeltaLocator()
locator.intervald['hours'] = [3] # only show every 3 hours
For forcing ticks in one specific interval only,
:class:`FixedTimedeltaLocator` might be preferred.
"""
def __init__(self, minticks=5, maxticks=None):
super().__init__()
self.intervald = {
'days': [1, 2, 5, 10, 20, 25, 50, 100, 200, 500, 1000, 2000,
5000, 10000, 20000, 50000, 100000, 200000, 500000,
1000000],
'hours': [1, 2, 3, 4, 6, 8, 12],
'minutes': [1, 2, 3, 5, 10, 15, 20, 30],
'seconds': [1, 2, 3, 5, 10, 15, 20, 30],
'microseconds': [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000,
5000, 10000, 20000, 50000, 100000, 200000, 500000,
1000000],
} # mind the default in the docstring
self.minticks = minticks
self.maxticks = {'days': 11, 'hours': 12,
'minutes': 11, 'seconds': 11, 'microseconds': 8}
if maxticks is not None:
try:
self.maxticks.update(maxticks)
except TypeError:
# Assume we were given an integer. Use this as the maximum
# number of ticks for every frequency and create a
# dictionary for this
self.maxticks = dict.fromkeys(self.base_units, maxticks)
self._freq = 1.0 # default is daily
[docs] def __call__(self):
# docstring inherited
tmin, tmax = self.viewlim_to_td()
locator = self.get_locator(tmin, tmax)
return locator()
[docs] def tick_values(self, vmin, vmax):
locator = self.get_locator(vmin, vmax)
return locator.tick_values(vmin, vmax)
[docs] def nonsingular(self, vmin, vmax):
# whatever is thrown at us, we can scale the unit.
# But default nonsingular date plots at an ~4 day period.
if not np.isfinite(vmin) or not np.isfinite(vmax):
# Except if there is no data, then use 1 day - 2 days as default.
return (timedelta2num(datetime.timedelta(days=1)),
timedelta2num(datetime.timedelta(days=2)))
if vmax < vmin:
vmin, vmax = vmax, vmin
if vmin == vmax:
vmin -= 2
vmax += 2
return vmin, vmax
def _get_unit(self):
return self._freq
[docs] def get_locator(self, vmin, vmax):
"""
Create the best locator based on the given limits.
This will choose the settings for a
:class:`matplotlib.ticker.MultipleLocator`
based on the available base units and associated intervals.
The locator is created so that there are as few ticks as possible
but more ticks than specified with min_ticks in init.
Returns
-------
instance of :class:`matplotlib.ticker.MultipleLocator`
"""
tdelta = vmax - vmin
# take absolute difference
if vmin > vmax:
tdelta = -tdelta
tdelta = timedelta2num(tdelta)
# find an appropriate base unit and interval for it
base = self._get_base(tdelta)
factor = self.base_factors[base]
norm_delta = tdelta * factor
self._freq = 1/factor
interval = self._get_interval_for_base(norm_delta, base)
return self._create_locator(base, interval)
def _get_base(self, tdelta):
# find appropriate base unit for given time delta
base = 'days' # fallback
for base in self.base_units:
try:
factor = self.base_factors[base]
if tdelta * factor >= self.minticks:
break
except KeyError:
continue # intervald was modified
return base
def _get_interval_for_base(self, norm_delta, base):
# find appropriate interval for given delta and min ticks
# norm_delta = tdelta * base_factor
base_intervals = self.intervald[base]
interval = 1 # fallback (and for static analysis)
# for interval in reversed(base_intervals):
# if norm_delta // interval >= self.minticks:
for interval in base_intervals:
if norm_delta // interval <= self.maxticks[base]:
break
return interval
[docs]class TimedeltaConverter(units.ConversionInterface):
"""
Converter for `datetime.timedelta`, `numpy.timedelta64` and
`pandas.Timedelta` data.
The 'unit' tag for such data is None.
Parameters
----------
formatter_args : dict, optional
A dictionary of keyword arguments which are passed on to
:class:`AutoTimedeltaFormatter` instances.
"""
def __init__(self, formatter_args=None):
super().__init__()
if not formatter_args:
self.formatter_args = {}
else:
self.formatter_args = formatter_args
[docs] def axisinfo(self, unit, axis):
"""
Return the `~matplotlib.units.AxisInfo`.
The *unit* and *axis* arguments are required but not used.
"""
majloc = AutoTimedeltaLocator()
majfmt = AutoTimedeltaFormatter(majloc, **self.formatter_args)
datemin = datetime.timedelta(days=1)
datemax = datetime.timedelta(days=2)
return units.AxisInfo(majloc=majloc, majfmt=majfmt, label='',
default_limits=(datemin, datemax))
[docs] @staticmethod
def convert(value, unit, axis):
"""
If *value* is not already a number or sequence of numbers, convert it
with `timedelta2num`.
The *unit* and *axis* arguments are not used.
"""
return timedelta2num(value)
[docs]class ConciseTimedeltaConverter(TimedeltaConverter):
"""
Converter for `datetime.timedelta`, `numpy.timedelta64` and
`pandas.Timedelta` data (prefers short tick formats).
The 'unit' tag for such data is None.
Parameters
----------
formatter_args : dict, optional
A dictionary of keyword arguments which are passed on to
:class:`ConciseTimedeltaFormatter` instances.
"""
def __init__(self, formatter_args=None):
super().__init__()
if not formatter_args:
self.formatter_args = {}
else:
self.formatter_args = formatter_args
[docs] def axisinfo(self, unit, axis):
# docstring inherited
majloc = AutoTimedeltaLocator()
majfmt = ConciseTimedeltaFormatter(majloc, **self.formatter_args)
datemin = datetime.timedelta(days=1)
datemax = datetime.timedelta(days=2)
return units.AxisInfo(majloc=majloc, majfmt=majfmt, label='',
default_limits=(datemin, datemax))