mgplot

Provide a frontend to matplotlib for working with timeseries data, indexed with a PeriodIndex.

This package simplifiers the creation of common plots used in economic and financial analysis, such as bar plots, line plots, growth plots, and seasonal trend plots. It also includes utilities for color management and finalising plots with consistent styling.

  1"""Provide a frontend to matplotlib for working with timeseries data, indexed with a PeriodIndex.
  2
  3This package simplifiers the creation of common plots used in economic and financial analysis,
  4such as bar plots, line plots, growth plots, and seasonal trend plots. It also includes utilities
  5for color management and finalising plots with consistent styling.
  6"""
  7
  8# --- version and author
  9import importlib.metadata
 10
 11# --- local imports
 12#    Do not import the utilities, axis_utils nor keyword_checking modules here.
 13from mgplot.bar_plot import BarKwargs, bar_plot
 14from mgplot.colors import (
 15    abbreviate_state,
 16    colorise_list,
 17    contrast,
 18    get_color,
 19    get_party_palette,
 20    state_abbrs,
 21    state_names,
 22)
 23from mgplot.fill_between_plot import FillBetweenKwargs, fill_between_plot
 24from mgplot.finalise_plot import FinaliseKwargs, finalise_plot
 25from mgplot.finalisers import (
 26    bar_plot_finalise,
 27    fill_between_plot_finalise,
 28    growth_plot_finalise,
 29    line_plot_finalise,
 30    postcovid_plot_finalise,
 31    revision_plot_finalise,
 32    run_plot_finalise,
 33    seastrend_plot_finalise,
 34    series_growth_plot_finalise,
 35    summary_plot_finalise,
 36)
 37from mgplot.growth_plot import (
 38    GrowthKwargs,
 39    SeriesGrowthKwargs,
 40    calc_growth,
 41    growth_plot,
 42    series_growth_plot,
 43)
 44from mgplot.line_plot import LineKwargs, line_plot
 45from mgplot.multi_plot import multi_column, multi_start, plot_then_finalise
 46from mgplot.postcovid_plot import PostcovidKwargs, postcovid_plot
 47from mgplot.revision_plot import revision_plot
 48from mgplot.run_plot import RunKwargs, run_plot
 49from mgplot.seastrend_plot import seastrend_plot
 50from mgplot.settings import (
 51    clear_chart_dir,
 52    get_setting,
 53    set_chart_dir,
 54    set_setting,
 55)
 56from mgplot.summary_plot import SummaryKwargs, summary_plot
 57
 58# --- version and author
 59try:
 60    __version__ = importlib.metadata.version(__name__)
 61except importlib.metadata.PackageNotFoundError:
 62    __version__ = "0.0.0"  # Fallback for development mode
 63__author__ = "Bryan Palmer"
 64
 65
 66# --- public API
 67__all__ = (
 68    "BarKwargs",
 69    "FillBetweenKwargs",
 70    "FinaliseKwargs",
 71    "GrowthKwargs",
 72    "LineKwargs",
 73    "PostcovidKwargs",
 74    "RunKwargs",
 75    "SeriesGrowthKwargs",
 76    "SummaryKwargs",
 77    "__author__",
 78    "__version__",
 79    "abbreviate_state",
 80    "bar_plot",
 81    "bar_plot_finalise",
 82    "fill_between_plot",
 83    "fill_between_plot_finalise",
 84    "calc_growth",
 85    "clear_chart_dir",
 86    "colorise_list",
 87    "contrast",
 88    "finalise_plot",
 89    "get_color",
 90    "get_party_palette",
 91    "get_setting",
 92    "growth_plot",
 93    "growth_plot_finalise",
 94    "line_plot",
 95    "line_plot_finalise",
 96    "multi_column",
 97    "multi_start",
 98    "plot_then_finalise",
 99    "postcovid_plot",
100    "postcovid_plot_finalise",
101    "revision_plot",
102    "revision_plot_finalise",
103    "run_plot",
104    "run_plot",
105    "run_plot_finalise",
106    "seastrend_plot",
107    "seastrend_plot_finalise",
108    "series_growth_plot",
109    "series_growth_plot_finalise",
110    "set_chart_dir",
111    "set_setting",
112    "state_abbrs",
113    "state_names",
114    "summary_plot",
115    "summary_plot_finalise",
116)
class BarKwargs(mgplot.keyword_checking.BaseKwargs):
41class BarKwargs(BaseKwargs):
42    """Keyword arguments for the bar_plot function."""
43
44    # --- options for the entire bar plot
45    ax: NotRequired[Axes | None]
46    stacked: NotRequired[bool]
47    max_ticks: NotRequired[int]
48    plot_from: NotRequired[int | Period]
49    label_rotation: NotRequired[int | float]
50    # --- options for each bar ...
51    color: NotRequired[str | Sequence[str]]
52    label_series: NotRequired[bool | Sequence[bool]]
53    width: NotRequired[float | int | Sequence[float | int]]
54    zorder: NotRequired[int | float | Sequence[int | float]]
55    # --- options for bar annotations
56    annotate: NotRequired[bool]
57    fontsize: NotRequired[int | float | str]
58    fontname: NotRequired[str]
59    rounding: NotRequired[int]
60    rotation: NotRequired[int | float]
61    annotate_color: NotRequired[str]
62    above: NotRequired[bool]

Keyword arguments for the bar_plot function.

ax: NotRequired[matplotlib.axes._axes.Axes | None]
stacked: NotRequired[bool]
max_ticks: NotRequired[int]
plot_from: NotRequired[int | pandas.Period]
label_rotation: NotRequired[int | float]
color: NotRequired[str | Sequence[str]]
label_series: NotRequired[bool | Sequence[bool]]
width: NotRequired[float | int | Sequence[float | int]]
zorder: NotRequired[float | int | Sequence[float | int]]
annotate: NotRequired[bool]
fontsize: NotRequired[int | float | str]
fontname: NotRequired[str]
rounding: NotRequired[int]
rotation: NotRequired[int | float]
annotate_color: NotRequired[str]
above: NotRequired[bool]
class FillBetweenKwargs(mgplot.keyword_checking.BaseKwargs):
21class FillBetweenKwargs(BaseKwargs):
22    """Keyword arguments for the fill_between_plot function."""
23
24    ax: NotRequired[Axes | None]
25    color: NotRequired[str]
26    alpha: NotRequired[float]
27    label: NotRequired[str | None]
28    linewidth: NotRequired[float]
29    edgecolor: NotRequired[str | None]
30    zorder: NotRequired[int | float]
31    plot_from: NotRequired[int | None]
32    max_ticks: NotRequired[int]

Keyword arguments for the fill_between_plot function.

ax: NotRequired[matplotlib.axes._axes.Axes | None]
color: NotRequired[str]
alpha: NotRequired[float]
label: NotRequired[str | None]
linewidth: NotRequired[float]
edgecolor: NotRequired[str | None]
zorder: NotRequired[int | float]
plot_from: NotRequired[int | None]
max_ticks: NotRequired[int]
class FinaliseKwargs(mgplot.keyword_checking.BaseKwargs):
31class FinaliseKwargs(BaseKwargs):
32    """Keyword arguments for the finalise_plot function."""
33
34    # --- value options
35    suptitle: NotRequired[str | None]
36    title: NotRequired[str | None]
37    xlabel: NotRequired[str | None]
38    ylabel: NotRequired[str | None]
39    xlim: NotRequired[tuple[float, float] | None]
40    ylim: NotRequired[tuple[float, float] | None]
41    xticks: NotRequired[list[float] | None]
42    yticks: NotRequired[list[float] | None]
43    xscale: NotRequired[str | None]
44    yscale: NotRequired[str | None]
45    # --- splat options
46    legend: NotRequired[bool | dict[str, Any] | None]
47    axhspan: NotRequired[dict[str, Any] | None]
48    axvspan: NotRequired[dict[str, Any] | None]
49    axhline: NotRequired[dict[str, Any] | None]
50    axvline: NotRequired[dict[str, Any] | None]
51    # --- options for annotations
52    lfooter: NotRequired[str]
53    rfooter: NotRequired[str]
54    lheader: NotRequired[str]
55    rheader: NotRequired[str]
56    # --- file/save options
57    pre_tag: NotRequired[str]
58    tag: NotRequired[str]
59    chart_dir: NotRequired[str]
60    file_type: NotRequired[str]
61    dpi: NotRequired[int]
62    figsize: NotRequired[tuple[float, float]]
63    show: NotRequired[bool]
64    # --- other options
65    preserve_lims: NotRequired[bool]
66    remove_legend: NotRequired[bool]
67    zero_y: NotRequired[bool]
68    y0: NotRequired[bool]
69    x0: NotRequired[bool]
70    axisbelow: NotRequired[bool]
71    dont_save: NotRequired[bool]
72    dont_close: NotRequired[bool]

