mgplot.summary_plot

Produce a summary plot for the data in a given DataFrame.

  1"""Produce a summary plot for the data in a given DataFrame."""
  2
  3# system imports
  4from typing import Any, NotRequired, SupportsFloat, Unpack
  5
  6from matplotlib.axes import Axes
  7
  8# analytic third-party imports
  9from numpy import array, ndarray
 10from pandas import DataFrame, Period
 11
 12from mgplot.finalise_plot import make_legend
 13from mgplot.keyword_checking import (
 14    BaseKwargs,
 15    report_kwargs,
 16    validate_kwargs,
 17)
 18
 19# local imports
 20from mgplot.settings import DataT
 21from mgplot.utilities import check_clean_timeseries, constrain_data, get_axes, label_period
 22
 23# --- constants
 24ME = "summary_plot"
 25ZSCORES = "zscores"
 26ZSCALED = "zscaled"
 27
 28# Plot layout constants
 29SPAN_LIMIT = 1.15
 30SPACE_MARGIN = 0.2
 31DEFAULT_FONT_SIZE = 10
 32SMALL_FONT_SIZE = "x-small"
 33SMALL_MARKER_SIZE = 5
 34REFERENCE_LINE_WIDTH = 0.5
 35DEFAULT_MIDDLE = 0.8
 36DEFAULT_PLOT_FROM = 0
 37HIGH_PRECISION_THRESHOLD = 1
 38
 39
 40class SummaryKwargs(BaseKwargs):
 41    """Keyword arguments for the summary_plot function."""
 42
 43    ax: NotRequired[Axes | None]
 44    verbose: NotRequired[bool]
 45    middle: NotRequired[float]
 46    plot_type: NotRequired[str]
 47    plot_from: NotRequired[int | Period]
 48    legend: NotRequired[bool | dict[str, Any] | None]
 49    xlabel: NotRequired[str | None]
 50
 51
 52# --- functions
 53def calc_quantiles(middle: float) -> ndarray:
 54    """Calculate the quantiles for the middle of the data."""
 55    return array([(1 - middle) / 2.0, 1 - (1 - middle) / 2.0])
 56
 57
 58def calculate_z(
 59    original: DataFrame,
 60    middle: float,
 61    *,
 62    verbose: bool = False,
 63) -> tuple[DataFrame, DataFrame]:
 64    """Calculate z-scores, scaled z-scores and middle quantiles.
 65
 66    Args:
 67        original: DataFrame containing the original data.
 68        middle: float, the proportion of data to highlight in the middle (eg. 0.8 for 80%).
 69        verbose: bool, whether to print the summary data.
 70
 71    Returns:
 72        tuple[DataFrame, DataFrame]: z_scores and z_scaled DataFrames.
 73
 74    Raises:
 75        ValueError: If original DataFrame is empty or has zero variance.
 76
 77    """
 78    if original.empty:
 79        raise ValueError("Cannot calculate z-scores for empty DataFrame")
 80
 81    # Check for zero variance
 82    std_dev = original.std()
 83    if (std_dev == 0).any():
 84        raise ValueError("Cannot calculate z-scores when standard deviation is zero")
 85
 86    # Calculate z-scores
 87    z_scores: DataFrame = (original - original.mean()) / std_dev
 88
 89    # Scale z-scores between -1 and +1
 90    z_min = z_scores.min()
 91    z_max = z_scores.max()
 92    z_range = z_max - z_min
 93
 94    # Avoid division by zero in scaling
 95    if (z_range == 0).any():
 96        z_scaled: DataFrame = z_scores.copy() * 0  # All zeros if no variance
 97    else:
 98        z_scaled = (((z_scores - z_min) / z_range) - 0.5) * 2
 99
