mgplot.line_plot

line_plot.py: Plot a series or a dataframe with lines.

  1"""
  2line_plot.py:
  3Plot a series or a dataframe with lines.
  4"""
  5
  6# --- imports
  7from typing import Any, Final, Unpack, NotRequired
  8from collections.abc import Sequence
  9import math
 10from matplotlib.pyplot import Axes
 11from pandas import DataFrame, Series, Period
 12
 13from mgplot.settings import DataT, get_setting
 14from mgplot.axis_utils import set_labels, map_periodindex
 15from mgplot.keyword_checking import validate_kwargs, report_kwargs, BaseKwargs
 16
 17from mgplot.utilities import (
 18    apply_defaults,
 19    get_color_list,
 20    get_axes,
 21    constrain_data,
 22    check_clean_timeseries,
 23    default_rounding,
 24)
 25
 26# --- constants
 27ME: Final[str] = "line_plot"
 28
 29
 30class LineKwargs(BaseKwargs):
 31    """Keyword arguments for the line_plot function."""
 32
 33    # --- options for the entire line plot
 34    ax: NotRequired[Axes | None]
 35    style: NotRequired[str | Sequence[str]]
 36    width: NotRequired[float | int | Sequence[float | int]]
 37    color: NotRequired[str | Sequence[str]]
 38    alpha: NotRequired[float | Sequence[float]]
 39    drawstyle: NotRequired[str | Sequence[str] | None]
 40    marker: NotRequired[str | Sequence[str] | None]
 41    markersize: NotRequired[float | Sequence[float] | int | None]
 42    dropna: NotRequired[bool | Sequence[bool]]
 43    annotate: NotRequired[bool | Sequence[bool]]
 44    rounding: NotRequired[Sequence[int | bool] | int | bool | None]
 45    fontsize: NotRequired[Sequence[str | int | float] | str | int | float]
 46    fontname: NotRequired[str | Sequence[str]]
 47    rotation: NotRequired[Sequence[int | float] | int | float]
 48    annotate_color: NotRequired[str | Sequence[str] | bool | Sequence[bool] | None]
 49    plot_from: NotRequired[int | Period | None]
 50    label_series: NotRequired[bool | Sequence[bool] | None]
 51    max_ticks: NotRequired[int]
 52
 53
 54# --- functions
 55def annotate_series(
 56    series: Series,
 57    axes: Axes,
 58    **kwargs,  # "fontsize", "rounding",
 59) -> None:
 60    """Annotate the right-hand end-point of a line-plotted series."""
 61
 62    # --- check the series has a value to annotate
 63    latest = series.dropna()
 64    if series.empty:
 65        return
 66    x, y = latest.index[-1], latest.iloc[-1]
 67    if y is None or math.isnan(y):
 68        return
 69
 70    # --- extract fontsize - could be None, bool, int or str.
 71    fontsize = kwargs.get("fontsize", "small")
 72    if fontsize is None or isinstance(fontsize, bool):
 73        fontsize = "small"
 74    fontname = kwargs.get("fontname", "Helvetica")
 75    rotation = kwargs.get("rotation", 0)
 76
 77    # --- add the annotation
 78    color = kwargs["color"]
 79    rounding = default_rounding(value=y, provided=kwargs.get("rounding", None))
 80    r_string = f"  {y:.{rounding}f}" if rounding > 0 else f"  {int(y)}"
 81    axes.text(
 82        x=x,
 83        y=y,
 84        s=r_string,
 85        ha="left",
 86        va="center",
 87        fontsize=fontsize,
 88        font=fontname,
 89        rotation=rotation,
 90        color=color,
 91    )
 92
 93
 94def _get_style_width_color_etc(
 95    item_count, num_data_points, **kwargs
 96) -> tuple[dict[str, list | tuple], dict[str, Any]]:
 97    """
 98    Get the plot-line attributes arguemnts.
 99    Returns a dictionary of lists of attributes for each line, and
100    a modified kwargs dictionary.
101    """
102
103    data_point_thresh = 151  # switch from wide to narrow lines
104    line_defaults: dict[str, Any] = {
105        "style": ("solid" if item_count < 4 else ["solid", "dashed", "dashdot", "dotted"]),
106        "width": (
107            get_setting("line_normal") if num_data_points > data_point_thresh else get_setting("line_wide")
108        ),
109        "color": get_color_list(item_count),
110        "alpha": 1.0,
111        "drawstyle": None,
112        "marker": None,
113        "markersize": 10,
114        "dropna": True,
115        "annotate": False,
116        "rounding": True,
117        "fontsize": "small",
118        "fontname": "Helvetica",
119        "rotation": 0,
120        "annotate_color": True,
121        "label_series": True,
122    }
123
124    return apply_defaults(item_count, line_defaults, kwargs)
125
126
127def line_plot(data: DataT, **kwargs: Unpack[LineKwargs]) -> Axes:
128    """
129    Build a single plot from the data passed in.
130    This can be a single- or multiple-line plot.
131    Return the axes object for the build.
132
133    Arguments:
134    - data: DataFrame | Series - data to plot
135    - kwargs: Unpack[LineKwargs]
136
137    Returns:
138    - axes: Axes - the axes object for the plot
139    """
140
141    # --- check the kwargs
142    report_kwargs(caller=ME, **kwargs)
143    validate_kwargs(schema=LineKwargs, caller=ME, **kwargs)
144
145    # --- check the data
146    data = check_clean_timeseries(data, ME)
147    df = DataFrame(data)  # we are only plotting DataFrames
148    df, kwargs_d = constrain_data(df, **kwargs)
149
150    # --- convert PeriodIndex to Integer Index
151    saved_pi = map_periodindex(df)
152    if saved_pi is not None:
153        df = saved_pi[0]
154
155    # --- some special defaults
156    kwargs_d["label_series"] = (
157        kwargs_d.get("label_series", True) if len(df.columns) > 1 else kwargs_d.get("label_series", False)
158    )
159
160    # --- Let's plot
161    axes, kwargs_d = get_axes(**kwargs_d)  # get the axes to plot on
162    if df.empty or df.isna().all().all():
163        # Note: finalise plot should ignore an empty axes object
164        print(f"Warning: No data to plot in {ME}().")
165        return axes
166
167    # --- get the arguments for each line we will plot ...
168    item_count = len(df.columns)
169    num_data_points = len(df)
170    swce, kwargs_d = _get_style_width_color_etc(item_count, num_data_points, **kwargs_d)
171
172    for i, column in enumerate(df.columns):
173        series = df[column]
174        series = series.dropna() if "dropna" in swce and swce["dropna"][i] else series
175        if series.empty or series.isna().all():
176            print(f"Warning: No data to plot for {column} in line_plot().")
177            continue
178
179        series.plot(
180            # Note: pandas will plot PeriodIndex against their ordinal values
181            ls=swce["style"][i],
182            lw=swce["width"][i],
183            color=swce["color"][i],
184            alpha=swce["alpha"][i],
185            marker=swce["marker"][i],
186            ms=swce["markersize"][i],
187            drawstyle=swce["drawstyle"][i],
188            label=(column if "label_series" in swce and swce["label_series"][i] else f"_{column}_"),
189            ax=axes,
190        )
191
192        if swce["annotate"][i] is None or not swce["annotate"][i]:
193            continue
194
195        color = swce["color"][i] if swce["annotate_color"][i] is True else swce["annotate_color"][i]
196        annotate_series(
197            series,
198            axes,
199            color=color,
200            rounding=swce["rounding"][i],
201            fontsize=swce["fontsize"][i],
202            fontname=swce["fontname"][i],
203            rotation=swce["rotation"][i],
204        )
205
206    # --- set the labels
207    if saved_pi is not None:
208        set_labels(axes, saved_pi[1], kwargs_d.get("max_ticks", get_setting("max_ticks")))
209
210    return axes
ME: Final[str] = 'line_plot'
class LineKwargs(mgplot.keyword_checking.BaseKwargs):
31class LineKwargs(BaseKwargs):
32    """Keyword arguments for the line_plot function."""
33
34    # --- options for the entire line plot
35    ax: NotRequired[Axes | None]
36    style: NotRequired[str | Sequence[str]]
37    width: NotRequired[float | int | Sequence[float | int]]
38    color: NotRequired[str | Sequence[str]]
39    alpha: NotRequired[float | Sequence[float]]
40    drawstyle: NotRequired[str | Sequence[str] | None]
41    marker: NotRequired[str | Sequence[str] | None]
42    markersize: NotRequired[float | Sequence[float] | int | None]
43    dropna: NotRequired[bool | Sequence[bool]]
44    annotate: NotRequired[bool | Sequence[bool]]
45    rounding: NotRequired[Sequence[int | bool] | int | bool | None]
46    fontsize: NotRequired[Sequence[str | int | float] | str | int | float]
47    fontname: NotRequired[str | Sequence[str]]
48    rotation: NotRequired[Sequence[int | float] | int | float]
49    annotate_color: NotRequired[str | Sequence[str] | bool | Sequence[bool] | None]
50    plot_from: NotRequired[int | Period | None]
51    label_series: NotRequired[bool | Sequence[bool] | None]
52    max_ticks: NotRequired[int]