Keyword arguments for the finalise_plot function.

suptitle: NotRequired[str | None]
title: NotRequired[str | None]
xlabel: NotRequired[str | None]
ylabel: NotRequired[str | None]
xlim: NotRequired[tuple[float, float] | None]
ylim: NotRequired[tuple[float, float] | None]
xticks: NotRequired[list[float] | None]
yticks: NotRequired[list[float] | None]
xscale: NotRequired[str | None]
yscale: NotRequired[str | None]
legend: NotRequired[bool | dict[str, Any] | None]
axhspan: NotRequired[dict[str, Any] | None]
axvspan: NotRequired[dict[str, Any] | None]
axhline: NotRequired[dict[str, Any] | None]
axvline: NotRequired[dict[str, Any] | None]
lfooter: NotRequired[str]
rfooter: NotRequired[str]
lheader: NotRequired[str]
rheader: NotRequired[str]
pre_tag: NotRequired[str]
tag: NotRequired[str]
chart_dir: NotRequired[str]
file_type: NotRequired[str]
dpi: NotRequired[int]
figsize: NotRequired[tuple[float, float]]
show: NotRequired[bool]
preserve_lims: NotRequired[bool]
remove_legend: NotRequired[bool]
zero_y: NotRequired[bool]
y0: NotRequired[bool]
x0: NotRequired[bool]
axisbelow: NotRequired[bool]
dont_save: NotRequired[bool]
dont_close: NotRequired[bool]
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.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 LineKwargs(mgplot.keyword_checking.BaseKwargs):
28class LineKwargs(BaseKwargs):
29    """Keyword arguments for the line_plot function."""
30
31    # --- options for the entire line plot
32    ax: NotRequired[Axes | None]
33    style: NotRequired[str | Sequence[str]]
34    width: NotRequired[float | int | Sequence[float | int]]
35    color: NotRequired[str | Sequence[str]]
36    alpha: NotRequired[float | Sequence[float]]
37    drawstyle: NotRequired[str | Sequence[str] | None]
38    marker: NotRequired[str | Sequence[str] | None]
39    markersize: NotRequired[float | Sequence[float] | int | None]
40    zorder: NotRequired[int | float | Sequence[int | float]]
41    dropna: NotRequired[bool | Sequence[bool]]
42    annotate: NotRequired[bool | Sequence[bool]]
43    rounding: NotRequired[Sequence[int | bool] | int | bool | None]
44    fontsize: NotRequired[Sequence[str | int | float] | str | int | float]
45    fontname: NotRequired[str | Sequence[str]]
46    rotation: NotRequired[Sequence[int | float] | int | float]
47    annotate_color: NotRequired[str | Sequence[str] | bool | Sequence[bool] | None]
48    plot_from: NotRequired[int | Period | None]
49    label_series: NotRequired[bool | Sequence[bool] | None]
50    max_ticks: NotRequired[int]

Keyword arguments for the line_plot function.

ax: NotRequired[matplotlib.axes._axes.Axes | None]
style: NotRequired[str | Sequence[str]]
width: NotRequired[float | int | Sequence[float | int]]
color: NotRequired[str | Sequence[str]]
alpha: NotRequired[float | Sequence[float]]
drawstyle: NotRequired[str | Sequence[str] | None]
marker: NotRequired[str | Sequence[str] | None]
markersize: NotRequired[float | Sequence[float] | int | None]
zorder: NotRequired[float | int | Sequence[float | int]]
dropna: NotRequired[bool | Sequence[bool]]
annotate: NotRequired[bool | Sequence[bool]]
rounding: NotRequired[Sequence[int | bool] | int | bool | None]
fontsize: NotRequired[Sequence[str | int | float] | str | int | float]
fontname: NotRequired[str | Sequence[str]]
rotation: NotRequired[float | int | Sequence[float | int]]
annotate_color: NotRequired[str | Sequence[str] | bool | Sequence[bool] | None]
plot_from: NotRequired[int | pandas.Period | None]
label_series: NotRequired[bool | Sequence[bool] | None]
max_ticks: NotRequired[int]
class PostcovidKwargs(mgplot.LineKwargs):
30class PostcovidKwargs(LineKwargs):
31    """Keyword arguments for the post-COVID plot."""
32
33    start_r: NotRequired[Period]  # start of regression period
34    end_r: NotRequired[Period]  # end of regression period

Keyword arguments for the post-COVID plot.

start_r: NotRequired[pandas.Period]
end_r: NotRequired[pandas.Period]
class RunKwargs(mgplot.LineKwargs):
32class RunKwargs(LineKwargs):
33    """Keyword arguments for the run_plot function."""
34
35    threshold: NotRequired[float]
36    direction: NotRequired[str]
37    highlight_color: NotRequired[str | Sequence[str]]
38    highlight_label: NotRequired[str | Sequence[str]]

Keyword arguments for the run_plot function.

threshold: NotRequired[float]
direction: NotRequired[str]
highlight_color: NotRequired[str | Sequence[str]]
highlight_label: NotRequired[str | Sequence[str]]
class SeriesGrowthKwargs(mgplot.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]
class SummaryKwargs(mgplot.keyword_checking.BaseKwargs):
41class SummaryKwargs(BaseKwargs):
42    """Keyword arguments for the summary_plot function."""
43
44    ax: NotRequired[Axes | None]
45    verbose: NotRequired[bool]
46    middle: NotRequired[float]
47    plot_type: NotRequired[str]
48    plot_from: NotRequired[int | Period]
49    legend: NotRequired[bool | dict[str, Any] | None]
50    xlabel: NotRequired[str | None]

Keyword arguments for the summary_plot function.

ax: NotRequired[matplotlib.axes._axes.Axes | None]
verbose: NotRequired[bool]
middle: NotRequired[float]
plot_type: NotRequired[str]
plot_from: NotRequired[int | pandas.Period]
legend: NotRequired[bool | dict[str, Any] | None]
xlabel: NotRequired[str | None]
__author__ = 'Bryan Palmer'
__version__ = '0.2.20'
def abbreviate_state(state: str) -> str:
158def abbreviate_state(state: str) -> str:
159    """Abbreviate long-form state names.
160
161    Args:
162        state: str - the long-form state name.
163
164    Return the abbreviation for a state name.
165
166    """
167    return _state_names_multi.get(state.lower(), state)

Abbreviate long-form state names.

Args: state: str - the long-form state name.

Return the abbreviation for a state name.