100    if verbose:
101        if original.index.empty:
102            raise ValueError("Cannot display statistics for empty DataFrame")
103
104        q_middle = calc_quantiles(middle)
105        frame = DataFrame(
106            {
107                "count": original.count(),
108                "mean": original.mean(),
109                "median": original.median(),
110                "min shaded": original.quantile(q=q_middle[0]),
111                "max shaded": original.quantile(q=q_middle[1]),
112                "z-scores": z_scores.iloc[-1],
113                "scaled": z_scaled.iloc[-1],
114            },
115        )
116        print(frame)
117
118    return z_scores, z_scaled
119
120
121def plot_middle_bars(
122    adjusted: DataFrame,
123    middle: float,
124    kwargs: dict[str, Any],
125) -> Axes:
126    """Plot the middle (typically 80%) of the data as a bar."""
127    if adjusted.empty:
128        raise ValueError("Cannot plot bars for empty DataFrame")
129
130    q = calc_quantiles(middle)
131    lo_hi: DataFrame = adjusted.quantile(q=q).T  # get the middle section of data
132
133    low = min(adjusted.iloc[-1].min(), lo_hi.min().min(), -SPAN_LIMIT) - SPACE_MARGIN
134    high = max(adjusted.iloc[-1].max(), lo_hi.max().max(), SPAN_LIMIT) + SPACE_MARGIN
135    kwargs["xlim"] = (low, high)  # update the kwargs with the xlim
136    ax, _ = get_axes(**kwargs)
137    ax.barh(
138        y=lo_hi.index,
139        width=lo_hi[q[1]] - lo_hi[q[0]],
140        left=lo_hi[q[0]],
141        color="#bbbbbb",
142        label=f"Middle {middle * 100:0.0f}% of prints",
143    )
144    return ax
145
146
147def plot_latest_datapoint(
148    ax: Axes,
149    original: DataFrame,
150    adjusted: DataFrame,
151    font_size: int | str,
152) -> None:
153    """Add the latest datapoints to the summary plot."""
154    if adjusted.empty or original.empty:
155        raise ValueError("Cannot plot datapoints for empty DataFrame")
156
157    ax.scatter(adjusted.iloc[-1], adjusted.columns, color="darkorange", label="Latest")
158    row = adjusted.index[-1]
159    for col_num, col_name in enumerate(original.columns):
160        raw_adj = adjusted.at[row, col_name]
161        raw_orig = original.at[row, col_name]
162        if not isinstance(raw_adj, SupportsFloat) or not isinstance(raw_orig, SupportsFloat):
163            raise TypeError(f"Expected numeric data for {col_name}, got {type(raw_orig).__name__}")
164        x_adj = float(raw_adj)
165        x_orig = float(raw_orig)
166        precision = 2 if abs(x_orig) < HIGH_PRECISION_THRESHOLD else 1
167        ax.text(
168            x=x_adj,
169            y=col_num,
170            s=f"{x_orig:.{precision}f}",
171            ha="center",
172            va="center",
173            size=font_size,
174        )
175
176
177def label_extremes(
178    ax: Axes,
179    data: tuple[DataFrame, DataFrame],
180    plot_type: str,
181    font_size: int | str,
182    kwargs: dict[str, Any],  # must be a dictionary, not a splat
183) -> None:
184    """Label the extremes in the scaled plots."""
185    original, adjusted = data
186    low, high = kwargs["xlim"]
187    ax.set_xlim(low, high)  # set the x-axis limits
188    if plot_type == ZSCALED:
189        ax.scatter(
190            adjusted.median(),
191            adjusted.columns,
192            color="darkorchid",
193            marker="x",
194            s=SMALL_MARKER_SIZE,
195            label="Median",
196        )
197        for col_num, col_name in enumerate(original.columns):
198            minima, maxima = original[col_name].min(), original[col_name].max()
199            min_precision = 2 if abs(minima) < HIGH_PRECISION_THRESHOLD else 1
200            max_precision = 2 if abs(maxima) < HIGH_PRECISION_THRESHOLD else 1
201            ax.text(
202                low,
203                col_num,
204                f" {minima:.{min_precision}f}",
205                ha="left",
206                va="center",
207                size=font_size,
208            )
209            ax.text(
210                high,
211                col_num,
212                f"{maxima:.{max_precision}f} ",
213                ha="right",
214                va="center",
215                size=font_size,
216            )
217
218
219def horizontal_bar_plot(
220    original: DataFrame,
221    adjusted: DataFrame,
222    middle: float,
223    plot_type: str,
224    kwargs: dict[str, Any],  # must be a dictionary, not a splat
225) -> Axes:
226    """Plot horizontal bars for the middle of the data."""
227    ax = plot_middle_bars(adjusted, middle, kwargs)
228    font_size = SMALL_FONT_SIZE
229    plot_latest_datapoint(ax, original, adjusted, font_size)
230    label_extremes(ax, data=(original, adjusted), plot_type=plot_type, font_size=font_size, kwargs=kwargs)
231
232    return ax
233
234
235def label_x_axis(plot_from: int | Period, label: str | None, plot_type: str, ax: Axes, df: DataFrame) -> None:
236    """Label the x-axis for the plot."""
237    start: Period = plot_from if isinstance(plot_from, Period) else df.index[plot_from]
238    if label is not None:
239        if not label:
240            if plot_type == ZSCORES:
241                label = f"Z-scores for prints since {label_period(start)}"
242            else:
243                label = f"-1 to 1 scaled z-scores since {label_period(start)}"
244        ax.set_xlabel(label)
245
246
247def mark_reference_lines(plot_type: str, ax: Axes) -> None:
248    """Mark the reference lines for the plot."""
249    line_color = "#555555"
250    line_style = "--"
251
252    if plot_type == ZSCALED:
253        ax.axvline(-1, color=line_color, linewidth=REFERENCE_LINE_WIDTH, linestyle=line_style, label="-1")
254        ax.axvline(1, color=line_color, linewidth=REFERENCE_LINE_WIDTH, linestyle=line_style, label="+1")
255    elif plot_type == ZSCORES:
256        ax.axvline(0, color=line_color, linewidth=REFERENCE_LINE_WIDTH, linestyle=line_style, label="0")
257
258
259def plot_the_data(df: DataFrame, **kwargs: Unpack[SummaryKwargs]) -> tuple[Axes, str]:
260    """Plot the data as a summary plot.
261
262    Args:
263        df: DataFrame - the data to plot.
264        kwargs: SummaryKwargs, additional keyword arguments for the plot.
265
266    Returns:
267        tuple[Axes, str]: A tuple comprising the Axes object and plot type ('zscores' or 'zscaled').
268
269    Raises:
270        ValueError: If middle value is not between 0 and 1, or if plot_type is invalid.
271
272    """
273    verbose = kwargs.pop("verbose", False)
274    middle = float(kwargs.pop("middle", DEFAULT_MIDDLE))
275    plot_type = kwargs.pop("plot_type", ZSCORES)
276
277    # Validate inputs
278    if not 0 < middle < 1:
279        raise ValueError(f"Middle value must be between 0 and 1, got {middle}")
280    if plot_type not in (ZSCORES, ZSCALED):
281        raise ValueError(f"plot_type must be '{ZSCORES}' or '{ZSCALED}', got '{plot_type}'")
282
283    subset, kwargsd = constrain_data(df, **kwargs)
284    z_scores, z_scaled = calculate_z(subset, middle, verbose=verbose)
285
286    # plot as required by the plot_types argument
287    adjusted = z_scores if plot_type == ZSCORES else z_scaled
288    ax = horizontal_bar_plot(subset, adjusted, middle, plot_type, kwargsd)
289    ax.tick_params(axis="y", labelsize="small")
290    make_legend(ax, legend=kwargsd["legend"])
291    ax.set_xlim(kwargsd.get("xlim"))  # provide space for the labels
292
293    return ax, plot_type
294
295
296# --- public
297def summary_plot(data: DataT, **kwargs: Unpack[SummaryKwargs]) -> Axes:
298    """Plot a summary of historical data for a given DataFrame.
299
300    Args:
301        data: DataFrame containing the summary data. The column names are
302              used as labels for the plot.
303        kwargs: Additional arguments for the plot, including middle (float),
304               plot_type (str), verbose (bool), and standard plotting options.
305
306    Returns:
307        Axes: A matplotlib Axes object containing the summary plot.
308
309    Raises:
310        TypeError: If data is not a DataFrame.
311
312    """
313    # --- check the kwargs
314    report_kwargs(caller=ME, **kwargs)
315    validate_kwargs(schema=SummaryKwargs, caller=ME, **kwargs)
316
317    # --- check the data
318    data = check_clean_timeseries(data, ME)
319    if not isinstance(data, DataFrame):
320        raise TypeError("data must be a pandas DataFrame for summary_plot()")
321
322    # --- legend
323    kwargs["legend"] = kwargs.get(
324        "legend",
325        {
326            # put the legend below the x-axis label
327            "loc": "upper center",
328            "fontsize": "xx-small",
329            "bbox_to_anchor": (0.5, -0.125),
330            "ncol": 4,
331        },
332    )
333
334    # --- and plot it ...
335    ax, plot_type = plot_the_data(data, **kwargs)
336    label_x_axis(
337        kwargs.get("plot_from", DEFAULT_PLOT_FROM),
338        label=kwargs.get("xlabel", ""),
339        plot_type=plot_type,
340        ax=ax,
341        df=data,
342    )
343    mark_reference_lines(plot_type, ax)
344
345    return ax
ME = 'summary_plot'
ZSCORES = 'zscores'
ZSCALED = 'zscaled'
SPAN_LIMIT = 1.15
SPACE_MARGIN = 0.2
DEFAULT_FONT_SIZE = 10
SMALL_FONT_SIZE = 'x-small'
SMALL_MARKER_SIZE = 5
REFERENCE_LINE_WIDTH = 0.5
DEFAULT_MIDDLE = 0.8
DEFAULT_PLOT_FROM = 0
HIGH_PRECISION_THRESHOLD = 1
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]
def calc_quantiles(middle: float) -> numpy.ndarray:
54def calc_quantiles(middle: float) -> ndarray:
55    """Calculate the quantiles for the middle of the data."""
56    return array([(1 - middle) / 2.0, 1 - (1 - middle) / 2.0])

