1136 lines
36 KiB
Python
1136 lines
36 KiB
Python
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import datetime as pydt
|
|
from datetime import (
|
|
datetime,
|
|
timedelta,
|
|
tzinfo,
|
|
)
|
|
import functools
|
|
from typing import (
|
|
TYPE_CHECKING,
|
|
Any,
|
|
cast,
|
|
)
|
|
import warnings
|
|
|
|
import matplotlib.dates as mdates
|
|
from matplotlib.ticker import (
|
|
AutoLocator,
|
|
Formatter,
|
|
Locator,
|
|
)
|
|
from matplotlib.transforms import nonsingular
|
|
import matplotlib.units as munits
|
|
import numpy as np
|
|
|
|
from pandas._libs import lib
|
|
from pandas._libs.tslibs import (
|
|
Timestamp,
|
|
to_offset,
|
|
)
|
|
from pandas._libs.tslibs.dtypes import (
|
|
FreqGroup,
|
|
periods_per_day,
|
|
)
|
|
from pandas._typing import (
|
|
F,
|
|
npt,
|
|
)
|
|
|
|
from pandas.core.dtypes.common import (
|
|
is_float,
|
|
is_float_dtype,
|
|
is_integer,
|
|
is_integer_dtype,
|
|
is_nested_list_like,
|
|
)
|
|
|
|
from pandas import (
|
|
Index,
|
|
Series,
|
|
get_option,
|
|
)
|
|
import pandas.core.common as com
|
|
from pandas.core.indexes.datetimes import date_range
|
|
from pandas.core.indexes.period import (
|
|
Period,
|
|
PeriodIndex,
|
|
period_range,
|
|
)
|
|
import pandas.core.tools.datetimes as tools
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Generator
|
|
|
|
from matplotlib.axis import Axis
|
|
|
|
from pandas._libs.tslibs.offsets import BaseOffset
|
|
|
|
|
|
_mpl_units = {} # Cache for units overwritten by us
|
|
|
|
|
|
def get_pairs():
|
|
pairs = [
|
|
(Timestamp, DatetimeConverter),
|
|
(Period, PeriodConverter),
|
|
(pydt.datetime, DatetimeConverter),
|
|
(pydt.date, DatetimeConverter),
|
|
(pydt.time, TimeConverter),
|
|
(np.datetime64, DatetimeConverter),
|
|
]
|
|
return pairs
|
|
|
|
|
|
def register_pandas_matplotlib_converters(func: F) -> F:
|
|
"""
|
|
Decorator applying pandas_converters.
|
|
"""
|
|
|
|
@functools.wraps(func)
|
|
def wrapper(*args, **kwargs):
|
|
with pandas_converters():
|
|
return func(*args, **kwargs)
|
|
|
|
return cast(F, wrapper)
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def pandas_converters() -> Generator[None, None, None]:
|
|
"""
|
|
Context manager registering pandas' converters for a plot.
|
|
|
|
See Also
|
|
--------
|
|
register_pandas_matplotlib_converters : Decorator that applies this.
|
|
"""
|
|
value = get_option("plotting.matplotlib.register_converters")
|
|
|
|
if value:
|
|
# register for True or "auto"
|
|
register()
|
|
try:
|
|
yield
|
|
finally:
|
|
if value == "auto":
|
|
# only deregister for "auto"
|
|
deregister()
|
|
|
|
|
|
def register() -> None:
|
|
pairs = get_pairs()
|
|
for type_, cls in pairs:
|
|
# Cache previous converter if present
|
|
if type_ in munits.registry and not isinstance(munits.registry[type_], cls):
|
|
previous = munits.registry[type_]
|
|
_mpl_units[type_] = previous
|
|
# Replace with pandas converter
|
|
munits.registry[type_] = cls()
|
|
|
|
|
|
def deregister() -> None:
|
|
# Renamed in pandas.plotting.__init__
|
|
for type_, cls in get_pairs():
|
|
# We use type to catch our classes directly, no inheritance
|
|
if type(munits.registry.get(type_)) is cls:
|
|
munits.registry.pop(type_)
|
|
|
|
# restore the old keys
|
|
for unit, formatter in _mpl_units.items():
|
|
if type(formatter) not in {DatetimeConverter, PeriodConverter, TimeConverter}:
|
|
# make it idempotent by excluding ours.
|
|
munits.registry[unit] = formatter
|
|
|
|
|
|
def _to_ordinalf(tm: pydt.time) -> float:
|
|
tot_sec = tm.hour * 3600 + tm.minute * 60 + tm.second + tm.microsecond / 10**6
|
|
return tot_sec
|
|
|
|
|
|
def time2num(d):
|
|
if isinstance(d, str):
|
|
parsed = Timestamp(d)
|
|
return _to_ordinalf(parsed.time())
|
|
if isinstance(d, pydt.time):
|
|
return _to_ordinalf(d)
|
|
return d
|
|
|
|
|
|
class TimeConverter(munits.ConversionInterface):
|
|
@staticmethod
|
|
def convert(value, unit, axis):
|
|
valid_types = (str, pydt.time)
|
|
if isinstance(value, valid_types) or is_integer(value) or is_float(value):
|
|
return time2num(value)
|
|
if isinstance(value, Index):
|
|
return value.map(time2num)
|
|
if isinstance(value, (list, tuple, np.ndarray, Index)):
|
|
return [time2num(x) for x in value]
|
|
return value
|
|
|
|
@staticmethod
|
|
def axisinfo(unit, axis) -> munits.AxisInfo | None:
|
|
if unit != "time":
|
|
return None
|
|
|
|
majloc = AutoLocator()
|
|
majfmt = TimeFormatter(majloc)
|
|
return munits.AxisInfo(majloc=majloc, majfmt=majfmt, label="time")
|
|
|
|
@staticmethod
|
|
def default_units(x, axis) -> str:
|
|
return "time"
|
|
|
|
|
|
# time formatter
|
|
class TimeFormatter(Formatter):
|
|
def __init__(self, locs) -> None:
|
|
self.locs = locs
|
|
|
|
def __call__(self, x, pos: int | None = 0) -> str:
|
|
"""
|
|
Return the time of day as a formatted string.
|
|
|
|
Parameters
|
|
----------
|
|
x : float
|
|
The time of day specified as seconds since 00:00 (midnight),
|
|
with up to microsecond precision.
|
|
pos
|
|
Unused
|
|
|
|
Returns
|
|
-------
|
|
str
|
|
A string in HH:MM:SS.mmmuuu format. Microseconds,
|
|
milliseconds and seconds are only displayed if non-zero.
|
|
"""
|
|
fmt = "%H:%M:%S.%f"
|
|
s = int(x)
|
|
msus = round((x - s) * 10**6)
|
|
ms = msus // 1000
|
|
us = msus % 1000
|
|
m, s = divmod(s, 60)
|
|
h, m = divmod(m, 60)
|
|
_, h = divmod(h, 24)
|
|
if us != 0:
|
|
return pydt.time(h, m, s, msus).strftime(fmt)
|
|
elif ms != 0:
|
|
return pydt.time(h, m, s, msus).strftime(fmt)[:-3]
|
|
elif s != 0:
|
|
return pydt.time(h, m, s).strftime("%H:%M:%S")
|
|
|
|
return pydt.time(h, m).strftime("%H:%M")
|
|
|
|
|
|
# Period Conversion
|
|
|
|
|
|
class PeriodConverter(mdates.DateConverter):
|
|
@staticmethod
|
|
def convert(values, units, axis):
|
|
if is_nested_list_like(values):
|
|
values = [PeriodConverter._convert_1d(v, units, axis) for v in values]
|
|
else:
|
|
values = PeriodConverter._convert_1d(values, units, axis)
|
|
return values
|
|
|
|
@staticmethod
|
|
def _convert_1d(values, units, axis):
|
|
if not hasattr(axis, "freq"):
|
|
raise TypeError("Axis must have `freq` set to convert to Periods")
|
|
valid_types = (str, datetime, Period, pydt.date, pydt.time, np.datetime64)
|
|
with warnings.catch_warnings():
|
|
warnings.filterwarnings(
|
|
"ignore", "Period with BDay freq is deprecated", category=FutureWarning
|
|
)
|
|
warnings.filterwarnings(
|
|
"ignore", r"PeriodDtype\[B\] is deprecated", category=FutureWarning
|
|
)
|
|
if (
|
|
isinstance(values, valid_types)
|
|
or is_integer(values)
|
|
or is_float(values)
|
|
):
|
|
return get_datevalue(values, axis.freq)
|
|
elif isinstance(values, PeriodIndex):
|
|
return values.asfreq(axis.freq).asi8
|
|
elif isinstance(values, Index):
|
|
return values.map(lambda x: get_datevalue(x, axis.freq))
|
|
elif lib.infer_dtype(values, skipna=False) == "period":
|
|
# https://github.com/pandas-dev/pandas/issues/24304
|
|
# convert ndarray[period] -> PeriodIndex
|
|
return PeriodIndex(values, freq=axis.freq).asi8
|
|
elif isinstance(values, (list, tuple, np.ndarray, Index)):
|
|
return [get_datevalue(x, axis.freq) for x in values]
|
|
return values
|
|
|
|
|
|
def get_datevalue(date, freq):
|
|
if isinstance(date, Period):
|
|
return date.asfreq(freq).ordinal
|
|
elif isinstance(date, (str, datetime, pydt.date, pydt.time, np.datetime64)):
|
|
return Period(date, freq).ordinal
|
|
elif (
|
|
is_integer(date)
|
|
or is_float(date)
|
|
or (isinstance(date, (np.ndarray, Index)) and (date.size == 1))
|
|
):
|
|
return date
|
|
elif date is None:
|
|
return None
|
|
raise ValueError(f"Unrecognizable date '{date}'")
|
|
|
|
|
|
# Datetime Conversion
|
|
class DatetimeConverter(mdates.DateConverter):
|
|
@staticmethod
|
|
def convert(values, unit, axis):
|
|
# values might be a 1-d array, or a list-like of arrays.
|
|
if is_nested_list_like(values):
|
|
values = [DatetimeConverter._convert_1d(v, unit, axis) for v in values]
|
|
else:
|
|
values = DatetimeConverter._convert_1d(values, unit, axis)
|
|
return values
|
|
|
|
@staticmethod
|
|
def _convert_1d(values, unit, axis):
|
|
def try_parse(values):
|
|
try:
|
|
return mdates.date2num(tools.to_datetime(values))
|
|
except Exception:
|
|
return values
|
|
|
|
if isinstance(values, (datetime, pydt.date, np.datetime64, pydt.time)):
|
|
return mdates.date2num(values)
|
|
elif is_integer(values) or is_float(values):
|
|
return values
|
|
elif isinstance(values, str):
|
|
return try_parse(values)
|
|
elif isinstance(values, (list, tuple, np.ndarray, Index, Series)):
|
|
if isinstance(values, Series):
|
|
# https://github.com/matplotlib/matplotlib/issues/11391
|
|
# Series was skipped. Convert to DatetimeIndex to get asi8
|
|
values = Index(values)
|
|
if isinstance(values, Index):
|
|
values = values.values
|
|
if not isinstance(values, np.ndarray):
|
|
values = com.asarray_tuplesafe(values)
|
|
|
|
if is_integer_dtype(values) or is_float_dtype(values):
|
|
return values
|
|
|
|
try:
|
|
values = tools.to_datetime(values)
|
|
except Exception:
|
|
pass
|
|
|
|
values = mdates.date2num(values)
|
|
|
|
return values
|
|
|
|
@staticmethod
|
|
def axisinfo(unit: tzinfo | None, axis) -> munits.AxisInfo:
|
|
"""
|
|
Return the :class:`~matplotlib.units.AxisInfo` for *unit*.
|
|
|
|
*unit* is a tzinfo instance or None.
|
|
The *axis* argument is required but not used.
|
|
"""
|
|
tz = unit
|
|
|
|
majloc = PandasAutoDateLocator(tz=tz)
|
|
majfmt = PandasAutoDateFormatter(majloc, tz=tz)
|
|
datemin = pydt.date(2000, 1, 1)
|
|
datemax = pydt.date(2010, 1, 1)
|
|
|
|
return munits.AxisInfo(
|
|
majloc=majloc, majfmt=majfmt, label="", default_limits=(datemin, datemax)
|
|
)
|
|
|
|
|
|
class PandasAutoDateFormatter(mdates.AutoDateFormatter):
|
|
def __init__(self, locator, tz=None, defaultfmt: str = "%Y-%m-%d") -> None:
|
|
mdates.AutoDateFormatter.__init__(self, locator, tz, defaultfmt)
|
|
|
|
|
|
class PandasAutoDateLocator(mdates.AutoDateLocator):
|
|
def get_locator(self, dmin, dmax):
|
|
"""Pick the best locator based on a distance."""
|
|
tot_sec = (dmax - dmin).total_seconds()
|
|
|
|
if abs(tot_sec) < self.minticks:
|
|
self._freq = -1
|
|
locator = MilliSecondLocator(self.tz)
|
|
locator.set_axis(self.axis)
|
|
|
|
# error: Item "None" of "Axis | _DummyAxis | _AxisWrapper | None"
|
|
# has no attribute "get_data_interval"
|
|
locator.axis.set_view_interval( # type: ignore[union-attr]
|
|
*self.axis.get_view_interval() # type: ignore[union-attr]
|
|
)
|
|
locator.axis.set_data_interval( # type: ignore[union-attr]
|
|
*self.axis.get_data_interval() # type: ignore[union-attr]
|
|
)
|
|
return locator
|
|
|
|
return mdates.AutoDateLocator.get_locator(self, dmin, dmax)
|
|
|
|
def _get_unit(self):
|
|
return MilliSecondLocator.get_unit_generic(self._freq)
|
|
|
|
|
|
class MilliSecondLocator(mdates.DateLocator):
|
|
UNIT = 1.0 / (24 * 3600 * 1000)
|
|
|
|
def __init__(self, tz) -> None:
|
|
mdates.DateLocator.__init__(self, tz)
|
|
self._interval = 1.0
|
|
|
|
def _get_unit(self):
|
|
return self.get_unit_generic(-1)
|
|
|
|
@staticmethod
|
|
def get_unit_generic(freq):
|
|
unit = mdates.RRuleLocator.get_unit_generic(freq)
|
|
if unit < 0:
|
|
return MilliSecondLocator.UNIT
|
|
return unit
|
|
|
|
def __call__(self):
|
|
# if no data have been set, this will tank with a ValueError
|
|
try:
|
|
dmin, dmax = self.viewlim_to_dt()
|
|
except ValueError:
|
|
return []
|
|
|
|
# We need to cap at the endpoints of valid datetime
|
|
nmax, nmin = mdates.date2num((dmax, dmin))
|
|
|
|
num = (nmax - nmin) * 86400 * 1000
|
|
max_millis_ticks = 6
|
|
for interval in [1, 10, 50, 100, 200, 500]:
|
|
if num <= interval * (max_millis_ticks - 1):
|
|
self._interval = interval
|
|
break
|
|
# We went through the whole loop without breaking, default to 1
|
|
self._interval = 1000.0
|
|
|
|
estimate = (nmax - nmin) / (self._get_unit() * self._get_interval())
|
|
|
|
if estimate > self.MAXTICKS * 2:
|
|
raise RuntimeError(
|
|
"MillisecondLocator estimated to generate "
|
|
f"{estimate:d} ticks from {dmin} to {dmax}: exceeds Locator.MAXTICKS"
|
|
f"* 2 ({self.MAXTICKS * 2:d}) "
|
|
)
|
|
|
|
interval = self._get_interval()
|
|
freq = f"{interval}ms"
|
|
tz = self.tz.tzname(None)
|
|
st = dmin.replace(tzinfo=None)
|
|
ed = dmin.replace(tzinfo=None)
|
|
all_dates = date_range(start=st, end=ed, freq=freq, tz=tz).astype(object)
|
|
|
|
try:
|
|
if len(all_dates) > 0:
|
|
locs = self.raise_if_exceeds(mdates.date2num(all_dates))
|
|
return locs
|
|
except Exception: # pragma: no cover
|
|
pass
|
|
|
|
lims = mdates.date2num([dmin, dmax])
|
|
return lims
|
|
|
|
def _get_interval(self):
|
|
return self._interval
|
|
|
|
def autoscale(self):
|
|
"""
|
|
Set the view limits to include the data range.
|
|
"""
|
|
# We need to cap at the endpoints of valid datetime
|
|
dmin, dmax = self.datalim_to_dt()
|
|
|
|
vmin = mdates.date2num(dmin)
|
|
vmax = mdates.date2num(dmax)
|
|
|
|
return self.nonsingular(vmin, vmax)
|
|
|
|
|
|
def _from_ordinal(x, tz: tzinfo | None = None) -> datetime:
|
|
ix = int(x)
|
|
dt = datetime.fromordinal(ix)
|
|
remainder = float(x) - ix
|
|
hour, remainder = divmod(24 * remainder, 1)
|
|
minute, remainder = divmod(60 * remainder, 1)
|
|
second, remainder = divmod(60 * remainder, 1)
|
|
microsecond = int(1_000_000 * remainder)
|
|
if microsecond < 10:
|
|
microsecond = 0 # compensate for rounding errors
|
|
dt = datetime(
|
|
dt.year, dt.month, dt.day, int(hour), int(minute), int(second), microsecond
|
|
)
|
|
if tz is not None:
|
|
dt = dt.astimezone(tz)
|
|
|
|
if microsecond > 999990: # compensate for rounding errors
|
|
dt += timedelta(microseconds=1_000_000 - microsecond)
|
|
|
|
return dt
|
|
|
|
|
|
# Fixed frequency dynamic tick locators and formatters
|
|
|
|
# -------------------------------------------------------------------------
|
|
# --- Locators ---
|
|
# -------------------------------------------------------------------------
|
|
|
|
|
|
def _get_default_annual_spacing(nyears) -> tuple[int, int]:
|
|
"""
|
|
Returns a default spacing between consecutive ticks for annual data.
|
|
"""
|
|
if nyears < 11:
|
|
(min_spacing, maj_spacing) = (1, 1)
|
|
elif nyears < 20:
|
|
(min_spacing, maj_spacing) = (1, 2)
|
|
elif nyears < 50:
|
|
(min_spacing, maj_spacing) = (1, 5)
|
|
elif nyears < 100:
|
|
(min_spacing, maj_spacing) = (5, 10)
|
|
elif nyears < 200:
|
|
(min_spacing, maj_spacing) = (5, 25)
|
|
elif nyears < 600:
|
|
(min_spacing, maj_spacing) = (10, 50)
|
|
else:
|
|
factor = nyears // 1000 + 1
|
|
(min_spacing, maj_spacing) = (factor * 20, factor * 100)
|
|
return (min_spacing, maj_spacing)
|
|
|
|
|
|
def _period_break(dates: PeriodIndex, period: str) -> npt.NDArray[np.intp]:
|
|
"""
|
|
Returns the indices where the given period changes.
|
|
|
|
Parameters
|
|
----------
|
|
dates : PeriodIndex
|
|
Array of intervals to monitor.
|
|
period : str
|
|
Name of the period to monitor.
|
|
"""
|
|
mask = _period_break_mask(dates, period)
|
|
return np.nonzero(mask)[0]
|
|
|
|
|
|
def _period_break_mask(dates: PeriodIndex, period: str) -> npt.NDArray[np.bool_]:
|
|
current = getattr(dates, period)
|
|
previous = getattr(dates - 1 * dates.freq, period)
|
|
return current != previous
|
|
|
|
|
|
def has_level_label(label_flags: npt.NDArray[np.intp], vmin: float) -> bool:
|
|
"""
|
|
Returns true if the ``label_flags`` indicate there is at least one label
|
|
for this level.
|
|
|
|
if the minimum view limit is not an exact integer, then the first tick
|
|
label won't be shown, so we must adjust for that.
|
|
"""
|
|
if label_flags.size == 0 or (
|
|
label_flags.size == 1 and label_flags[0] == 0 and vmin % 1 > 0.0
|
|
):
|
|
return False
|
|
else:
|
|
return True
|
|
|
|
|
|
def _get_periods_per_ymd(freq: BaseOffset) -> tuple[int, int, int]:
|
|
# error: "BaseOffset" has no attribute "_period_dtype_code"
|
|
dtype_code = freq._period_dtype_code # type: ignore[attr-defined]
|
|
freq_group = FreqGroup.from_period_dtype_code(dtype_code)
|
|
|
|
ppd = -1 # placeholder for above-day freqs
|
|
|
|
if dtype_code >= FreqGroup.FR_HR.value:
|
|
# error: "BaseOffset" has no attribute "_creso"
|
|
ppd = periods_per_day(freq._creso) # type: ignore[attr-defined]
|
|
ppm = 28 * ppd
|
|
ppy = 365 * ppd
|
|
elif freq_group == FreqGroup.FR_BUS:
|
|
ppm = 19
|
|
ppy = 261
|
|
elif freq_group == FreqGroup.FR_DAY:
|
|
ppm = 28
|
|
ppy = 365
|
|
elif freq_group == FreqGroup.FR_WK:
|
|
ppm = 3
|
|
ppy = 52
|
|
elif freq_group == FreqGroup.FR_MTH:
|
|
ppm = 1
|
|
ppy = 12
|
|
elif freq_group == FreqGroup.FR_QTR:
|
|
ppm = -1 # placerholder
|
|
ppy = 4
|
|
elif freq_group == FreqGroup.FR_ANN:
|
|
ppm = -1 # placeholder
|
|
ppy = 1
|
|
else:
|
|
raise NotImplementedError(f"Unsupported frequency: {dtype_code}")
|
|
|
|
return ppd, ppm, ppy
|
|
|
|
|
|
def _daily_finder(vmin, vmax, freq: BaseOffset) -> np.ndarray:
|
|
# error: "BaseOffset" has no attribute "_period_dtype_code"
|
|
dtype_code = freq._period_dtype_code # type: ignore[attr-defined]
|
|
|
|
periodsperday, periodspermonth, periodsperyear = _get_periods_per_ymd(freq)
|
|
|
|
# save this for later usage
|
|
vmin_orig = vmin
|
|
(vmin, vmax) = (int(vmin), int(vmax))
|
|
span = vmax - vmin + 1
|
|
|
|
with warnings.catch_warnings():
|
|
warnings.filterwarnings(
|
|
"ignore", "Period with BDay freq is deprecated", category=FutureWarning
|
|
)
|
|
warnings.filterwarnings(
|
|
"ignore", r"PeriodDtype\[B\] is deprecated", category=FutureWarning
|
|
)
|
|
dates_ = period_range(
|
|
start=Period(ordinal=vmin, freq=freq),
|
|
end=Period(ordinal=vmax, freq=freq),
|
|
freq=freq,
|
|
)
|
|
|
|
# Initialize the output
|
|
info = np.zeros(
|
|
span, dtype=[("val", np.int64), ("maj", bool), ("min", bool), ("fmt", "|S20")]
|
|
)
|
|
info["val"][:] = dates_.asi8
|
|
info["fmt"][:] = ""
|
|
info["maj"][[0, -1]] = True
|
|
# .. and set some shortcuts
|
|
info_maj = info["maj"]
|
|
info_min = info["min"]
|
|
info_fmt = info["fmt"]
|
|
|
|
def first_label(label_flags):
|
|
if (label_flags[0] == 0) and (label_flags.size > 1) and ((vmin_orig % 1) > 0.0):
|
|
return label_flags[1]
|
|
else:
|
|
return label_flags[0]
|
|
|
|
# Case 1. Less than a month
|
|
if span <= periodspermonth:
|
|
day_start = _period_break(dates_, "day")
|
|
month_start = _period_break(dates_, "month")
|
|
year_start = _period_break(dates_, "year")
|
|
|
|
def _hour_finder(label_interval: int, force_year_start: bool) -> None:
|
|
target = dates_.hour
|
|
mask = _period_break_mask(dates_, "hour")
|
|
info_maj[day_start] = True
|
|
info_min[mask & (target % label_interval == 0)] = True
|
|
info_fmt[mask & (target % label_interval == 0)] = "%H:%M"
|
|
info_fmt[day_start] = "%H:%M\n%d-%b"
|
|
info_fmt[year_start] = "%H:%M\n%d-%b\n%Y"
|
|
if force_year_start and not has_level_label(year_start, vmin_orig):
|
|
info_fmt[first_label(day_start)] = "%H:%M\n%d-%b\n%Y"
|
|
|
|
def _minute_finder(label_interval: int) -> None:
|
|
target = dates_.minute
|
|
hour_start = _period_break(dates_, "hour")
|
|
mask = _period_break_mask(dates_, "minute")
|
|
info_maj[hour_start] = True
|
|
info_min[mask & (target % label_interval == 0)] = True
|
|
info_fmt[mask & (target % label_interval == 0)] = "%H:%M"
|
|
info_fmt[day_start] = "%H:%M\n%d-%b"
|
|
info_fmt[year_start] = "%H:%M\n%d-%b\n%Y"
|
|
|
|
def _second_finder(label_interval: int) -> None:
|
|
target = dates_.second
|
|
minute_start = _period_break(dates_, "minute")
|
|
mask = _period_break_mask(dates_, "second")
|
|
info_maj[minute_start] = True
|
|
info_min[mask & (target % label_interval == 0)] = True
|
|
info_fmt[mask & (target % label_interval == 0)] = "%H:%M:%S"
|
|
info_fmt[day_start] = "%H:%M:%S\n%d-%b"
|
|
info_fmt[year_start] = "%H:%M:%S\n%d-%b\n%Y"
|
|
|
|
if span < periodsperday / 12000:
|
|
_second_finder(1)
|
|
elif span < periodsperday / 6000:
|
|
_second_finder(2)
|
|
elif span < periodsperday / 2400:
|
|
_second_finder(5)
|
|
elif span < periodsperday / 1200:
|
|
_second_finder(10)
|
|
elif span < periodsperday / 800:
|
|
_second_finder(15)
|
|
elif span < periodsperday / 400:
|
|
_second_finder(30)
|
|
elif span < periodsperday / 150:
|
|
_minute_finder(1)
|
|
elif span < periodsperday / 70:
|
|
_minute_finder(2)
|
|
elif span < periodsperday / 24:
|
|
_minute_finder(5)
|
|
elif span < periodsperday / 12:
|
|
_minute_finder(15)
|
|
elif span < periodsperday / 6:
|
|
_minute_finder(30)
|
|
elif span < periodsperday / 2.5:
|
|
_hour_finder(1, False)
|
|
elif span < periodsperday / 1.5:
|
|
_hour_finder(2, False)
|
|
elif span < periodsperday * 1.25:
|
|
_hour_finder(3, False)
|
|
elif span < periodsperday * 2.5:
|
|
_hour_finder(6, True)
|
|
elif span < periodsperday * 4:
|
|
_hour_finder(12, True)
|
|
else:
|
|
info_maj[month_start] = True
|
|
info_min[day_start] = True
|
|
info_fmt[day_start] = "%d"
|
|
info_fmt[month_start] = "%d\n%b"
|
|
info_fmt[year_start] = "%d\n%b\n%Y"
|
|
if not has_level_label(year_start, vmin_orig):
|
|
if not has_level_label(month_start, vmin_orig):
|
|
info_fmt[first_label(day_start)] = "%d\n%b\n%Y"
|
|
else:
|
|
info_fmt[first_label(month_start)] = "%d\n%b\n%Y"
|
|
|
|
# Case 2. Less than three months
|
|
elif span <= periodsperyear // 4:
|
|
month_start = _period_break(dates_, "month")
|
|
info_maj[month_start] = True
|
|
if dtype_code < FreqGroup.FR_HR.value:
|
|
info["min"] = True
|
|
else:
|
|
day_start = _period_break(dates_, "day")
|
|
info["min"][day_start] = True
|
|
week_start = _period_break(dates_, "week")
|
|
year_start = _period_break(dates_, "year")
|
|
info_fmt[week_start] = "%d"
|
|
info_fmt[month_start] = "\n\n%b"
|
|
info_fmt[year_start] = "\n\n%b\n%Y"
|
|
if not has_level_label(year_start, vmin_orig):
|
|
if not has_level_label(month_start, vmin_orig):
|
|
info_fmt[first_label(week_start)] = "\n\n%b\n%Y"
|
|
else:
|
|
info_fmt[first_label(month_start)] = "\n\n%b\n%Y"
|
|
# Case 3. Less than 14 months ...............
|
|
elif span <= 1.15 * periodsperyear:
|
|
year_start = _period_break(dates_, "year")
|
|
month_start = _period_break(dates_, "month")
|
|
week_start = _period_break(dates_, "week")
|
|
info_maj[month_start] = True
|
|
info_min[week_start] = True
|
|
info_min[year_start] = False
|
|
info_min[month_start] = False
|
|
info_fmt[month_start] = "%b"
|
|
info_fmt[year_start] = "%b\n%Y"
|
|
if not has_level_label(year_start, vmin_orig):
|
|
info_fmt[first_label(month_start)] = "%b\n%Y"
|
|
# Case 4. Less than 2.5 years ...............
|
|
elif span <= 2.5 * periodsperyear:
|
|
year_start = _period_break(dates_, "year")
|
|
quarter_start = _period_break(dates_, "quarter")
|
|
month_start = _period_break(dates_, "month")
|
|
info_maj[quarter_start] = True
|
|
info_min[month_start] = True
|
|
info_fmt[quarter_start] = "%b"
|
|
info_fmt[year_start] = "%b\n%Y"
|
|
# Case 4. Less than 4 years .................
|
|
elif span <= 4 * periodsperyear:
|
|
year_start = _period_break(dates_, "year")
|
|
month_start = _period_break(dates_, "month")
|
|
info_maj[year_start] = True
|
|
info_min[month_start] = True
|
|
info_min[year_start] = False
|
|
|
|
month_break = dates_[month_start].month
|
|
jan_or_jul = month_start[(month_break == 1) | (month_break == 7)]
|
|
info_fmt[jan_or_jul] = "%b"
|
|
info_fmt[year_start] = "%b\n%Y"
|
|
# Case 5. Less than 11 years ................
|
|
elif span <= 11 * periodsperyear:
|
|
year_start = _period_break(dates_, "year")
|
|
quarter_start = _period_break(dates_, "quarter")
|
|
info_maj[year_start] = True
|
|
info_min[quarter_start] = True
|
|
info_min[year_start] = False
|
|
info_fmt[year_start] = "%Y"
|
|
# Case 6. More than 12 years ................
|
|
else:
|
|
year_start = _period_break(dates_, "year")
|
|
year_break = dates_[year_start].year
|
|
nyears = span / periodsperyear
|
|
(min_anndef, maj_anndef) = _get_default_annual_spacing(nyears)
|
|
major_idx = year_start[(year_break % maj_anndef == 0)]
|
|
info_maj[major_idx] = True
|
|
minor_idx = year_start[(year_break % min_anndef == 0)]
|
|
info_min[minor_idx] = True
|
|
info_fmt[major_idx] = "%Y"
|
|
|
|
return info
|
|
|
|
|
|
def _monthly_finder(vmin, vmax, freq: BaseOffset) -> np.ndarray:
|
|
_, _, periodsperyear = _get_periods_per_ymd(freq)
|
|
|
|
vmin_orig = vmin
|
|
(vmin, vmax) = (int(vmin), int(vmax))
|
|
span = vmax - vmin + 1
|
|
|
|
# Initialize the output
|
|
info = np.zeros(
|
|
span, dtype=[("val", int), ("maj", bool), ("min", bool), ("fmt", "|S8")]
|
|
)
|
|
info["val"] = np.arange(vmin, vmax + 1)
|
|
dates_ = info["val"]
|
|
info["fmt"] = ""
|
|
year_start = (dates_ % 12 == 0).nonzero()[0]
|
|
info_maj = info["maj"]
|
|
info_fmt = info["fmt"]
|
|
|
|
if span <= 1.15 * periodsperyear:
|
|
info_maj[year_start] = True
|
|
info["min"] = True
|
|
|
|
info_fmt[:] = "%b"
|
|
info_fmt[year_start] = "%b\n%Y"
|
|
|
|
if not has_level_label(year_start, vmin_orig):
|
|
if dates_.size > 1:
|
|
idx = 1
|
|
else:
|
|
idx = 0
|
|
info_fmt[idx] = "%b\n%Y"
|
|
|
|
elif span <= 2.5 * periodsperyear:
|
|
quarter_start = (dates_ % 3 == 0).nonzero()
|
|
info_maj[year_start] = True
|
|
# TODO: Check the following : is it really info['fmt'] ?
|
|
# 2023-09-15 this is reached in test_finder_monthly
|
|
info["fmt"][quarter_start] = True
|
|
info["min"] = True
|
|
|
|
info_fmt[quarter_start] = "%b"
|
|
info_fmt[year_start] = "%b\n%Y"
|
|
|
|
elif span <= 4 * periodsperyear:
|
|
info_maj[year_start] = True
|
|
info["min"] = True
|
|
|
|
jan_or_jul = (dates_ % 12 == 0) | (dates_ % 12 == 6)
|
|
info_fmt[jan_or_jul] = "%b"
|
|
info_fmt[year_start] = "%b\n%Y"
|
|
|
|
elif span <= 11 * periodsperyear:
|
|
quarter_start = (dates_ % 3 == 0).nonzero()
|
|
info_maj[year_start] = True
|
|
info["min"][quarter_start] = True
|
|
|
|
info_fmt[year_start] = "%Y"
|
|
|
|
else:
|
|
nyears = span / periodsperyear
|
|
(min_anndef, maj_anndef) = _get_default_annual_spacing(nyears)
|
|
years = dates_[year_start] // 12 + 1
|
|
major_idx = year_start[(years % maj_anndef == 0)]
|
|
info_maj[major_idx] = True
|
|
info["min"][year_start[(years % min_anndef == 0)]] = True
|
|
|
|
info_fmt[major_idx] = "%Y"
|
|
|
|
return info
|
|
|
|
|
|
def _quarterly_finder(vmin, vmax, freq: BaseOffset) -> np.ndarray:
|
|
_, _, periodsperyear = _get_periods_per_ymd(freq)
|
|
vmin_orig = vmin
|
|
(vmin, vmax) = (int(vmin), int(vmax))
|
|
span = vmax - vmin + 1
|
|
|
|
info = np.zeros(
|
|
span, dtype=[("val", int), ("maj", bool), ("min", bool), ("fmt", "|S8")]
|
|
)
|
|
info["val"] = np.arange(vmin, vmax + 1)
|
|
info["fmt"] = ""
|
|
dates_ = info["val"]
|
|
info_maj = info["maj"]
|
|
info_fmt = info["fmt"]
|
|
year_start = (dates_ % 4 == 0).nonzero()[0]
|
|
|
|
if span <= 3.5 * periodsperyear:
|
|
info_maj[year_start] = True
|
|
info["min"] = True
|
|
|
|
info_fmt[:] = "Q%q"
|
|
info_fmt[year_start] = "Q%q\n%F"
|
|
if not has_level_label(year_start, vmin_orig):
|
|
if dates_.size > 1:
|
|
idx = 1
|
|
else:
|
|
idx = 0
|
|
info_fmt[idx] = "Q%q\n%F"
|
|
|
|
elif span <= 11 * periodsperyear:
|
|
info_maj[year_start] = True
|
|
info["min"] = True
|
|
info_fmt[year_start] = "%F"
|
|
|
|
else:
|
|
# https://github.com/pandas-dev/pandas/pull/47602
|
|
years = dates_[year_start] // 4 + 1970
|
|
nyears = span / periodsperyear
|
|
(min_anndef, maj_anndef) = _get_default_annual_spacing(nyears)
|
|
major_idx = year_start[(years % maj_anndef == 0)]
|
|
info_maj[major_idx] = True
|
|
info["min"][year_start[(years % min_anndef == 0)]] = True
|
|
info_fmt[major_idx] = "%F"
|
|
|
|
return info
|
|
|
|
|
|
def _annual_finder(vmin, vmax, freq: BaseOffset) -> np.ndarray:
|
|
# Note: small difference here vs other finders in adding 1 to vmax
|
|
(vmin, vmax) = (int(vmin), int(vmax + 1))
|
|
span = vmax - vmin + 1
|
|
|
|
info = np.zeros(
|
|
span, dtype=[("val", int), ("maj", bool), ("min", bool), ("fmt", "|S8")]
|
|
)
|
|
info["val"] = np.arange(vmin, vmax + 1)
|
|
info["fmt"] = ""
|
|
dates_ = info["val"]
|
|
|
|
(min_anndef, maj_anndef) = _get_default_annual_spacing(span)
|
|
major_idx = dates_ % maj_anndef == 0
|
|
minor_idx = dates_ % min_anndef == 0
|
|
info["maj"][major_idx] = True
|
|
info["min"][minor_idx] = True
|
|
info["fmt"][major_idx] = "%Y"
|
|
|
|
return info
|
|
|
|
|
|
def get_finder(freq: BaseOffset):
|
|
# error: "BaseOffset" has no attribute "_period_dtype_code"
|
|
dtype_code = freq._period_dtype_code # type: ignore[attr-defined]
|
|
fgroup = FreqGroup.from_period_dtype_code(dtype_code)
|
|
|
|
if fgroup == FreqGroup.FR_ANN:
|
|
return _annual_finder
|
|
elif fgroup == FreqGroup.FR_QTR:
|
|
return _quarterly_finder
|
|
elif fgroup == FreqGroup.FR_MTH:
|
|
return _monthly_finder
|
|
elif (dtype_code >= FreqGroup.FR_BUS.value) or fgroup == FreqGroup.FR_WK:
|
|
return _daily_finder
|
|
else: # pragma: no cover
|
|
raise NotImplementedError(f"Unsupported frequency: {dtype_code}")
|
|
|
|
|
|
class TimeSeries_DateLocator(Locator):
|
|
"""
|
|
Locates the ticks along an axis controlled by a :class:`Series`.
|
|
|
|
Parameters
|
|
----------
|
|
freq : BaseOffset
|
|
Valid frequency specifier.
|
|
minor_locator : {False, True}, optional
|
|
Whether the locator is for minor ticks (True) or not.
|
|
dynamic_mode : {True, False}, optional
|
|
Whether the locator should work in dynamic mode.
|
|
base : {int}, optional
|
|
quarter : {int}, optional
|
|
month : {int}, optional
|
|
day : {int}, optional
|
|
"""
|
|
|
|
axis: Axis
|
|
|
|
def __init__(
|
|
self,
|
|
freq: BaseOffset,
|
|
minor_locator: bool = False,
|
|
dynamic_mode: bool = True,
|
|
base: int = 1,
|
|
quarter: int = 1,
|
|
month: int = 1,
|
|
day: int = 1,
|
|
plot_obj=None,
|
|
) -> None:
|
|
freq = to_offset(freq, is_period=True)
|
|
self.freq = freq
|
|
self.base = base
|
|
(self.quarter, self.month, self.day) = (quarter, month, day)
|
|
self.isminor = minor_locator
|
|
self.isdynamic = dynamic_mode
|
|
self.offset = 0
|
|
self.plot_obj = plot_obj
|
|
self.finder = get_finder(freq)
|
|
|
|
def _get_default_locs(self, vmin, vmax):
|
|
"""Returns the default locations of ticks."""
|
|
locator = self.finder(vmin, vmax, self.freq)
|
|
|
|
if self.isminor:
|
|
return np.compress(locator["min"], locator["val"])
|
|
return np.compress(locator["maj"], locator["val"])
|
|
|
|
def __call__(self):
|
|
"""Return the locations of the ticks."""
|
|
# axis calls Locator.set_axis inside set_m<xxxx>_formatter
|
|
|
|
vi = tuple(self.axis.get_view_interval())
|
|
vmin, vmax = vi
|
|
if vmax < vmin:
|
|
vmin, vmax = vmax, vmin
|
|
if self.isdynamic:
|
|
locs = self._get_default_locs(vmin, vmax)
|
|
else: # pragma: no cover
|
|
base = self.base
|
|
(d, m) = divmod(vmin, base)
|
|
vmin = (d + 1) * base
|
|
# error: No overload variant of "range" matches argument types "float",
|
|
# "float", "int"
|
|
locs = list(range(vmin, vmax + 1, base)) # type: ignore[call-overload]
|
|
return locs
|
|
|
|
def autoscale(self):
|
|
"""
|
|
Sets the view limits to the nearest multiples of base that contain the
|
|
data.
|
|
"""
|
|
# requires matplotlib >= 0.98.0
|
|
(vmin, vmax) = self.axis.get_data_interval()
|
|
|
|
locs = self._get_default_locs(vmin, vmax)
|
|
(vmin, vmax) = locs[[0, -1]]
|
|
if vmin == vmax:
|
|
vmin -= 1
|
|
vmax += 1
|
|
return nonsingular(vmin, vmax)
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
# --- Formatter ---
|
|
# -------------------------------------------------------------------------
|
|
|
|
|
|
class TimeSeries_DateFormatter(Formatter):
|
|
"""
|
|
Formats the ticks along an axis controlled by a :class:`PeriodIndex`.
|
|
|
|
Parameters
|
|
----------
|
|
freq : BaseOffset
|
|
Valid frequency specifier.
|
|
minor_locator : bool, default False
|
|
Whether the current formatter should apply to minor ticks (True) or
|
|
major ticks (False).
|
|
dynamic_mode : bool, default True
|
|
Whether the formatter works in dynamic mode or not.
|
|
"""
|
|
|
|
axis: Axis
|
|
|
|
def __init__(
|
|
self,
|
|
freq: BaseOffset,
|
|
minor_locator: bool = False,
|
|
dynamic_mode: bool = True,
|
|
plot_obj=None,
|
|
) -> None:
|
|
freq = to_offset(freq, is_period=True)
|
|
self.format = None
|
|
self.freq = freq
|
|
self.locs: list[Any] = [] # unused, for matplotlib compat
|
|
self.formatdict: dict[Any, Any] | None = None
|
|
self.isminor = minor_locator
|
|
self.isdynamic = dynamic_mode
|
|
self.offset = 0
|
|
self.plot_obj = plot_obj
|
|
self.finder = get_finder(freq)
|
|
|
|
def _set_default_format(self, vmin, vmax):
|
|
"""Returns the default ticks spacing."""
|
|
info = self.finder(vmin, vmax, self.freq)
|
|
|
|
if self.isminor:
|
|
format = np.compress(info["min"] & np.logical_not(info["maj"]), info)
|
|
else:
|
|
format = np.compress(info["maj"], info)
|
|
self.formatdict = {x: f for (x, _, _, f) in format}
|
|
return self.formatdict
|
|
|
|
def set_locs(self, locs) -> None:
|
|
"""Sets the locations of the ticks"""
|
|
# don't actually use the locs. This is just needed to work with
|
|
# matplotlib. Force to use vmin, vmax
|
|
|
|
self.locs = locs
|
|
|
|
(vmin, vmax) = tuple(self.axis.get_view_interval())
|
|
if vmax < vmin:
|
|
(vmin, vmax) = (vmax, vmin)
|
|
self._set_default_format(vmin, vmax)
|
|
|
|
def __call__(self, x, pos: int | None = 0) -> str:
|
|
if self.formatdict is None:
|
|
return ""
|
|
else:
|
|
fmt = self.formatdict.pop(x, "")
|
|
if isinstance(fmt, np.bytes_):
|
|
fmt = fmt.decode("utf-8")
|
|
with warnings.catch_warnings():
|
|
warnings.filterwarnings(
|
|
"ignore",
|
|
"Period with BDay freq is deprecated",
|
|
category=FutureWarning,
|
|
)
|
|
period = Period(ordinal=int(x), freq=self.freq)
|
|
assert isinstance(period, Period)
|
|
return period.strftime(fmt)
|
|
|
|
|
|
class TimeSeries_TimedeltaFormatter(Formatter):
|
|
"""
|
|
Formats the ticks along an axis controlled by a :class:`TimedeltaIndex`.
|
|
"""
|
|
|
|
axis: Axis
|
|
|
|
@staticmethod
|
|
def format_timedelta_ticks(x, pos, n_decimals: int) -> str:
|
|
"""
|
|
Convert seconds to 'D days HH:MM:SS.F'
|
|
"""
|
|
s, ns = divmod(x, 10**9) # TODO(non-nano): this looks like it assumes ns
|
|
m, s = divmod(s, 60)
|
|
h, m = divmod(m, 60)
|
|
d, h = divmod(h, 24)
|
|
decimals = int(ns * 10 ** (n_decimals - 9))
|
|
s = f"{int(h):02d}:{int(m):02d}:{int(s):02d}"
|
|
if n_decimals > 0:
|
|
s += f".{decimals:0{n_decimals}d}"
|
|
if d != 0:
|
|
s = f"{int(d):d} days {s}"
|
|
return s
|
|
|
|
def __call__(self, x, pos: int | None = 0) -> str:
|
|
(vmin, vmax) = tuple(self.axis.get_view_interval())
|
|
n_decimals = min(int(np.ceil(np.log10(100 * 10**9 / abs(vmax - vmin)))), 9)
|
|
return self.format_timedelta_ticks(x, pos, n_decimals)
|