mgplot.finalise_plot
finalise_plot.py: This module provides a function to finalise and save plots to the file system. It is used to publish plots.
1""" 2finalise_plot.py: 3This module provides a function to finalise and save plots to the 4file system. It is used to publish plots. 5""" 6 7# --- imports 8from typing import Any, Final, NotRequired, Unpack, Callable 9from collections.abc import Sequence 10import re 11import matplotlib as mpl 12import matplotlib.pyplot as plt 13from matplotlib.pyplot import Axes, Figure 14 15from mgplot.settings import get_setting 16from mgplot.keyword_checking import validate_kwargs, report_kwargs, BaseKwargs 17 18 19# --- constants 20ME: Final[str] = "finalise_plot" 21 22 23class FinaliseKwargs(BaseKwargs): 24 """Keyword arguments for the finalise_plot function.""" 25 26 # --- value options 27 title: NotRequired[str | None] 28 xlabel: NotRequired[str | None] 29 ylabel: NotRequired[str | None] 30 xlim: NotRequired[tuple[float, float] | None] 31 ylim: NotRequired[tuple[float, float] | None] 32 xticks: NotRequired[list[float] | None] 33 yticks: NotRequired[list[float] | None] 34 x_scale: NotRequired[str | None] 35 y_scale: NotRequired[str | None] 36 # --- splat options 37 legend: NotRequired[bool | dict[str, Any] | None] 38 axhspan: NotRequired[dict[str, Any]] 39 axvspan: NotRequired[dict[str, Any]] 40 axhline: NotRequired[dict[str, Any]] 41 axvline: NotRequired[dict[str, Any]] 42 # --- options for annotations 43 lfooter: NotRequired[str] 44 rfooter: NotRequired[str] 45 lheader: NotRequired[str] 46 rheader: NotRequired[str] 47 # --- file/save options 48 pre_tag: NotRequired[str] 49 tag: NotRequired[str] 50 chart_dir: NotRequired[str] 51 file_type: NotRequired[str] 52 dpi: NotRequired[int] 53 figsize: NotRequired[tuple[float, float]] 54 show: NotRequired[bool] 55 # --- other options 56 preserve_lims: NotRequired[bool] 57 remove_legend: NotRequired[bool] 58 zero_y: NotRequired[bool] 59 y0: NotRequired[bool] 60 x0: NotRequired[bool] 61 dont_save: NotRequired[bool] 62 dont_close: NotRequired[bool] 63 64 65value_kwargs = ( 66 "title", 67 "xlabel", 68 "ylabel", 69 "xlim", 70 "ylim", 71 "xticks", 72 "yticks", 73 "x_scale", 74 "y_scale", 75) 76splat_kwargs = ( 77 "legend", 78 "axhspan", 79 "axvspan", 80 "axhline", 81 "axvline", 82) 83annotation_kwargs = ( 84 "lfooter", 85 "rfooter", 86 "lheader", 87 "rheader", 88) 89 90 91# filename limitations - regex used to map the plot title to a filename 92_remove = re.compile(r"[^0-9A-Za-z]") # sensible file names from alphamum title 93_reduce = re.compile(r"[-]+") # eliminate multiple hyphens 94 95 96def make_legend(axes: Axes, legend: None | bool | dict[str, Any]) -> None: 97 """Create a legend for the plot.""" 98 99 if legend is None or legend is False: 100 return 101 102 if legend is True: # use the global default settings 103 legend = get_setting("legend") 104 105 if isinstance(legend, dict): 106 axes.legend(**legend) 107 return 108 109 print(f"Warning: expected dict argument for legend, but got {type(legend)}.") 110 111 112def apply_value_kwargs(axes: Axes, settings: Sequence[str], **kwargs) -> None: 113 """Set matplotlib elements by name using Axes.set().""" 114 115 for setting in settings: 116 value = kwargs.get(setting, None) 117 if value is None and setting not in ("title", "xlabel", "ylabel"): 118 continue 119 function: dict[str, Callable[[], str]] = { 120 "xlabel": axes.get_xlabel, 121 "ylabel": axes.get_ylabel, 122 "title": axes.get_title, 123 } 124 125 def fail() -> str: 126 return "" 127 128 if value is None and function.get(setting, fail)(): 129 # setting is already set 130 continue 131 axes.set(**{setting: value}) 132 133 134def apply_splat_kwargs(axes: Axes, settings: tuple, **kwargs) -> None: 135 """ 136 Set matplotlib elements dynamically using setting_name and splat. 137 This is used for legend, axhspan, axvspan, axhline, and axvline. 138 These can be ignored if not in kwargs, or set to None in kwargs. 139 """ 140 141 for method_name in settings: 142 if method_name in kwargs: 143 if method_name == "legend": 144 # special case for legend 145 make_legend(axes, kwargs[method_name]) 146 continue 147 148 if kwargs[method_name] is None or kwargs[method_name] is False: 149 continue 150 151 if kwargs[method_name] is True: # use the global default settings 152 kwargs[method_name] = get_setting(method_name) 153 154 # splat the kwargs to the method 155 if isinstance(kwargs[method_name], dict): 156 method = getattr(axes, method_name) 157 method(**kwargs[method_name]) 158 else: 159 print( 160 f"Warning expected dict argument for {method_name} but got " 161 + f"{type(kwargs[method_name])}." 162 ) 163 164 165def apply_annotations(axes: Axes, **kwargs) -> None: 166 """Set figure size and apply chart annotations.""" 167 168 fig = axes.figure 169 fig_size = kwargs.get("figsize", get_setting("figsize")) 170 if not isinstance(fig, mpl.figure.SubFigure): 171 fig.set_size_inches(*fig_size) 172 173 annotations = { 174 "rfooter": (0.99, 0.001, "right", "bottom"), 175 "lfooter": (0.01, 0.001, "left", "bottom"), 176 "rheader": (0.99, 0.999, "right", "top"), 177 "lheader": (0.01, 0.999, "left", "top"), 178 } 179 180 for annotation in annotation_kwargs: 181 if annotation in kwargs: 182 x_pos, y_pos, h_align, v_align = annotations[annotation] 183 fig.text( 184 x_pos, 185 y_pos, 186 kwargs[annotation], 187 ha=h_align, 188 va=v_align, 189 fontsize=8, 190 fontstyle="italic", 191 color="#999999", 192 ) 193 194 195def apply_late_kwargs(axes: Axes, **kwargs) -> None: 196 """Apply settings found in kwargs, after plotting the data.""" 197 apply_splat_kwargs(axes, splat_kwargs, **kwargs) 198 199 200def apply_kwargs(axes: Axes, **kwargs) -> None: 201 """Apply settings found in kwargs.""" 202 203 def check_kwargs(name): 204 return name in kwargs and kwargs[name] 205 206 apply_value_kwargs(axes, value_kwargs, **kwargs) 207 apply_annotations(axes, **kwargs) 208 209 if check_kwargs("zero_y"): 210 bottom, top = axes.get_ylim() 211 adj = (top - bottom) * 0.02 212 if bottom > -adj: 213 axes.set_ylim(bottom=-adj) 214 if top < adj: 215 axes.set_ylim(top=adj) 216 217 if check_kwargs("y0"): 218 low, high = axes.get_ylim() 219 if low < 0 < high: 220 axes.axhline(y=0, lw=0.66, c="#555555") 221 222 if check_kwargs("x0"): 223 low, high = axes.get_xlim() 224 if low < 0 < high: 225 axes.axvline(x=0, lw=0.66, c="#555555") 226 227 228def save_to_file(fig: Figure, **kwargs) -> None: 229 """Save the figure to file.""" 230 231 saving = not kwargs.get("dont_save", False) # save by default 232 if saving: 233 chart_dir = kwargs.get("chart_dir", get_setting("chart_dir")) 234 if not chart_dir.endswith("/"): 235 chart_dir += "/" 236 237 title = kwargs.get("title", "") 238 max_title_len = 150 # avoid overly long file names 239 shorter = title if len(title) < max_title_len else title[:max_title_len] 240 pre_tag = kwargs.get("pre_tag", "") 241 tag = kwargs.get("tag", "") 242 file_title = re.sub(_remove, "-", shorter).lower() 243 file_title = re.sub(_reduce, "-", file_title) 244 file_type = kwargs.get("file_type", get_setting("file_type")).lower() 245 dpi = kwargs.get("dpi", get_setting("dpi")) 246 fig.savefig(f"{chart_dir}{pre_tag}{file_title}-{tag}.{file_type}", dpi=dpi) 247 248 249# - public functions for finalise_plot() 250 251 252def finalise_plot(axes: Axes, **kwargs: Unpack[FinaliseKwargs]) -> None: 253 """ 254 A function to finalise and save plots to the file system. The filename 255 for the saved plot is constructed from the global chart_dir, the plot's title, 256 any specified tag text, and the file_type for the plot. 257 258 Arguments: 259 - axes - matplotlib axes object - required 260 - kwargs: FinaliseKwargs 261 262 Returns: 263 - None 264 """ 265 266 # --- check the kwargs 267 me = "finalise_plot" 268 report_kwargs(caller=me, **kwargs) 269 validate_kwargs(schema=FinaliseKwargs, caller=me, **kwargs) 270 271 # --- sanity checks 272 if len(axes.get_children()) < 1: 273 print("Warning: finalise_plot() called with empty axes, which was ignored.") 274 return 275 276 # --- remember axis-limits should we need to restore thems 277 xlim, ylim = axes.get_xlim(), axes.get_ylim() 278 279 # margins 280 axes.margins(0.02) 281 axes.autoscale(tight=False) # This is problematic ... 282 283 apply_kwargs(axes, **kwargs) 284 285 # tight layout and save the figure 286 fig = axes.figure 287 if "preserve_lims" in kwargs and kwargs["preserve_lims"]: 288 # restore the original limits of the axes 289 axes.set_xlim(xlim) 290 axes.set_ylim(ylim) 291 if not isinstance(fig, mpl.figure.SubFigure): # mypy 292 fig.tight_layout(pad=1.1) 293 apply_late_kwargs(axes, **kwargs) 294 legend = axes.get_legend() 295 if legend and kwargs.get("remove_legend", False): 296 legend.remove() 297 if not isinstance(fig, mpl.figure.SubFigure): # mypy 298 save_to_file(fig, **kwargs) 299 300 # show the plot in Jupyter Lab 301 if "show" in kwargs and kwargs["show"]: 302 plt.show() 303 304 # And close 305 closing = True if "dont_close" not in kwargs else not kwargs["dont_close"] 306 if closing: 307 plt.close()
24class FinaliseKwargs(BaseKwargs): 25 """Keyword arguments for the finalise_plot function.""" 26 27 # --- value options 28 title: NotRequired[str | None] 29 xlabel: NotRequired[str | None] 30 ylabel: NotRequired[str | None] 31 xlim: NotRequired[tuple[float, float] | None] 32 ylim: NotRequired[tuple[float, float] | None] 33 xticks: NotRequired[list[float] | None] 34 yticks: NotRequired[list[float] | None] 35 x_scale: NotRequired[str | None] 36 y_scale: NotRequired[str | None] 37 # --- splat options 38 legend: NotRequired[bool | dict[str, Any] | None] 39 axhspan: NotRequired[dict[str, Any]] 40 axvspan: NotRequired[dict[str, Any]] 41 axhline: NotRequired[dict[str, Any]] 42 axvline: NotRequired[dict[str, Any]] 43 # --- options for annotations 44 lfooter: NotRequired[str] 45 rfooter: NotRequired[str] 46 lheader: NotRequired[str] 47 rheader: NotRequired[str] 48 # --- file/save options 49 pre_tag: NotRequired[str] 50 tag: NotRequired[str] 51 chart_dir: NotRequired[str] 52 file_type: NotRequired[str] 53 dpi: NotRequired[int] 54 figsize: NotRequired[tuple[float, float]] 55 show: NotRequired[bool] 56 # --- other options 57 preserve_lims: NotRequired[bool] 58 remove_legend: NotRequired[bool] 59 zero_y: NotRequired[bool] 60 y0: NotRequired[bool] 61 x0: NotRequired[bool] 62 dont_save: NotRequired[bool] 63 dont_close: NotRequired[bool]
Keyword arguments for the finalise_plot function.
97def make_legend(axes: Axes, legend: None | bool | dict[str, Any]) -> None: 98 """Create a legend for the plot.""" 99 100 if legend is None or legend is False: 101 return 102 103 if legend is True: # use the global default settings 104 legend = get_setting("legend") 105 106 if isinstance(legend, dict): 107 axes.legend(**legend) 108 return 109 110 print(f"Warning: expected dict argument for legend, but got {type(legend)}.")
Create a legend for the plot.
113def apply_value_kwargs(axes: Axes, settings: Sequence[str], **kwargs) -> None: 114 """Set matplotlib elements by name using Axes.set().""" 115 116 for setting in settings: 117 value = kwargs.get(setting, None) 118 if value is None and setting not in ("title", "xlabel", "ylabel"): 119 continue 120 function: dict[str, Callable[[], str]] = { 121 "xlabel": axes.get_xlabel, 122 "ylabel": axes.get_ylabel, 123 "title": axes.get_title, 124 } 125 126 def fail() -> str: 127 return "" 128 129 if value is None and function.get(setting, fail)(): 130 # setting is already set 131 continue 132 axes.set(**{setting: value})
Set matplotlib elements by name using Axes.set().
135def apply_splat_kwargs(axes: Axes, settings: tuple, **kwargs) -> None: 136 """ 137 Set matplotlib elements dynamically using setting_name and splat. 138 This is used for legend, axhspan, axvspan, axhline, and axvline. 139 These can be ignored if not in kwargs, or set to None in kwargs. 140 """ 141 142 for method_name in settings: 143 if method_name in kwargs: 144 if method_name == "legend": 145 # special case for legend 146 make_legend(axes, kwargs[method_name]) 147 continue 148 149 if kwargs[method_name] is None or kwargs[method_name] is False: 150 continue 151 152 if kwargs[method_name] is True: # use the global default settings 153 kwargs[method_name] = get_setting(method_name) 154 155 # splat the kwargs to the method 156 if isinstance(kwargs[method_name], dict): 157 method = getattr(axes, method_name) 158 method(**kwargs[method_name]) 159 else: 160 print( 161 f"Warning expected dict argument for {method_name} but got " 162 + f"{type(kwargs[method_name])}." 163 )
Set matplotlib elements dynamically using setting_name and splat. This is used for legend, axhspan, axvspan, axhline, and axvline. These can be ignored if not in kwargs, or set to None in kwargs.
166def apply_annotations(axes: Axes, **kwargs) -> None: 167 """Set figure size and apply chart annotations.""" 168 169 fig = axes.figure 170 fig_size = kwargs.get("figsize", get_setting("figsize")) 171 if not isinstance(fig, mpl.figure.SubFigure): 172 fig.set_size_inches(*fig_size) 173 174 annotations = { 175 "rfooter": (0.99, 0.001, "right", "bottom"), 176 "lfooter": (0.01, 0.001, "left", "bottom"), 177 "rheader": (0.99, 0.999, "right", "top"), 178 "lheader": (0.01, 0.999, "left", "top"), 179 } 180 181 for annotation in annotation_kwargs: 182 if annotation in kwargs: 183 x_pos, y_pos, h_align, v_align = annotations[annotation] 184 fig.text( 185 x_pos, 186 y_pos, 187 kwargs[annotation], 188 ha=h_align, 189 va=v_align, 190 fontsize=8, 191 fontstyle="italic", 192 color="#999999", 193 )
Set figure size and apply chart annotations.
196def apply_late_kwargs(axes: Axes, **kwargs) -> None: 197 """Apply settings found in kwargs, after plotting the data.""" 198 apply_splat_kwargs(axes, splat_kwargs, **kwargs)
Apply settings found in kwargs, after plotting the data.
201def apply_kwargs(axes: Axes, **kwargs) -> None: 202 """Apply settings found in kwargs.""" 203 204 def check_kwargs(name): 205 return name in kwargs and kwargs[name] 206 207 apply_value_kwargs(axes, value_kwargs, **kwargs) 208 apply_annotations(axes, **kwargs) 209 210 if check_kwargs("zero_y"): 211 bottom, top = axes.get_ylim() 212 adj = (top - bottom) * 0.02 213 if bottom > -adj: 214 axes.set_ylim(bottom=-adj) 215 if top < adj: 216 axes.set_ylim(top=adj) 217 218 if check_kwargs("y0"): 219 low, high = axes.get_ylim() 220 if low < 0 < high: 221 axes.axhline(y=0, lw=0.66, c="#555555") 222 223 if check_kwargs("x0"): 224 low, high = axes.get_xlim() 225 if low < 0 < high: 226 axes.axvline(x=0, lw=0.66, c="#555555")
Apply settings found in kwargs.
229def save_to_file(fig: Figure, **kwargs) -> None: 230 """Save the figure to file.""" 231 232 saving = not kwargs.get("dont_save", False) # save by default 233 if saving: 234 chart_dir = kwargs.get("chart_dir", get_setting("chart_dir")) 235 if not chart_dir.endswith("/"): 236 chart_dir += "/" 237 238 title = kwargs.get("title", "") 239 max_title_len = 150 # avoid overly long file names 240 shorter = title if len(title) < max_title_len else title[:max_title_len] 241 pre_tag = kwargs.get("pre_tag", "") 242 tag = kwargs.get("tag", "") 243 file_title = re.sub(_remove, "-", shorter).lower() 244 file_title = re.sub(_reduce, "-", file_title) 245 file_type = kwargs.get("file_type", get_setting("file_type")).lower() 246 dpi = kwargs.get("dpi", get_setting("dpi")) 247 fig.savefig(f"{chart_dir}{pre_tag}{file_title}-{tag}.{file_type}", dpi=dpi)
Save the figure to file.
253def finalise_plot(axes: Axes, **kwargs: Unpack[FinaliseKwargs]) -> None: 254 """ 255 A function to finalise and save plots to the file system. The filename 256 for the saved plot is constructed from the global chart_dir, the plot's title, 257 any specified tag text, and the file_type for the plot. 258 259 Arguments: 260 - axes - matplotlib axes object - required 261 - kwargs: FinaliseKwargs 262 263 Returns: 264 - None 265 """ 266 267 # --- check the kwargs 268 me = "finalise_plot" 269 report_kwargs(caller=me, **kwargs) 270 validate_kwargs(schema=FinaliseKwargs, caller=me, **kwargs) 271 272 # --- sanity checks 273 if len(axes.get_children()) < 1: 274 print("Warning: finalise_plot() called with empty axes, which was ignored.") 275 return 276 277 # --- remember axis-limits should we need to restore thems 278 xlim, ylim = axes.get_xlim(), axes.get_ylim() 279 280 # margins 281 axes.margins(0.02) 282 axes.autoscale(tight=False) # This is problematic ... 283 284 apply_kwargs(axes, **kwargs) 285 286 # tight layout and save the figure 287 fig = axes.figure 288 if "preserve_lims" in kwargs and kwargs["preserve_lims"]: 289 # restore the original limits of the axes 290 axes.set_xlim(xlim) 291 axes.set_ylim(ylim) 292 if not isinstance(fig, mpl.figure.SubFigure): # mypy 293 fig.tight_layout(pad=1.1) 294 apply_late_kwargs(axes, **kwargs) 295 legend = axes.get_legend() 296 if legend and kwargs.get("remove_legend", False): 297 legend.remove() 298 if not isinstance(fig, mpl.figure.SubFigure): # mypy 299 save_to_file(fig, **kwargs) 300 301 # show the plot in Jupyter Lab 302 if "show" in kwargs and kwargs["show"]: 303 plt.show() 304 305 # And close 306 closing = True if "dont_close" not in kwargs else not kwargs["dont_close"] 307 if closing: 308 plt.close()
A function to finalise and save plots to the file system. The filename for the saved plot is constructed from the global chart_dir, the plot's title, any specified tag text, and the file_type for the plot.
Arguments:
- axes - matplotlib axes object - required
- kwargs: FinaliseKwargs
Returns: - None