def bar_plot( data: ~DataT, **kwargs: Unpack[BarKwargs]) -> matplotlib.axes._axes.Axes:
215def bar_plot(data: DataT, **kwargs: Unpack[BarKwargs]) -> Axes:
216    """Create a bar plot from the given data.
217
218    Each column in the DataFrame will be stacked on top of each other,
219    with positive values above zero and negative values below zero.
220
221    Args:
222        data: Series | DataFrame - The data to plot. Can be a DataFrame or a Series.
223        **kwargs: BarKwargs - Additional keyword arguments for customization.
224        (see BarKwargs for details)
225
226    Note: This function does not assume all data is timeseries with a PeriodIndex.
227
228    Returns:
229        axes: Axes - The axes for the plot.
230
231    """
232    # --- check the kwargs
233    report_kwargs(caller=ME, **kwargs)
234    validate_kwargs(schema=BarKwargs, caller=ME, **kwargs)
235
236    # --- get the data
237    # no call to check_clean_timeseries here, as bar plots are not
238    # necessarily timeseries data. If the data is a Series, it will be
239    # converted to a DataFrame with a single column.
240    df = DataFrame(data)  # really we are only plotting DataFrames
241    df, kwargs_d = constrain_data(df, **kwargs)
242    item_count = len(df.columns)
243
244    # --- deal with string indices
245    saved_strings = map_stringindex(df)
246    if saved_strings is not None:
247        df = saved_strings[0]
248
249    # --- deal with complete PeriodIndex indices
250    saved_pi = map_periodindex(df)
251    if saved_pi is not None:
252        df = saved_pi[0]  # extract the reindexed DataFrame from the PeriodIndex
253
254    # --- set up the default arguments
255    chart_defaults: dict[str, bool | int] = {
256        "stacked": False,
257        "max_ticks": DEFAULT_MAX_TICKS,
258        "label_series": item_count > 1,
259        "label_rotation": 0,
260    }
261    chart_args = {k: kwargs_d.get(k, v) for k, v in chart_defaults.items()}
262
263    bar_defaults = {
264        "color": get_color_list(item_count),
265        "width": get_setting("bar_width"),
266        "label_series": item_count > 1,
267        "zorder": None,
268    }
269    above = kwargs_d.get("above", False)
270    anno_args: AnnoKwargs = {
271        "annotate": kwargs_d.get("annotate", False),
272        "fontsize": kwargs_d.get("fontsize", "small"),
273        "fontname": kwargs_d.get("fontname", "Helvetica"),
274        "rotation": kwargs_d.get("rotation", 0),
275        "rounding": kwargs_d.get("rounding", True),
276        "color": kwargs_d.get("annotate_color", "black" if above else "white"),
277        "above": above,
278    }
279    bar_args, remaining_kwargs = apply_defaults(item_count, bar_defaults, kwargs_d)
280
281    # --- plot the data
282    axes, remaining_kwargs = get_axes(**dict(remaining_kwargs))
283    if chart_args["stacked"]:
284        stacked(axes, df, anno_args, **bar_args)
285    else:
286        grouped(axes, df, anno_args, **bar_args)
287
288    # --- handle index labels and rotation
289    if saved_strings is not None:
290        axes.set_xticks(range(len(saved_strings[1])))
291        axes.set_xticklabels(saved_strings[1])
292    elif saved_pi is not None:
293        set_labels(axes, saved_pi[1], chart_args["max_ticks"])
294    plt.xticks(rotation=chart_args["label_rotation"])
295
296    return axes

Create a bar plot from the given data.

Each column in the DataFrame will be stacked on top of each other, with positive values above zero and negative values below zero.

Args: data: Series | DataFrame - The data to plot. Can be a DataFrame or a Series. **kwargs: BarKwargs - Additional keyword arguments for customization. (see BarKwargs for details)

Note: This function does not assume all data is timeseries with a PeriodIndex.

Returns: axes: Axes - The axes for the plot.

def bar_plot_finalise(data: ~DataT, **kwargs: Unpack[mgplot.finalisers.BPFKwargs]) -> None:
138def bar_plot_finalise(
139    data: DataT,
140    **kwargs: Unpack[BPFKwargs],
141) -> None:
142    """Call bar_plot() and finalise_plot().
143
144    Args:
145        data: The data to be plotted.
146        kwargs: Combined bar plot and finalise plot keyword arguments.
147
148    """
149    validate_kwargs(schema=BPFKwargs, caller="bar_plot_finalise", **kwargs)
150    kwargs = impose_legend(kwargs=kwargs, data=data)
151    plot_then_finalise(
152        data,
153        function=bar_plot,
154        **kwargs,
155    )

Call bar_plot() and finalise_plot().

Args: data: The data to be plotted. kwargs: Combined bar plot and finalise plot keyword arguments.

def fill_between_plot( data: pandas.DataFrame, **kwargs: Unpack[FillBetweenKwargs]) -> matplotlib.axes._axes.Axes:
 35def fill_between_plot(data: DataFrame, **kwargs: Unpack[FillBetweenKwargs]) -> Axes:
 36    """Plot a filled region between lower and upper bounds.
 37
 38    Args:
 39        data: DataFrame - A two-column DataFrame with PeriodIndex.
 40              The first column is the lower bound, the second is the upper bound.
 41        kwargs: FillBetweenKwargs - keyword arguments for the plot.
 42
 43    Returns:
 44        Axes - matplotlib Axes object.
 45
 46    Raises:
 47        TypeError: If data is not a DataFrame.
 48        ValueError: If data does not have exactly two columns.
 49
 50    """
 51    # --- validate inputs
 52    report_kwargs(caller=ME, **kwargs)
 53    validate_kwargs(schema=FillBetweenKwargs, caller=ME, **kwargs)
 54
 55    if not isinstance(data, DataFrame):
 56        raise TypeError(f"data must be a DataFrame for {ME}()")
 57
 58    if len(data.columns) != REQUIRED_COLUMNS:
 59        raise ValueError(f"data must have exactly two columns for {ME}(), got {len(data.columns)}")
 60
 61    # --- check and constrain data
 62    data = check_clean_timeseries(data, ME)
 63    data, kwargs_d = constrain_data(data, **kwargs)
 64
 65    # --- handle PeriodIndex conversion
 66    saved_pi = map_periodindex(data)
 67    if saved_pi is not None:
 68        data = saved_pi[0]
 69
 70    # --- get axes
 71    axes, kwargs_d = get_axes(**kwargs_d)
 72
 73    if data.empty or data.isna().all().all():
 74        print(f"Warning: No data to plot in {ME}().")
 75        return axes
 76
 77    # --- extract bounds
 78    lower = data.iloc[:, 0]
 79    upper = data.iloc[:, 1]
 80
 81    # --- extract plot arguments
 82    color = kwargs_d.get("color", DEFAULT_COLOR)
 83    alpha = kwargs_d.get("alpha", DEFAULT_ALPHA)
 84    label = kwargs_d.get("label", None)
 85    linewidth = kwargs_d.get("linewidth", 0)
 86    edgecolor = kwargs_d.get("edgecolor", None)
 87    zorder = kwargs_d.get("zorder", None)
 88
 89    # --- plot
 90    axes.fill_between(
 91        data.index,
 92        lower,
 93        upper,
 94        color=color,
 95        alpha=alpha,
 96        label=label,
 97        linewidth=linewidth,
 98        edgecolor=edgecolor,
 99        zorder=zorder,
100    )
101
102    # --- set axis labels
103    if saved_pi is not None:
104        set_labels(axes, saved_pi[1], kwargs_d.get("max_ticks", get_setting("max_ticks")))
105
106    return axes

Plot a filled region between lower and upper bounds.

Args: data: DataFrame - A two-column DataFrame with PeriodIndex. The first column is the lower bound, the second is the upper bound. kwargs: FillBetweenKwargs - keyword arguments for the plot.

Returns: Axes - matplotlib Axes object.

Raises: TypeError: If data is not a DataFrame. ValueError: If data does not have exactly two columns.

def fill_between_plot_finalise( data: pandas.DataFrame, **kwargs: Unpack[mgplot.finalisers.FBPFKwargs]) -> None:
158def fill_between_plot_finalise(
159    data: DataFrame,
160    **kwargs: Unpack[FBPFKwargs],
161) -> None:
162    """Call fill_between_plot() and finalise_plot().
163
164    Args:
165        data: DataFrame with two columns (lower bound, upper bound).
166        kwargs: Combined fill_between plot and finalise plot keyword arguments.
167
168    """
169    validate_kwargs(schema=FBPFKwargs, caller="fill_between_plot_finalise", **kwargs)
170    kwargs = impose_legend(kwargs=kwargs, data=data)
171    plot_then_finalise(
172        data,
173        function=fill_between_plot,
174        **kwargs,
175    )

Call fill_between_plot() and finalise_plot().

Args: data: DataFrame with two columns (lower bound, upper bound). kwargs: Combined fill_between plot and finalise plot keyword arguments.

def calc_growth(series: pandas.Series) -> pandas.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 clear_chart_dir() -> None:
146def clear_chart_dir() -> None:
147    """Remove all graph-image files from the global chart_dir."""
148    chart_dir = get_setting("chart_dir")
149    Path(chart_dir).mkdir(parents=True, exist_ok=True)
150    for ext in IMAGE_EXTENSIONS:
151        for fs_object in Path(chart_dir).glob(f"*.{ext}"):
152            if fs_object.is_file():
153                fs_object.unlink()

Remove all graph-image files from the global chart_dir.

def colorise_list(party_list: Iterable[str]) -> list[str]:
103def colorise_list(party_list: Iterable[str]) -> list[str]:
104    """Return a list of party/state colors for a party_list."""
105    return [get_color(x) for x in party_list]

Return a list of party/state colors for a party_list.