Calculate the quantiles for the middle of the data.

def calculate_z( original: pandas.DataFrame, middle: float, *, verbose: bool = False) -> tuple[pandas.DataFrame, pandas.DataFrame]:
 59def calculate_z(
 60    original: DataFrame,
 61    middle: float,
 62    *,
 63    verbose: bool = False,
 64) -> tuple[DataFrame, DataFrame]:
 65    """Calculate z-scores, scaled z-scores and middle quantiles.
 66
 67    Args:
 68        original: DataFrame containing the original data.
 69        middle: float, the proportion of data to highlight in the middle (eg. 0.8 for 80%).
 70        verbose: bool, whether to print the summary data.
 71
 72    Returns:
 73        tuple[DataFrame, DataFrame]: z_scores and z_scaled DataFrames.
 74
 75    Raises:
 76        ValueError: If original DataFrame is empty or has zero variance.
 77
 78    """
 79    if original.empty:
 80        raise ValueError("Cannot calculate z-scores for empty DataFrame")
 81
 82    # Check for zero variance
 83    std_dev = original.std()
 84    if (std_dev == 0).any():
 85        raise ValueError("Cannot calculate z-scores when standard deviation is zero")
 86
 87    # Calculate z-scores
 88    z_scores: DataFrame = (original - original.mean()) / std_dev
 89
 90    # Scale z-scores between -1 and +1
 91    z_min = z_scores.min()
 92    z_max = z_scores.max()
 93    z_range = z_max - z_min
 94
 95    # Avoid division by zero in scaling
 96    if (z_range == 0).any():
 97        z_scaled: DataFrame = z_scores.copy() * 0  # All zeros if no variance
 98    else:
 99        z_scaled = (((z_scores - z_min) / z_range) - 0.5) * 2
