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

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._libs.tslibs.period.Period | None]
label_series: NotRequired[bool | Sequence[bool] | None]
max_ticks: NotRequired[int]
class AnnotateKwargs(typing.TypedDict):
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

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.core.series.Series, axes: matplotlib.axes._axes.Axes, **kwargs: Unpack[AnnotateKwargs]) -> None:
 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    )

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]]:
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))

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:
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(axes, saved_pi[1], kwargs_d.get("max_ticks", get_setting("max_ticks")))
228
229    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