timeseries.py 10 KB

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