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.
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.
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