100
101    if verbose:
102        if original.index.empty:
103            raise ValueError("Cannot display statistics for empty DataFrame")
104
105        q_middle = calc_quantiles(middle)
106        frame = DataFrame(
107            {
108                "count": original.count(),
109                "mean": original.mean(),
110                "median": original.median(),
111                "min shaded": original.quantile(q=q_middle[0]),
112                "max shaded": original.quantile(q=q_middle[1]),
113                "z-scores": z_scores.iloc[-1],
114                "scaled": z_scaled.iloc[-1],
115            },
116        )
117        print(frame)
118
119    return z_scores, z_scaled

Calculate z-scores, scaled z-scores and middle quantiles.

Args: original: DataFrame containing the original data. middle: float, the proportion of data to highlight in the middle (eg. 0.8 for 80%). verbose: bool, whether to print the summary data.

Returns: tuple[DataFrame, DataFrame]: z_scores and z_scaled DataFrames.

Raises: ValueError: If original DataFrame is empty or has zero variance.

def plot_middle_bars( adjusted: pandas.DataFrame, middle: float, kwargs: dict[str, typing.Any]) -> matplotlib.axes._axes.Axes:
122def plot_middle_bars(
123    adjusted: DataFrame,
124    middle: float,
125    kwargs: dict[str, Any],
126) -> Axes:
127    """Plot the middle (typically 80%) of the data as a bar."""
128    if adjusted.empty:
129        raise ValueError("Cannot plot bars for empty DataFrame")
130
131    q = calc_quantiles(middle)
132    lo_hi: DataFrame = adjusted.quantile(q=q).T  # get the middle section of data
133
134    low = min(adjusted.iloc[-1].min(), lo_hi.min().min(), -SPAN_LIMIT) - SPACE_MARGIN
135    high = max(adjusted.iloc[-1].max(), lo_hi.max().max(), SPAN_LIMIT) + SPACE_MARGIN
136    kwargs["xlim"] = (low, high)  # update the kwargs with the xlim
137    ax, _ = get_axes(**kwargs)
138    ax.barh(
139        y=lo_hi.index,
140        width=lo_hi[q[1]] - lo_hi[q[0]],
141        left=lo_hi[q[0]],
142        color="#bbbbbb",
143        label=f"Middle {middle * 100:0.0f}% of prints",
144    )
145    return ax