def contrast(orig_color: str) -> str:
108def contrast(orig_color: str) -> str:
109    """Provide a contrasting color to any party color."""
110    new_color = DEFAULT_CONTRAST_COLOR
111    match orig_color:
112        case "royalblue":
113            new_color = "indianred"
114        case "indianred":
115            new_color = "royalblue"
116
117        case "darkorange":
118            new_color = "mediumblue"
119        case "mediumblue":
120            new_color = "darkorange"
121
122        case "seagreen":
123            new_color = "darkblue"
124
125        case color if color == DEFAULT_UNKNOWN_COLOR:
126            new_color = "hotpink"
127
128    return new_color

Provide a contrasting color to any party color.

def finalise_plot( axes: matplotlib.axes._axes.Axes, **kwargs: Unpack[FinaliseKwargs]) -> None:
343def finalise_plot(axes: Axes, **kwargs: Unpack[FinaliseKwargs]) -> None:
344    """Finalise and save plots to the file system.
345
346    The filename for the saved plot is constructed from the global
347    chart_dir, the plot's title, any specified tag text, and the
348    file_type for the plot.
349
350    Args:
351        axes: Axes - matplotlib axes object - required
352        kwargs: FinaliseKwargs
353
354    """
355    # --- check the kwargs
356    report_kwargs(caller=ME, **kwargs)
357    validate_kwargs(schema=FinaliseKwargs, caller=ME, **kwargs)
358
359    # --- sanity checks
360    if len(axes.get_children()) < 1:
361        print(f"Warning: {ME}() called with an empty axes, which was ignored.")
362        return
363
364    # --- remember axis-limits should we need to restore thems
365    xlim, ylim = axes.get_xlim(), axes.get_ylim()
366
367    # margins
368    axes.margins(DEFAULT_MARGIN)
369    axes.autoscale(tight=False)  # This is problematic ...
370
371    apply_kwargs(axes, **kwargs)
372
373    # tight layout and save the figure
374    fig = axes.figure
375    if suptitle := kwargs.get("suptitle"):
376        fig.suptitle(suptitle)
377    if kwargs.get("preserve_lims"):
378        # restore the original limits of the axes
379        axes.set_xlim(xlim)
380        axes.set_ylim(ylim)
381    if not isinstance(fig, SubFigure):
382        fig.tight_layout(pad=TIGHT_LAYOUT_PAD)
383    apply_late_kwargs(axes, **kwargs)
384    legend = axes.get_legend()
385    if legend and kwargs.get("remove_legend", False):
386        legend.remove()
387    if not isinstance(fig, SubFigure):
388        save_to_file(fig, **kwargs)
389
390    # show the plot in Jupyter Lab
391    if kwargs.get("show"):
392        plt.show()
393
394    # And close
395    if not kwargs.get("dont_close", False):
396        plt.close()

Finalise and save plots to the file system.

The filename for the saved plot is constructed from the global chart_dir, the plot's title, any specified tag text, and the file_type for the plot.

Args: axes: Axes - matplotlib axes object - required kwargs: FinaliseKwargs

def get_color(s: str) -> str:
 45def get_color(s: str) -> str:
 46    """Return a matplotlib color for a party label or an Australian state/territory.
 47
 48    Args:
 49        s: str - the party label or Australian state/territory name.
 50
 51    Returns a color string that can be used in matplotlib plots.
 52
 53    """
 54    # Flattened color map for better readability
 55    color_map: dict[str, str] = {
 56        # --- Australian states and territories
 57        "wa": "gold",
 58        "western australia": "gold",
 59        "sa": "red",
 60        "south australia": "red",
 61        "nt": "#CC7722",  # ochre
 62        "northern territory": "#CC7722",
 63        "nsw": "deepskyblue",
 64        "new south wales": "deepskyblue",
 65        "act": "blue",
 66        "australian capital territory": "blue",
 67        "vic": "navy",
 68        "victoria": "navy",
 69        "tas": "seagreen",  # bottle green #006A4E?
 70        "tasmania": "seagreen",
 71        "qld": "#c32148",  # a lighter maroon
 72        "queensland": "#c32148",
 73        "australia": "grey",
 74        "aus": "grey",
 75        # --- political parties
 76        "dissatisfied": "darkorange",  # must be before satisfied
 77        "satisfied": "mediumblue",
 78        "lnp": "royalblue",
 79        "l/np": "royalblue",
 80        "liberal": "royalblue",
 81        "liberals": "royalblue",
 82        "coalition": "royalblue",
 83        "dutton": "royalblue",
 84        "ley": "royalblue",
 85        "liberal and/or nationals": "royalblue",
 86        "nat": "forestgreen",
 87        "nats": "forestgreen",
 88        "national": "forestgreen",
 89        "nationals": "forestgreen",
 90        "alp": "#dd0000",
 91        "labor": "#dd0000",
 92        "albanese": "#dd0000",
 93        "grn": "limegreen",
 94        "green": "limegreen",
 95        "greens": "limegreen",
 96        "other": "darkorange",
 97        "oth": "darkorange",
 98    }
 99
100    return color_map.get(s.lower(), DEFAULT_UNKNOWN_COLOR)

Return a matplotlib color for a party label or an Australian state/territory.

Args: s: str - the party label or Australian state/territory name.

Returns a color string that can be used in matplotlib plots.

def get_party_palette(party_text: str) -> str:
21def get_party_palette(party_text: str) -> str:
22    """Return a matplotlib color-map name based on party_text.
23
24    Works for Australian major political parties.
25
26    Args:
27        party_text: str - the party label or name.
28
29    """
30    # Note: light to dark colormaps work best for sequential data visualization
31    match party_text.lower():
32        case "alp" | "labor":
33            return "Reds"
34        case "l/np" | "coalition":
35            return "Blues"
36        case "grn" | "green" | "greens":
37            return "Greens"
38        case "oth" | "other":
39            return "YlOrBr"
40        case "onp" | "one nation":
41            return "YlGnBu"
42    return DEFAULT_PARTY_PALETTE

Return a matplotlib color-map name based on party_text.

Works for Australian major political parties.

Args: party_text: str - the party label or name.

def get_setting(setting: str) -> Any:
102def get_setting(setting: str) -> Any:
103    """Get a setting from the global settings.
104
105    Args:
106        setting: str - name of the setting to get.
107
108    Raises:
109        KeyError: if the setting is not found
110
111    Returns:
112        value: Any - the value of the setting
113
114    """
115    if setting not in get_fields():
116        raise KeyError(f"Setting '{setting}' not found in mgplot_defaults.")
117    return getattr(mgplot_defaults, setting)

Get a setting from the global settings.

Args: setting: str - name of the setting to get.

Raises: KeyError: if the setting is not found

Returns: value: Any - the value of the setting

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 growth_plot_finalise(data: ~DataT, **kwargs: Unpack[mgplot.finalisers.GrowthPFKwargs]) -> None:
178def growth_plot_finalise(data: DataT, **kwargs: Unpack[GrowthPFKwargs]) -> None:
179    """Call growth_plot() and finalise_plot().
180
181    Args:
182        data: The growth data to be plotted.
183        kwargs: Combined growth plot and finalise plot keyword arguments.
184
185    Note:
186        Use this when you are providing the raw growth data. Don't forget to
187        set the ylabel in kwargs.
188
189    """
190    validate_kwargs(schema=GrowthPFKwargs, caller="growth_plot_finalise", **kwargs)
191    kwargs = impose_legend(kwargs=kwargs, force=True)
192    plot_then_finalise(data=data, function=growth_plot, **kwargs)

Call growth_plot() and finalise_plot().

Args: data: The growth data to be plotted. kwargs: Combined growth plot and finalise plot keyword arguments.

Note: Use this when you are providing the raw growth data. Don't forget to set the ylabel in kwargs.

