mgplot.growth_plot

Plot period and annual/through-the-year growth rates on the same axes.

Key functions:

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

Keyword arguments for the growth_plot function.

ax: NotRequired[matplotlib.axes._axes.Axes | None]
plot_from: NotRequired[int | pandas.Period]
label_series: NotRequired[bool]
max_ticks: NotRequired[int]
tick_relabel: NotRequired[Callable[[str], str]]
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):
68class SeriesGrowthKwargs(GrowthKwargs):
69    """Keyword arguments for the series_growth_plot function."""
70
71    ylabel: NotRequired[str | None]

Keyword arguments for the series_growth_plot function.

ylabel: NotRequired[str | None]
common_transitions: dict[str, tuple[str, typing.Any]] = {'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: dict[str, tuple[str, typing.Any]] = {'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: dict[str, tuple[str, typing.Any]] = {'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.Series) -> pandas.DataFrame:
113def calc_growth(series: Series) -> DataFrame:
114    """Calculate annual and periodic growth for a pandas Series.
115
116    Args:
117        series: Series - a pandas series with a date-like PeriodIndex.
118
119    Returns:
120        DataFrame: A two column DataFrame with annual and periodic growth rates.
121
122    Raises:
123        TypeError if the series is not a pandas Series.
124        TypeError if the series index is not a PeriodIndex.
125        ValueError if the series is empty.
126        ValueError if the series index does not have a frequency of Q, M, or D.
127        ValueError if the series index has duplicates.
128
129    """
130    # --- sanity checks
131    if not isinstance(series, Series):
132        raise TypeError("The series argument must be a pandas Series")
133    if not isinstance(series.index, PeriodIndex):
134        raise TypeError("The series index must be a pandas PeriodIndex")
135    if series.empty:
136        raise ValueError("The series argument must not be empty")
137    freq = series.index.freqstr
138    if not freq or freq[0] not in FREQUENCY_TO_PERIODS:
139        raise ValueError("The series index must have a frequency of Q, M, or D")
140    if series.index.has_duplicates:
141        raise ValueError("The series index must not have duplicate values")
142
143    # --- ensure the index is complete and the date is sorted
144    complete = period_range(start=series.index.min(), end=series.index.max())
145    series = series.reindex(complete, fill_value=nan)
146    series = series.sort_index(ascending=True)
147
148    # --- calculate annual and periodic growth
149    freq = PeriodIndex(series.index).freqstr
150    if not freq or freq[0] not in FREQUENCY_TO_PERIODS:
151        raise ValueError("The series index must have a frequency of Q, M, or D")
152
153    freq_key = freq[0]
154    ppy = FREQUENCY_TO_PERIODS[freq_key]
155    annual = series.pct_change(periods=ppy) * 100
156    periodic = series.pct_change(periods=1) * 100
157    periodic_name = FREQUENCY_TO_NAME[freq_key] + " Growth"
158    return DataFrame(
159        {
160            "Annual Growth": annual,
161            periodic_name: periodic,
162        },
163    )

Calculate annual and periodic growth for a pandas Series.

Args: series: Series - a pandas series with a date-like PeriodIndex.

Returns: DataFrame: A two column DataFrame with annual and periodic growth rates.

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:
166def growth_plot(
167    data: DataT,
168    **kwargs: Unpack[GrowthKwargs],
169) -> Axes:
170    """Plot annual growth and periodic growth on the same axes.
171
172    Args:
173        data: A pandas DataFrame with two columns:
174        kwargs: GrowthKwargs
175
176    Returns:
177        axes: The matplotlib Axes object.
178
179    Raises:
180        TypeError if the data is not a 2-column DataFrame.
181        TypeError if the annual index is not a PeriodIndex.
182        ValueError if the annual and periodic series do not have the same index.
183
184    """
185    # --- check the kwargs
186    me = "growth_plot"
187    report_kwargs(caller=me, **kwargs)
188    validate_kwargs(GrowthKwargs, caller=me, **kwargs)
189
190    # --- data checks
191    data = check_clean_timeseries(data, me)
192    if len(data.columns) != TWO_COLUMNS:
193        raise TypeError("The data argument must be a pandas DataFrame with two columns")
194    data, kwargsd = constrain_data(data, **kwargs)
195
196    # --- get the series of interest ...
197    annual = data[data.columns[0]]
198    periodic = data[data.columns[1]]
199
200    # --- series names
201    annual.name = "Annual Growth"
202    freq = PeriodIndex(periodic.index).freqstr
203    if freq and freq[0] in FREQUENCY_TO_NAME:
204        periodic.name = FREQUENCY_TO_NAME[freq[0]] + " Growth"
205    else:
206        periodic.name = "Periodic Growth"
207
208    # --- convert PeriodIndex periodic growth data to integer indexed data.
209    saved_pi = map_periodindex(periodic)
210    if saved_pi is not None:
211        periodic = saved_pi[0]  # extract the reindexed DataFrame
212
213    # --- simple bar chart for the periodic growth
214    if "bar_anno_color" not in kwargsd or kwargsd["bar_anno_color"] is None:
215        kwargsd["bar_anno_color"] = "black" if kwargsd.get("above", False) else "white"
216    selected = package_kwargs(to_bar_plot, **kwargsd)
217    axes = bar_plot(periodic, **selected)
218
219    # --- and now the annual growth as a line
220    selected = package_kwargs(to_line_plot, **kwargsd)
221    line_plot(annual, ax=axes, **selected)
222
223    # --- fix the x-axis labels
224    if saved_pi is not None:
225        set_labels(
226            axes,
227            saved_pi[1],
228            kwargsd.get("max_ticks", 10),
229            tick_relabel=kwargsd.get("tick_relabel"),
230        )
231
232    # --- and done ...
233    return axes

Plot annual growth and periodic growth on the same axes.

Args: data: A pandas DataFrame with two columns: kwargs: GrowthKwargs

Returns: axes: The matplotlib Axes object.

Raises: TypeError if the data is not a 2-column DataFrame. 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:
236def series_growth_plot(
237    data: DataT,
238    **kwargs: Unpack[SeriesGrowthKwargs],
239) -> Axes:
240    """Plot annual and periodic growth in percentage terms from a pandas Series.
241
242    Args:
243        data: A pandas Series with an appropriate PeriodIndex.
244        kwargs: SeriesGrowthKwargs
245
246    """
247    # --- check the kwargs
248    me = "series_growth_plot"
249    report_kwargs(caller=me, **kwargs)
250    validate_kwargs(SeriesGrowthKwargs, caller=me, **kwargs)
251
252    # --- sanity checks
253    if not isinstance(data, Series):
254        raise TypeError("The data argument to series_growth_plot() must be a pandas Series")
255
256    # --- calculate growth and plot - add ylabel
257    ylabel: str | None = kwargs.pop("ylabel", None)
258    if ylabel is not None:
259        print(f"Did you intend to specify a value for the 'ylabel' in {me}()?")
260    ylabel = "Growth (%)" if ylabel is None else ylabel
261    growth = calc_growth(data)
262    ax = growth_plot(growth, **cast("GrowthKwargs", kwargs))
263    ax.set_ylabel(ylabel)
264    return ax

Plot annual and periodic growth in percentage terms from a pandas Series.

Args: data: A pandas Series with an appropriate PeriodIndex. kwargs: SeriesGrowthKwargs