Plot the middle (typically 80%) of the data as a bar.

def plot_latest_datapoint( ax: matplotlib.axes._axes.Axes, original: pandas.DataFrame, adjusted: pandas.DataFrame, font_size: int | str) -> None:
148def plot_latest_datapoint(
149    ax: Axes,
150    original: DataFrame,
151    adjusted: DataFrame,
152    font_size: int | str,
153) -> None:
154    """Add the latest datapoints to the summary plot."""
155    if adjusted.empty or original.empty:
156        raise ValueError("Cannot plot datapoints for empty DataFrame")
157
158    ax.scatter(adjusted.iloc[-1], adjusted.columns, color="darkorange", label="Latest")
159    row = adjusted.index[-1]
160    for col_num, col_name in enumerate(original.columns):
161        raw_adj = adjusted.at[row, col_name]
162        raw_orig = original.at[row, col_name]
163        if not isinstance(raw_adj, SupportsFloat) or not isinstance(raw_orig, SupportsFloat):
164            raise TypeError(f"Expected numeric data for {col_name}, got {type(raw_orig).__name__}")
165        x_adj = float(raw_adj)
166        x_orig = float(raw_orig)
167        precision = 2 if abs(x_orig) < HIGH_PRECISION_THRESHOLD else 1
168        ax.text(
169            x=x_adj,
170            y=col_num,
171            s=f"{x_orig:.{precision}f}",
172            ha="center",
173            va="center",
174            size=font_size,
175        )

Add the latest datapoints to the summary plot.

def label_extremes( ax: matplotlib.axes._axes.Axes, data: tuple[pandas.DataFrame, pandas.DataFrame], plot_type: str, font_size: int | str, kwargs: dict[str, typing.Any]) -> None:
178def label_extremes(
179    ax: Axes,
180    data: tuple[DataFrame, DataFrame],
181    plot_type: str,
182    font_size: int | str,
183    kwargs: dict[str, Any],  # must be a dictionary, not a splat
184) -> None:
185    """Label the extremes in the scaled plots."""
186    original, adjusted = data
187    low, high = kwargs["xlim"]
188    ax.set_xlim(low, high)  # set the x-axis limits
189    if plot_type == ZSCALED:
190        ax.scatter(
191            adjusted.median(),
192            adjusted.columns,
193            color="darkorchid",
194            marker="x",
195            s=SMALL_MARKER_SIZE,
196            label="Median",
197        )
198        for col_num, col_name in enumerate(original.columns):
199            minima, maxima = original[col_name].min(), original[col_name].max()
200            min_precision = 2 if abs(minima) < HIGH_PRECISION_THRESHOLD else 1
201            max_precision = 2 if abs(maxima) < HIGH_PRECISION_THRESHOLD else 1
202            ax.text(
203                low,
204                col_num,
205                f" {minima:.{min_precision}f}",
206                ha="left",
207                va="center",
208                size=font_size,
209            )
210            ax.text(
211                high,
212                col_num,
213                f"{maxima:.{max_precision}f} ",
214                ha="right",
215                va="center",
216                size=font_size,
217            )

Label the extremes in the scaled plots.

def horizontal_bar_plot( original: pandas.DataFrame, adjusted: pandas.DataFrame, middle: float, plot_type: str, kwargs: dict[str, typing.Any]) -> matplotlib.axes._axes.Axes:
220def horizontal_bar_plot(
221    original: DataFrame,
222    adjusted: DataFrame,
223    middle: float,
224    plot_type: str,
225    kwargs: dict[str, Any],  # must be a dictionary, not a splat
226) -> Axes:
227    """Plot horizontal bars for the middle of the data."""
228    ax = plot_middle_bars(adjusted, middle, kwargs)
229    font_size = SMALL_FONT_SIZE
230    plot_latest_datapoint(ax, original, adjusted, font_size)
231    label_extremes(ax, data=(original, adjusted), plot_type=plot_type, font_size=font_size, kwargs=kwargs)
232
233    return ax

