mgplot.run_plot

run_plot.py This code contains a function to plot and highlighted the 'runs' in a series.

  1"""
  2run_plot.py
  3This code contains a function to plot and highlighted
  4the 'runs' in a series.
  5"""
  6
  7# --- imports
  8from collections.abc import Sequence
  9from typing import Unpack, NotRequired
 10from pandas import Series, concat
 11from matplotlib.pyplot import Axes
 12from matplotlib import patheffects as pe
 13
 14from mgplot.settings import DataT, get_setting
 15from mgplot.axis_utils import map_periodindex, set_labels
 16from mgplot.line_plot import line_plot, LineKwargs
 17from mgplot.keyword_checking import (
 18    validate_kwargs,
 19    report_kwargs,
 20    limit_kwargs,
 21)
 22from mgplot.utilities import constrain_data, check_clean_timeseries
 23
 24# --- constants
 25ME = "run_plot"
 26
 27
 28class RunKwargs(LineKwargs):
 29    """Keyword arguments for the run_plot function."""
 30
 31    threshold: NotRequired[float]
 32    highlight: NotRequired[str | Sequence[str]]
 33    direction: NotRequired[str]
 34
 35
 36# --- functions
 37
 38
 39def _identify_runs(
 40    series: Series,
 41    threshold: float,
 42    up: bool,  # False means down
 43) -> tuple[Series, Series]:
 44    """Identify monotonic increasing/decreasing runs."""
 45
 46    diffed = series.diff()
 47    change_points = concat([diffed[diffed.gt(threshold)], diffed[diffed.lt(-threshold)]]).sort_index()
 48    if series.index[0] not in change_points.index:
 49        starting_point = Series([0], index=[series.index[0]])
 50        change_points = concat([change_points, starting_point]).sort_index()
 51    facing = change_points > 0 if up else change_points < 0
 52    cycles = (facing & ~facing.shift().astype(bool)).cumsum()
 53    return cycles[facing], change_points
 54
 55
 56def _plot_runs(
 57    axes: Axes,
 58    series: Series,
 59    up: bool,
 60    **kwargs,
 61) -> None:
 62    """Highlight the runs of a series."""
 63
 64    threshold = kwargs["threshold"]
 65    match kwargs.get("highlight"):  # make sure highlight is a color string
 66        case str():
 67            highlight = kwargs.get("highlight")
 68        case Sequence():
 69            highlight = kwargs["highlight"][0] if up else kwargs["highlight"][1]
 70        case _:
 71            raise ValueError(
 72                f"Invalid type for highlight: {type(kwargs.get('highlight'))}. Expected str or Sequence."
 73            )
 74
 75    # highlight the runs
 76    stretches, change_points = _identify_runs(series, threshold, up=up)
 77    for k in range(1, stretches.max() + 1):
 78        stretch = stretches[stretches == k]
 79        axes.axvspan(
 80            stretch.index.min(),
 81            stretch.index.max(),
 82            color=highlight,
 83            zorder=-1,
 84        )
 85        space_above = series.max() - series[stretch.index].max()
 86        space_below = series[stretch.index].min() - series.min()
 87        y_pos, vert_align = (series.max(), "top") if space_above > space_below else (series.min(), "bottom")
 88        text = axes.text(
 89            x=stretch.index.min(),
 90            y=y_pos,
 91            s=(change_points[stretch.index].sum().round(kwargs["rounding"]).astype(str) + " pp"),
 92            va=vert_align,
 93            ha="left",
 94            fontsize="x-small",
 95            rotation=90,
 96        )
 97        text.set_path_effects([pe.withStroke(linewidth=5, foreground="w")])
 98
 99
