mgplot.growth_plot

growth_plot.py: plot period and annual/through-the-year growth rates on the same axes.

  • calc_growth()
  • growth_plot()
  • series_growth_plot()
  1"""
  2growth_plot.py:
  3plot period and annual/through-the-year growth rates on the same axes.
  4- calc_growth()
  5- growth_plot()
  6- series_growth_plot()
  7"""
  8
  9# --- imports
 10from typing import Unpack, NotRequired, cast
 11from pandas import Series, DataFrame, Period, PeriodIndex, period_range
 12from numpy import nan
 13from matplotlib.pyplot import Axes
 14
 15from mgplot.bar_plot import bar_plot
 16from mgplot.line_plot import line_plot
 17from mgplot.axis_utils import map_periodindex
 18from mgplot.settings import DataT
 19from mgplot.axis_utils import set_labels
 20from mgplot.utilities import check_clean_timeseries, constrain_data
 21from mgplot.keyword_checking import (
 22    validate_kwargs,
 23    report_kwargs,
 24    BaseKwargs,
 25    TransitionKwargs,
 26    package_kwargs,
 27)
 28
 29# === constants
 30
 31
 32# - overarching constants
 33class GrowthKwargs(BaseKwargs):
 34    """Keyword arguments for the growth_plot function."""
 35
 36    # --- common options
 37    ax: NotRequired[Axes | None]
 38    plot_from: NotRequired[int | Period]
 39    label_series: NotRequired[bool]
 40    max_ticks: NotRequired[int]
 41    # --- options passed to the line plot
 42    line_width: NotRequired[float | int]
 43    line_color: NotRequired[str]
 44    line_style: NotRequired[str]
 45    annotate_line: NotRequired[bool]
 46    line_rounding: NotRequired[bool | int]
 47    line_fontsize: NotRequired[str | int | float]
 48    line_fontname: NotRequired[str]
 49    line_anno_color: NotRequired[str]
 50    # --- options passed to the bar plot
 51    annotate_bars: NotRequired[bool]
 52    bar_fontsize: NotRequired[str | int | float]
 53    bar_fontname: NotRequired[str]
 54    bar_rounding: NotRequired[int]
 55    bar_width: NotRequired[float]
 56    bar_color: NotRequired[str]
 57    bar_anno_color: NotRequired[str]
 58    bar_rotation: NotRequired[int | float]
 59
 60
 61class SeriesGrowthKwargs(GrowthKwargs):
 62    """Keyword arguments for the series_growth_plot function."""
 63
 64    ylabel: NotRequired[str | None]
 65
 66
 67# - transition of kwargs from growth_plot to line_plot
 68common_transitions: TransitionKwargs = {
 69    # arg-to-growth_plot : (arg-to-line_plot, default_value)
 70    "label_series": ("label_series", True),
 71    "ax": ("ax", None),
 72    "max_ticks": ("max_ticks", None),
 73    "plot_from": ("plot_from", None),
 74    "report_kwargs": ("report_kwargs", None),
 75}
 76
 77to_line_plot: TransitionKwargs = common_transitions | {
 78    # arg-to-growth_plot : (arg-to-line_plot, default_value)
 79    "line_width": ("width", None),
 80    "line_color": ("color", "darkblue"),
 81    "line_style": ("style", None),
 82    "annotate_line": ("annotate", True),
 83    "line_rounding": ("rounding", None),
 84    "line_fontsize": ("fontsize", None),
 85    "line_fontname": ("fontname", None),
 86    "line_anno_color": ("annotate_color", None),
 87}
 88
 89# - constants for the bar plot
 90to_bar_plot: TransitionKwargs = common_transitions | {
 91    # arg-to-growth_plot : (arg-to-bar_plot, default_value)
 92    "bar_width": ("width", 0.8),
 93    "bar_color": ("color", "#dd0000"),
 94    "annotate_bars": ("annotate", True),
 95    "bar_rounding": ("rounding", None),
 96    "above": ("above", False),
 97    "bar_rotation": ("rotation", None),
 98    "bar_fontsize": ("fontsize", None),
 99    "bar_fontname": ("fontname", None),
100    "bar_anno_color": ("annotate_color", None),
101}
102
103
104# === functions
105# --- public functions
106def calc_growth(series: Series) -> DataFrame:
107    """
108    Calculate annual and periodic growth for a pandas Series,
109    where the index is a PeriodIndex.
110
111    Args:
112    -   series: A pandas Series with an appropriate PeriodIndex.
113
114    Returns a two column DataFrame:
115
116    Raises
117    -   TypeError if the series is not a pandas Series.
118    -   TypeError if the series index is not a PeriodIndex.
119    -   ValueError if the series is empty.
120    -   ValueError if the series index does not have a frequency of Q, M, or D.
121    -   ValueError if the series index has duplicates.
122    """
123
124    # --- sanity checks
125    if not isinstance(series, Series):
126        raise TypeError("The series argument must be a pandas Series")
127    if not isinstance(series.index, PeriodIndex):
128        raise TypeError("The series index must be a pandas PeriodIndex")
129    if series.empty:
130        raise ValueError("The series argument must not be empty")
131    if series.index.freqstr[0] not in ("Q", "M", "D"):
132        raise ValueError("The series index must have a frequency of Q, M, or D")
133    if series.index.has_duplicates:
134        raise ValueError("The series index must not have duplicate values")
135
136    # --- ensure the index is complete and the date is sorted
137    complete = period_range(start=series.index.min(), end=series.index.max())
138    series = series.reindex(complete, fill_value=nan)
139    series = series.sort_index(ascending=True)
140
141    # --- calculate annual and periodic growth
142    ppy = {"Q": 4, "M": 12, "D": 365}[PeriodIndex(series.index).freqstr[:1]]
143    annual = series.pct_change(periods=ppy) * 100
144    periodic = series.pct_change(periods=1) * 100
145    periodic_name = {4: "Quarterly", 12: "Monthly", 365: "Daily"}[ppy] + " Growth"
146    return DataFrame(
147        {
148            "Annual Growth": annual,
149            periodic_name: periodic,
150        }
151    )
152
153
154def growth_plot(
155    data: DataT,
156    **kwargs: Unpack[GrowthKwargs],
157) -> Axes:
158    """
159    Plot annual growth (as a line) and periodic growth (as bars)
160    on the same axes.
161
162    Args:
163    -   data: A pandas DataFrame with two columns:
164    -   kwargs: GrowthKwargs
165
166            Returns:
167    -   axes: The matplotlib Axes object.
168
169    Raises:
170    -   TypeError if the annual and periodic arguments are not pandas Series.
171    -   TypeError if the annual index is not a PeriodIndex.
172    -   ValueError if the annual and periodic series do not have the same index.
173    """
174
175    # --- check the kwargs
176    me = "growth_plot"
177    report_kwargs(caller=me, **kwargs)
178    validate_kwargs(GrowthKwargs, caller=me, **kwargs)
179
180    # --- data checks
181    data = check_clean_timeseries(data, me)
182    if len(data.columns) != 2:
183        raise TypeError("The data argument must be a pandas DataFrame with two columns")
184    data, kwargsd = constrain_data(data, **kwargs)
185
186    # --- get the series of interest ...
187    annual = data[data.columns[0]]
188    periodic = data[data.columns[1]]
189
190    # --- series names
191    annual.name = "Annual Growth"
192    periodic.name = {"M": "Monthly", "Q": "Quarterly", "D": "Daily"}[
193        PeriodIndex(periodic.index).freqstr[:1]
194    ] + " Growth"
195
196    # --- convert PeriodIndex periodic growth data to integer indexed data.
197    saved_pi = map_periodindex(periodic)
198    if saved_pi is not None:
199        periodic = saved_pi[0]  # extract the reindexed DataFrame
200
201    # --- simple bar chart for the periodic growth
202    if "bar_anno_color" not in kwargsd or kwargsd["bar_anno_color"] is None:
203        kwargsd["bar_anno_color"] = "black" if kwargsd.get("above", False) else "white"
204    selected = package_kwargs(to_bar_plot, **kwargsd)
205    axes = bar_plot(periodic, **selected)
206
207    # --- and now the annual growth as a line
208    selected = package_kwargs(to_line_plot, **kwargsd)
209    line_plot(annual, ax=axes, **selected)
210
211    # --- fix the x-axis labels
212    if saved_pi is not None:
213        set_labels(axes, saved_pi[1], kwargsd.get("max_ticks", 10))
214
215    # --- and done ...
216    return axes
217
218
219def series_growth_plot(
220    data: DataT,
221    **kwargs: Unpack[SeriesGrowthKwargs],
222) -> Axes:
223    """
224    Plot annual and periodic growth in percentage terms from
225    a pandas Series, and finalise the plot.
226
227    Args:
228    -   data: A pandas Series with an appropriate PeriodIndex.
229    -   kwargs: SeriesGrowthKwargs
230        -   takes much the same kwargs as for growth_plot()
231    """
232
233    # --- check the kwargs
234    me = "series_growth_plot"
235    report_kwargs(caller=me, **kwargs)
236    validate_kwargs(SeriesGrowthKwargs, caller=me, **kwargs)
237
238    # --- sanity checks
239    if not isinstance(data, Series):
240        raise TypeError("The data argument to series_growth_plot() must be a pandas Series")
241
242    # --- calculate growth and plot - add ylabel
243    ylabel: str | None = kwargs.pop("ylabel", None)
244    if ylabel is not None:
245        print(f"Did you intend to specify a value for the 'ylabel' in {me}()?")
246    ylabel = "Growth (%)" if ylabel is None else ylabel
247    growth = calc_growth(data)
248    ax = growth_plot(growth, **cast(GrowthKwargs, kwargs))
249    ax.set_ylabel(ylabel)
250    return ax
class GrowthKwargs(mgplot.keyword_checking.BaseKwargs):
34class GrowthKwargs(BaseKwargs):
35    """Keyword arguments for the growth_plot function."""
36
37    # --- common options
38    ax: NotRequired[Axes | None]
39    plot_from: NotRequired[int | Period]
40    label_series: NotRequired[bool]
41    max_ticks: NotRequired[int]
42    # --- options passed to the line plot
43    line_width: NotRequired[float | int]
44    line_color: NotRequired[str]
45    line_style: NotRequired[str]
46    annotate_line: NotRequired[bool]
47    line_rounding: NotRequired[bool | int]
48    line_fontsize: NotRequired[str | int | float]
49    line_fontname: NotRequired[str]
50    line_anno_color: NotRequired[str]
51    # --- options passed to the bar plot
52    annotate_bars: NotRequired[bool]
53    bar_fontsize: NotRequired[str | int | float]
54    bar_fontname: NotRequired[str]
55    bar_rounding: NotRequired[int]
56    bar_width: NotRequired[float]
57    bar_color: NotRequired[str]
58    bar_anno_color: NotRequired[str]
59    bar_rotation: NotRequired[int | float]