Keyword arguments for the line_plot function.

ax: NotRequired[matplotlib.axes._axes.Axes | None]
style: NotRequired[str | Sequence[str]]
width: NotRequired[float | int | Sequence[float | int]]
color: NotRequired[str | Sequence[str]]
alpha: NotRequired[float | Sequence[float]]
drawstyle: NotRequired[str | Sequence[str] | None]
marker: NotRequired[str | Sequence[str] | None]
markersize: NotRequired[float | Sequence[float] | int | None]
dropna: NotRequired[bool | Sequence[bool]]
annotate: NotRequired[bool | Sequence[bool]]
rounding: NotRequired[Sequence[int | bool] | int | bool | None]
fontsize: NotRequired[Sequence[str | int | float] | str | int | float]
fontname: NotRequired[str | Sequence[str]]
rotation: NotRequired[float | int | Sequence[float | int]]
annotate_color: NotRequired[str | Sequence[str] | bool | Sequence[bool] | None]
plot_from: NotRequired[int | pandas._libs.tslibs.period.Period | None]
label_series: NotRequired[bool | Sequence[bool] | None]
max_ticks: NotRequired[int]
def annotate_series( series: pandas.core.series.Series, axes: matplotlib.axes._axes.Axes, **kwargs) -> None:
56def annotate_series(
57    series: Series,
58    axes: Axes,
59    **kwargs,  # "fontsize", "rounding",
60) -> None:
61    """Annotate the right-hand end-point of a line-plotted series."""
62
63    # --- check the series has a value to annotate
64    latest = series.dropna()
65    if series.empty:
66        return
67    x, y = latest.index[-1], latest.iloc[-1]
68    if y is None or math.isnan(y):
69        return
70
71    # --- extract fontsize - could be None, bool, int or str.
72    fontsize = kwargs.get("fontsize", "small")
73    if fontsize is None or isinstance(fontsize, bool):
74        fontsize = "small"
75    fontname = kwargs.get("fontname", "Helvetica")
76    rotation = kwargs.get("rotation", 0)
77
78    # --- add the annotation
79    color = kwargs["color"]
80    rounding = default_rounding(value=y, provided=kwargs.get("rounding", None))
81    r_string = f"  {y:.{rounding}f}" if rounding > 0 else f"  {int(y)}"
82    axes.text(
83        x=x,
84        y=y,
85        s=r_string,
86        ha="left",
87        va="center",
88        fontsize=fontsize,
89        font=fontname,
90        rotation=rotation,
91        color=color,
92    )

