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