Keyword arguments for the growth_plot function.

ax: NotRequired[matplotlib.axes._axes.Axes | None]
plot_from: NotRequired[int | pandas._libs.tslibs.period.Period]
label_series: NotRequired[bool]
max_ticks: NotRequired[int]
line_width: NotRequired[int | float]
line_color: NotRequired[str]
line_style: NotRequired[str]
annotate_line: NotRequired[bool]
line_rounding: NotRequired[bool | int]
line_fontsize: NotRequired[int | float | str]
line_fontname: NotRequired[str]
line_anno_color: NotRequired[str]
annotate_bars: NotRequired[bool]
bar_fontsize: NotRequired[int | float | str]
bar_fontname: NotRequired[str]
bar_rounding: NotRequired[int]
bar_width: NotRequired[float]
bar_color: NotRequired[str]
bar_anno_color: NotRequired[str]
bar_rotation: NotRequired[int | float]
class SeriesGrowthKwargs(GrowthKwargs):
62class SeriesGrowthKwargs(GrowthKwargs):
63    """Keyword arguments for the series_growth_plot function."""
64
65    ylabel: NotRequired[str | None]

Keyword arguments for the series_growth_plot function.

ylabel: NotRequired[str | None]
common_transitions: mgplot.keyword_checking.TransitionKwargs = {'label_series': ('label_series', True), 'ax': ('ax', None), 'max_ticks': ('max_ticks', None), 'plot_from': ('plot_from', None), 'report_kwargs': ('report_kwargs', None)}
to_line_plot: mgplot.keyword_checking.TransitionKwargs = {'label_series': ('label_series', True), 'ax': ('ax', None), 'max_ticks': ('max_ticks', None), 'plot_from': ('plot_from', None), 'report_kwargs': ('report_kwargs', None), 'line_width': ('width', None), 'line_color': ('color', 'darkblue'), 'line_style': ('style', None), 'annotate_line': ('annotate', True), 'line_rounding': ('rounding', None), 'line_fontsize': ('fontsize', None), 'line_fontname': ('fontname', None), 'line_anno_color': ('annotate_color', None)}
to_bar_plot: mgplot.keyword_checking.TransitionKwargs = {'label_series': ('label_series', True), 'ax': ('ax', None), 'max_ticks': ('max_ticks', None), 'plot_from': ('plot_from', None), 'report_kwargs': ('report_kwargs', None), 'bar_width': ('width', 0.8), 'bar_color': ('color', '#dd0000'), 'annotate_bars': ('annotate', True), 'bar_rounding': ('rounding', None), 'above': ('above', False), 'bar_rotation': ('rotation', None), 'bar_fontsize': ('fontsize', None), 'bar_fontname': ('fontname', None), 'bar_anno_color': ('annotate_color', None)}
def calc_growth(series: pandas.core.series.Series) -> pandas.core.frame.DataFrame:
107def calc_growth(series: Series) -> DataFrame:
108    """
109    Calculate annual and periodic growth for a pandas Series,
110    where the index is a PeriodIndex.
111
112    Args:
113    -   series: A pandas Series with an appropriate PeriodIndex.
114
115    Returns a two column DataFrame:
116
117    Raises
118    -   TypeError if the series is not a pandas Series.
119    -   TypeError if the series index is not a PeriodIndex.
120    -   ValueError if the series is empty.
121    -   ValueError if the series index does not have a frequency of Q, M, or D.
122    -   ValueError if the series index has duplicates.
123    """
124
125    # --- sanity checks
126    if not isinstance(series, Series):
127        raise TypeError("The series argument must be a pandas Series")
128    if not isinstance(series.index, PeriodIndex):
129        raise TypeError("The series index must be a pandas PeriodIndex")
130    if series.empty:
131        raise ValueError("The series argument must not be empty")
132    if series.index.freqstr[0] not in ("Q", "M", "D"):
133        raise ValueError("The series index must have a frequency of Q, M, or D")
134    if series.index.has_duplicates:
135        raise ValueError("The series index must not have duplicate values")
136
137    # --- ensure the index is complete and the date is sorted
138    complete = period_range(start=series.index.min(), end=series.index.max())
139    series = series.reindex(complete, fill_value=nan)
140    series = series.sort_index(ascending=True)
141
142    # --- calculate annual and periodic growth
143    ppy = {"Q": 4, "M": 12, "D": 365}[PeriodIndex(series.index).freqstr[:1]]
144    annual = series.pct_change(periods=ppy) * 100
145    periodic = series.pct_change(periods=1) * 100
146    periodic_name = {4: "Quarterly", 12: "Monthly", 365: "Daily"}[ppy] + " Growth"
147    return DataFrame(
148        {
149            "Annual Growth": annual,
150            periodic_name: periodic,
151        }
152    )

