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

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]
class AnnotateKwargs(typing.TypedDict):
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

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

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