Plot horizontal bars for the middle of the data.

def label_x_axis( plot_from: int | pandas.Period, label: str | None, plot_type: str, ax: matplotlib.axes._axes.Axes, df: pandas.DataFrame) -> None:
236def label_x_axis(plot_from: int | Period, label: str | None, plot_type: str, ax: Axes, df: DataFrame) -> None:
237    """Label the x-axis for the plot."""
238    start: Period = plot_from if isinstance(plot_from, Period) else df.index[plot_from]
239    if label is not None:
240        if not label:
241            if plot_type == ZSCORES:
242                label = f"Z-scores for prints since {label_period(start)}"
243            else:
244                label = f"-1 to 1 scaled z-scores since {label_period(start)}"
245        ax.set_xlabel(label)

Label the x-axis for the plot.

def mark_reference_lines(plot_type: str, ax: matplotlib.axes._axes.Axes) -> None:
248def mark_reference_lines(plot_type: str, ax: Axes) -> None:
249    """Mark the reference lines for the plot."""
250    line_color = "#555555"
251    line_style = "--"
252
253    if plot_type == ZSCALED:
254        ax.axvline(-1, color=line_color, linewidth=REFERENCE_LINE_WIDTH, linestyle=line_style, label="-1")
255        ax.axvline(1, color=line_color, linewidth=REFERENCE_LINE_WIDTH, linestyle=line_style, label="+1")
256    elif plot_type == ZSCORES:
257        ax.axvline(0, color=line_color, linewidth=REFERENCE_LINE_WIDTH, linestyle=line_style, label="0")

Mark the reference lines for the plot.

def plot_the_data( df: pandas.DataFrame, **kwargs: Unpack[SummaryKwargs]) -> tuple[matplotlib.axes._axes.Axes, str]:
260def plot_the_data(df: DataFrame, **kwargs: Unpack[SummaryKwargs]) -> tuple[Axes, str]:
261    """Plot the data as a summary plot.
262
263    Args:
264        df: DataFrame - the data to plot.
265        kwargs: SummaryKwargs, additional keyword arguments for the plot.
266
267    Returns:
268        tuple[Axes, str]: A tuple comprising the Axes object and plot type ('zscores' or 'zscaled').
269
270    Raises:
271        ValueError: If middle value is not between 0 and 1, or if plot_type is invalid.
272
273    """
274    verbose = kwargs.pop("verbose", False)
275    middle = float(kwargs.pop("middle", DEFAULT_MIDDLE))
276    plot_type = kwargs.pop("plot_type", ZSCORES)
277
278    # Validate inputs
279    if not 0 < middle < 1:
280        raise ValueError(f"Middle value must be between 0 and 1, got {middle}")
281    if plot_type not in (ZSCORES, ZSCALED):
282        raise ValueError(f"plot_type must be '{ZSCORES}' or '{ZSCALED}', got '{plot_type}'")
283
284    subset, kwargsd = constrain_data(df, **kwargs)
285    z_scores, z_scaled = calculate_z(subset, middle, verbose=verbose)
286
287    # plot as required by the plot_types argument
288    adjusted = z_scores if plot_type == ZSCORES else z_scaled
289    ax = horizontal_bar_plot(subset, adjusted, middle, plot_type, kwargsd)
290    ax.tick_params(axis="y", labelsize="small")
291    make_legend(ax, legend=kwargsd["legend"])
292    ax.set_xlim(kwargsd.get("xlim"))  # provide space for the labels
293
294    return ax, plot_type

Plot the data as a summary plot.

Args: df: DataFrame - the data to plot. kwargs: SummaryKwargs, additional keyword arguments for the plot.

Returns: tuple[Axes, str]: A tuple comprising the Axes object and plot type ('zscores' or 'zscaled').

Raises: ValueError: If middle value is not between 0 and 1, or if plot_type is invalid.

def summary_plot( data: ~DataT, **kwargs: Unpack[SummaryKwargs]) -> matplotlib.axes._axes.Axes:
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.