Calculate annual and periodic growth for a pandas Series, where the index is a PeriodIndex.

Args:

  • series: A pandas Series with an appropriate PeriodIndex.

Returns a two column DataFrame:

Raises

  • TypeError if the series is not a pandas Series.
  • TypeError if the series index is not a PeriodIndex.
  • ValueError if the series is empty.
  • ValueError if the series index does not have a frequency of Q, M, or D.
  • ValueError if the series index has duplicates.
def growth_plot( data: ~DataT, **kwargs: Unpack[GrowthKwargs]) -> matplotlib.axes._axes.Axes:
155def growth_plot(
156    data: DataT,
157    **kwargs: Unpack[GrowthKwargs],
158) -> Axes:
159    """
160    Plot annual growth (as a line) and periodic growth (as bars)
161    on the same axes.
162
163    Args:
164    -   data: A pandas DataFrame with two columns:
165    -   kwargs: GrowthKwargs
166
167            Returns:
168    -   axes: The matplotlib Axes object.
169
170    Raises:
171    -   TypeError if the annual and periodic arguments are not pandas Series.
172    -   TypeError if the annual index is not a PeriodIndex.
173    -   ValueError if the annual and periodic series do not have the same index.
174    """
175
176    # --- check the kwargs
177    me = "growth_plot"
178    report_kwargs(caller=me, **kwargs)
179    validate_kwargs(GrowthKwargs, caller=me, **kwargs)
180
181    # --- data checks
182    data = check_clean_timeseries(data, me)
183    if len(data.columns) != 2:
184        raise TypeError("The data argument must be a pandas DataFrame with two columns")
185    data, kwargsd = constrain_data(data, **kwargs)
186
187    # --- get the series of interest ...
188    annual = data[data.columns[0]]
189    periodic = data[data.columns[1]]
190
191    # --- series names
192    annual.name = "Annual Growth"
193    periodic.name = {"M": "Monthly", "Q": "Quarterly", "D": "Daily"}[
194        PeriodIndex(periodic.index).freqstr[:1]
195    ] + " Growth"
196
197    # --- convert PeriodIndex periodic growth data to integer indexed data.
198    saved_pi = map_periodindex(periodic)
199    if saved_pi is not None:
200        periodic = saved_pi[0]  # extract the reindexed DataFrame
201
202    # --- simple bar chart for the periodic growth
203    if "bar_anno_color" not in kwargsd or kwargsd["bar_anno_color"] is None:
204        kwargsd["bar_anno_color"] = "black" if kwargsd.get("above", False) else "white"
205    selected = package_kwargs(to_bar_plot, **kwargsd)
206    axes = bar_plot(periodic, **selected)
207
208    # --- and now the annual growth as a line
209    selected = package_kwargs(to_line_plot, **kwargsd)
210    line_plot(annual, ax=axes, **selected)
211
212    # --- fix the x-axis labels
213    if saved_pi is not None:
214        set_labels(axes, saved_pi[1], kwargsd.get("max_ticks", 10))
215
216    # --- and done ...
217    return axes

