converter.py 36 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139
  1. from __future__ import annotations
  2. import contextlib
  3. import datetime as pydt
  4. from datetime import (
  5. datetime,
  6. timedelta,
  7. tzinfo,
  8. )
  9. import functools
  10. from typing import (
  11. TYPE_CHECKING,
  12. Any,
  13. cast,
  14. )
  15. import warnings
  16. import matplotlib.dates as mdates
  17. from matplotlib.ticker import (
  18. AutoLocator,
  19. Formatter,
  20. Locator,
  21. )
  22. from matplotlib.transforms import nonsingular
  23. import matplotlib.units as munits
  24. import numpy as np
  25. from pandas._libs import lib
  26. from pandas._libs.tslibs import (
  27. Timestamp,
  28. to_offset,
  29. )
  30. from pandas._libs.tslibs.dtypes import (
  31. FreqGroup,
  32. periods_per_day,
  33. )
  34. from pandas._typing import (
  35. F,
  36. npt,
  37. )
  38. from pandas.core.dtypes.common import (
  39. is_float,
  40. is_float_dtype,
  41. is_integer,
  42. is_integer_dtype,
  43. is_nested_list_like,
  44. )
  45. from pandas import (
  46. Index,
  47. Series,
  48. get_option,
  49. )
  50. import pandas.core.common as com
  51. from pandas.core.indexes.datetimes import date_range
  52. from pandas.core.indexes.period import (
  53. Period,
  54. PeriodIndex,
  55. period_range,
  56. )
  57. import pandas.core.tools.datetimes as tools
  58. if TYPE_CHECKING:
  59. from collections.abc import Generator
  60. from matplotlib.axis import Axis
  61. from pandas._libs.tslibs.offsets import BaseOffset
  62. _mpl_units = {} # Cache for units overwritten by us
  63. def get_pairs():
  64. pairs = [
  65. (Timestamp, DatetimeConverter),
  66. (Period, PeriodConverter),
  67. (pydt.datetime, DatetimeConverter),
  68. (pydt.date, DatetimeConverter),
  69. (pydt.time, TimeConverter),
  70. (np.datetime64, DatetimeConverter),
  71. ]
  72. return pairs
  73. def register_pandas_matplotlib_converters(func: F) -> F:
  74. """
  75. Decorator applying pandas_converters.
  76. """
  77. @functools.wraps(func)
  78. def wrapper(*args, **kwargs):
  79. with pandas_converters():
  80. return func(*args, **kwargs)
  81. return cast(F, wrapper)
  82. @contextlib.contextmanager
  83. def pandas_converters() -> Generator[None, None, None]:
  84. """
  85. Context manager registering pandas' converters for a plot.
  86. See Also
  87. --------
  88. register_pandas_matplotlib_converters : Decorator that applies this.
  89. """
  90. value = get_option("plotting.matplotlib.register_converters")
  91. if value:
  92. # register for True or "auto"
  93. register()
  94. try:
  95. yield
  96. finally:
  97. if value == "auto":
  98. # only deregister for "auto"
  99. deregister()
  100. def register() -> None:
  101. pairs = get_pairs()
  102. for type_, cls in pairs:
  103. # Cache previous converter if present
  104. if type_ in munits.registry and not isinstance(munits.registry[type_], cls):
  105. previous = munits.registry[type_]
  106. _mpl_units[type_] = previous
  107. # Replace with pandas converter
  108. munits.registry[type_] = cls()
  109. def deregister() -> None:
  110. # Renamed in pandas.plotting.__init__
  111. for type_, cls in get_pairs():
  112. # We use type to catch our classes directly, no inheritance
  113. if type(munits.registry.get(type_)) is cls:
  114. munits.registry.pop(type_)
  115. # restore the old keys
  116. for unit, formatter in _mpl_units.items():
  117. if type(formatter) not in {DatetimeConverter, PeriodConverter, TimeConverter}:
  118. # make it idempotent by excluding ours.
  119. munits.registry[unit] = formatter
  120. def _to_ordinalf(tm: pydt.time) -> float:
  121. tot_sec = tm.hour * 3600 + tm.minute * 60 + tm.second + tm.microsecond / 10**6
  122. return tot_sec
  123. def time2num(d):
  124. if isinstance(d, str):
  125. parsed = Timestamp(d)
  126. return _to_ordinalf(parsed.time())
  127. if isinstance(d, pydt.time):
  128. return _to_ordinalf(d)
  129. return d
  130. class TimeConverter(munits.ConversionInterface):
  131. @staticmethod
  132. def convert(value, unit, axis):
  133. valid_types = (str, pydt.time)
  134. if isinstance(value, valid_types) or is_integer(value) or is_float(value):
  135. return time2num(value)
  136. if isinstance(value, Index):
  137. return value.map(time2num)
  138. if isinstance(value, (list, tuple, np.ndarray, Index)):
  139. return [time2num(x) for x in value]
  140. return value
  141. @staticmethod
  142. def axisinfo(unit, axis) -> munits.AxisInfo | None:
  143. if unit != "time":
  144. return None
  145. majloc = AutoLocator()
  146. majfmt = TimeFormatter(majloc)
  147. return munits.AxisInfo(majloc=majloc, majfmt=majfmt, label="time")
  148. @staticmethod
  149. def default_units(x, axis) -> str:
  150. return "time"
  151. # time formatter
  152. class TimeFormatter(Formatter):
  153. def __init__(self, locs) -> None:
  154. self.locs = locs
  155. def __call__(self, x, pos: int | None = 0) -> str:
  156. """
  157. Return the time of day as a formatted string.
  158. Parameters
  159. ----------
  160. x : float
  161. The time of day specified as seconds since 00:00 (midnight),
  162. with up to microsecond precision.
  163. pos
  164. Unused
  165. Returns
  166. -------
  167. str
  168. A string in HH:MM:SS.mmmuuu format. Microseconds,
  169. milliseconds and seconds are only displayed if non-zero.
  170. """
  171. fmt = "%H:%M:%S.%f"
  172. s = int(x)
  173. msus = round((x - s) * 10**6)
  174. ms = msus // 1000
  175. us = msus % 1000
  176. m, s = divmod(s, 60)
  177. h, m = divmod(m, 60)
  178. _, h = divmod(h, 24)
  179. if us != 0:
  180. return pydt.time(h, m, s, msus).strftime(fmt)
  181. elif ms != 0:
  182. return pydt.time(h, m, s, msus).strftime(fmt)[:-3]
  183. elif s != 0:
  184. return pydt.time(h, m, s).strftime("%H:%M:%S")
  185. return pydt.time(h, m).strftime("%H:%M")
  186. # Period Conversion
  187. class PeriodConverter(mdates.DateConverter):
  188. @staticmethod
  189. def convert(values, units, axis):
  190. if is_nested_list_like(values):
  191. values = [PeriodConverter._convert_1d(v, units, axis) for v in values]
  192. else:
  193. values = PeriodConverter._convert_1d(values, units, axis)
  194. return values
  195. @staticmethod
  196. def _convert_1d(values, units, axis):
  197. if not hasattr(axis, "freq"):
  198. raise TypeError("Axis must have `freq` set to convert to Periods")
  199. valid_types = (str, datetime, Period, pydt.date, pydt.time, np.datetime64)
  200. with warnings.catch_warnings():
  201. warnings.filterwarnings(
  202. "ignore", "Period with BDay freq is deprecated", category=FutureWarning
  203. )
  204. warnings.filterwarnings(
  205. "ignore", r"PeriodDtype\[B\] is deprecated", category=FutureWarning
  206. )
  207. if (
  208. isinstance(values, valid_types)
  209. or is_integer(values)
  210. or is_float(values)
  211. ):
  212. return get_datevalue(values, axis.freq)
  213. elif isinstance(values, PeriodIndex):
  214. return values.asfreq(axis.freq).asi8
  215. elif isinstance(values, Index):
  216. return values.map(lambda x: get_datevalue(x, axis.freq))
  217. elif lib.infer_dtype(values, skipna=False) == "period":
  218. # https://github.com/pandas-dev/pandas/issues/24304
  219. # convert ndarray[period] -> PeriodIndex
  220. return PeriodIndex(values, freq=axis.freq).asi8
  221. elif isinstance(values, (list, tuple, np.ndarray, Index)):
  222. return [get_datevalue(x, axis.freq) for x in values]
  223. return values
  224. def get_datevalue(date, freq):
  225. if isinstance(date, Period):
  226. return date.asfreq(freq).ordinal
  227. elif isinstance(date, (str, datetime, pydt.date, pydt.time, np.datetime64)):
  228. return Period(date, freq).ordinal
  229. elif (
  230. is_integer(date)
  231. or is_float(date)
  232. or (isinstance(date, (np.ndarray, Index)) and (date.size == 1))
  233. ):
  234. return date
  235. elif date is None:
  236. return None
  237. raise ValueError(f"Unrecognizable date '{date}'")
  238. # Datetime Conversion
  239. class DatetimeConverter(mdates.DateConverter):
  240. @staticmethod
  241. def convert(values, unit, axis):
  242. # values might be a 1-d array, or a list-like of arrays.
  243. if is_nested_list_like(values):
  244. values = [DatetimeConverter._convert_1d(v, unit, axis) for v in values]
  245. else:
  246. values = DatetimeConverter._convert_1d(values, unit, axis)
  247. return values
  248. @staticmethod
  249. def _convert_1d(values, unit, axis):
  250. def try_parse(values):
  251. try:
  252. return mdates.date2num(tools.to_datetime(values))
  253. except Exception:
  254. return values
  255. if isinstance(values, (datetime, pydt.date, np.datetime64, pydt.time)):
  256. return mdates.date2num(values)
  257. elif is_integer(values) or is_float(values):
  258. return values
  259. elif isinstance(values, str):
  260. return try_parse(values)
  261. elif isinstance(values, (list, tuple, np.ndarray, Index, Series)):
  262. if isinstance(values, Series):
  263. # https://github.com/matplotlib/matplotlib/issues/11391
  264. # Series was skipped. Convert to DatetimeIndex to get asi8
  265. values = Index(values)
  266. if isinstance(values, Index):
  267. values = values.values
  268. if not isinstance(values, np.ndarray):
  269. values = com.asarray_tuplesafe(values)
  270. if is_integer_dtype(values) or is_float_dtype(values):
  271. return values
  272. try:
  273. values = tools.to_datetime(values)
  274. except Exception:
  275. pass
  276. values = mdates.date2num(values)
  277. return values
  278. @staticmethod
  279. def axisinfo(unit: tzinfo | None, axis) -> munits.AxisInfo:
  280. """
  281. Return the :class:`~matplotlib.units.AxisInfo` for *unit*.
  282. *unit* is a tzinfo instance or None.
  283. The *axis* argument is required but not used.
  284. """
  285. tz = unit
  286. majloc = PandasAutoDateLocator(tz=tz)
  287. majfmt = PandasAutoDateFormatter(majloc, tz=tz)
  288. datemin = pydt.date(2000, 1, 1)
  289. datemax = pydt.date(2010, 1, 1)
  290. return munits.AxisInfo(
  291. majloc=majloc, majfmt=majfmt, label="", default_limits=(datemin, datemax)
  292. )
  293. class PandasAutoDateFormatter(mdates.AutoDateFormatter):
  294. def __init__(self, locator, tz=None, defaultfmt: str = "%Y-%m-%d") -> None:
  295. mdates.AutoDateFormatter.__init__(self, locator, tz, defaultfmt)
  296. class PandasAutoDateLocator(mdates.AutoDateLocator):
  297. def get_locator(self, dmin, dmax):
  298. """Pick the best locator based on a distance."""
  299. tot_sec = (dmax - dmin).total_seconds()
  300. if abs(tot_sec) < self.minticks:
  301. self._freq = -1
  302. locator = MilliSecondLocator(self.tz)
  303. locator.set_axis(self.axis)
  304. # error: Item "None" of "Axis | _DummyAxis | _AxisWrapper | None"
  305. # has no attribute "get_data_interval"
  306. locator.axis.set_view_interval( # type: ignore[union-attr]
  307. *self.axis.get_view_interval() # type: ignore[union-attr]
  308. )
  309. locator.axis.set_data_interval( # type: ignore[union-attr]
  310. *self.axis.get_data_interval() # type: ignore[union-attr]
  311. )
  312. return locator
  313. return mdates.AutoDateLocator.get_locator(self, dmin, dmax)
  314. def _get_unit(self):
  315. return MilliSecondLocator.get_unit_generic(self._freq)
  316. class MilliSecondLocator(mdates.DateLocator):
  317. UNIT = 1.0 / (24 * 3600 * 1000)
  318. def __init__(self, tz) -> None:
  319. mdates.DateLocator.__init__(self, tz)
  320. self._interval = 1.0
  321. def _get_unit(self):
  322. return self.get_unit_generic(-1)
  323. @staticmethod
  324. def get_unit_generic(freq):
  325. unit = mdates.RRuleLocator.get_unit_generic(freq)
  326. if unit < 0:
  327. return MilliSecondLocator.UNIT
  328. return unit
  329. def __call__(self):
  330. # if no data have been set, this will tank with a ValueError
  331. try:
  332. dmin, dmax = self.viewlim_to_dt()
  333. except ValueError:
  334. return []
  335. # We need to cap at the endpoints of valid datetime
  336. nmax, nmin = mdates.date2num((dmax, dmin))
  337. num = (nmax - nmin) * 86400 * 1000
  338. max_millis_ticks = 6
  339. for interval in [1, 10, 50, 100, 200, 500]:
  340. if num <= interval * (max_millis_ticks - 1):
  341. self._interval = interval
  342. break
  343. # We went through the whole loop without breaking, default to 1
  344. self._interval = 1000.0
  345. estimate = (nmax - nmin) / (self._get_unit() * self._get_interval())
  346. if estimate > self.MAXTICKS * 2:
  347. raise RuntimeError(
  348. "MillisecondLocator estimated to generate "
  349. f"{estimate:d} ticks from {dmin} to {dmax}: exceeds Locator.MAXTICKS"
  350. f"* 2 ({self.MAXTICKS * 2:d}) "
  351. )
  352. interval = self._get_interval()
  353. freq = f"{interval}ms"
  354. tz = self.tz.tzname(None)
  355. st = dmin.replace(tzinfo=None)
  356. ed = dmin.replace(tzinfo=None)
  357. all_dates = date_range(start=st, end=ed, freq=freq, tz=tz).astype(object)
  358. try:
  359. if len(all_dates) > 0:
  360. locs = self.raise_if_exceeds(mdates.date2num(all_dates))
  361. return locs
  362. except Exception: # pragma: no cover
  363. pass
  364. lims = mdates.date2num([dmin, dmax])
  365. return lims
  366. def _get_interval(self):
  367. return self._interval
  368. def autoscale(self):
  369. """
  370. Set the view limits to include the data range.
  371. """
  372. # We need to cap at the endpoints of valid datetime
  373. dmin, dmax = self.datalim_to_dt()
  374. vmin = mdates.date2num(dmin)
  375. vmax = mdates.date2num(dmax)
  376. return self.nonsingular(vmin, vmax)
  377. def _from_ordinal(x, tz: tzinfo | None = None) -> datetime:
  378. ix = int(x)
  379. dt = datetime.fromordinal(ix)
  380. remainder = float(x) - ix
  381. hour, remainder = divmod(24 * remainder, 1)
  382. minute, remainder = divmod(60 * remainder, 1)
  383. second, remainder = divmod(60 * remainder, 1)
  384. microsecond = int(1_000_000 * remainder)
  385. if microsecond < 10:
  386. microsecond = 0 # compensate for rounding errors
  387. dt = datetime(
  388. dt.year, dt.month, dt.day, int(hour), int(minute), int(second), microsecond
  389. )
  390. if tz is not None:
  391. dt = dt.astimezone(tz)
  392. if microsecond > 999990: # compensate for rounding errors
  393. dt += timedelta(microseconds=1_000_000 - microsecond)
  394. return dt
  395. # Fixed frequency dynamic tick locators and formatters
  396. # -------------------------------------------------------------------------
  397. # --- Locators ---
  398. # -------------------------------------------------------------------------
  399. def _get_default_annual_spacing(nyears) -> tuple[int, int]:
  400. """
  401. Returns a default spacing between consecutive ticks for annual data.
  402. """
  403. if nyears < 11:
  404. (min_spacing, maj_spacing) = (1, 1)
  405. elif nyears < 20:
  406. (min_spacing, maj_spacing) = (1, 2)
  407. elif nyears < 50:
  408. (min_spacing, maj_spacing) = (1, 5)
  409. elif nyears < 100:
  410. (min_spacing, maj_spacing) = (5, 10)
  411. elif nyears < 200:
  412. (min_spacing, maj_spacing) = (5, 25)
  413. elif nyears < 600:
  414. (min_spacing, maj_spacing) = (10, 50)
  415. else:
  416. factor = nyears // 1000 + 1
  417. (min_spacing, maj_spacing) = (factor * 20, factor * 100)
  418. return (min_spacing, maj_spacing)
  419. def _period_break(dates: PeriodIndex, period: str) -> npt.NDArray[np.intp]:
  420. """
  421. Returns the indices where the given period changes.
  422. Parameters
  423. ----------
  424. dates : PeriodIndex
  425. Array of intervals to monitor.
  426. period : str
  427. Name of the period to monitor.
  428. """
  429. mask = _period_break_mask(dates, period)
  430. return np.nonzero(mask)[0]
  431. def _period_break_mask(dates: PeriodIndex, period: str) -> npt.NDArray[np.bool_]:
  432. current = getattr(dates, period)
  433. previous = getattr(dates - 1 * dates.freq, period)
  434. return current != previous
  435. def has_level_label(label_flags: npt.NDArray[np.intp], vmin: float) -> bool:
  436. """
  437. Returns true if the ``label_flags`` indicate there is at least one label
  438. for this level.
  439. if the minimum view limit is not an exact integer, then the first tick
  440. label won't be shown, so we must adjust for that.
  441. """
  442. if label_flags.size == 0 or (
  443. label_flags.size == 1 and label_flags[0] == 0 and vmin % 1 > 0.0
  444. ):
  445. return False
  446. else:
  447. return True
  448. def _get_periods_per_ymd(freq: BaseOffset) -> tuple[int, int, int]:
  449. # error: "BaseOffset" has no attribute "_period_dtype_code"
  450. dtype_code = freq._period_dtype_code # type: ignore[attr-defined]
  451. freq_group = FreqGroup.from_period_dtype_code(dtype_code)
  452. ppd = -1 # placeholder for above-day freqs
  453. if dtype_code >= FreqGroup.FR_HR.value:
  454. # error: "BaseOffset" has no attribute "_creso"
  455. ppd = periods_per_day(freq._creso) # type: ignore[attr-defined]
  456. ppm = 28 * ppd
  457. ppy = 365 * ppd
  458. elif freq_group == FreqGroup.FR_BUS:
  459. ppm = 19
  460. ppy = 261
  461. elif freq_group == FreqGroup.FR_DAY:
  462. ppm = 28
  463. ppy = 365
  464. elif freq_group == FreqGroup.FR_WK:
  465. ppm = 3
  466. ppy = 52
  467. elif freq_group == FreqGroup.FR_MTH:
  468. ppm = 1
  469. ppy = 12
  470. elif freq_group == FreqGroup.FR_QTR:
  471. ppm = -1 # placerholder
  472. ppy = 4
  473. elif freq_group == FreqGroup.FR_ANN:
  474. ppm = -1 # placeholder
  475. ppy = 1
  476. else:
  477. raise NotImplementedError(f"Unsupported frequency: {dtype_code}")
  478. return ppd, ppm, ppy
  479. @functools.cache
  480. def _daily_finder(vmin: float, vmax: float, freq: BaseOffset) -> np.ndarray:
  481. # error: "BaseOffset" has no attribute "_period_dtype_code"
  482. dtype_code = freq._period_dtype_code # type: ignore[attr-defined]
  483. periodsperday, periodspermonth, periodsperyear = _get_periods_per_ymd(freq)
  484. # save this for later usage
  485. vmin_orig = vmin
  486. (vmin, vmax) = (int(vmin), int(vmax))
  487. span = vmax - vmin + 1
  488. with warnings.catch_warnings():
  489. warnings.filterwarnings(
  490. "ignore", "Period with BDay freq is deprecated", category=FutureWarning
  491. )
  492. warnings.filterwarnings(
  493. "ignore", r"PeriodDtype\[B\] is deprecated", category=FutureWarning
  494. )
  495. dates_ = period_range(
  496. start=Period(ordinal=vmin, freq=freq),
  497. end=Period(ordinal=vmax, freq=freq),
  498. freq=freq,
  499. )
  500. # Initialize the output
  501. info = np.zeros(
  502. span, dtype=[("val", np.int64), ("maj", bool), ("min", bool), ("fmt", "|S20")]
  503. )
  504. info["val"][:] = dates_.asi8
  505. info["fmt"][:] = ""
  506. info["maj"][[0, -1]] = True
  507. # .. and set some shortcuts
  508. info_maj = info["maj"]
  509. info_min = info["min"]
  510. info_fmt = info["fmt"]
  511. def first_label(label_flags):
  512. if (label_flags[0] == 0) and (label_flags.size > 1) and ((vmin_orig % 1) > 0.0):
  513. return label_flags[1]
  514. else:
  515. return label_flags[0]
  516. # Case 1. Less than a month
  517. if span <= periodspermonth:
  518. day_start = _period_break(dates_, "day")
  519. month_start = _period_break(dates_, "month")
  520. year_start = _period_break(dates_, "year")
  521. def _hour_finder(label_interval: int, force_year_start: bool) -> None:
  522. target = dates_.hour
  523. mask = _period_break_mask(dates_, "hour")
  524. info_maj[day_start] = True
  525. info_min[mask & (target % label_interval == 0)] = True
  526. info_fmt[mask & (target % label_interval == 0)] = "%H:%M"
  527. info_fmt[day_start] = "%H:%M\n%d-%b"
  528. info_fmt[year_start] = "%H:%M\n%d-%b\n%Y"
  529. if force_year_start and not has_level_label(year_start, vmin_orig):
  530. info_fmt[first_label(day_start)] = "%H:%M\n%d-%b\n%Y"
  531. def _minute_finder(label_interval: int) -> None:
  532. target = dates_.minute
  533. hour_start = _period_break(dates_, "hour")
  534. mask = _period_break_mask(dates_, "minute")
  535. info_maj[hour_start] = True
  536. info_min[mask & (target % label_interval == 0)] = True
  537. info_fmt[mask & (target % label_interval == 0)] = "%H:%M"
  538. info_fmt[day_start] = "%H:%M\n%d-%b"
  539. info_fmt[year_start] = "%H:%M\n%d-%b\n%Y"
  540. def _second_finder(label_interval: int) -> None:
  541. target = dates_.second
  542. minute_start = _period_break(dates_, "minute")
  543. mask = _period_break_mask(dates_, "second")
  544. info_maj[minute_start] = True
  545. info_min[mask & (target % label_interval == 0)] = True
  546. info_fmt[mask & (target % label_interval == 0)] = "%H:%M:%S"
  547. info_fmt[day_start] = "%H:%M:%S\n%d-%b"
  548. info_fmt[year_start] = "%H:%M:%S\n%d-%b\n%Y"
  549. if span < periodsperday / 12000:
  550. _second_finder(1)
  551. elif span < periodsperday / 6000:
  552. _second_finder(2)
  553. elif span < periodsperday / 2400:
  554. _second_finder(5)
  555. elif span < periodsperday / 1200:
  556. _second_finder(10)
  557. elif span < periodsperday / 800:
  558. _second_finder(15)
  559. elif span < periodsperday / 400:
  560. _second_finder(30)
  561. elif span < periodsperday / 150:
  562. _minute_finder(1)
  563. elif span < periodsperday / 70:
  564. _minute_finder(2)
  565. elif span < periodsperday / 24:
  566. _minute_finder(5)
  567. elif span < periodsperday / 12:
  568. _minute_finder(15)
  569. elif span < periodsperday / 6:
  570. _minute_finder(30)
  571. elif span < periodsperday / 2.5:
  572. _hour_finder(1, False)
  573. elif span < periodsperday / 1.5:
  574. _hour_finder(2, False)
  575. elif span < periodsperday * 1.25:
  576. _hour_finder(3, False)
  577. elif span < periodsperday * 2.5:
  578. _hour_finder(6, True)
  579. elif span < periodsperday * 4:
  580. _hour_finder(12, True)
  581. else:
  582. info_maj[month_start] = True
  583. info_min[day_start] = True
  584. info_fmt[day_start] = "%d"
  585. info_fmt[month_start] = "%d\n%b"
  586. info_fmt[year_start] = "%d\n%b\n%Y"
  587. if not has_level_label(year_start, vmin_orig):
  588. if not has_level_label(month_start, vmin_orig):
  589. info_fmt[first_label(day_start)] = "%d\n%b\n%Y"
  590. else:
  591. info_fmt[first_label(month_start)] = "%d\n%b\n%Y"
  592. # Case 2. Less than three months
  593. elif span <= periodsperyear // 4:
  594. month_start = _period_break(dates_, "month")
  595. info_maj[month_start] = True
  596. if dtype_code < FreqGroup.FR_HR.value:
  597. info["min"] = True
  598. else:
  599. day_start = _period_break(dates_, "day")
  600. info["min"][day_start] = True
  601. week_start = _period_break(dates_, "week")
  602. year_start = _period_break(dates_, "year")
  603. info_fmt[week_start] = "%d"
  604. info_fmt[month_start] = "\n\n%b"
  605. info_fmt[year_start] = "\n\n%b\n%Y"
  606. if not has_level_label(year_start, vmin_orig):
  607. if not has_level_label(month_start, vmin_orig):
  608. info_fmt[first_label(week_start)] = "\n\n%b\n%Y"
  609. else:
  610. info_fmt[first_label(month_start)] = "\n\n%b\n%Y"
  611. # Case 3. Less than 14 months ...............
  612. elif span <= 1.15 * periodsperyear:
  613. year_start = _period_break(dates_, "year")
  614. month_start = _period_break(dates_, "month")
  615. week_start = _period_break(dates_, "week")
  616. info_maj[month_start] = True
  617. info_min[week_start] = True
  618. info_min[year_start] = False
  619. info_min[month_start] = False
  620. info_fmt[month_start] = "%b"
  621. info_fmt[year_start] = "%b\n%Y"
  622. if not has_level_label(year_start, vmin_orig):
  623. info_fmt[first_label(month_start)] = "%b\n%Y"
  624. # Case 4. Less than 2.5 years ...............
  625. elif span <= 2.5 * periodsperyear:
  626. year_start = _period_break(dates_, "year")
  627. quarter_start = _period_break(dates_, "quarter")
  628. month_start = _period_break(dates_, "month")
  629. info_maj[quarter_start] = True
  630. info_min[month_start] = True
  631. info_fmt[quarter_start] = "%b"
  632. info_fmt[year_start] = "%b\n%Y"
  633. # Case 4. Less than 4 years .................
  634. elif span <= 4 * periodsperyear:
  635. year_start = _period_break(dates_, "year")
  636. month_start = _period_break(dates_, "month")
  637. info_maj[year_start] = True
  638. info_min[month_start] = True
  639. info_min[year_start] = False
  640. month_break = dates_[month_start].month
  641. jan_or_jul = month_start[(month_break == 1) | (month_break == 7)]
  642. info_fmt[jan_or_jul] = "%b"
  643. info_fmt[year_start] = "%b\n%Y"
  644. # Case 5. Less than 11 years ................
  645. elif span <= 11 * periodsperyear:
  646. year_start = _period_break(dates_, "year")
  647. quarter_start = _period_break(dates_, "quarter")
  648. info_maj[year_start] = True
  649. info_min[quarter_start] = True
  650. info_min[year_start] = False
  651. info_fmt[year_start] = "%Y"
  652. # Case 6. More than 12 years ................
  653. else:
  654. year_start = _period_break(dates_, "year")
  655. year_break = dates_[year_start].year
  656. nyears = span / periodsperyear
  657. (min_anndef, maj_anndef) = _get_default_annual_spacing(nyears)
  658. major_idx = year_start[(year_break % maj_anndef == 0)]
  659. info_maj[major_idx] = True
  660. minor_idx = year_start[(year_break % min_anndef == 0)]
  661. info_min[minor_idx] = True
  662. info_fmt[major_idx] = "%Y"
  663. return info
  664. @functools.cache
  665. def _monthly_finder(vmin: float, vmax: float, freq: BaseOffset) -> np.ndarray:
  666. _, _, periodsperyear = _get_periods_per_ymd(freq)
  667. vmin_orig = vmin
  668. (vmin, vmax) = (int(vmin), int(vmax))
  669. span = vmax - vmin + 1
  670. # Initialize the output
  671. info = np.zeros(
  672. span, dtype=[("val", int), ("maj", bool), ("min", bool), ("fmt", "|S8")]
  673. )
  674. info["val"] = np.arange(vmin, vmax + 1)
  675. dates_ = info["val"]
  676. info["fmt"] = ""
  677. year_start = (dates_ % 12 == 0).nonzero()[0]
  678. info_maj = info["maj"]
  679. info_fmt = info["fmt"]
  680. if span <= 1.15 * periodsperyear:
  681. info_maj[year_start] = True
  682. info["min"] = True
  683. info_fmt[:] = "%b"
  684. info_fmt[year_start] = "%b\n%Y"
  685. if not has_level_label(year_start, vmin_orig):
  686. if dates_.size > 1:
  687. idx = 1
  688. else:
  689. idx = 0
  690. info_fmt[idx] = "%b\n%Y"
  691. elif span <= 2.5 * periodsperyear:
  692. quarter_start = (dates_ % 3 == 0).nonzero()
  693. info_maj[year_start] = True
  694. # TODO: Check the following : is it really info['fmt'] ?
  695. # 2023-09-15 this is reached in test_finder_monthly
  696. info["fmt"][quarter_start] = True
  697. info["min"] = True
  698. info_fmt[quarter_start] = "%b"
  699. info_fmt[year_start] = "%b\n%Y"
  700. elif span <= 4 * periodsperyear:
  701. info_maj[year_start] = True
  702. info["min"] = True
  703. jan_or_jul = (dates_ % 12 == 0) | (dates_ % 12 == 6)
  704. info_fmt[jan_or_jul] = "%b"
  705. info_fmt[year_start] = "%b\n%Y"
  706. elif span <= 11 * periodsperyear:
  707. quarter_start = (dates_ % 3 == 0).nonzero()
  708. info_maj[year_start] = True
  709. info["min"][quarter_start] = True
  710. info_fmt[year_start] = "%Y"
  711. else:
  712. nyears = span / periodsperyear
  713. (min_anndef, maj_anndef) = _get_default_annual_spacing(nyears)
  714. years = dates_[year_start] // 12 + 1
  715. major_idx = year_start[(years % maj_anndef == 0)]
  716. info_maj[major_idx] = True
  717. info["min"][year_start[(years % min_anndef == 0)]] = True
  718. info_fmt[major_idx] = "%Y"
  719. return info
  720. @functools.cache
  721. def _quarterly_finder(vmin: float, vmax: float, freq: BaseOffset) -> np.ndarray:
  722. _, _, periodsperyear = _get_periods_per_ymd(freq)
  723. vmin_orig = vmin
  724. (vmin, vmax) = (int(vmin), int(vmax))
  725. span = vmax - vmin + 1
  726. info = np.zeros(
  727. span, dtype=[("val", int), ("maj", bool), ("min", bool), ("fmt", "|S8")]
  728. )
  729. info["val"] = np.arange(vmin, vmax + 1)
  730. info["fmt"] = ""
  731. dates_ = info["val"]
  732. info_maj = info["maj"]
  733. info_fmt = info["fmt"]
  734. year_start = (dates_ % 4 == 0).nonzero()[0]
  735. if span <= 3.5 * periodsperyear:
  736. info_maj[year_start] = True
  737. info["min"] = True
  738. info_fmt[:] = "Q%q"
  739. info_fmt[year_start] = "Q%q\n%F"
  740. if not has_level_label(year_start, vmin_orig):
  741. if dates_.size > 1:
  742. idx = 1
  743. else:
  744. idx = 0
  745. info_fmt[idx] = "Q%q\n%F"
  746. elif span <= 11 * periodsperyear:
  747. info_maj[year_start] = True
  748. info["min"] = True
  749. info_fmt[year_start] = "%F"
  750. else:
  751. # https://github.com/pandas-dev/pandas/pull/47602
  752. years = dates_[year_start] // 4 + 1970
  753. nyears = span / periodsperyear
  754. (min_anndef, maj_anndef) = _get_default_annual_spacing(nyears)
  755. major_idx = year_start[(years % maj_anndef == 0)]
  756. info_maj[major_idx] = True
  757. info["min"][year_start[(years % min_anndef == 0)]] = True
  758. info_fmt[major_idx] = "%F"
  759. return info
  760. @functools.cache
  761. def _annual_finder(vmin: float, vmax: float, freq: BaseOffset) -> np.ndarray:
  762. # Note: small difference here vs other finders in adding 1 to vmax
  763. (vmin, vmax) = (int(vmin), int(vmax + 1))
  764. span = vmax - vmin + 1
  765. info = np.zeros(
  766. span, dtype=[("val", int), ("maj", bool), ("min", bool), ("fmt", "|S8")]
  767. )
  768. info["val"] = np.arange(vmin, vmax + 1)
  769. info["fmt"] = ""
  770. dates_ = info["val"]
  771. (min_anndef, maj_anndef) = _get_default_annual_spacing(span)
  772. major_idx = dates_ % maj_anndef == 0
  773. minor_idx = dates_ % min_anndef == 0
  774. info["maj"][major_idx] = True
  775. info["min"][minor_idx] = True
  776. info["fmt"][major_idx] = "%Y"
  777. return info
  778. def get_finder(freq: BaseOffset):
  779. # error: "BaseOffset" has no attribute "_period_dtype_code"
  780. dtype_code = freq._period_dtype_code # type: ignore[attr-defined]
  781. fgroup = FreqGroup.from_period_dtype_code(dtype_code)
  782. if fgroup == FreqGroup.FR_ANN:
  783. return _annual_finder
  784. elif fgroup == FreqGroup.FR_QTR:
  785. return _quarterly_finder
  786. elif fgroup == FreqGroup.FR_MTH:
  787. return _monthly_finder
  788. elif (dtype_code >= FreqGroup.FR_BUS.value) or fgroup == FreqGroup.FR_WK:
  789. return _daily_finder
  790. else: # pragma: no cover
  791. raise NotImplementedError(f"Unsupported frequency: {dtype_code}")
  792. class TimeSeries_DateLocator(Locator):
  793. """
  794. Locates the ticks along an axis controlled by a :class:`Series`.
  795. Parameters
  796. ----------
  797. freq : BaseOffset
  798. Valid frequency specifier.
  799. minor_locator : {False, True}, optional
  800. Whether the locator is for minor ticks (True) or not.
  801. dynamic_mode : {True, False}, optional
  802. Whether the locator should work in dynamic mode.
  803. base : {int}, optional
  804. quarter : {int}, optional
  805. month : {int}, optional
  806. day : {int}, optional
  807. """
  808. axis: Axis
  809. def __init__(
  810. self,
  811. freq: BaseOffset,
  812. minor_locator: bool = False,
  813. dynamic_mode: bool = True,
  814. base: int = 1,
  815. quarter: int = 1,
  816. month: int = 1,
  817. day: int = 1,
  818. plot_obj=None,
  819. ) -> None:
  820. freq = to_offset(freq, is_period=True)
  821. self.freq = freq
  822. self.base = base
  823. (self.quarter, self.month, self.day) = (quarter, month, day)
  824. self.isminor = minor_locator
  825. self.isdynamic = dynamic_mode
  826. self.offset = 0
  827. self.plot_obj = plot_obj
  828. self.finder = get_finder(freq)
  829. def _get_default_locs(self, vmin, vmax):
  830. """Returns the default locations of ticks."""
  831. locator = self.finder(vmin, vmax, self.freq)
  832. if self.isminor:
  833. return np.compress(locator["min"], locator["val"])
  834. return np.compress(locator["maj"], locator["val"])
  835. def __call__(self):
  836. """Return the locations of the ticks."""
  837. # axis calls Locator.set_axis inside set_m<xxxx>_formatter
  838. vi = tuple(self.axis.get_view_interval())
  839. vmin, vmax = vi
  840. if vmax < vmin:
  841. vmin, vmax = vmax, vmin
  842. if self.isdynamic:
  843. locs = self._get_default_locs(vmin, vmax)
  844. else: # pragma: no cover
  845. base = self.base
  846. (d, m) = divmod(vmin, base)
  847. vmin = (d + 1) * base
  848. # error: No overload variant of "range" matches argument types "float",
  849. # "float", "int"
  850. locs = list(range(vmin, vmax + 1, base)) # type: ignore[call-overload]
  851. return locs
  852. def autoscale(self):
  853. """
  854. Sets the view limits to the nearest multiples of base that contain the
  855. data.
  856. """
  857. # requires matplotlib >= 0.98.0
  858. (vmin, vmax) = self.axis.get_data_interval()
  859. locs = self._get_default_locs(vmin, vmax)
  860. (vmin, vmax) = locs[[0, -1]]
  861. if vmin == vmax:
  862. vmin -= 1
  863. vmax += 1
  864. return nonsingular(vmin, vmax)
  865. # -------------------------------------------------------------------------
  866. # --- Formatter ---
  867. # -------------------------------------------------------------------------
  868. class TimeSeries_DateFormatter(Formatter):
  869. """
  870. Formats the ticks along an axis controlled by a :class:`PeriodIndex`.
  871. Parameters
  872. ----------
  873. freq : BaseOffset
  874. Valid frequency specifier.
  875. minor_locator : bool, default False
  876. Whether the current formatter should apply to minor ticks (True) or
  877. major ticks (False).
  878. dynamic_mode : bool, default True
  879. Whether the formatter works in dynamic mode or not.
  880. """
  881. axis: Axis
  882. def __init__(
  883. self,
  884. freq: BaseOffset,
  885. minor_locator: bool = False,
  886. dynamic_mode: bool = True,
  887. plot_obj=None,
  888. ) -> None:
  889. freq = to_offset(freq, is_period=True)
  890. self.format = None
  891. self.freq = freq
  892. self.locs: list[Any] = [] # unused, for matplotlib compat
  893. self.formatdict: dict[Any, Any] | None = None
  894. self.isminor = minor_locator
  895. self.isdynamic = dynamic_mode
  896. self.offset = 0
  897. self.plot_obj = plot_obj
  898. self.finder = get_finder(freq)
  899. def _set_default_format(self, vmin, vmax):
  900. """Returns the default ticks spacing."""
  901. info = self.finder(vmin, vmax, self.freq)
  902. if self.isminor:
  903. format = np.compress(info["min"] & np.logical_not(info["maj"]), info)
  904. else:
  905. format = np.compress(info["maj"], info)
  906. self.formatdict = {x: f for (x, _, _, f) in format}
  907. return self.formatdict
  908. def set_locs(self, locs) -> None:
  909. """Sets the locations of the ticks"""
  910. # don't actually use the locs. This is just needed to work with
  911. # matplotlib. Force to use vmin, vmax
  912. self.locs = locs
  913. (vmin, vmax) = tuple(self.axis.get_view_interval())
  914. if vmax < vmin:
  915. (vmin, vmax) = (vmax, vmin)
  916. self._set_default_format(vmin, vmax)
  917. def __call__(self, x, pos: int | None = 0) -> str:
  918. if self.formatdict is None:
  919. return ""
  920. else:
  921. fmt = self.formatdict.pop(x, "")
  922. if isinstance(fmt, np.bytes_):
  923. fmt = fmt.decode("utf-8")
  924. with warnings.catch_warnings():
  925. warnings.filterwarnings(
  926. "ignore",
  927. "Period with BDay freq is deprecated",
  928. category=FutureWarning,
  929. )
  930. period = Period(ordinal=int(x), freq=self.freq)
  931. assert isinstance(period, Period)
  932. return period.strftime(fmt)
  933. class TimeSeries_TimedeltaFormatter(Formatter):
  934. """
  935. Formats the ticks along an axis controlled by a :class:`TimedeltaIndex`.
  936. """
  937. axis: Axis
  938. @staticmethod
  939. def format_timedelta_ticks(x, pos, n_decimals: int) -> str:
  940. """
  941. Convert seconds to 'D days HH:MM:SS.F'
  942. """
  943. s, ns = divmod(x, 10**9) # TODO(non-nano): this looks like it assumes ns
  944. m, s = divmod(s, 60)
  945. h, m = divmod(m, 60)
  946. d, h = divmod(h, 24)
  947. decimals = int(ns * 10 ** (n_decimals - 9))
  948. s = f"{int(h):02d}:{int(m):02d}:{int(s):02d}"
  949. if n_decimals > 0:
  950. s += f".{decimals:0{n_decimals}d}"
  951. if d != 0:
  952. s = f"{int(d):d} days {s}"
  953. return s
  954. def __call__(self, x, pos: int | None = 0) -> str:
  955. (vmin, vmax) = tuple(self.axis.get_view_interval())
  956. n_decimals = min(int(np.ceil(np.log10(100 * 10**9 / abs(vmax - vmin)))), 9)
  957. return self.format_timedelta_ticks(x, pos, n_decimals)