mgplot.line_plot

Plot a series or a dataframe with lines.

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

Keyword arguments for the line_plot function.

ax: NotRequired[matplotlib.axes._axes.Axes | None]
style: NotRequired[str | Sequence[str]]
width: NotRequired[float | int | Sequence[float | int]]
color: NotRequired[str | Sequence[str]]
alpha: NotRequired[float | Sequence[float]]
drawstyle: NotRequired[str | Sequence[str] | None]
marker: NotRequired[str | Sequence[str] | None]
markersize: NotRequired[float | Sequence[float] | int | None]
zorder: NotRequired[float | int | Sequence[float | int]]
dropna: NotRequired[bool | Sequence[bool]]
annotate: NotRequired[bool | Sequence[bool]]
rounding: NotRequired[Sequence[int | bool] | int | bool | None]
fontsize: NotRequired[Sequence[str | int | float] | str | int | float]
fontname: NotRequired[str | Sequence[str]]
rotation: NotRequired[float | int | Sequence[float | int]]
annotate_color: NotRequired[str | Sequence[str] | bool | Sequence[bool] | None]
plot_from: NotRequired[int | pandas.Period | None]
label_series: NotRequired[bool | Sequence[bool] | None]
max_ticks: NotRequired[int]
tick_relabel: NotRequired[Callable[[str], str]]
class AnnotateKwargs(typing.TypedDict):
54class AnnotateKwargs(TypedDict):
55    """Keyword arguments for the annotate_series function."""
56
57    color: str
58    rounding: int | bool
59    fontsize: str | int | float
60    fontname: str
61    rotation: int | float

Keyword arguments for the annotate_series function.

color: str
rounding: int | bool
fontsize: str | int | float
fontname: str
rotation: int | float
def annotate_series( series: pandas.Series, axes: matplotlib.axes._axes.Axes, **kwargs: Unpack[AnnotateKwargs]) -> None:
 65def annotate_series(
 66    series: Series,
 67    axes: Axes,
 68    **kwargs: Unpack[AnnotateKwargs],
 69) -> None:
 70    """Annotate the right-hand end-point of a line-plotted series."""
 71    # --- check the series has a value to annotate
 72    latest: Series = series.dropna()
 73    if latest.empty or not is_numeric_dtype(latest):
 74        return
 75    x: int | float = latest.index[-1]  # type: ignore[assignment]
 76    y: int | float = latest.iloc[-1]
 77    if y is None or math.isnan(y):
 78        return
 79
 80    # --- extract fontsize - could be None, bool, int or str.
 81    fontsize = kwargs.get("fontsize", "small")
 82    if fontsize is None or isinstance(fontsize, bool):
 83        fontsize = "small"
 84    fontname = kwargs.get("fontname", "Helvetica")
 85    rotation = kwargs.get("rotation", 0)
 86
 87    # --- add the annotation
 88    color = kwargs.get("color")
 89    if color is None:
 90        raise ValueError("color is required for annotation")
 91    rounding = default_rounding(value=y, provided=kwargs.get("rounding"))
 92    r_string = f"  {y:.{rounding}f}" if rounding > 0 else f"  {int(y)}"
 93    axes.text(
 94        x=x,
 95        y=y,
 96        s=r_string,
 97        ha="left",
 98        va="center",
 99        fontsize=fontsize,
100        font=fontname,
101        rotation=rotation,
102        color=color,
103    )

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

def get_style_width_color_etc( item_count: int, num_data_points: int, **kwargs: Unpack[LineKwargs]) -> tuple[dict[str, list | tuple], dict[str, typing.Any]]:
106def get_style_width_color_etc(
107    item_count: int,
108    num_data_points: int,
109    **kwargs: Unpack[LineKwargs],
110) -> tuple[dict[str, list | tuple], dict[str, Any]]:
111    """Get the plot-line attributes arguemnts.
112
113    Args:
114        item_count: Number of data series to plot (columns in DataFrame)
115        num_data_points: Number of data points in the series (rows in DataFrame)
116        kwargs: LineKwargs - other arguments
117
118    Returns a tuple comprising:
119        - swce: dict[str, list | tuple] - style, width, color, etc. for each line
120        - kwargs_d: dict[str, Any] - the kwargs with defaults applied for the line plot
121
122    """
123    data_point_thresh = 151  # switch from wide to narrow lines
124    force_lines_styles = 4
125
126    line_defaults: dict[str, Any] = {
127        "style": ("solid" if item_count <= force_lines_styles else ["solid", "dashed", "dashdot", "dotted"]),
128        "width": (
129            get_setting("line_normal") if num_data_points > data_point_thresh else get_setting("line_wide")
130        ),
131        "color": get_color_list(item_count),
132        "alpha": 1.0,
133        "drawstyle": None,
134        "marker": None,
135        "markersize": 10,
136        "zorder": None,
137        "dropna": True,
138        "annotate": False,
139        "rounding": True,
140        "fontsize": "small",
141        "fontname": "Helvetica",
142        "rotation": 0,
143        "annotate_color": True,
144        "label_series": True,
145    }
146
147    return apply_defaults(item_count, line_defaults, dict(kwargs))

Get the plot-line attributes arguemnts.

Args: item_count: Number of data series to plot (columns in DataFrame) num_data_points: Number of data points in the series (rows in DataFrame) kwargs: LineKwargs - other arguments

Returns a tuple comprising: - swce: dict[str, list | tuple] - style, width, color, etc. for each line - kwargs_d: dict[str, Any] - the kwargs with defaults applied for the line plot

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