def line_plot( data: ~DataT, **kwargs: Unpack[LineKwargs]) -> matplotlib.axes._axes.Axes:
149def line_plot(data: DataT, **kwargs: Unpack[LineKwargs]) -> Axes:
150    """Build a single or multi-line plot.
151
152    Args:
153        data: DataFrame | Series - data to plot
154        kwargs: LineKwargs - keyword arguments for the line plot
155
156    Returns:
157    - axes: Axes - the axes object for the plot
158
159    """
160    # --- check the kwargs
161    report_kwargs(caller=ME, **kwargs)
162    validate_kwargs(schema=LineKwargs, caller=ME, **kwargs)
163
164    # --- check the data
165    data = check_clean_timeseries(data, ME)
166    df = DataFrame(data)  # we are only plotting DataFrames
167    df, kwargs_d = constrain_data(df, **kwargs)
168
169    # --- convert PeriodIndex to Integer Index
170    saved_pi = map_periodindex(df)
171    if saved_pi is not None:
172        df = saved_pi[0]
173
174    if isinstance(df.index, PeriodIndex):
175        print("Internal error: data is still a PeriodIndex - come back here and fix it")
176
177    # --- Let's plot
178    axes, kwargs_d = get_axes(**kwargs_d)  # get the axes to plot on
179    if df.empty or df.isna().all().all():
180        # Note: finalise plot should ignore an empty axes object
181        print(f"Warning: No data to plot in {ME}().")
182        return axes
183
184    # --- get the arguments for each line we will plot ...
185    item_count = len(df.columns)
186    num_data_points = len(df)
187    swce, kwargs_d = get_style_width_color_etc(item_count, num_data_points, **kwargs_d)
188
189    for i, column in enumerate(df.columns):
190        series = df[column]
191        series = series.dropna() if "dropna" in swce and swce["dropna"][i] else series
192        if series.empty or series.isna().all():
193            print(f"Warning: No data to plot for {column} in line_plot().")
194            continue
195
196        axes.plot(
197            # using matplotlib, as pandas can set xlabel/ylabel
198            series.index,  # x
199            series,  # y
200            ls=swce["style"][i],
201            lw=swce["width"][i],
202            color=swce["color"][i],
203            alpha=swce["alpha"][i],
204            marker=swce["marker"][i],
205            ms=swce["markersize"][i],
206            drawstyle=swce["drawstyle"][i],
207            zorder=swce["zorder"][i],
208            label=(column if "label_series" in swce and swce["label_series"][i] else f"_{column}_"),
209        )
210
211        if swce["annotate"][i] is None or not swce["annotate"][i]:
212            continue
213
214        color = swce["color"][i] if swce["annotate_color"][i] is True else swce["annotate_color"][i]
215        annotate_series(
216            series,
217            axes,
218            color=color,
219            rounding=swce["rounding"][i],
220            fontsize=swce["fontsize"][i],
221            fontname=swce["fontname"][i],
222            rotation=swce["rotation"][i],
223        )
224
225    # --- set the labels
226    if saved_pi is not None:
227        set_labels(axes, saved_pi[1], kwargs_d.get("max_ticks", get_setting("max_ticks")))
228
229    return axes

Build a single or multi-line plot.

Args: data: DataFrame | Series - data to plot kwargs: LineKwargs - keyword arguments for the line plot

Returns:

  • axes: Axes - the axes object for the plot
def line_plot_finalise(data: ~DataT, **kwargs: Unpack[mgplot.finalisers.LPFKwargs]) -> None:
195def line_plot_finalise(
196    data: DataT,
197    **kwargs: Unpack[LPFKwargs],
198) -> None:
199    """Call line_plot() then finalise_plot().
200
201    Args:
202        data: The data to be plotted.
203        kwargs: Combined line plot and finalise plot keyword arguments.
204
205    """
206    validate_kwargs(schema=LPFKwargs, caller="line_plot_finalise", **kwargs)
207    kwargs = impose_legend(kwargs=kwargs, data=data)
208    plot_then_finalise(data, function=line_plot, **kwargs)

Call line_plot() then finalise_plot().

Args: data: The data to be plotted. kwargs: Combined line plot and finalise plot keyword arguments.

def multi_column( data: pandas.DataFrame, function: Callable | list[Callable], **kwargs: Any) -> None:
273def multi_column(
274    data: DataFrame,
275    function: Callable | list[Callable],
276    **kwargs: Any,
277) -> None:
278    """Create multiple plots, one for each column in a DataFrame.
279
280    Args:
281        data: DataFrame - The data to be plotted.
282        function: Callable | list[Callable] - The plotting function(s) to be used.
283        kwargs: Any - Additional keyword arguments passed to plotting functions.
284
285    Returns:
286        None
287
288    Raises:
289        TypeError: If data is not a DataFrame.
290        ValueError: If DataFrame is empty or has no columns.
291
292    Note:
293        The plot title will be kwargs["title"] plus the column name.
294
295    """
296    # --- sanity checks
297    me = "multi_column"
298    report_kwargs(caller=me, **kwargs)
299    if not isinstance(data, DataFrame):
300        raise TypeError("data must be a pandas DataFrame for multi_column()")
301    if data.empty:
302        raise ValueError("DataFrame cannot be empty")
303    if len(data.columns) == 0:
304        raise ValueError("DataFrame must have at least one column")
305
306    # --- check the function argument
307    title_stem = kwargs.get("title", "")
308    tag: Final[str] = kwargs.get("tag", "")
309    first, kwargs["function"] = first_unchain(function)
310    if not kwargs["function"]:
311        del kwargs["function"]  # remove the function key if it is empty
312
313    # --- iterate over the columns
314    for i, col in enumerate(data.columns):
315        series = data[col]  # Extract as Series, not single-column DataFrame
316        kwargs["title"] = f"{title_stem}{col}" if title_stem else str(col)
317        kwargs["tag"] = _generate_tag(tag, i)
318        first(series, **kwargs)

Create multiple plots, one for each column in a DataFrame.

Args: data: DataFrame - The data to be plotted. function: Callable | list[Callable] - The plotting function(s) to be used. kwargs: Any - Additional keyword arguments passed to plotting functions.

Returns: None

Raises: TypeError: If data is not a DataFrame. ValueError: If DataFrame is empty or has no columns.

Note: The plot title will be kwargs["title"] plus the column name.

def multi_start( data: ~DataT, function: Callable | list[Callable], starts: Iterable[None | pandas.Period | int], **kwargs: Any) -> None:
215def multi_start(
216    data: DataT,
217    function: Callable | list[Callable],
218    starts: Iterable[None | Period | int],
219    **kwargs: Any,
220) -> None:
221    """Create multiple plots with different starting points.
222
223    Args:
224        data: Series | DataFrame - The data to be plotted.
225        function: Callable | list[Callable] - desired plotting function(s).
226        starts: Iterable[Period | int | None] - The starting points for each plot.
227        kwargs: Any - Additional keyword arguments passed to plotting functions.
228
229    Returns:
230        None
231
232    Raises:
233        TypeError: If starts is not an iterable of None, Period or int.
234        ValueError: If starts contains invalid values or is empty.
235
236    Note:
237        kwargs['tag'] is used to create a unique tag for each plot.
238
239    """
240    # --- sanity checks
241    me = "multi_start"
242    report_kwargs(caller=me, **kwargs)
243    if not isinstance(starts, Iterable):
244        raise TypeError("starts must be an iterable of None, Period or int")
245
246    # Convert to list to validate contents and check if empty
247    starts_list = list(starts)
248    if not starts_list:
249        raise ValueError("starts cannot be empty")
250
251    # Validate each start value
252    for i, start in enumerate(starts_list):
253        if start is not None and not isinstance(start, (Period, int)):
254            raise TypeError(
255                f"Start value at index {i} must be None, Period, or int, got {type(start).__name__}"
256            )
257
258    # --- check the function argument
259    original_tag: Final[str] = kwargs.get("tag", "")
260    first, kwargs["function"] = first_unchain(function)
261    if not kwargs["function"]:
262        del kwargs["function"]  # remove the function key if it is empty
263
264    # --- iterate over the starts
265    for i, start in enumerate(starts_list):
266        kw = kwargs.copy()  # copy to avoid modifying the original kwargs
267        this_tag = _generate_tag(original_tag, i)
268        kw["tag"] = this_tag
269        kw["plot_from"] = start  # rely on plotting function to constrain the data
270        first(data, **kw)

Create multiple plots with different starting points.

Args: data: Series | DataFrame - The data to be plotted. function: Callable | list[Callable] - desired plotting function(s). starts: Iterable[Period | int | None] - The starting points for each plot. kwargs: Any - Additional keyword arguments passed to plotting functions.

Returns: None

Raises: TypeError: If starts is not an iterable of None, Period or int. ValueError: If starts contains invalid values or is empty.

Note: kwargs['tag'] is used to create a unique tag for each plot.

