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

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:
164def growth_plot(
165    data: DataT,
166    **kwargs: Unpack[GrowthKwargs],
167) -> Axes:
168    """Plot annual growth and periodic growth on the same axes.
169
170    Args:
171        data: A pandas DataFrame with two columns:
172        kwargs: GrowthKwargs
173
174    Returns:
175        axes: The matplotlib Axes object.
176
177    Raises:
178        TypeError if the data is not a 2-column DataFrame.
179        TypeError if the annual index is not a PeriodIndex.
180        ValueError if the annual and periodic series do not have the same index.
181
182    """
183    # --- check the kwargs
184    me = "growth_plot"
185    report_kwargs(caller=me, **kwargs)
186    validate_kwargs(GrowthKwargs, caller=me, **kwargs)
187
188    # --- data checks
189    data = check_clean_timeseries(data, me)
190    if len(data.columns) != TWO_COLUMNS:
191        raise TypeError("The data argument must be a pandas DataFrame with two columns")
192    data, kwargsd = constrain_data(data, **kwargs)
193
194    # --- get the series of interest ...
195    annual = data[data.columns[0]]
196    periodic = data[data.columns[1]]
197
198    # --- series names
199    annual.name = "Annual Growth"
200    freq = PeriodIndex(periodic.index).freqstr
201    if freq and freq[0] in FREQUENCY_TO_NAME:
202        periodic.name = FREQUENCY_TO_NAME[freq[0]] + " Growth"
203    else:
204        periodic.name = "Periodic Growth"
205
206    # --- convert PeriodIndex periodic growth data to integer indexed data.
207    saved_pi = map_periodindex(periodic)
208    if saved_pi is not None:
209        periodic = saved_pi[0]  # extract the reindexed DataFrame
210
211    # --- simple bar chart for the periodic growth
212    if "bar_anno_color" not in kwargsd or kwargsd["bar_anno_color"] is None:
213        kwargsd["bar_anno_color"] = "black" if kwargsd.get("above", False) else "white"
214    selected = package_kwargs(to_bar_plot, **kwargsd)
215    axes = bar_plot(periodic, **selected)
216
217    # --- and now the annual growth as a line
218    selected = package_kwargs(to_line_plot, **kwargsd)
219    line_plot(annual, ax=axes, **selected)
220
221    # --- fix the x-axis labels
222    if saved_pi is not None:
223        set_labels(axes, saved_pi[1], kwargsd.get("max_ticks", 10))
224
225    # --- and done ...
226    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:
229def series_growth_plot(
230    data: DataT,
231    **kwargs: Unpack[SeriesGrowthKwargs],
232) -> Axes:
233    """Plot annual and periodic growth in percentage terms from a pandas Series.
234
235    Args:
236        data: A pandas Series with an appropriate PeriodIndex.
237        kwargs: SeriesGrowthKwargs
238
239    """
240    # --- check the kwargs
241    me = "series_growth_plot"
242    report_kwargs(caller=me, **kwargs)
243    validate_kwargs(SeriesGrowthKwargs, caller=me, **kwargs)
244
245    # --- sanity checks
246    if not isinstance(data, Series):
247        raise TypeError("The data argument to series_growth_plot() must be a pandas Series")
248
249    # --- calculate growth and plot - add ylabel
250    ylabel: str | None = kwargs.pop("ylabel", None)
251    if ylabel is not None:
252        print(f"Did you intend to specify a value for the 'ylabel' in {me}()?")
253    ylabel = "Growth (%)" if ylabel is None else ylabel
254    growth = calc_growth(data)
255    ax = growth_plot(growth, **cast("GrowthKwargs", kwargs))
256    ax.set_ylabel(ylabel)
257    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