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