Plot annual growth (as a line) and periodic growth (as bars) on the same axes.

Args:

  • data: A pandas DataFrame with two columns:
  • kwargs: GrowthKwargs
    Returns:

  • axes: The matplotlib Axes object.

Raises:

  • TypeError if the annual and periodic arguments are not pandas Series.
  • TypeError if the annual index is not a PeriodIndex.
  • ValueError if the annual and periodic series do not have the same index.
def series_growth_plot( data: ~DataT, **kwargs: Unpack[SeriesGrowthKwargs]) -> matplotlib.axes._axes.Axes:
220def series_growth_plot(
221    data: DataT,
222    **kwargs: Unpack[SeriesGrowthKwargs],
223) -> Axes:
224    """
225    Plot annual and periodic growth in percentage terms from
226    a pandas Series, and finalise the plot.
227
228    Args:
229    -   data: A pandas Series with an appropriate PeriodIndex.
230    -   kwargs: SeriesGrowthKwargs
231        -   takes much the same kwargs as for growth_plot()
232    """
233
234    # --- check the kwargs
235    me = "series_growth_plot"
236    report_kwargs(caller=me, **kwargs)
237    validate_kwargs(SeriesGrowthKwargs, caller=me, **kwargs)
238
239    # --- sanity checks
240    if not isinstance(data, Series):
241        raise TypeError("The data argument to series_growth_plot() must be a pandas Series")
242
243    # --- calculate growth and plot - add ylabel
244    ylabel: str | None = kwargs.pop("ylabel", None)
245    if ylabel is not None:
246        print(f"Did you intend to specify a value for the 'ylabel' in {me}()?")
247    ylabel = "Growth (%)" if ylabel is None else ylabel
248    growth = calc_growth(data)
249    ax = growth_plot(growth, **cast(GrowthKwargs, kwargs))
250    ax.set_ylabel(ylabel)
251    return ax

Plot annual and periodic growth in percentage terms from a pandas Series, and finalise the plot.

Args:

  • data: A pandas Series with an appropriate PeriodIndex.
  • kwargs: SeriesGrowthKwargs
    • takes much the same kwargs as for growth_plot()