def plot_then_finalise(data: ~DataT, function: Callable | list[Callable], **kwargs: Any) -> None:
152def plot_then_finalise(
153    data: DataT,
154    function: Callable | list[Callable],
155    **kwargs: Any,
156) -> None:
157    """Chain a plotting function with the finalise_plot() function.
158
159    Args:
160        data: Series | DataFrame - The data to be plotted.
161        function: Callable | list[Callable] - the desired plotting function(s).
162        kwargs: Any - Additional keyword arguments.
163
164    Returns None.
165
166    """
167    # --- checks
168    me = "plot_then_finalise"
169    report_kwargs(caller=me, **kwargs)
170    # validate once we have established the first function
171
172    # data is not checked here, assume it is checked by the called
173    # plot function.
174
175    first, kwargs["function"] = first_unchain(function)
176    if not kwargs["function"]:
177        del kwargs["function"]  # remove the function key if it is empty
178
179    # Check that forbidden functions are not called first
180    if hasattr(first, "__name__") and first.__name__ in FORBIDDEN_FIRST_FUNCTIONS:
181        raise ValueError(
182            f"Function '{first.__name__}' should not be called by {me}. Call it before calling {me}."
183        )
184
185    if first in EXPECTED_CALLABLES:
186        expected = EXPECTED_CALLABLES[first]
187        plot_kwargs = limit_kwargs(expected, **kwargs)
188    else:
189        # this is an unexpected Callable, so we will give it a try
190        print(f"Unknown proposed function: {first}; nonetheless, will give it a try.")
191        expected = BaseKwargs
192        plot_kwargs = kwargs.copy()
193
194    # --- validate the original kwargs (could not do before now)
195    kw_types = (
196        # combine the expected kwargs types with the finalise kwargs types
197        dict(cast("dict[str, Any]", expected.__annotations__))
198        | dict(cast("dict[str, Any]", FinaliseKwargs.__annotations__))
199    )
200    validate_kwargs(schema=kw_types, caller=me, **kwargs)
201
202    # --- call the first function with the data and selected plot kwargs
203    axes = first(data, **plot_kwargs)
204
205    # --- prepare finalise kwargs (remove overlapping arguments)
206    fp_kwargs = limit_kwargs(FinaliseKwargs, **kwargs)
207    # Remove any arguments that were already used in the plot function
208    used_plot_args = set(plot_kwargs.keys())
209    fp_kwargs = {k: v for k, v in fp_kwargs.items() if k not in used_plot_args}
210
211    # --- finalise the plot
212    finalise_plot(axes, **fp_kwargs)

Chain a plotting function with the finalise_plot() function.

Args: data: Series | DataFrame - The data to be plotted. function: Callable | list[Callable] - the desired plotting function(s). kwargs: Any - Additional keyword arguments.

Returns None.

def postcovid_plot( data: ~DataT, **kwargs: Unpack[PostcovidKwargs]) -> matplotlib.axes._axes.Axes:
132def postcovid_plot(data: DataT, **kwargs: Unpack[PostcovidKwargs]) -> Axes:
133    """Plot a series with a PeriodIndex, including a post-COVID projection.
134
135    Args:
136        data: Series - the series to be plotted.
137        kwargs: PostcovidKwargs - plotting arguments.
138
139    Raises:
140        TypeError if series is not a pandas Series
141        TypeError if series does not have a PeriodIndex
142        ValueError if series does not have a D, M or Q frequency
143        ValueError if regression start is after regression end
144
145    """
146
147    # --- failure
148    def failure() -> Axes:
149        print("postcovid_plot(): plotting the raw data only.")
150        remove: list[Literal["plot_from", "start_r", "end_r"]] = ["plot_from", "start_r", "end_r"]
151        for key in remove:
152            kwargs.pop(key, None)
153        return line_plot(
154            data,
155            **cast("LineKwargs", kwargs),
156        )
157
158    # --- check the kwargs
159    report_kwargs(caller=ME, **kwargs)
160    validate_kwargs(schema=PostcovidKwargs, caller=ME, **kwargs)
161
162    # --- check the data
163    data = check_clean_timeseries(data, ME)
164    if not isinstance(data, Series):
165        raise TypeError("The series argument must be a pandas Series")
166
167    # --- rely on line_plot() to validate kwargs, but remove any that are not relevant
168    if "plot_from" in kwargs:
169        print("Warning: the 'plot_from' argument is ignored in postcovid_plot().")
170        kwargs.pop("plot_from", None)
171
172    # --- set the regression period
173    start_r, end_r, robust = regression_period(data, **kwargs)
174    kwargs.pop("start_r", None)  # remove from kwargs to avoid confusion
175    kwargs.pop("end_r", None)  # remove from kwargs to avoid confusion
176    if not robust:
177        return failure()
178
179    # --- combine data and projection
180    if start_r < data.dropna().index.min():
181        print(f"Caution: Regression start period pre-dates the series index: {start_r=}")
182    recent_data = data[data.index >= start_r].copy()
183    recent_data.name = "Series"
184    projection_data = get_projection(recent_data, end_r)
185    if projection_data.empty:
186        return failure()
187    projection_data.name = "Pre-COVID projection"
188
189    # --- Create DataFrame with proper column alignment
190    combined_data = DataFrame(
191        {
192            projection_data.name: projection_data,
193            recent_data.name: recent_data,
194        }
195    )
196
197    # --- activate plot settings
198    kwargs["width"] = kwargs.pop(
199        "width",
200        (get_setting("line_normal"), get_setting("line_wide")),
201    )  # series line is thicker than projection
202    kwargs["style"] = kwargs.pop("style", ("--", "-"))  # dashed regression line
203    kwargs["label_series"] = kwargs.pop("label_series", True)
204    kwargs["annotate"] = kwargs.pop("annotate", (False, True))  # annotate series only
205    kwargs["color"] = kwargs.pop("color", ("darkblue", "#dd0000"))
206    kwargs["dropna"] = kwargs.pop("dropna", False)  # drop NaN values
207
208    return line_plot(
209        combined_data,
210        **cast("LineKwargs", kwargs),
211    )

Plot a series with a PeriodIndex, including a post-COVID projection.

Args: data: Series - the series to be plotted. kwargs: PostcovidKwargs - plotting arguments.

Raises: TypeError if series is not a pandas Series TypeError if series does not have a PeriodIndex ValueError if series does not have a D, M or Q frequency ValueError if regression start is after regression end

def postcovid_plot_finalise(data: ~DataT, **kwargs: Unpack[mgplot.finalisers.PCFKwargs]) -> None:
211def postcovid_plot_finalise(
212    data: DataT,
213    **kwargs: Unpack[PCFKwargs],
214) -> None:
215    """Call postcovid_plot() and finalise_plot().
216
217    Args:
218        data: The data to be plotted.
219        kwargs: Combined postcovid plot and finalise plot keyword arguments.
220
221    """
222    validate_kwargs(schema=PCFKwargs, caller="postcovid_plot_finalise", **kwargs)
223    kwargs = impose_legend(kwargs=kwargs, force=True)
224    plot_then_finalise(data, function=postcovid_plot, **kwargs)

Call postcovid_plot() and finalise_plot().

Args: data: The data to be plotted. kwargs: Combined postcovid plot and finalise plot keyword arguments.

def revision_plot( data: ~DataT, **kwargs: Unpack[LineKwargs]) -> matplotlib.axes._axes.Axes:
21def revision_plot(data: DataT, **kwargs: Unpack[LineKwargs]) -> Axes:
22    """Plot the revisions to ABS data.
23
24    Args:
25        data: DataFrame - the data to plot, with a column for each data revision.
26               Must have at least 2 columns to show meaningful revision comparisons.
27        kwargs: LineKwargs - additional keyword arguments for the line_plot function.
28
29    Returns:
30        Axes: A matplotlib Axes object containing the revision plot.
31
32    Raises:
33        TypeError: If data is not a DataFrame.
34        ValueError: If DataFrame has fewer than 2 columns for revision comparison.
35
36    """
37    # --- check the kwargs and data
38    report_kwargs(caller=ME, **kwargs)
39    validate_kwargs(schema=LineKwargs, caller=ME, **kwargs)
40    data = check_clean_timeseries(data, ME)
41
42    # --- additional checks
43    if not isinstance(data, DataFrame):
44        print(f"{ME}() requires a DataFrame with columns for each revision, not a Series or any other type.")
45        raise TypeError(f"{ME}() requires a DataFrame, got {type(data).__name__}")
46
47    if data.shape[1] < MIN_REVISION_COLUMNS:
48        raise ValueError(
49            f"{ME}() requires at least {MIN_REVISION_COLUMNS} columns for revision comparison, "
50            f"but got {data.shape[1]} columns"
51        )
52
53    # --- set defaults for revision visualization
54    kwargs["plot_from"] = kwargs.get("plot_from", DEFAULT_PLOT_FROM)
55    kwargs["annotate"] = kwargs.get("annotate", True)
56    kwargs["annotate_color"] = kwargs.get("annotate_color", "black")
57    kwargs["rounding"] = kwargs.get("rounding", 3)
58
59    # --- plot
60    return line_plot(data, **kwargs)