100def run_plot(data: DataT, **kwargs: Unpack[RunKwargs]) -> Axes:
101    """Plot a series of percentage rates, highlighting the increasing runs.
102
103    Arguments
104     - data - ordered pandas Series of percentages, with PeriodIndex
105     - **kwargs: RunKwargs
106
107    Return
108     - matplotlib Axes object"""
109
110    # --- check the kwargs
111    report_kwargs(caller="run_plot", **kwargs)
112    validate_kwargs(schema=RunKwargs, caller=ME, **kwargs)
113
114    # --- check the data
115    series = check_clean_timeseries(data, ME)
116    if not isinstance(series, Series):
117        raise TypeError("series must be a pandas Series for run_plot()")
118    series, kwargs_d = constrain_data(series, **kwargs)
119
120    # --- convert PeriodIndex if needed
121    saved_pi = map_periodindex(series)
122    if saved_pi is not None:
123        series = saved_pi[0]
124
125    # --- default arguments - in **kwargs_d
126    kwargs_d["threshold"] = kwargs_d.get("threshold", 0.1)
127    kwargs_d["direction"] = kwargs_d.get("direction", "both")
128    kwargs_d["rounding"] = kwargs_d.get("rounding", 2)
129    kwargs_d["highlight"] = kwargs_d.get(
130        "highlight",
131        (
132            ("gold", "skyblue")
133            if kwargs_d["direction"] == "both"
134            else "gold"
135            if kwargs_d["direction"] == "up"
136            else "skyblue"
137        ),
138    )
139    kwargs_d["color"] = kwargs_d.get("color", "darkblue")
140
141    # --- plot the line
142    kwargs_d["drawstyle"] = kwargs_d.get("drawstyle", "steps-post")
143    lp_kwargs = limit_kwargs(LineKwargs, **kwargs_d)
144    axes = line_plot(series, **lp_kwargs)
145
146    # plot the runs
147    match kwargs["direction"]:
148        case "up":
149            _plot_runs(axes, series, up=True, **kwargs_d)
150        case "down":
151            _plot_runs(axes, series, up=False, **kwargs_d)
152        case "both":
153            _plot_runs(axes, series, up=True, **kwargs_d)
154            _plot_runs(axes, series, up=False, **kwargs_d)
155        case _:
156            raise ValueError(
157                f"Invalid value for direction: {kwargs['direction']}. Expected 'up', 'down', or 'both'."
158            )
159
160    # --- set the labels
161    if saved_pi is not None:
162        set_labels(axes, saved_pi[1], kwargs.get("max_ticks", get_setting("max_ticks")))
163
164    return axes
ME = 'run_plot'
class RunKwargs(mgplot.line_plot.LineKwargs):
29class RunKwargs(LineKwargs):
30    """Keyword arguments for the run_plot function."""
31
32    threshold: NotRequired[float]
33    highlight: NotRequired[str | Sequence[str]]
34    direction: NotRequired[str]

Keyword arguments for the run_plot function.

threshold: NotRequired[float]
highlight: NotRequired[str | Sequence[str]]
direction: NotRequired[str]
def run_plot( data: ~DataT, **kwargs: Unpack[RunKwargs]) -> matplotlib.axes._axes.Axes:
101def run_plot(data: DataT, **kwargs: Unpack[RunKwargs]) -> Axes:
102    """Plot a series of percentage rates, highlighting the increasing runs.
103
104    Arguments
105     - data - ordered pandas Series of percentages, with PeriodIndex
106     - **kwargs: RunKwargs
107
108    Return
109     - matplotlib Axes object"""
110
111    # --- check the kwargs
112    report_kwargs(caller="run_plot", **kwargs)
113    validate_kwargs(schema=RunKwargs, caller=ME, **kwargs)
114
115    # --- check the data
116    series = check_clean_timeseries(data, ME)
117    if not isinstance(series, Series):
118        raise TypeError("series must be a pandas Series for run_plot()")
119    series, kwargs_d = constrain_data(series, **kwargs)
120
121    # --- convert PeriodIndex if needed
122    saved_pi = map_periodindex(series)
123    if saved_pi is not None:
124        series = saved_pi[0]
125
126    # --- default arguments - in **kwargs_d
127    kwargs_d["threshold"] = kwargs_d.get("threshold", 0.1)
128    kwargs_d["direction"] = kwargs_d.get("direction", "both")
129    kwargs_d["rounding"] = kwargs_d.get("rounding", 2)
130    kwargs_d["highlight"] = kwargs_d.get(
131        "highlight",
132        (
133            ("gold", "skyblue")
134            if kwargs_d["direction"] == "both"
135            else "gold"
136            if kwargs_d["direction"] == "up"
137            else "skyblue"
138        ),
139    )
140    kwargs_d["color"] = kwargs_d.get("color", "darkblue")
141
142    # --- plot the line
143    kwargs_d["drawstyle"] = kwargs_d.get("drawstyle", "steps-post")
144    lp_kwargs = limit_kwargs(LineKwargs, **kwargs_d)
145    axes = line_plot(series, **lp_kwargs)
146
147    # plot the runs
148    match kwargs["direction"]:
149        case "up":
150            _plot_runs(axes, series, up=True, **kwargs_d)
151        case "down":
152            _plot_runs(axes, series, up=False, **kwargs_d)
153        case "both":
154            _plot_runs(axes, series, up=True, **kwargs_d)
155            _plot_runs(axes, series, up=False, **kwargs_d)
156        case _:
157            raise ValueError(
158                f"Invalid value for direction: {kwargs['direction']}. Expected 'up', 'down', or 'both'."
159            )
160
161    # --- set the labels
162    if saved_pi is not None:
163        set_labels(axes, saved_pi[1], kwargs.get("max_ticks", get_setting("max_ticks")))
164
165    return axes

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

Arguments

  • data - ordered pandas Series of percentages, with PeriodIndex
  • **kwargs: RunKwargs

Return

  • matplotlib Axes object