Annotate the right-hand end-point of a line-plotted series.

def line_plot( data: ~DataT, **kwargs: Unpack[LineKwargs]) -> matplotlib.axes._axes.Axes:
128def line_plot(data: DataT, **kwargs: Unpack[LineKwargs]) -> Axes:
129    """
130    Build a single plot from the data passed in.
131    This can be a single- or multiple-line plot.
132    Return the axes object for the build.
133
134    Arguments:
135    - data: DataFrame | Series - data to plot
136    - kwargs: Unpack[LineKwargs]
137
138    Returns:
139    - axes: Axes - the axes object for the plot
140    """
141
142    # --- check the kwargs
143    report_kwargs(caller=ME, **kwargs)
144    validate_kwargs(schema=LineKwargs, caller=ME, **kwargs)
145
146    # --- check the data
147    data = check_clean_timeseries(data, ME)
148    df = DataFrame(data)  # we are only plotting DataFrames
149    df, kwargs_d = constrain_data(df, **kwargs)
150
151    # --- convert PeriodIndex to Integer Index
152    saved_pi = map_periodindex(df)
153    if saved_pi is not None:
154        df = saved_pi[0]
155
156    # --- some special defaults
157    kwargs_d["label_series"] = (
158        kwargs_d.get("label_series", True) if len(df.columns) > 1 else kwargs_d.get("label_series", False)
159    )
160
161    # --- Let's plot
162    axes, kwargs_d = get_axes(**kwargs_d)  # get the axes to plot on
163    if df.empty or df.isna().all().all():
164        # Note: finalise plot should ignore an empty axes object
165        print(f"Warning: No data to plot in {ME}().")
166        return axes
167
168    # --- get the arguments for each line we will plot ...
169    item_count = len(df.columns)
170    num_data_points = len(df)
171    swce, kwargs_d = _get_style_width_color_etc(item_count, num_data_points, **kwargs_d)
172
173    for i, column in enumerate(df.columns):
174        series = df[column]
175        series = series.dropna() if "dropna" in swce and swce["dropna"][i] else series
176        if series.empty or series.isna().all():
177            print(f"Warning: No data to plot for {column} in line_plot().")
178            continue
179
180        series.plot(
181            # Note: pandas will plot PeriodIndex against their ordinal values
182            ls=swce["style"][i],
183            lw=swce["width"][i],
184            color=swce["color"][i],
185            alpha=swce["alpha"][i],
186            marker=swce["marker"][i],
187            ms=swce["markersize"][i],
188            drawstyle=swce["drawstyle"][i],
189            label=(column if "label_series" in swce and swce["label_series"][i] else f"_{column}_"),
190            ax=axes,
191        )
192
193        if swce["annotate"][i] is None or not swce["annotate"][i]:
194            continue
195
196        color = swce["color"][i] if swce["annotate_color"][i] is True else swce["annotate_color"][i]
197        annotate_series(
198            series,
199            axes,
200            color=color,
201            rounding=swce["rounding"][i],
202            fontsize=swce["fontsize"][i],
203            fontname=swce["fontname"][i],
204            rotation=swce["rotation"][i],
205        )
206
207    # --- set the labels
208    if saved_pi is not None:
209        set_labels(axes, saved_pi[1], kwargs_d.get("max_ticks", get_setting("max_ticks")))
210
211    return axes

Build a single plot from the data passed in. This can be a single- or multiple-line plot. Return the axes object for the build.

Arguments:

  • data: DataFrame | Series - data to plot
  • kwargs: Unpack[LineKwargs]

Returns:

  • axes: Axes - the axes object for the plot