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 chart_subdir, 52 clear_chart_dir, 53 get_setting, 54 set_chart_dir, 55 set_setting, 56) 57from mgplot.summary_plot import SummaryKwargs, summary_plot 58 59# --- version and author 60try: 61 __version__ = importlib.metadata.version(__name__) 62except importlib.metadata.PackageNotFoundError: 63 __version__ = "0.0.0" # Fallback for development mode 64__author__ = "Bryan Palmer" 65 66 67# --- public API 68__all__ = ( 69 "BarKwargs", 70 "FillBetweenKwargs", 71 "FinaliseKwargs", 72 "GrowthKwargs", 73 "LineKwargs", 74 "PostcovidKwargs", 75 "RunKwargs", 76 "SeriesGrowthKwargs", 77 "SummaryKwargs", 78 "__author__", 79 "__version__", 80 "abbreviate_state", 81 "bar_plot", 82 "bar_plot_finalise", 83 "calc_growth", 84 "chart_subdir", 85 "clear_chart_dir", 86 "colorise_list", 87 "contrast", 88 "fill_between_plot", 89 "fill_between_plot_finalise", 90 "finalise_plot", 91 "get_color", 92 "get_party_palette", 93 "get_setting", 94 "growth_plot", 95 "growth_plot_finalise", 96 "line_plot", 97 "line_plot_finalise", 98 "multi_column", 99 "multi_start", 100 "plot_then_finalise", 101 "postcovid_plot", 102 "postcovid_plot_finalise", 103 "revision_plot", 104 "revision_plot_finalise", 105 "run_plot", 106 "run_plot", 107 "run_plot_finalise", 108 "seastrend_plot", 109 "seastrend_plot_finalise", 110 "series_growth_plot", 111 "series_growth_plot_finalise", 112 "set_chart_dir", 113 "set_setting", 114 "state_abbrs", 115 "state_names", 116 "summary_plot", 117 "summary_plot_finalise", 118)
40class BarKwargs(BaseKwargs): 41 """Keyword arguments for the bar_plot function.""" 42 43 # --- options for the entire bar plot 44 ax: NotRequired[Axes | None] 45 stacked: NotRequired[bool] 46 horizontal: NotRequired[bool] 47 max_ticks: NotRequired[int] 48 tick_relabel: NotRequired[Callable[[str], str]] 49 plot_from: NotRequired[int | Period] 50 label_rotation: NotRequired[int | float] 51 # --- options for each bar ... 52 color: NotRequired[str | Sequence[str]] 53 label_series: NotRequired[bool | Sequence[bool]] 54 width: NotRequired[float | int | Sequence[float | int]] 55 zorder: NotRequired[int | float | Sequence[int | float]] 56 # --- options for bar annotations 57 annotate: NotRequired[bool] 58 fontsize: NotRequired[int | float | str] 59 fontname: NotRequired[str] 60 rounding: NotRequired[int] 61 rotation: NotRequired[int | float] 62 annotate_color: NotRequired[str] 63 above: NotRequired[bool]
Keyword arguments for the bar_plot function.
22class FillBetweenKwargs(BaseKwargs): 23 """Keyword arguments for the fill_between_plot function.""" 24 25 ax: NotRequired[Axes | None] 26 color: NotRequired[str] 27 alpha: NotRequired[float] 28 label: NotRequired[str | None] 29 linewidth: NotRequired[float] 30 edgecolor: NotRequired[str | None] 31 zorder: NotRequired[int | float] 32 plot_from: NotRequired[int | None] 33 max_ticks: NotRequired[int] 34 tick_relabel: NotRequired[Callable[[str], str]]
Keyword arguments for the fill_between_plot function.
34class FinaliseKwargs(BaseKwargs): 35 """Keyword arguments for the finalise_plot function.""" 36 37 # --- value options 38 suptitle: NotRequired[str | None] 39 title: NotRequired[str | None] 40 xlabel: NotRequired[str | None] 41 ylabel: NotRequired[str | None] 42 xlim: NotRequired[tuple[float, float] | None] 43 ylim: NotRequired[tuple[float, float] | None] 44 xticks: NotRequired[list[float] | None] 45 yticks: NotRequired[list[float] | None] 46 xscale: NotRequired[str | None] 47 yscale: NotRequired[str | None] 48 # --- splat options 49 legend: NotRequired[bool | dict[str, Any] | None] 50 axhspan: NotRequired[dict[str, Any] | Sequence[dict[str, Any]] | None] 51 axvspan: NotRequired[dict[str, Any] | Sequence[dict[str, Any]] | None] 52 axhline: NotRequired[dict[str, Any] | Sequence[dict[str, Any]] | None] 53 axvline: NotRequired[dict[str, Any] | Sequence[dict[str, Any]] | None] 54 # --- options for annotations 55 lfooter: NotRequired[str] 56 rfooter: NotRequired[str] 57 lheader: NotRequired[str] 58 rheader: NotRequired[str] 59 # --- file/save options 60 pre_tag: NotRequired[str] 61 tag: NotRequired[str] 62 filename: NotRequired[str] 63 chart_dir: NotRequired[str] 64 file_type: NotRequired[str] 65 dpi: NotRequired[int] 66 figsize: NotRequired[tuple[float, float]] 67 show: NotRequired[bool] 68 # --- other options 69 preserve_lims: NotRequired[bool] 70 remove_legend: NotRequired[bool] 71 zero_y: NotRequired[bool] 72 y0: NotRequired[bool] 73 x0: NotRequired[bool] 74 axisbelow: NotRequired[bool] 75 dont_save: NotRequired[bool] 76 dont_close: NotRequired[bool] 77 axes_only: NotRequired[bool]
Keyword arguments for the finalise_plot function.
39class GrowthKwargs(BaseKwargs): 40 """Keyword arguments for the growth_plot function.""" 41 42 # --- common options 43 ax: NotRequired[Axes | None] 44 plot_from: NotRequired[int | Period] 45 label_series: NotRequired[bool] 46 max_ticks: NotRequired[int] 47 tick_relabel: NotRequired[Callable[[str], str]] 48 # --- options passed to the line plot 49 line_width: NotRequired[float | int] 50 line_color: NotRequired[str] 51 line_style: NotRequired[str] 52 annotate_line: NotRequired[bool] 53 line_rounding: NotRequired[bool | int] 54 line_fontsize: NotRequired[str | int | float] 55 line_fontname: NotRequired[str] 56 line_anno_color: NotRequired[str] 57 # --- options passed to the bar plot 58 annotate_bars: NotRequired[bool] 59 bar_fontsize: NotRequired[str | int | float] 60 bar_fontname: NotRequired[str] 61 bar_rounding: NotRequired[int] 62 bar_width: NotRequired[float] 63 bar_color: NotRequired[str] 64 bar_anno_color: NotRequired[str] 65 bar_rotation: NotRequired[int | float]
Keyword arguments for the growth_plot function.
34class LineKwargs(BaseKwargs): 35 """Keyword arguments for the line_plot function.""" 36 37 # --- options for the entire line plot 38 ax: NotRequired[Axes | None] 39 style: NotRequired[str | Sequence[str]] 40 width: NotRequired[float | int | Sequence[float | int]] 41 color: NotRequired[str | Sequence[str]] 42 alpha: NotRequired[float | Sequence[float]] 43 drawstyle: NotRequired[str | Sequence[str] | None] 44 marker: NotRequired[str | Sequence[str] | None] 45 markersize: NotRequired[float | Sequence[float] | int | None] 46 zorder: NotRequired[int | float | Sequence[int | float]] 47 dropna: NotRequired[bool | Sequence[bool]] 48 annotate: NotRequired[bool | Sequence[bool]] 49 rounding: NotRequired[Sequence[int | bool] | int | bool | None] 50 fontsize: NotRequired[Sequence[str | int | float] | str | int | float] 51 fontname: NotRequired[str | Sequence[str]] 52 rotation: NotRequired[Sequence[int | float] | int | float] 53 annotate_color: NotRequired[str | Sequence[str] | bool | Sequence[bool] | None] 54 near_end: NotRequired[float] 55 plot_from: NotRequired[int | Period | None] 56 label_series: NotRequired[bool | Sequence[bool] | None] 57 max_ticks: NotRequired[int] 58 tick_relabel: NotRequired[Callable[[str], str]]
Keyword arguments for the line_plot function.
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.
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.
68class SeriesGrowthKwargs(GrowthKwargs): 69 """Keyword arguments for the series_growth_plot function.""" 70 71 ylabel: NotRequired[str | None]
Keyword arguments for the series_growth_plot function.
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.
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.
252def bar_plot(data: DataT, **kwargs: Unpack[BarKwargs]) -> Axes: 253 """Create a bar plot from the given data. 254 255 Each column in the DataFrame will be stacked on top of each other, 256 with positive values above zero and negative values below zero. 257 258 Args: 259 data: Series | DataFrame - The data to plot. Can be a DataFrame or a Series. 260 **kwargs: BarKwargs - Additional keyword arguments for customization. 261 (see BarKwargs for details) 262 263 Note: This function does not assume all data is timeseries with a PeriodIndex. 264 265 Returns: 266 axes: Axes - The axes for the plot. 267 268 """ 269 # --- check the kwargs 270 report_kwargs(caller=ME, **kwargs) 271 validate_kwargs(schema=BarKwargs, caller=ME, **kwargs) 272 273 # --- get the data 274 # no call to check_clean_timeseries here, as bar plots are not 275 # necessarily timeseries data. If the data is a Series, it will be 276 # converted to a DataFrame with a single column. 277 df = DataFrame(data) # really we are only plotting DataFrames 278 df, kwargs_d = constrain_data(df, **kwargs) 279 item_count = len(df.columns) 280 281 # --- deal with string indices 282 saved_strings = map_stringindex(df) 283 if saved_strings is not None: 284 df = saved_strings[0] 285 286 # --- deal with complete PeriodIndex indices 287 saved_pi = map_periodindex(df) 288 if saved_pi is not None: 289 df = saved_pi[0] # extract the reindexed DataFrame from the PeriodIndex 290 291 # --- set up the default arguments 292 chart_defaults: dict[str, bool | int] = { 293 "stacked": False, 294 "horizontal": False, 295 "max_ticks": DEFAULT_MAX_TICKS, 296 "label_series": item_count > 1, 297 "label_rotation": 0, 298 } 299 chart_args = {k: kwargs_d.get(k, v) for k, v in chart_defaults.items()} 300 301 # --- horizontal bars are for categorical data, not PeriodIndex timeseries 302 horizontal = bool(chart_args["horizontal"]) 303 if horizontal and saved_pi is not None: 304 print(f"Warning: horizontal=True is not supported with a PeriodIndex in {ME}(); plotting vertical.") 305 horizontal = False 306 307 # --- single series + one colour per bar => per-bar colours 308 user_color = kwargs_d.get("color") 309 if ( 310 item_count == 1 311 and isinstance(user_color, Sequence) 312 and not isinstance(user_color, str) 313 and len(user_color) == len(df) 314 ): 315 kwargs_d["color"] = [list(user_color)] # one series whose colour is a per-bar list 316 317 bar_defaults = { 318 "color": get_color_list(item_count), 319 "width": get_setting("bar_width"), 320 "label_series": item_count > 1, 321 "zorder": None, 322 } 323 above = kwargs_d.get("above", False) 324 anno_args: AnnoKwargs = { 325 "annotate": kwargs_d.get("annotate", False), 326 "fontsize": kwargs_d.get("fontsize", "small"), 327 "fontname": kwargs_d.get("fontname", "Helvetica"), 328 "rotation": kwargs_d.get("rotation", 0), 329 "rounding": kwargs_d.get("rounding", True), 330 "color": kwargs_d.get("annotate_color", "black" if above else "white"), 331 "above": above, 332 } 333 bar_args, remaining_kwargs = apply_defaults(item_count, bar_defaults, kwargs_d) 334 335 # --- plot the data 336 axes, remaining_kwargs = get_axes(**dict(remaining_kwargs)) 337 if chart_args["stacked"]: 338 stacked(axes, df, anno_args, horizontal=horizontal, **bar_args) 339 else: 340 grouped(axes, df, anno_args, horizontal=horizontal, **bar_args) 341 342 # --- handle index labels and rotation 343 if saved_strings is not None: 344 if horizontal: 345 axes.set_yticks(range(len(saved_strings[1]))) 346 axes.set_yticklabels(saved_strings[1], rotation=chart_args["label_rotation"]) 347 else: 348 axes.set_xticks(range(len(saved_strings[1]))) 349 axes.set_xticklabels(saved_strings[1], rotation=chart_args["label_rotation"]) 350 elif saved_pi is not None: 351 set_labels( 352 axes, 353 saved_pi[1], 354 chart_args["max_ticks"], 355 rotation=chart_args["label_rotation"], 356 tick_relabel=kwargs_d.get("tick_relabel"), 357 ) 358 359 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.
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.
113def calc_growth(series: Series) -> DataFrame: 114 """Calculate annual and periodic growth for a pandas Series. 115 116 Args: 117 series: Series - a pandas series with a date-like PeriodIndex. 118 119 Returns: 120 DataFrame: A two column DataFrame with annual and periodic growth rates. 121 122 Raises: 123 TypeError if the series is not a pandas Series. 124 TypeError if the series index is not a PeriodIndex. 125 ValueError if the series is empty. 126 ValueError if the series index does not have a frequency of Q, M, or D. 127 ValueError if the series index has duplicates. 128 129 """ 130 # --- sanity checks 131 if not isinstance(series, Series): 132 raise TypeError("The series argument must be a pandas Series") 133 if not isinstance(series.index, PeriodIndex): 134 raise TypeError("The series index must be a pandas PeriodIndex") 135 if series.empty: 136 raise ValueError("The series argument must not be empty") 137 freq = series.index.freqstr 138 if not freq or freq[0] not in FREQUENCY_TO_PERIODS: 139 raise ValueError("The series index must have a frequency of Q, M, or D") 140 if series.index.has_duplicates: 141 raise ValueError("The series index must not have duplicate values") 142 143 # --- ensure the index is complete and the date is sorted 144 complete = period_range(start=series.index.min(), end=series.index.max()) 145 series = series.reindex(complete, fill_value=nan) 146 series = series.sort_index(ascending=True) 147 148 # --- calculate annual and periodic growth 149 freq = PeriodIndex(series.index).freqstr 150 if not freq or freq[0] not in FREQUENCY_TO_PERIODS: 151 raise ValueError("The series index must have a frequency of Q, M, or D") 152 153 freq_key = freq[0] 154 ppy = FREQUENCY_TO_PERIODS[freq_key] 155 annual = series.pct_change(periods=ppy) * 100 156 periodic = series.pct_change(periods=1) * 100 157 periodic_name = FREQUENCY_TO_NAME[freq_key] + " Growth" 158 return DataFrame( 159 { 160 "Annual Growth": annual, 161 periodic_name: periodic, 162 }, 163 )
Calculate annual and periodic growth for a pandas Series.
Args: series: Series - a pandas series with a date-like PeriodIndex.
Returns: DataFrame: A two column DataFrame with annual and periodic growth rates.
Raises: TypeError if the series is not a pandas Series. TypeError if the series index is not a PeriodIndex. ValueError if the series is empty. ValueError if the series index does not have a frequency of Q, M, or D. ValueError if the series index has duplicates.
176@contextmanager 177def chart_subdir(name: str, *, clear: bool = False) -> Generator[str]: 178 """Temporarily redirect chart output to a subdirectory of the current chart_dir. 179 180 Args: 181 name: str - subdirectory name (relative to the current chart_dir). 182 clear: bool - if True, clear the subdirectory of graph-image files 183 on entry. Only set this on the first use of a subdirectory in a 184 notebook; later cells writing to the same subdirectory should 185 leave it False. 186 187 Yields: 188 str - the subdirectory path. 189 190 Note: The previous chart directory is restored on exit, even if an 191 exception is raised. 192 193 """ 194 main_dir = get_setting("chart_dir") 195 sub_dir = str(Path(main_dir) / name) 196 set_chart_dir(sub_dir) 197 if clear: 198 clear_chart_dir() 199 try: 200 yield sub_dir 201 finally: 202 set_setting("chart_dir", main_dir)
Temporarily redirect chart output to a subdirectory of the current chart_dir.
Args: name: str - subdirectory name (relative to the current chart_dir). clear: bool - if True, clear the subdirectory of graph-image files on entry. Only set this on the first use of a subdirectory in a notebook; later cells writing to the same subdirectory should leave it False.
Yields: str - the subdirectory path.
Note: The previous chart directory is restored on exit, even if an exception is raised.
148def clear_chart_dir() -> None: 149 """Remove all graph-image files from the global chart_dir.""" 150 chart_dir = get_setting("chart_dir") 151 Path(chart_dir).mkdir(parents=True, exist_ok=True) 152 for ext in IMAGE_EXTENSIONS: 153 for fs_object in Path(chart_dir).glob(f"*.{ext}"): 154 if fs_object.is_file(): 155 fs_object.unlink()
Remove all graph-image files from the global chart_dir.
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.
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.
37def fill_between_plot(data: DataFrame, **kwargs: Unpack[FillBetweenKwargs]) -> Axes: 38 """Plot a filled region between lower and upper bounds. 39 40 Args: 41 data: DataFrame - A two-column DataFrame with PeriodIndex. 42 The first column is the lower bound, the second is the upper bound. 43 kwargs: FillBetweenKwargs - keyword arguments for the plot. 44 45 Returns: 46 Axes - matplotlib Axes object. 47 48 Raises: 49 TypeError: If data is not a DataFrame. 50 ValueError: If data does not have exactly two columns. 51 52 """ 53 # --- validate inputs 54 report_kwargs(caller=ME, **kwargs) 55 validate_kwargs(schema=FillBetweenKwargs, caller=ME, **kwargs) 56 57 if not isinstance(data, DataFrame): 58 raise TypeError(f"data must be a DataFrame for {ME}()") 59 60 if len(data.columns) != REQUIRED_COLUMNS: 61 raise ValueError(f"data must have exactly two columns for {ME}(), got {len(data.columns)}") 62 63 # --- check and constrain data 64 data = check_clean_timeseries(data, ME) 65 data, kwargs_d = constrain_data(data, **kwargs) 66 67 # --- handle PeriodIndex conversion 68 saved_pi = map_periodindex(data) 69 if saved_pi is not None: 70 data = saved_pi[0] 71 72 # --- get axes 73 axes, kwargs_d = get_axes(**kwargs_d) 74 75 if data.empty or data.isna().all().all(): 76 print(f"Warning: No data to plot in {ME}().") 77 return axes 78 79 # --- extract bounds 80 lower = data.iloc[:, 0] 81 upper = data.iloc[:, 1] 82 83 # --- extract plot arguments 84 color = kwargs_d.get("color", DEFAULT_COLOR) 85 alpha = kwargs_d.get("alpha", DEFAULT_ALPHA) 86 label = kwargs_d.get("label", None) 87 linewidth = kwargs_d.get("linewidth", 0) 88 edgecolor = kwargs_d.get("edgecolor", None) 89 zorder = kwargs_d.get("zorder", None) 90 91 # --- plot 92 axes.fill_between( 93 data.index, 94 lower, 95 upper, 96 color=color, 97 alpha=alpha, 98 label=label, 99 linewidth=linewidth, 100 edgecolor=edgecolor, 101 zorder=zorder, 102 ) 103 104 # --- set axis labels 105 if saved_pi is not None: 106 set_labels( 107 axes, 108 saved_pi[1], 109 kwargs_d.get("max_ticks", get_setting("max_ticks")), 110 tick_relabel=kwargs_d.get("tick_relabel"), 111 ) 112 113 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.
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.
408def finalise_plot(axes: Axes, **kwargs: Unpack[FinaliseKwargs]) -> None: 409 """Finalise and save plots to the file system. 410 411 The filename for the saved plot is constructed from the global 412 chart_dir, the plot's title, any specified tag text, and the 413 file_type for the plot. 414 415 Args: 416 axes: Axes - matplotlib axes object - required 417 kwargs: FinaliseKwargs 418 419 """ 420 # --- check the kwargs 421 report_kwargs(caller=ME, **kwargs) 422 validate_kwargs(schema=FinaliseKwargs, caller=ME, **kwargs) 423 424 # --- sanity checks 425 if len(axes.get_children()) < 1: 426 print(f"Warning: {ME}() called with an empty axes, which was ignored.") 427 return 428 429 # --- remember axis-limits should we need to restore thems 430 xlim, ylim = axes.get_xlim(), axes.get_ylim() 431 432 # margins 433 axes.margins(DEFAULT_MARGIN) 434 axes.autoscale(tight=False) # This is problematic ... 435 436 apply_kwargs(axes, **kwargs) 437 438 # tight layout and save the figure 439 fig = axes.figure 440 axes_only = kwargs.get("axes_only", False) 441 if not axes_only and (suptitle := kwargs.get("suptitle")): 442 fig.suptitle(suptitle) 443 if kwargs.get("preserve_lims"): 444 # restore the original limits of the axes 445 axes.set_xlim(xlim) 446 axes.set_ylim(ylim) 447 if not axes_only and not isinstance(fig, SubFigure): 448 fig.tight_layout(pad=TIGHT_LAYOUT_PAD) 449 apply_late_kwargs(axes, **kwargs) 450 # axvspan/axvline in late_kwargs may have widened xlim beyond what 451 # set_labels() last saw; regenerate ticks from the updated view. 452 refresh_period_labels(axes) 453 # de-collide end-of-line annotations now that the layout/limits are final 454 resolve_annotation_collisions(axes) 455 legend = axes.get_legend() 456 if legend and kwargs.get("remove_legend", False): 457 legend.remove() 458 if not axes_only and not isinstance(fig, SubFigure): 459 save_to_file(fig, **kwargs) 460 461 # show the plot in Jupyter Lab 462 if not axes_only and kwargs.get("show"): 463 plt.show() 464 465 # And close - the figure this axes belongs to, not pyplot's current figure 466 if not axes_only and not kwargs.get("dont_close", False): 467 root = fig 468 while isinstance(root, SubFigure): 469 root = root.figure 470 plt.close(root)
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
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.
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.
104def get_setting(setting: str) -> Any: 105 """Get a setting from the global settings. 106 107 Args: 108 setting: str - name of the setting to get. 109 110 Raises: 111 KeyError: if the setting is not found 112 113 Returns: 114 value: Any - the value of the setting 115 116 """ 117 if setting not in get_fields(): 118 raise KeyError(f"Setting '{setting}' not found in mgplot_defaults.") 119 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
166def growth_plot( 167 data: DataT, 168 **kwargs: Unpack[GrowthKwargs], 169) -> Axes: 170 """Plot annual growth and periodic growth on the same axes. 171 172 Args: 173 data: A pandas DataFrame with two columns: 174 kwargs: GrowthKwargs 175 176 Returns: 177 axes: The matplotlib Axes object. 178 179 Raises: 180 TypeError if the data is not a 2-column DataFrame. 181 TypeError if the annual index is not a PeriodIndex. 182 ValueError if the annual and periodic series do not have the same index. 183 184 """ 185 # --- check the kwargs 186 me = "growth_plot" 187 report_kwargs(caller=me, **kwargs) 188 validate_kwargs(GrowthKwargs, caller=me, **kwargs) 189 190 # --- data checks 191 data = check_clean_timeseries(data, me) 192 if len(data.columns) != TWO_COLUMNS: 193 raise TypeError("The data argument must be a pandas DataFrame with two columns") 194 data, kwargsd = constrain_data(data, **kwargs) 195 196 # --- get the series of interest ... 197 annual = data[data.columns[0]] 198 periodic = data[data.columns[1]] 199 200 # --- series names 201 annual.name = "Annual Growth" 202 freq = PeriodIndex(periodic.index).freqstr 203 if freq and freq[0] in FREQUENCY_TO_NAME: 204 periodic.name = FREQUENCY_TO_NAME[freq[0]] + " Growth" 205 else: 206 periodic.name = "Periodic Growth" 207 208 # --- convert PeriodIndex periodic growth data to integer indexed data. 209 saved_pi = map_periodindex(periodic) 210 if saved_pi is not None: 211 periodic = saved_pi[0] # extract the reindexed DataFrame 212 213 # --- simple bar chart for the periodic growth 214 if "bar_anno_color" not in kwargsd or kwargsd["bar_anno_color"] is None: 215 kwargsd["bar_anno_color"] = "black" if kwargsd.get("above", False) else "white" 216 selected = package_kwargs(to_bar_plot, **kwargsd) 217 axes = bar_plot(periodic, **selected) 218 219 # --- and now the annual growth as a line 220 selected = package_kwargs(to_line_plot, **kwargsd) 221 line_plot(annual, ax=axes, **selected) 222 223 # --- fix the x-axis labels 224 if saved_pi is not None: 225 set_labels( 226 axes, 227 saved_pi[1], 228 kwargsd.get("max_ticks", 10), 229 tick_relabel=kwargsd.get("tick_relabel"), 230 ) 231 232 # --- and done ... 233 return axes
Plot annual growth and periodic growth on the same axes.
Args: data: A pandas DataFrame with two columns: kwargs: GrowthKwargs
Returns: axes: The matplotlib Axes object.
Raises: TypeError if the data is not a 2-column DataFrame. TypeError if the annual index is not a PeriodIndex. ValueError if the annual and periodic series do not have the same index.
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.
161def line_plot(data: DataT, **kwargs: Unpack[LineKwargs]) -> Axes: 162 """Build a single or multi-line plot. 163 164 Args: 165 data: DataFrame | Series - data to plot 166 kwargs: LineKwargs - keyword arguments for the line plot 167 168 Returns: 169 - axes: Axes - the axes object for the plot 170 171 """ 172 # --- check the kwargs 173 report_kwargs(caller=ME, **kwargs) 174 validate_kwargs(schema=LineKwargs, caller=ME, **kwargs) 175 176 # --- check the data 177 data = check_clean_timeseries(data, ME) 178 df = DataFrame(data) # we are only plotting DataFrames 179 df, kwargs_d = constrain_data(df, **kwargs) 180 181 # --- convert PeriodIndex to Integer Index 182 saved_pi = map_periodindex(df) 183 if saved_pi is not None: 184 df = saved_pi[0] 185 186 if isinstance(df.index, PeriodIndex): 187 print("Internal error: data is still a PeriodIndex - come back here and fix it") 188 189 # --- Let's plot 190 axes, kwargs_d = get_axes(**kwargs_d) # get the axes to plot on 191 if df.empty or df.isna().all().all(): 192 # Note: finalise plot should ignore an empty axes object 193 print(f"Warning: No data to plot in {ME}().") 194 return axes 195 196 # --- get the arguments for each line we will plot ... 197 item_count = len(df.columns) 198 num_data_points = len(df) 199 swce, kwargs_d = get_style_width_color_etc(item_count, num_data_points, **kwargs_d) 200 201 drawn_lines: list[Line2D] = [] # every data line - obstacles for label de-collision 202 annotations: list[tuple[Text, Line2D | None]] = [] # (label, the line it annotates) 203 for i, column in enumerate(df.columns): 204 series = df[column] 205 series = series.dropna() if "dropna" in swce and swce["dropna"][i] else series 206 if series.empty or series.isna().all(): 207 print(f"Warning: No data to plot for {column} in line_plot().") 208 continue 209 210 lines = axes.plot( 211 # using matplotlib, as pandas can set xlabel/ylabel 212 series.index, # x 213 series, # y 214 ls=swce["style"][i], 215 lw=swce["width"][i], 216 color=swce["color"][i], 217 alpha=swce["alpha"][i], 218 marker=swce["marker"][i], 219 ms=swce["markersize"][i], 220 drawstyle=swce["drawstyle"][i], 221 zorder=swce["zorder"][i], 222 label=(column if "label_series" in swce and swce["label_series"][i] else f"_{column}_"), 223 ) 224 drawn_lines.extend(lines) 225 226 if swce["annotate"][i] is None or not swce["annotate"][i]: 227 continue 228 229 color = swce["color"][i] if swce["annotate_color"][i] is True else swce["annotate_color"][i] 230 text = annotate_series( 231 series, 232 axes, 233 color=color, 234 rounding=swce["rounding"][i], 235 fontsize=swce["fontsize"][i], 236 fontname=swce["fontname"][i], 237 rotation=swce["rotation"][i], 238 ) 239 if text is not None: 240 annotations.append((text, lines[0] if lines else None)) 241 242 # --- register annotations so finalise_plot() can de-collide them 243 if annotations: 244 register_annotations( 245 axes, 246 annotations, 247 drawn_lines, 248 kwargs_d.get("near_end", DEFAULT_NEAR_END), 249 ) 250 251 # --- set the labels 252 if saved_pi is not None: 253 set_labels( 254 axes, 255 saved_pi[1], 256 kwargs_d.get("max_ticks", get_setting("max_ticks")), 257 tick_relabel=kwargs_d.get("tick_relabel"), 258 ) 259 260 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
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.
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.
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.
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.
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
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.
22def revision_plot(data: DataT, **kwargs: Unpack[LineKwargs]) -> Axes: 23 """Plot the revisions to ABS data. 24 25 Args: 26 data: DataFrame - the data to plot, with a column for each data revision. 27 Must have at least 2 columns to show meaningful revision comparisons. 28 kwargs: LineKwargs - additional keyword arguments for the line_plot function. 29 30 Returns: 31 Axes: A matplotlib Axes object containing the revision plot. 32 33 Raises: 34 TypeError: If data is not a DataFrame. 35 ValueError: If DataFrame has fewer than 2 columns for revision comparison. 36 37 """ 38 # --- check the kwargs and data 39 report_kwargs(caller=ME, **kwargs) 40 validate_kwargs(schema=LineKwargs, caller=ME, **kwargs) 41 data = check_clean_timeseries(data, ME) 42 43 # --- additional checks 44 if not isinstance(data, DataFrame): 45 print(f"{ME}() requires a DataFrame with columns for each revision, not a Series or any other type.") 46 raise TypeError(f"{ME}() requires a DataFrame, got {type(data).__name__}") 47 48 if data.shape[1] < MIN_REVISION_COLUMNS: 49 raise ValueError( 50 f"{ME}() requires at least {MIN_REVISION_COLUMNS} columns for revision comparison, " 51 f"but got {data.shape[1]} columns" 52 ) 53 54 # --- set defaults for revision visualization 55 kwargs["plot_from"] = kwargs.get("plot_from", DEFAULT_PLOT_FROM) 56 kwargs["annotate"] = kwargs.get("annotate", True) 57 kwargs["annotate_color"] = kwargs.get("annotate_color", "black") 58 kwargs["rounding"] = kwargs.get("rounding", 3) 59 kwargs["near_end"] = kwargs.get("near_end", DEFAULT_NEAR_END) 60 61 # --- plot 62 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.
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.
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( 213 axes, 214 saved_pi[1], 215 kwargs.get("max_ticks", get_setting("max_ticks")), 216 tick_relabel=kwargs.get("tick_relabel"), 217 ) 218 219 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
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.
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
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.
236def series_growth_plot( 237 data: DataT, 238 **kwargs: Unpack[SeriesGrowthKwargs], 239) -> Axes: 240 """Plot annual and periodic growth in percentage terms from a pandas Series. 241 242 Args: 243 data: A pandas Series with an appropriate PeriodIndex. 244 kwargs: SeriesGrowthKwargs 245 246 """ 247 # --- check the kwargs 248 me = "series_growth_plot" 249 report_kwargs(caller=me, **kwargs) 250 validate_kwargs(SeriesGrowthKwargs, caller=me, **kwargs) 251 252 # --- sanity checks 253 if not isinstance(data, Series): 254 raise TypeError("The data argument to series_growth_plot() must be a pandas Series") 255 256 # --- calculate growth and plot - add ylabel 257 ylabel: str | None = kwargs.pop("ylabel", None) 258 if ylabel is not None: 259 print(f"Did you intend to specify a value for the 'ylabel' in {me}()?") 260 ylabel = "Growth (%)" if ylabel is None else ylabel 261 growth = calc_growth(data) 262 ax = growth_plot(growth, **cast("GrowthKwargs", kwargs)) 263 ax.set_ylabel(ylabel) 264 return ax
Plot annual and periodic growth in percentage terms from a pandas Series.
Args: data: A pandas Series with an appropriate PeriodIndex. kwargs: SeriesGrowthKwargs
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.
158def set_chart_dir(chart_dir: str) -> None: 159 """Set a global chart directory for finalise_plot(). 160 161 Args: 162 chart_dir: str - the directory to set as the chart directory 163 164 Note: Path.mkdir() may raise an exception if a directory cannot be created. 165 166 Note: This is a wrapper for set_setting() to set the chart_dir setting, and 167 create the directory if it does not exist. 168 169 """ 170 if not chart_dir or chart_dir.isspace(): 171 chart_dir = DEFAULT_CHART_DIR # avoid empty/whitespace strings 172 Path(chart_dir).mkdir(parents=True, exist_ok=True) 173 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.
122def set_setting(setting: str, value: Any) -> None: 123 """Set a setting in the global settings. 124 125 Args: 126 setting: str - name of the setting to set (see get_setting()) 127 value: Any - the value to set the setting to 128 129 Raises: 130 KeyError: if the setting is not found 131 ValueError: if the value is invalid for the setting 132 133 """ 134 if setting not in get_fields(): 135 raise KeyError(f"Setting '{setting}' not found in mgplot_defaults.") 136 137 # Basic validation for some settings 138 if setting == "chart_dir" and not isinstance(value, str): 139 raise ValueError(f"chart_dir must be a string, got {type(value)}") 140 if setting == "dpi" and (not isinstance(value, int) or value <= 0): 141 raise ValueError(f"dpi must be a positive integer, got {value}") 142 if setting == "max_ticks" and (not isinstance(value, int) or value <= 0): 143 raise ValueError(f"max_ticks must be a positive integer, got {value}") 144 145 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
298def summary_plot(data: DataT, **kwargs: Unpack[SummaryKwargs]) -> Axes: 299 """Plot a summary of historical data for a given DataFrame. 300 301 Args: 302 data: DataFrame containing the summary data. The column names are 303 used as labels for the plot. 304 kwargs: Additional arguments for the plot, including middle (float), 305 plot_type (str), verbose (bool), and standard plotting options. 306 307 Returns: 308 Axes: A matplotlib Axes object containing the summary plot. 309 310 Raises: 311 TypeError: If data is not a DataFrame. 312 313 """ 314 # --- check the kwargs 315 report_kwargs(caller=ME, **kwargs) 316 validate_kwargs(schema=SummaryKwargs, caller=ME, **kwargs) 317 318 # --- check the data 319 data = check_clean_timeseries(data, ME) 320 if not isinstance(data, DataFrame): 321 raise TypeError("data must be a pandas DataFrame for summary_plot()") 322 323 # --- legend 324 kwargs["legend"] = kwargs.get( 325 "legend", 326 { 327 # put the legend below the x-axis label 328 "loc": "upper center", 329 "fontsize": "xx-small", 330 "bbox_to_anchor": (0.5, -0.125), 331 "ncol": 4, 332 }, 333 ) 334 335 # --- and plot it ... 336 ax, plot_type = plot_the_data(data, **kwargs) 337 label_x_axis( 338 kwargs.get("plot_from", DEFAULT_PLOT_FROM), 339 label=kwargs.get("xlabel", ""), 340 plot_type=plot_type, 341 ax=ax, 342 df=data, 343 ) 344 mark_reference_lines(plot_type, ax) 345 346 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.
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.