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
  8import math
  9from collections.abc import Sequence
 10from matplotlib.pyplot import Axes
 11from pandas import DataFrame, Series, Period
 12
 13from mgplot.settings import DataT, get_setting
 14from mgplot.kw_type_checking import (
 15    report_kwargs,
 16    validate_kwargs,
 17    validate_expected,
 18    ExpectedTypeDict,
 19)
 20from mgplot.utilities import (
 21    apply_defaults,
 22    get_color_list,
 23    get_axes,
 24    constrain_data,
 25    check_clean_timeseries,
 26    default_rounding,
 27)
 28from mgplot.keyword_names import (
 29    AX,
 30    DROPNA,
 31    PLOT_FROM,
 32    LABEL_SERIES,
 33    STYLE,
 34    DRAWSTYLE,
 35    MARKER,
 36    MARKERSIZE,
 37    WIDTH,
 38    COLOR,
 39    ALPHA,
 40    ANNOTATE,
 41    ROUNDING,
 42    FONTSIZE,
 43    FONTNAME,
 44    ROTATION,
 45    ANNOTATE_COLOR,
 46)
 47
 48# --- constants
 49LINE_KW_TYPES: ExpectedTypeDict = {
 50    AX: (Axes, type(None)),
 51    STYLE: (str, Sequence, (str,)),
 52    WIDTH: (float, int, Sequence, (float, int)),
 53    COLOR: (str, Sequence, (str,)),  # line color
 54    ALPHA: (float, Sequence, (float,)),
 55    DRAWSTYLE: (str, Sequence, (str,), type(None)),
 56    MARKER: (str, Sequence, (str,), type(None)),
 57    MARKERSIZE: (float, Sequence, (float,), int, type(None)),
 58    DROPNA: (bool, Sequence, (bool,)),
 59    ANNOTATE: (bool, Sequence, (bool,)),
 60    ROUNDING: (Sequence, (bool, int), int, bool, type(None)),
 61    FONTSIZE: (Sequence, (str, int), str, int, type(None)),
 62    FONTNAME: (str, Sequence, (str,)),
 63    ROTATION: (int, float, Sequence, (int, float)),
 64    ANNOTATE_COLOR: (str, Sequence, (str,), bool, Sequence, (bool,), type(None)),
 65    PLOT_FROM: (int, Period, type(None)),
 66    LABEL_SERIES: (bool, Sequence, (bool,), type(None)),
 67}
 68validate_expected(LINE_KW_TYPES, "line_plot")
 69
 70
 71# --- functions
 72def annotate_series(
 73    series: Series,
 74    axes: Axes,
 75    **kwargs,  # "fontsize", "rounding",
 76) -> None:
 77    """Annotate the right-hand end-point of a line-plotted series."""
 78
 79    # --- check the series has a value to annotate
 80    latest = series.dropna()
 81    if series.empty:
 82        return
 83    x, y = latest.index[-1], latest.iloc[-1]
 84    if y is None or math.isnan(y):
 85        return
 86
 87    # --- extract fontsize - could be None, bool, int or str.
 88    fontsize = kwargs.get(FONTSIZE, "small")
 89    if fontsize is None or isinstance(fontsize, bool):
 90        fontsize = "small"
 91    fontname = kwargs.get(FONTNAME, "Helvetica")
 92    rotation = kwargs.get(ROTATION, 0)
 93
 94    # --- add the annotation
 95    color = kwargs["color"]
 96    rounding = default_rounding(value=y, provided=kwargs.get(ROUNDING, None))
 97    r_string = f"  {y:.{rounding}f}" if rounding > 0 else f"  {int(y)}"
 98    axes.text(
 99        x=x,
100        y=y,
101        s=r_string,
102        ha="left",
103        va="center",
104        fontsize=fontsize,
105        font=fontname,
106        rotation=rotation,
107        color=color,
108    )
109
110
111def _get_style_width_color_etc(
112    item_count, num_data_points, **kwargs
113) -> tuple[dict[str, list | tuple], dict[str, Any]]:
114    """
115    Get the plot-line attributes arguemnts.
116    Returns a dictionary of lists of attributes for each line, and
117    a modified kwargs dictionary.
118    """
119
120    data_point_thresh = 151  # switch from wide to narrow lines
121    line_defaults: dict[str, Any] = {
122        STYLE: "solid" if item_count < 4 else ["solid", "dashed", "dashdot", "dotted"],
123        WIDTH: (
124            get_setting("line_normal")
125            if num_data_points > data_point_thresh
126            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, kwargs)
144
145
146def line_plot(data: DataT, **kwargs) -> Axes:
147    """
148    Build a single plot from the data passed in.
149    This can be a single- or multiple-line plot.
150    Return the axes object for the build.
151
152    Agruments:
153    - data: DataFrame | Series - data to plot
154    - kwargs:
155        /* chart wide arguments */
156        - ax: Axes | None - axes to plot on (optional)
157        /* individual line arguments */
158        - dropna: bool | list[bool] - whether to delete NAs frm the
159          data before plotting [optional]
160        - color: str | list[str] - line colors.
161        - width: float | list[float] - line widths [optional].
162        - style: str | list[str] - line styles [optional].
163        - alpha: float | list[float] - line transparencies [optional].
164        - marker: str | list[str] - line markers [optional].
165        - marker_size: float | list[float] - line marker sizes [optional].
166        /* end of line annotation arguments */
167        - annotate: bool | list[bool] - whether to annotate a series.
168        - rounding: int | bool | list[int | bool] - number of decimal places
169          to round an annotation. If True, a default between 0 and 2 is
170          used.
171        - fontsize: int | str | list[int | str] - font size for the
172          annotation.
173        - fontname: str - font name for the annotation.
174        - rotation: int | float | list[int | float] - rotation of the
175          annotation text.
176        - drawstyle: str | list[str] - matplotlib line draw styles.
177        - annotate_color: str | list[str] | bool | list[bool] - color
178          for the annotation text.  If True, the same color as the line.
179
180    Returns:
181    - axes: Axes - the axes object for the plot
182    """
183
184    # --- check the kwargs
185    me = "line_plot"
186    report_kwargs(called_from=me, **kwargs)
187    kwargs = validate_kwargs(LINE_KW_TYPES, me, **kwargs)
188
189    # --- check the data
190    data = check_clean_timeseries(data, me)
191    df = DataFrame(data)  # we are only plotting DataFrames
192    df, kwargs = constrain_data(df, **kwargs)
193
194    # --- some special defaults
195    kwargs[LABEL_SERIES] = (
196        kwargs.get(LABEL_SERIES, True)
197        if len(df.columns) > 1
198        else kwargs.get(LABEL_SERIES, False)
199    )
200
201    # --- Let's plot
202    axes, kwargs = get_axes(**kwargs)  # get the axes to plot on
203    if df.empty or df.isna().all().all():
204        # Note: finalise plot should ignore an empty axes object
205        print(f"Warning: No data to plot in {me}().")
206        return axes
207
208    # --- get the arguments for each line we will plot ...
209    item_count = len(df.columns)
210    num_data_points = len(df)
211    swce, kwargs = _get_style_width_color_etc(item_count, num_data_points, **kwargs)
212
213    for i, column in enumerate(df.columns):
214        series = df[column]
215        series = series.dropna() if DROPNA in swce and swce[DROPNA][i] else series
216        if series.empty or series.isna().all():
217            print(f"Warning: No data to plot for {column} in line_plot().")
218            continue
219
220        series.plot(
221            # Note: pandas will plot PeriodIndex against their ordinal values
222            ls=swce[STYLE][i],
223            lw=swce[WIDTH][i],
224            color=swce[COLOR][i],
225            alpha=swce[ALPHA][i],
226            marker=swce[MARKER][i],
227            ms=swce[MARKERSIZE][i],
228            drawstyle=swce[DRAWSTYLE][i],
229            label=(
230                column
231                if LABEL_SERIES in swce and swce[LABEL_SERIES][i]
232                else f"_{column}_"
233            ),
234            ax=axes,
235        )
236
237        if swce[ANNOTATE][i] is None or not swce[ANNOTATE][i]:
238            continue
239
240        color = (
241            swce[COLOR][i]
242            if swce[ANNOTATE_COLOR][i] is True
243            else swce[ANNOTATE_COLOR][i]
244        )
245        annotate_series(
246            series,
247            axes,
248            color=color,
249            rounding=swce[ROUNDING][i],
250            fontsize=swce[FONTSIZE][i],
251            fontname=swce[FONTNAME][i],
252            rotation=swce[ROTATION][i],
253        )
254
255    return axes
LINE_KW_TYPES: mgplot.kw_type_checking.ExpectedTypeDict = {'ax': (<class 'matplotlib.axes._axes.Axes'>, <class 'NoneType'>), 'style': (<class 'str'>, <class 'collections.abc.Sequence'>, (<class 'str'>,)), 'width': (<class 'float'>, <class 'int'>, <class 'collections.abc.Sequence'>, (<class 'float'>, <class 'int'>)), 'color': (<class 'str'>, <class 'collections.abc.Sequence'>, (<class 'str'>,)), 'alpha': (<class 'float'>, <class 'collections.abc.Sequence'>, (<class 'float'>,)), 'drawstyle': (<class 'str'>, <class 'collections.abc.Sequence'>, (<class 'str'>,), <class 'NoneType'>), 'marker': (<class 'str'>, <class 'collections.abc.Sequence'>, (<class 'str'>,), <class 'NoneType'>), 'markersize': (<class 'float'>, <class 'collections.abc.Sequence'>, (<class 'float'>,), <class 'int'>, <class 'NoneType'>), 'dropna': (<class 'bool'>, <class 'collections.abc.Sequence'>, (<class 'bool'>,)), 'annotate': (<class 'bool'>, <class 'collections.abc.Sequence'>, (<class 'bool'>,)), 'rounding': (<class 'collections.abc.Sequence'>, (<class 'bool'>, <class 'int'>), <class 'int'>, <class 'bool'>, <class 'NoneType'>), 'fontsize': (<class 'collections.abc.Sequence'>, (<class 'str'>, <class 'int'>), <class 'str'>, <class 'int'>, <class 'NoneType'>), 'fontname': (<class 'str'>, <class 'collections.abc.Sequence'>, (<class 'str'>,)), 'rotation': (<class 'int'>, <class 'float'>, <class 'collections.abc.Sequence'>, (<class 'int'>, <class 'float'>)), 'annotate_color': (<class 'str'>, <class 'collections.abc.Sequence'>, (<class 'str'>,), <class 'bool'>, <class 'collections.abc.Sequence'>, (<class 'bool'>,), <class 'NoneType'>), 'plot_from': (<class 'int'>, <class 'pandas._libs.tslibs.period.Period'>, <class 'NoneType'>), 'label_series': (<class 'bool'>, <class 'collections.abc.Sequence'>, (<class 'bool'>,), <class 'NoneType'>)}
def annotate_series( series: pandas.core.series.Series, axes: matplotlib.axes._axes.Axes, **kwargs) -> None:
 73def annotate_series(
 74    series: Series,
 75    axes: Axes,
 76    **kwargs,  # "fontsize", "rounding",
 77) -> None:
 78    """Annotate the right-hand end-point of a line-plotted series."""
 79
 80    # --- check the series has a value to annotate
 81    latest = series.dropna()
 82    if series.empty:
 83        return
 84    x, y = latest.index[-1], latest.iloc[-1]
 85    if y is None or math.isnan(y):
 86        return
 87
 88    # --- extract fontsize - could be None, bool, int or str.
 89    fontsize = kwargs.get(FONTSIZE, "small")
 90    if fontsize is None or isinstance(fontsize, bool):
 91        fontsize = "small"
 92    fontname = kwargs.get(FONTNAME, "Helvetica")
 93    rotation = kwargs.get(ROTATION, 0)
 94
 95    # --- add the annotation
 96    color = kwargs["color"]
 97    rounding = default_rounding(value=y, provided=kwargs.get(ROUNDING, None))
 98    r_string = f"  {y:.{rounding}f}" if rounding > 0 else f"  {int(y)}"
 99    axes.text(
100        x=x,
101        y=y,
102        s=r_string,
103        ha="left",
104        va="center",
105        fontsize=fontsize,
106        font=fontname,
107        rotation=rotation,
108        color=color,
109    )

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

def line_plot(data: ~DataT, **kwargs) -> matplotlib.axes._axes.Axes:
147def line_plot(data: DataT, **kwargs) -> Axes:
148    """
149    Build a single plot from the data passed in.
150    This can be a single- or multiple-line plot.
151    Return the axes object for the build.
152
153    Agruments:
154    - data: DataFrame | Series - data to plot
155    - kwargs:
156        /* chart wide arguments */
157        - ax: Axes | None - axes to plot on (optional)
158        /* individual line arguments */
159        - dropna: bool | list[bool] - whether to delete NAs frm the
160          data before plotting [optional]
161        - color: str | list[str] - line colors.
162        - width: float | list[float] - line widths [optional].
163        - style: str | list[str] - line styles [optional].
164        - alpha: float | list[float] - line transparencies [optional].
165        - marker: str | list[str] - line markers [optional].
166        - marker_size: float | list[float] - line marker sizes [optional].
167        /* end of line annotation arguments */
168        - annotate: bool | list[bool] - whether to annotate a series.
169        - rounding: int | bool | list[int | bool] - number of decimal places
170          to round an annotation. If True, a default between 0 and 2 is
171          used.
172        - fontsize: int | str | list[int | str] - font size for the
173          annotation.
174        - fontname: str - font name for the annotation.
175        - rotation: int | float | list[int | float] - rotation of the
176          annotation text.
177        - drawstyle: str | list[str] - matplotlib line draw styles.
178        - annotate_color: str | list[str] | bool | list[bool] - color
179          for the annotation text.  If True, the same color as the line.
180
181    Returns:
182    - axes: Axes - the axes object for the plot
183    """
184
185    # --- check the kwargs
186    me = "line_plot"
187    report_kwargs(called_from=me, **kwargs)
188    kwargs = validate_kwargs(LINE_KW_TYPES, me, **kwargs)
189
190    # --- check the data
191    data = check_clean_timeseries(data, me)
192    df = DataFrame(data)  # we are only plotting DataFrames
193    df, kwargs = constrain_data(df, **kwargs)
194
195    # --- some special defaults
196    kwargs[LABEL_SERIES] = (
197        kwargs.get(LABEL_SERIES, True)
198        if len(df.columns) > 1
199        else kwargs.get(LABEL_SERIES, False)
200    )
201
202    # --- Let's plot
203    axes, kwargs = get_axes(**kwargs)  # get the axes to plot on
204    if df.empty or df.isna().all().all():
205        # Note: finalise plot should ignore an empty axes object
206        print(f"Warning: No data to plot in {me}().")
207        return axes
208
209    # --- get the arguments for each line we will plot ...
210    item_count = len(df.columns)
211    num_data_points = len(df)
212    swce, kwargs = _get_style_width_color_etc(item_count, num_data_points, **kwargs)
213
214    for i, column in enumerate(df.columns):
215        series = df[column]
216        series = series.dropna() if DROPNA in swce and swce[DROPNA][i] else series
217        if series.empty or series.isna().all():
218            print(f"Warning: No data to plot for {column} in line_plot().")
219            continue
220
221        series.plot(
222            # Note: pandas will plot PeriodIndex against their ordinal values
223            ls=swce[STYLE][i],
224            lw=swce[WIDTH][i],
225            color=swce[COLOR][i],
226            alpha=swce[ALPHA][i],
227            marker=swce[MARKER][i],
228            ms=swce[MARKERSIZE][i],
229            drawstyle=swce[DRAWSTYLE][i],
230            label=(
231                column
232                if LABEL_SERIES in swce and swce[LABEL_SERIES][i]
233                else f"_{column}_"
234            ),
235            ax=axes,
236        )
237
238        if swce[ANNOTATE][i] is None or not swce[ANNOTATE][i]:
239            continue
240
241        color = (
242            swce[COLOR][i]
243            if swce[ANNOTATE_COLOR][i] is True
244            else swce[ANNOTATE_COLOR][i]
245        )
246        annotate_series(
247            series,
248            axes,
249            color=color,
250            rounding=swce[ROUNDING][i],
251            fontsize=swce[FONTSIZE][i],
252            fontname=swce[FONTNAME][i],
253            rotation=swce[ROTATION][i],
254        )
255
256    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.

Agruments:

  • data: DataFrame | Series - data to plot
  • kwargs: /* chart wide arguments /
    • ax: Axes | None - axes to plot on (optional) / individual line arguments /
    • dropna: bool | list[bool] - whether to delete NAs frm the data before plotting [optional]
    • color: str | list[str] - line colors.
    • width: float | list[float] - line widths [optional].
    • style: str | list[str] - line styles [optional].
    • alpha: float | list[float] - line transparencies [optional].
    • marker: str | list[str] - line markers [optional].
    • marker_size: float | list[float] - line marker sizes [optional]. / end of line annotation arguments */
    • annotate: bool | list[bool] - whether to annotate a series.
    • rounding: int | bool | list[int | bool] - number of decimal places to round an annotation. If True, a default between 0 and 2 is used.
    • fontsize: int | str | list[int | str] - font size for the annotation.
    • fontname: str - font name for the annotation.
    • rotation: int | float | list[int | float] - rotation of the annotation text.
    • drawstyle: str | list[str] - matplotlib line draw styles.
    • annotate_color: str | list[str] | bool | list[bool] - color for the annotation text. If True, the same color as the line.

Returns:

  • axes: Axes - the axes object for the plot