Plot the revisions to ABS data.

Args: data: DataFrame - the data to plot, with a column for each data revision. Must have at least 2 columns to show meaningful revision comparisons. kwargs: LineKwargs - additional keyword arguments for the line_plot function.

Returns: Axes: A matplotlib Axes object containing the revision plot.

Raises: TypeError: If data is not a DataFrame. ValueError: If DataFrame has fewer than 2 columns for revision comparison.

def revision_plot_finalise(data: ~DataT, **kwargs: Unpack[mgplot.finalisers.RevPFKwargs]) -> None:
227def revision_plot_finalise(
228    data: DataT,
229    **kwargs: Unpack[RevPFKwargs],
230) -> None:
231    """Call revision_plot() and finalise_plot().
232
233    Args:
234        data: The revision data to be plotted.
235        kwargs: Combined revision plot and finalise plot keyword arguments.
236
237    """
238    validate_kwargs(schema=RevPFKwargs, caller="revision_plot_finalise", **kwargs)
239    kwargs = impose_legend(kwargs=kwargs, force=True)
240    plot_then_finalise(data=data, function=revision_plot, **kwargs)

Call revision_plot() and finalise_plot().

Args: data: The revision data to be plotted. kwargs: Combined revision plot and finalise plot keyword arguments.

def run_plot( data: ~DataT, **kwargs: Unpack[RunKwargs]) -> matplotlib.axes._axes.Axes:
161def run_plot(data: DataT, **kwargs: Unpack[RunKwargs]) -> Axes:
162    """Plot a series of percentage rates, highlighting the increasing runs.
163
164    Arguments:
165        data: Series - ordered pandas Series of percentages, with PeriodIndex.
166        kwargs: RunKwargs - keyword arguments for the run_plot function.
167
168    Return:
169     - matplotlib Axes object
170
171    """
172    # --- validate inputs
173    report_kwargs(caller=ME, **kwargs)
174    validate_kwargs(schema=RunKwargs, caller=ME, **kwargs)
175
176    series = check_clean_timeseries(data, ME)
177    if not isinstance(series, Series):
178        raise TypeError("series must be a pandas Series for run_plot()")
179    series, kwargs_d = constrain_data(series, **kwargs)
180
181    # --- configure defaults and validate
182    direction = kwargs_d.get("direction", "both")
183    _configure_defaults(kwargs_d, direction)
184
185    threshold = kwargs_d["threshold"]
186    if threshold <= 0:
187        raise ValueError("Threshold must be positive")
188
189    # --- handle PeriodIndex conversion
190    saved_pi = map_periodindex(series)
191    if saved_pi is not None:
192        series = saved_pi[0]
193
194    # --- plot the line
195    lp_kwargs = limit_kwargs(LineKwargs, **kwargs_d)
196    axes = line_plot(series, **lp_kwargs)
197
198    # --- plot runs based on direction
199    run_label = kwargs_d.pop("highlight_label", None)
200    up_label, down_label = _resolve_labels(run_label, direction)
201
202    if direction in ("up", "both"):
203        _plot_runs(axes, series, run_label=up_label, up=True, **kwargs_d)
204    if direction in ("down", "both"):
205        _plot_runs(axes, series, run_label=down_label, up=False, **kwargs_d)
206
207    if direction not in ("up", "down", "both"):
208        raise ValueError(f"Invalid direction: {direction}. Expected 'up', 'down', or 'both'.")
209
210    # --- set axis labels
211    if saved_pi is not None:
212        set_labels(axes, saved_pi[1], kwargs.get("max_ticks", get_setting("max_ticks")))
213
214    return axes

Plot a series of percentage rates, highlighting the increasing runs.

Arguments: data: Series - ordered pandas Series of percentages, with PeriodIndex. kwargs: RunKwargs - keyword arguments for the run_plot function.

Return:

  • matplotlib Axes object
def run_plot_finalise(data: ~DataT, **kwargs: Unpack[mgplot.finalisers.RunPFKwargs]) -> None:
243def run_plot_finalise(
244    data: DataT,
245    **kwargs: Unpack[RunPFKwargs],
246) -> None:
247    """Call run_plot() and finalise_plot().
248
249    Args:
250        data: The data to be plotted.
251        kwargs: Combined run plot and finalise plot keyword arguments.
252
253    """
254    validate_kwargs(schema=RunPFKwargs, caller="run_plot_finalise", **kwargs)
255    kwargs = impose_legend(kwargs=kwargs, force="highlight_label" in kwargs)
256    plot_then_finalise(data=data, function=run_plot, **kwargs)

Call run_plot() and finalise_plot().

Args: data: The data to be plotted. kwargs: Combined run plot and finalise plot keyword arguments.

def seastrend_plot( data: ~DataT, **kwargs: Unpack[LineKwargs]) -> matplotlib.axes._axes.Axes:
19def seastrend_plot(data: DataT, **kwargs: Unpack[LineKwargs]) -> Axes:
20    """Produce a seasonal+trend plot.
21
22    Arguments:
23        data: DataFrame - the data to plot. Must have exactly 2 columns:
24                          Seasonal data in column 0, Trend data in column 1
25        kwargs: LineKwargs - additional keyword arguments to pass to line_plot()
26
27    Returns:
28        Axes: A matplotlib Axes object containing the seasonal+trend plot
29
30    Raises:
31        ValueError: If the DataFrame does not have exactly 2 columns
32
33    """
34    # --- check the kwargs
35    report_kwargs(caller=ME, **kwargs)
36    validate_kwargs(schema=LineKwargs, caller=ME, **kwargs)
37
38    # --- check the data
39    data = check_clean_timeseries(data, ME)
40    if data.shape[1] != REQUIRED_COLUMNS:
41        raise ValueError(
42            f"{ME}() expects a DataFrame with exactly {REQUIRED_COLUMNS} columns "
43            f"(seasonal and trend), but got {data.shape[1]} columns."
44        )
45
46    # --- set defaults for seasonal+trend visualization
47    kwargs["color"] = kwargs.get("color", get_color_list(REQUIRED_COLUMNS))
48    kwargs["width"] = kwargs.get("width", [get_setting("line_normal"), get_setting("line_wide")])
49    kwargs["style"] = kwargs.get("style", ["-", "-"])
50    kwargs["annotate"] = kwargs.get("annotate", [True, False])  # annotate seasonal, not trend
51    kwargs["rounding"] = kwargs.get("rounding", True)
52    kwargs["dropna"] = kwargs.get("dropna", False)  # series breaks are common in seas-trend data
53
54    return line_plot(
55        data,
56        **kwargs,
57    )

Produce a seasonal+trend plot.

Arguments: data: DataFrame - the data to plot. Must have exactly 2 columns: Seasonal data in column 0, Trend data in column 1 kwargs: LineKwargs - additional keyword arguments to pass to line_plot()

Returns: Axes: A matplotlib Axes object containing the seasonal+trend plot

Raises: ValueError: If the DataFrame does not have exactly 2 columns

def seastrend_plot_finalise(data: ~DataT, **kwargs: Unpack[mgplot.finalisers.SFKwargs]) -> None:
259def seastrend_plot_finalise(
260    data: DataT,
261    **kwargs: Unpack[SFKwargs],
262) -> None:
263    """Call seastrend_plot() and finalise_plot().
264
265    Args:
266        data: The seasonal and trend data to be plotted.
267        kwargs: Combined seastrend plot and finalise plot keyword arguments.
268
269    """
270    validate_kwargs(schema=SFKwargs, caller="seastrend_plot_finalise", **kwargs)
271    kwargs = impose_legend(kwargs=kwargs, force=True)
272    plot_then_finalise(data, function=seastrend_plot, **kwargs)

Call seastrend_plot() and finalise_plot().

