timeseries.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  1. # TODO: Use the fact that axis can have units to simplify the process
  2. from __future__ import annotations
  3. import functools
  4. from typing import (
  5. TYPE_CHECKING,
  6. Any,
  7. cast,
  8. )
  9. import warnings
  10. import numpy as np
  11. from pandas._libs.tslibs import (
  12. BaseOffset,
  13. Period,
  14. to_offset,
  15. )
  16. from pandas._libs.tslibs.dtypes import (
  17. OFFSET_TO_PERIOD_FREQSTR,
  18. FreqGroup,
  19. )
  20. from pandas.core.dtypes.generic import (
  21. ABCDatetimeIndex,
  22. ABCPeriodIndex,
  23. ABCTimedeltaIndex,
  24. )
  25. from pandas.io.formats.printing import pprint_thing
  26. from pandas.plotting._matplotlib.converter import (
  27. TimeSeries_DateFormatter,
  28. TimeSeries_DateLocator,
  29. TimeSeries_TimedeltaFormatter,
  30. )
  31. from pandas.tseries.frequencies import (
  32. get_period_alias,
  33. is_subperiod,
  34. is_superperiod,
  35. )
  36. if TYPE_CHECKING:
  37. from datetime import timedelta
  38. from matplotlib.axes import Axes
  39. from pandas._typing import NDFrameT
  40. from pandas import (
  41. DataFrame,
  42. DatetimeIndex,
  43. Index,
  44. PeriodIndex,
  45. Series,
  46. )
  47. # ---------------------------------------------------------------------
  48. # Plotting functions and monkey patches
  49. def maybe_resample(series: Series, ax: Axes, kwargs: dict[str, Any]):
  50. # resample against axes freq if necessary
  51. if "how" in kwargs:
  52. raise ValueError(
  53. "'how' is not a valid keyword for plotting functions. If plotting "
  54. "multiple objects on shared axes, resample manually first."
  55. )
  56. freq, ax_freq = _get_freq(ax, series)
  57. if freq is None: # pragma: no cover
  58. raise ValueError("Cannot use dynamic axis without frequency info")
  59. # Convert DatetimeIndex to PeriodIndex
  60. if isinstance(series.index, ABCDatetimeIndex):
  61. series = series.to_period(freq=freq)
  62. if ax_freq is not None and freq != ax_freq:
  63. if is_superperiod(freq, ax_freq): # upsample input
  64. series = series.copy()
  65. # error: "Index" has no attribute "asfreq"
  66. series.index = series.index.asfreq( # type: ignore[attr-defined]
  67. ax_freq, how="s"
  68. )
  69. freq = ax_freq
  70. elif _is_sup(freq, ax_freq): # one is weekly
  71. how = "last"
  72. series = getattr(series.resample("D"), how)().dropna()
  73. series = getattr(series.resample(ax_freq), how)().dropna()
  74. freq = ax_freq
  75. elif is_subperiod(freq, ax_freq) or _is_sub(freq, ax_freq):
  76. _upsample_others(ax, freq, kwargs)
  77. else: # pragma: no cover
  78. raise ValueError("Incompatible frequency conversion")
  79. return freq, series
  80. def _is_sub(f1: str, f2: str) -> bool:
  81. return (f1.startswith("W") and is_subperiod("D", f2)) or (
  82. f2.startswith("W") and is_subperiod(f1, "D")
  83. )
  84. def _is_sup(f1: str, f2: str) -> bool:
  85. return (f1.startswith("W") and is_superperiod("D", f2)) or (
  86. f2.startswith("W") and is_superperiod(f1, "D")
  87. )
  88. def _upsample_others(ax: Axes, freq: BaseOffset, kwargs: dict[str, Any]) -> None:
  89. legend = ax.get_legend()
  90. lines, labels = _replot_ax(ax, freq)
  91. _replot_ax(ax, freq)
  92. other_ax = None
  93. if hasattr(ax, "left_ax"):
  94. other_ax = ax.left_ax
  95. if hasattr(ax, "right_ax"):
  96. other_ax = ax.right_ax
  97. if other_ax is not None:
  98. rlines, rlabels = _replot_ax(other_ax, freq)
  99. lines.extend(rlines)
  100. labels.extend(rlabels)
  101. if legend is not None and kwargs.get("legend", True) and len(lines) > 0:
  102. title: str | None = legend.get_title().get_text()
  103. if title == "None":
  104. title = None
  105. ax.legend(lines, labels, loc="best", title=title)
  106. def _replot_ax(ax: Axes, freq: BaseOffset):
  107. data = getattr(ax, "_plot_data", None)
  108. # clear current axes and data
  109. # TODO #54485
  110. ax._plot_data = [] # type: ignore[attr-defined]
  111. ax.clear()
  112. decorate_axes(ax, freq)
  113. lines = []
  114. labels = []
  115. if data is not None:
  116. for series, plotf, kwds in data:
  117. series = series.copy()
  118. idx = series.index.asfreq(freq, how="S")
  119. series.index = idx
  120. # TODO #54485
  121. ax._plot_data.append((series, plotf, kwds)) # type: ignore[attr-defined]
  122. # for tsplot
  123. if isinstance(plotf, str):
  124. from pandas.plotting._matplotlib import PLOT_CLASSES
  125. plotf = PLOT_CLASSES[plotf]._plot
  126. lines.append(plotf(ax, series.index._mpl_repr(), series.values, **kwds)[0])
  127. labels.append(pprint_thing(series.name))
  128. return lines, labels
  129. def decorate_axes(ax: Axes, freq: BaseOffset) -> None:
  130. """Initialize axes for time-series plotting"""
  131. if not hasattr(ax, "_plot_data"):
  132. # TODO #54485
  133. ax._plot_data = [] # type: ignore[attr-defined]
  134. # TODO #54485
  135. ax.freq = freq # type: ignore[attr-defined]
  136. xaxis = ax.get_xaxis()
  137. # TODO #54485
  138. xaxis.freq = freq # type: ignore[attr-defined]
  139. def _get_ax_freq(ax: Axes):
  140. """
  141. Get the freq attribute of the ax object if set.
  142. Also checks shared axes (eg when using secondary yaxis, sharex=True
  143. or twinx)
  144. """
  145. ax_freq = getattr(ax, "freq", None)
  146. if ax_freq is None:
  147. # check for left/right ax in case of secondary yaxis
  148. if hasattr(ax, "left_ax"):
  149. ax_freq = getattr(ax.left_ax, "freq", None)
  150. elif hasattr(ax, "right_ax"):
  151. ax_freq = getattr(ax.right_ax, "freq", None)
  152. if ax_freq is None:
  153. # check if a shared ax (sharex/twinx) has already freq set
  154. shared_axes = ax.get_shared_x_axes().get_siblings(ax)
  155. if len(shared_axes) > 1:
  156. for shared_ax in shared_axes:
  157. ax_freq = getattr(shared_ax, "freq", None)
  158. if ax_freq is not None:
  159. break
  160. return ax_freq
  161. def _get_period_alias(freq: timedelta | BaseOffset | str) -> str | None:
  162. if isinstance(freq, BaseOffset):
  163. freqstr = freq.name
  164. else:
  165. freqstr = to_offset(freq, is_period=True).rule_code
  166. return get_period_alias(freqstr)
  167. def _get_freq(ax: Axes, series: Series):
  168. # get frequency from data
  169. freq = getattr(series.index, "freq", None)
  170. if freq is None:
  171. freq = getattr(series.index, "inferred_freq", None)
  172. freq = to_offset(freq, is_period=True)
  173. ax_freq = _get_ax_freq(ax)
  174. # use axes freq if no data freq
  175. if freq is None:
  176. freq = ax_freq
  177. # get the period frequency
  178. freq = _get_period_alias(freq)
  179. return freq, ax_freq
  180. def use_dynamic_x(ax: Axes, data: DataFrame | Series) -> bool:
  181. freq = _get_index_freq(data.index)
  182. ax_freq = _get_ax_freq(ax)
  183. if freq is None: # convert irregular if axes has freq info
  184. freq = ax_freq
  185. # do not use tsplot if irregular was plotted first
  186. elif (ax_freq is None) and (len(ax.get_lines()) > 0):
  187. return False
  188. if freq is None:
  189. return False
  190. freq_str = _get_period_alias(freq)
  191. if freq_str is None:
  192. return False
  193. # FIXME: hack this for 0.10.1, creating more technical debt...sigh
  194. if isinstance(data.index, ABCDatetimeIndex):
  195. # error: "BaseOffset" has no attribute "_period_dtype_code"
  196. freq_str = OFFSET_TO_PERIOD_FREQSTR.get(freq_str, freq_str)
  197. base = to_offset(
  198. freq_str, is_period=True
  199. )._period_dtype_code # type: ignore[attr-defined]
  200. x = data.index
  201. if base <= FreqGroup.FR_DAY.value:
  202. return x[:1].is_normalized
  203. period = Period(x[0], freq_str)
  204. assert isinstance(period, Period)
  205. return period.to_timestamp().tz_localize(x.tz) == x[0]
  206. return True
  207. def _get_index_freq(index: Index) -> BaseOffset | None:
  208. freq = getattr(index, "freq", None)
  209. if freq is None:
  210. freq = getattr(index, "inferred_freq", None)
  211. if freq == "B":
  212. # error: "Index" has no attribute "dayofweek"
  213. weekdays = np.unique(index.dayofweek) # type: ignore[attr-defined]
  214. if (5 in weekdays) or (6 in weekdays):
  215. freq = None
  216. freq = to_offset(freq)
  217. return freq
  218. def maybe_convert_index(ax: Axes, data: NDFrameT) -> NDFrameT:
  219. # tsplot converts automatically, but don't want to convert index
  220. # over and over for DataFrames
  221. if isinstance(data.index, (ABCDatetimeIndex, ABCPeriodIndex)):
  222. freq: str | BaseOffset | None = data.index.freq
  223. if freq is None:
  224. # We only get here for DatetimeIndex
  225. data.index = cast("DatetimeIndex", data.index)
  226. freq = data.index.inferred_freq
  227. freq = to_offset(freq)
  228. if freq is None:
  229. freq = _get_ax_freq(ax)
  230. if freq is None:
  231. raise ValueError("Could not get frequency alias for plotting")
  232. freq_str = _get_period_alias(freq)
  233. with warnings.catch_warnings():
  234. # suppress Period[B] deprecation warning
  235. # TODO: need to find an alternative to this before the deprecation
  236. # is enforced!
  237. warnings.filterwarnings(
  238. "ignore",
  239. r"PeriodDtype\[B\] is deprecated",
  240. category=FutureWarning,
  241. )
  242. if isinstance(data.index, ABCDatetimeIndex):
  243. data = data.tz_localize(None).to_period(freq=freq_str)
  244. elif isinstance(data.index, ABCPeriodIndex):
  245. data.index = data.index.asfreq(freq=freq_str)
  246. return data
  247. # Patch methods for subplot.
  248. def _format_coord(freq, t, y) -> str:
  249. time_period = Period(ordinal=int(t), freq=freq)
  250. return f"t = {time_period} y = {y:8f}"
  251. def format_dateaxis(
  252. subplot, freq: BaseOffset, index: DatetimeIndex | PeriodIndex
  253. ) -> None:
  254. """
  255. Pretty-formats the date axis (x-axis).
  256. Major and minor ticks are automatically set for the frequency of the
  257. current underlying series. As the dynamic mode is activated by
  258. default, changing the limits of the x axis will intelligently change
  259. the positions of the ticks.
  260. """
  261. from matplotlib import pylab
  262. # handle index specific formatting
  263. # Note: DatetimeIndex does not use this
  264. # interface. DatetimeIndex uses matplotlib.date directly
  265. if isinstance(index, ABCPeriodIndex):
  266. majlocator = TimeSeries_DateLocator(
  267. freq, dynamic_mode=True, minor_locator=False, plot_obj=subplot
  268. )
  269. minlocator = TimeSeries_DateLocator(
  270. freq, dynamic_mode=True, minor_locator=True, plot_obj=subplot
  271. )
  272. subplot.xaxis.set_major_locator(majlocator)
  273. subplot.xaxis.set_minor_locator(minlocator)
  274. majformatter = TimeSeries_DateFormatter(
  275. freq, dynamic_mode=True, minor_locator=False, plot_obj=subplot
  276. )
  277. minformatter = TimeSeries_DateFormatter(
  278. freq, dynamic_mode=True, minor_locator=True, plot_obj=subplot
  279. )
  280. subplot.xaxis.set_major_formatter(majformatter)
  281. subplot.xaxis.set_minor_formatter(minformatter)
  282. # x and y coord info
  283. subplot.format_coord = functools.partial(_format_coord, freq)
  284. elif isinstance(index, ABCTimedeltaIndex):
  285. subplot.xaxis.set_major_formatter(TimeSeries_TimedeltaFormatter())
  286. else:
  287. raise TypeError("index type not supported")
  288. pylab.draw_if_interactive()