Args: data: The seasonal and trend data to be plotted. kwargs: Combined seastrend plot and finalise plot keyword arguments.

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

def series_growth_plot_finalise(data: ~DataT, **kwargs: Unpack[mgplot.finalisers.SGFPKwargs]) -> None:
275def series_growth_plot_finalise(data: DataT, **kwargs: Unpack[SGFPKwargs]) -> None:
276    """Call series_growth_plot() and finalise_plot().
277
278    Args:
279        data: The series data to calculate and plot growth for.
280        kwargs: Combined series growth plot and finalise plot keyword arguments.
281
282    """
283    validate_kwargs(schema=SGFPKwargs, caller="series_growth_plot_finalise", **kwargs)
284    kwargs = impose_legend(kwargs=kwargs, force=True)
285    plot_then_finalise(data=data, function=series_growth_plot, **kwargs)

Call series_growth_plot() and finalise_plot().

Args: data: The series data to calculate and plot growth for. kwargs: Combined series growth plot and finalise plot keyword arguments.

def set_chart_dir(chart_dir: str) -> None:
156def set_chart_dir(chart_dir: str) -> None:
157    """Set a global chart directory for finalise_plot().
158
159    Args:
160        chart_dir: str - the directory to set as the chart directory
161
162    Note: Path.mkdir() may raise an exception if a directory cannot be created.
163
164    Note: This is a wrapper for set_setting() to set the chart_dir setting, and
165    create the directory if it does not exist.
166
167    """
168    if not chart_dir or chart_dir.isspace():
169        chart_dir = DEFAULT_CHART_DIR  # avoid empty/whitespace strings
170    Path(chart_dir).mkdir(parents=True, exist_ok=True)
171    set_setting("chart_dir", chart_dir)

Set a global chart directory for finalise_plot().

Args: chart_dir: str - the directory to set as the chart directory

Note: Path.mkdir() may raise an exception if a directory cannot be created.

Note: This is a wrapper for set_setting() to set the chart_dir setting, and create the directory if it does not exist.

def set_setting(setting: str, value: Any) -> None:
120def set_setting(setting: str, value: Any) -> None:
121    """Set a setting in the global settings.
122
123    Args:
124        setting: str - name of the setting to set (see get_setting())
125        value: Any - the value to set the setting to
126
127    Raises:
128        KeyError: if the setting is not found
129        ValueError: if the value is invalid for the setting
130
131    """
132    if setting not in get_fields():
133        raise KeyError(f"Setting '{setting}' not found in mgplot_defaults.")
134
135    # Basic validation for some settings
136    if setting == "chart_dir" and not isinstance(value, str):
137        raise ValueError(f"chart_dir must be a string, got {type(value)}")
138    if setting == "dpi" and (not isinstance(value, int) or value <= 0):
139        raise ValueError(f"dpi must be a positive integer, got {value}")
140    if setting == "max_ticks" and (not isinstance(value, int) or value <= 0):
141        raise ValueError(f"max_ticks must be a positive integer, got {value}")
142
143    setattr(mgplot_defaults, setting, value)

Set a setting in the global settings.

Args: setting: str - name of the setting to set (see get_setting()) value: Any - the value to set the setting to

Raises: KeyError: if the setting is not found ValueError: if the value is invalid for the setting

state_abbrs = ('NSW', 'Vic', 'Qld', 'SA', 'WA', 'Tas', 'NT', 'ACT')
state_names = ('New South Wales', 'Victoria', 'Queensland', 'South Australia', 'Western Australia', 'Tasmania', 'Northern Territory', 'Australian Capital Territory')
def summary_plot( data: ~DataT, **kwargs: Unpack[SummaryKwargs]) -> matplotlib.axes._axes.Axes:
294def summary_plot(data: DataT, **kwargs: Unpack[SummaryKwargs]) -> Axes:
295    """Plot a summary of historical data for a given DataFrame.
296
297    Args:
298        data: DataFrame containing the summary data. The column names are
299              used as labels for the plot.
300        kwargs: Additional arguments for the plot, including middle (float),
301               plot_type (str), verbose (bool), and standard plotting options.
302
303    Returns:
304        Axes: A matplotlib Axes object containing the summary plot.
305
306    Raises:
307        TypeError: If data is not a DataFrame.
308
309    """
310    # --- check the kwargs
311    report_kwargs(caller=ME, **kwargs)
312    validate_kwargs(schema=SummaryKwargs, caller=ME, **kwargs)
313
314    # --- check the data
315    data = check_clean_timeseries(data, ME)
316    if not isinstance(data, DataFrame):
317        raise TypeError("data must be a pandas DataFrame for summary_plot()")
318
319    # --- legend
320    kwargs["legend"] = kwargs.get(
321        "legend",
322        {
323            # put the legend below the x-axis label
324            "loc": "upper center",
325            "fontsize": "xx-small",
326            "bbox_to_anchor": (0.5, -0.125),
327            "ncol": 4,
328        },
329    )
330
331    # --- and plot it ...
332    ax, plot_type = plot_the_data(data, **kwargs)
333    label_x_axis(
334        kwargs.get("plot_from", DEFAULT_PLOT_FROM),
335        label=kwargs.get("xlabel", ""),
336        plot_type=plot_type,
337        ax=ax,
338        df=data,
339    )
340    mark_reference_lines(plot_type, ax)
341
342    return ax

Plot a summary of historical data for a given DataFrame.

Args: data: DataFrame containing the summary data. The column names are used as labels for the plot. kwargs: Additional arguments for the plot, including middle (float), plot_type (str), verbose (bool), and standard plotting options.

Returns: Axes: A matplotlib Axes object containing the summary plot.

Raises: TypeError: If data is not a DataFrame.

def summary_plot_finalise(data: ~DataT, **kwargs: Unpack[mgplot.finalisers.SumPFKwargs]) -> None:
288def summary_plot_finalise(
289    data: DataT,
290    **kwargs: Unpack[SumPFKwargs],
291) -> None:
292    """Call summary_plot() and finalise_plot().
293
294    This is more complex than most of the above convenience methods as it
295    creates multiple plots (one for each plot type).
296
297    Args:
298        data: DataFrame containing the summary data. The index must be a PeriodIndex.
299        kwargs: Combined summary plot and finalise plot keyword arguments.
300
301    Raises:
302        TypeError: If data is not a DataFrame with a PeriodIndex.
303        IndexError: If DataFrame is empty.
304
305    """
306    # --- validate data type and structure
307    if not isinstance(data, DataFrame) or not isinstance(data.index, PeriodIndex):
308        raise TypeError("Data must be a DataFrame with a PeriodIndex.")
309
310    if data.empty or len(data.index) == 0:
311        raise ValueError("DataFrame cannot be empty")
312
313    validate_kwargs(schema=SumPFKwargs, caller="summary_plot_finalise", **kwargs)
314
315    # --- set default title with bounds checking
316    kwargs["title"] = kwargs.get("title", f"Summary at {label_period(data.index[-1])}")
317    kwargs["preserve_lims"] = kwargs.get("preserve_lims", True)
318
319    # --- handle plot_from parameter with bounds checking
320    start: int | Period | None = kwargs.get("plot_from", 0)
321    if start is None:
322        start = data.index[0]
323    elif isinstance(start, int):
324        if abs(start) >= len(data.index):
325            raise IndexError(
326                f"plot_from index {start} out of range for DataFrame with {len(data.index)} rows"
327            )
328        start = data.index[start]
329
330    kwargs["plot_from"] = start
331    if not isinstance(start, Period):
332        raise TypeError("plot_from must be a Period or convertible to one")
333
334    # --- create plots for each plot type
335    pre_tag: str = kwargs.get("pre_tag", "")
336    for plot_type in SUMMARY_PLOT_TYPES:
337        plot_kwargs = kwargs.copy()  # Avoid modifying original kwargs
338        plot_kwargs["plot_type"] = plot_type
339        plot_kwargs["pre_tag"] = pre_tag + plot_type
340
341        plot_then_finalise(
342            data,
343            function=summary_plot,
344            **plot_kwargs,
345        )

Call summary_plot() and finalise_plot().

This is more complex than most of the above convenience methods as it creates multiple plots (one for each plot type).

Args: data: DataFrame containing the summary data. The index must be a PeriodIndex. kwargs: Combined summary plot and finalise plot keyword arguments.

Raises: TypeError: If data is not a DataFrame with a PeriodIndex. IndexError: If DataFrame is empty.