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 Final, Any 9import re 10import matplotlib as mpl 11import matplotlib.pyplot as plt 12from matplotlib.pyplot import Axes, Figure 13import matplotlib.dates as mdates 14 15from mgplot.settings import get_setting 16from mgplot.kw_type_checking import ( 17 report_kwargs, 18 validate_expected, 19 ExpectedTypeDict, 20 validate_kwargs, 21) 22 23 24# --- constants 25# filename limitations - regex used to map the plot title to a filename 26_remove = re.compile(r"[^0-9A-Za-z]") # sensible file names from alphamum title 27_reduce = re.compile(r"[-]+") # eliminate multiple hyphens 28 29# map of the acceptable kwargs for finalise_plot() 30# make sure "legend" is last in the _splat_kwargs tuple ... 31_splat_kwargs = ("axhspan", "axvspan", "axhline", "axvline", "legend") 32_value_must_kwargs = ("title", "xlabel", "ylabel") 33_value_may_kwargs = ("ylim", "xlim", "yscale", "xscale") 34_value_kwargs = _value_must_kwargs + _value_may_kwargs 35_annotation_kwargs = ("lfooter", "rfooter", "lheader", "rheader") 36 37_file_kwargs = ("pre_tag", "tag", "chart_dir", "file_type", "dpi") 38_fig_kwargs = ("figsize", "show", "preserve_lims", "remove_legend") 39_oth_kwargs = ( 40 "zero_y", 41 "y0", 42 "x0", 43 "dont_save", 44 "dont_close", 45 "concise_dates", 46) 47_ACCEPTABLE_KWARGS = frozenset( 48 _value_kwargs 49 + _splat_kwargs 50 + _file_kwargs 51 + _annotation_kwargs 52 + _fig_kwargs 53 + _oth_kwargs 54) 55 56FINALISE_KW_TYPES: Final[ExpectedTypeDict] = { 57 # - value kwargs 58 "title": (str, type(None)), 59 "xlabel": (str, type(None)), 60 "ylabel": (str, type(None)), 61 "ylim": (tuple, (float, int), type(None)), 62 "xlim": (tuple, (float, int), type(None)), 63 "yscale": (str, type(None)), 64 "xscale": (str, type(None)), 65 # - splat kwargs 66 "legend": (dict, (str, (int, float, str)), bool, type(None)), 67 "axhspan": (dict, (str, (int, float, str)), type(None)), 68 "axvspan": (dict, (str, (int, float, str)), type(None)), 69 "axhline": (dict, (str, (int, float, str)), type(None)), 70 "axvline": (dict, (str, (int, float, str)), type(None)), 71 # - file kwargs 72 "pre_tag": str, 73 "tag": str, 74 "chart_dir": str, 75 "file_type": str, 76 "dpi": int, 77 # - fig kwargs 78 "remove_legend": (type(None), bool), 79 "preserve_lims": (type(None), bool), 80 "figsize": (tuple, (float, int)), 81 "show": bool, 82 # - annotation kwargs 83 "lfooter": str, 84 "rfooter": str, 85 "lheader": str, 86 "rheader": str, 87 # - Other kwargs 88 "zero_y": bool, 89 "y0": bool, 90 "x0": bool, 91 "dont_save": bool, 92 "dont_close": bool, 93 "concise_dates": bool, 94} 95validate_expected(FINALISE_KW_TYPES, "finalise_plot") 96 97 98def _internal_consistency_kwargs(): 99 """Quick check to ensure that the kwargs checkers are consistent.""" 100 101 bad = False 102 for k in FINALISE_KW_TYPES: 103 if k not in _ACCEPTABLE_KWARGS: 104 bad = True 105 print(f"Key {k} in FINALISE_KW_TYPES but not _ACCEPTABLE_KWARGS") 106 107 for k in _ACCEPTABLE_KWARGS: 108 if k not in FINALISE_KW_TYPES: 109 bad = True 110 print(f"Key {k} in _ACCEPTABLE_KWARGS but not FINALISE_KW_TYPES") 111 112 if bad: 113 raise RuntimeError( 114 "Internal error: _ACCEPTABLE_KWARGS and FINALISE_KW_TYPES are inconsistent." 115 ) 116 117 118_internal_consistency_kwargs() 119 120 121# - private utility functions for finalise_plot() 122 123 124def make_legend(axes: Axes, legend: None | bool | dict[str, Any]) -> None: 125 """Create a legend for the plot.""" 126 127 if legend is None or legend is False: 128 return 129 130 if legend is True: # use the global default settings 131 legend = get_setting("legend") 132 133 if isinstance(legend, dict): 134 axes.legend(**legend) 135 return 136 137 print(f"Warning: expected dict argument for legend, but got {type(legend)}.") 138 139 140def _apply_value_kwargs(axes: Axes, settings: tuple, **kwargs) -> None: 141 """Set matplotlib elements by name using Axes.set().""" 142 143 for setting in settings: 144 value = kwargs.get(setting, None) 145 if value is None and setting not in _value_must_kwargs: 146 continue 147 if setting == "ylabel" and value is None and axes.get_ylabel(): 148 # already set - probably in series_growth_plot() - so skip 149 continue 150 axes.set(**{setting: value}) 151 152 153def _apply_splat_kwargs(axes: Axes, settings: tuple, **kwargs) -> None: 154 """ 155 Set matplotlib elements dynamically using setting_name and splat. 156 This is used for legend, axhspan, axvspan, axhline, and axvline. 157 These can be ignored if not in kwargs, or set to None in kwargs. 158 """ 159 160 for method_name in settings: 161 if method_name in kwargs: 162 163 if method_name == "legend": 164 # special case for legend 165 make_legend(axes, kwargs[method_name]) 166 continue 167 168 if kwargs[method_name] is None or kwargs[method_name] is False: 169 continue 170 171 if kwargs[method_name] is True: # use the global default settings 172 kwargs[method_name] = get_setting(method_name) 173 174 # splat the kwargs to the method 175 if isinstance(kwargs[method_name], dict): 176 method = getattr(axes, method_name) 177 method(**kwargs[method_name]) 178 else: 179 print( 180 f"Warning expected dict argument for {method_name} but got " 181 + f"{type(kwargs[method_name])}." 182 ) 183 184 185def _apply_annotations(axes: Axes, **kwargs) -> None: 186 """Set figure size and apply chart annotations.""" 187 188 fig = axes.figure 189 fig_size = get_setting("figsize") if "figsize" not in kwargs else kwargs["figsize"] 190 if not isinstance(fig, mpl.figure.SubFigure): 191 fig.set_size_inches(*fig_size) 192 193 annotations = { 194 "rfooter": (0.99, 0.001, "right", "bottom"), 195 "lfooter": (0.01, 0.001, "left", "bottom"), 196 "rheader": (0.99, 0.999, "right", "top"), 197 "lheader": (0.01, 0.999, "left", "top"), 198 } 199 200 for annotation in _annotation_kwargs: 201 if annotation in kwargs: 202 x_pos, y_pos, h_align, v_align = annotations[annotation] 203 fig.text( 204 x_pos, 205 y_pos, 206 kwargs[annotation], 207 ha=h_align, 208 va=v_align, 209 fontsize=8, 210 fontstyle="italic", 211 color="#999999", 212 ) 213 214 215def _apply_late_kwargs(axes: Axes, **kwargs) -> None: 216 """Apply settings found in kwargs, after plotting the data.""" 217 _apply_splat_kwargs(axes, _splat_kwargs, **kwargs) 218 219 220def _apply_kwargs(axes: Axes, **kwargs) -> None: 221 """Apply settings found in kwargs.""" 222 223 def check_kwargs(name): 224 return name in kwargs and kwargs[name] 225 226 _apply_value_kwargs(axes, _value_kwargs, **kwargs) 227 _apply_annotations(axes, **kwargs) 228 229 if check_kwargs("zero_y"): 230 bottom, top = axes.get_ylim() 231 adj = (top - bottom) * 0.02 232 if bottom > -adj: 233 axes.set_ylim(bottom=-adj) 234 if top < adj: 235 axes.set_ylim(top=adj) 236 237 if check_kwargs("y0"): 238 low, high = axes.get_ylim() 239 if low < 0 < high: 240 axes.axhline(y=0, lw=0.66, c="#555555") 241 242 if check_kwargs("x0"): 243 low, high = axes.get_xlim() 244 if low < 0 < high: 245 axes.axvline(x=0, lw=0.66, c="#555555") 246 247 if check_kwargs("concise_dates"): 248 locator = mdates.AutoDateLocator() 249 formatter = mdates.ConciseDateFormatter(locator) 250 axes.xaxis.set_major_locator(locator) 251 axes.xaxis.set_major_formatter(formatter) 252 253 254def _save_to_file(fig: Figure, **kwargs) -> None: 255 """Save the figure to file.""" 256 257 saving = not kwargs.get("dont_save", False) # save by default 258 if saving: 259 chart_dir = kwargs.get("chart_dir", get_setting("chart_dir")) 260 if not chart_dir.endswith("/"): 261 chart_dir += "/" 262 263 title = "" if "title" not in kwargs else kwargs["title"] 264 max_title_len = 150 # avoid overly long file names 265 shorter = title if len(title) < max_title_len else title[:max_title_len] 266 pre_tag = kwargs.get("pre_tag", "") 267 tag = kwargs.get("tag", "") 268 file_title = re.sub(_remove, "-", shorter).lower() 269 file_title = re.sub(_reduce, "-", file_title) 270 file_type = kwargs.get("file_type", get_setting("file_type")).lower() 271 dpi = kwargs.get("dpi", get_setting("file_dpi")) 272 fig.savefig(f"{chart_dir}{pre_tag}{file_title}-{tag}.{file_type}", dpi=dpi) 273 274 275# - public functions for finalise_plot() 276 277 278def finalise_plot(axes: Axes, **kwargs) -> None: 279 """ 280 A function to finalise and save plots to the file system. The filename 281 for the saved plot is constructed from the global chart_dir, the plot's title, 282 any specified tag text, and the file_type for the plot. 283 284 Arguments: 285 - axes - matplotlib axes object - required 286 - kwargs 287 - title: str - plot title, also used to create the save file name 288 - xlabel: str | None - text label for the x-axis 289 - ylabel: str | None - label for the y-axis 290 - pre_tag: str - text before the title in file name 291 - tag: str - text after the title in the file name 292 (useful for ensuring that same titled charts do not over-write) 293 - chart_dir: str - location of the chart directory 294 - file_type: str - specify a file type - eg. 'png' or 'svg' 295 - lfooter: str - text to display on bottom left of plot 296 - rfooter: str - text to display of bottom right of plot 297 - lheader: str - text to display on top left of plot 298 - rheader: str - text to display of top right of plot 299 - figsize: tuple[float, float] - figure size in inches - eg. (8, 4) 300 - show: bool - whether to show the plot or not 301 - zero_y: bool - ensure y=0 is included in the plot. 302 - y0: bool - highlight the y=0 line on the plot (if in scope) 303 - x0: bool - highlights the x=0 line on the plot 304 - dont_save: bool - dont save the plot to the file system 305 - dont_close: bool - dont close the plot 306 - dpi: int - dots per inch for the saved chart 307 - legend: bool | dict - if dict, use as the arguments to pass to axes.legend(), 308 if True pass the global default arguments to axes.legend() 309 - axhspan: dict - arguments to pass to axes.axhspan() 310 - axvspan: dict - arguments to pass to axes.axvspan() 311 - axhline: dict - arguments to pass to axes.axhline() 312 - axvline: dict - arguments to pass to axes.axvline() 313 - ylim: tuple[float, float] - set lower and upper y-axis limits 314 - xlim: tuple[float, float] - set lower and upper x-axis limits 315 - preserve_lims: bool - if True, preserve the original axes limits, 316 lims saved at the start, and restored after the tight layout 317 - remove_legend: bool | None - if True, remove the legend from the plot 318 - report_kwargs: bool - if True, report the kwargs used in this function 319 320 Returns: 321 - None 322 """ 323 324 # --- check the kwargs 325 me = "finalise_plot" 326 report_kwargs(called_from=me, **kwargs) 327 validate_kwargs(FINALISE_KW_TYPES, me, **kwargs) 328 329 # --- sanity checks 330 if len(axes.get_children()) < 1: 331 print("Warning: finalise_plot() called with empty axes, which was ignored.") 332 return 333 334 # --- remember should we need to restore the axes limits 335 xlim, ylim = axes.get_xlim(), axes.get_ylim() 336 337 # margins 338 # axes.use_sticky_margins = False ### CHECK THIS 339 axes.margins(0.02) 340 axes.autoscale(tight=False) # This is problematic ... 341 342 _apply_kwargs(axes, **kwargs) 343 344 # tight layout and save the figure 345 fig = axes.figure 346 if not isinstance(fig, mpl.figure.SubFigure): # should never be a SubFigure 347 fig.tight_layout(pad=1.1) 348 if "preserve_lims" in kwargs and kwargs["preserve_lims"]: 349 # restore the original limits of the axes 350 axes.set_xlim(xlim) 351 axes.set_ylim(ylim) 352 _apply_late_kwargs(axes, **kwargs) 353 legend = axes.get_legend() 354 if legend and kwargs.get("remove_legend", False): 355 legend.remove() 356 _save_to_file(fig, **kwargs) 357 358 # show the plot in Jupyter Lab 359 if "show" in kwargs and kwargs["show"]: 360 plt.show() 361 362 # And close 363 closing = True if "dont_close" not in kwargs else not kwargs["dont_close"] 364 if closing: 365 plt.close()
FINALISE_KW_TYPES: Final[ExpectedTypeDict] =
{'title': (<class 'str'>, <class 'NoneType'>), 'xlabel': (<class 'str'>, <class 'NoneType'>), 'ylabel': (<class 'str'>, <class 'NoneType'>), 'ylim': (<class 'tuple'>, (<class 'float'>, <class 'int'>), <class 'NoneType'>), 'xlim': (<class 'tuple'>, (<class 'float'>, <class 'int'>), <class 'NoneType'>), 'yscale': (<class 'str'>, <class 'NoneType'>), 'xscale': (<class 'str'>, <class 'NoneType'>), 'legend': (<class 'dict'>, (<class 'str'>, (<class 'int'>, <class 'float'>, <class 'str'>)), <class 'bool'>, <class 'NoneType'>), 'axhspan': (<class 'dict'>, (<class 'str'>, (<class 'int'>, <class 'float'>, <class 'str'>)), <class 'NoneType'>), 'axvspan': (<class 'dict'>, (<class 'str'>, (<class 'int'>, <class 'float'>, <class 'str'>)), <class 'NoneType'>), 'axhline': (<class 'dict'>, (<class 'str'>, (<class 'int'>, <class 'float'>, <class 'str'>)), <class 'NoneType'>), 'axvline': (<class 'dict'>, (<class 'str'>, (<class 'int'>, <class 'float'>, <class 'str'>)), <class 'NoneType'>), 'pre_tag': <class 'str'>, 'tag': <class 'str'>, 'chart_dir': <class 'str'>, 'file_type': <class 'str'>, 'dpi': <class 'int'>, 'remove_legend': (<class 'NoneType'>, <class 'bool'>), 'preserve_lims': (<class 'NoneType'>, <class 'bool'>), 'figsize': (<class 'tuple'>, (<class 'float'>, <class 'int'>)), 'show': <class 'bool'>, 'lfooter': <class 'str'>, 'rfooter': <class 'str'>, 'lheader': <class 'str'>, 'rheader': <class 'str'>, 'zero_y': <class 'bool'>, 'y0': <class 'bool'>, 'x0': <class 'bool'>, 'dont_save': <class 'bool'>, 'dont_close': <class 'bool'>, 'concise_dates': <class 'bool'>}
def
make_legend( axes: matplotlib.axes._axes.Axes, legend: None | bool | dict[str, typing.Any]) -> None:
125def make_legend(axes: Axes, legend: None | bool | dict[str, Any]) -> None: 126 """Create a legend for the plot.""" 127 128 if legend is None or legend is False: 129 return 130 131 if legend is True: # use the global default settings 132 legend = get_setting("legend") 133 134 if isinstance(legend, dict): 135 axes.legend(**legend) 136 return 137 138 print(f"Warning: expected dict argument for legend, but got {type(legend)}.")
Create a legend for the plot.
def
finalise_plot(axes: matplotlib.axes._axes.Axes, **kwargs) -> None:
279def finalise_plot(axes: Axes, **kwargs) -> None: 280 """ 281 A function to finalise and save plots to the file system. The filename 282 for the saved plot is constructed from the global chart_dir, the plot's title, 283 any specified tag text, and the file_type for the plot. 284 285 Arguments: 286 - axes - matplotlib axes object - required 287 - kwargs 288 - title: str - plot title, also used to create the save file name 289 - xlabel: str | None - text label for the x-axis 290 - ylabel: str | None - label for the y-axis 291 - pre_tag: str - text before the title in file name 292 - tag: str - text after the title in the file name 293 (useful for ensuring that same titled charts do not over-write) 294 - chart_dir: str - location of the chart directory 295 - file_type: str - specify a file type - eg. 'png' or 'svg' 296 - lfooter: str - text to display on bottom left of plot 297 - rfooter: str - text to display of bottom right of plot 298 - lheader: str - text to display on top left of plot 299 - rheader: str - text to display of top right of plot 300 - figsize: tuple[float, float] - figure size in inches - eg. (8, 4) 301 - show: bool - whether to show the plot or not 302 - zero_y: bool - ensure y=0 is included in the plot. 303 - y0: bool - highlight the y=0 line on the plot (if in scope) 304 - x0: bool - highlights the x=0 line on the plot 305 - dont_save: bool - dont save the plot to the file system 306 - dont_close: bool - dont close the plot 307 - dpi: int - dots per inch for the saved chart 308 - legend: bool | dict - if dict, use as the arguments to pass to axes.legend(), 309 if True pass the global default arguments to axes.legend() 310 - axhspan: dict - arguments to pass to axes.axhspan() 311 - axvspan: dict - arguments to pass to axes.axvspan() 312 - axhline: dict - arguments to pass to axes.axhline() 313 - axvline: dict - arguments to pass to axes.axvline() 314 - ylim: tuple[float, float] - set lower and upper y-axis limits 315 - xlim: tuple[float, float] - set lower and upper x-axis limits 316 - preserve_lims: bool - if True, preserve the original axes limits, 317 lims saved at the start, and restored after the tight layout 318 - remove_legend: bool | None - if True, remove the legend from the plot 319 - report_kwargs: bool - if True, report the kwargs used in this function 320 321 Returns: 322 - None 323 """ 324 325 # --- check the kwargs 326 me = "finalise_plot" 327 report_kwargs(called_from=me, **kwargs) 328 validate_kwargs(FINALISE_KW_TYPES, me, **kwargs) 329 330 # --- sanity checks 331 if len(axes.get_children()) < 1: 332 print("Warning: finalise_plot() called with empty axes, which was ignored.") 333 return 334 335 # --- remember should we need to restore the axes limits 336 xlim, ylim = axes.get_xlim(), axes.get_ylim() 337 338 # margins 339 # axes.use_sticky_margins = False ### CHECK THIS 340 axes.margins(0.02) 341 axes.autoscale(tight=False) # This is problematic ... 342 343 _apply_kwargs(axes, **kwargs) 344 345 # tight layout and save the figure 346 fig = axes.figure 347 if not isinstance(fig, mpl.figure.SubFigure): # should never be a SubFigure 348 fig.tight_layout(pad=1.1) 349 if "preserve_lims" in kwargs and kwargs["preserve_lims"]: 350 # restore the original limits of the axes 351 axes.set_xlim(xlim) 352 axes.set_ylim(ylim) 353 _apply_late_kwargs(axes, **kwargs) 354 legend = axes.get_legend() 355 if legend and kwargs.get("remove_legend", False): 356 legend.remove() 357 _save_to_file(fig, **kwargs) 358 359 # show the plot in Jupyter Lab 360 if "show" in kwargs and kwargs["show"]: 361 plt.show() 362 363 # And close 364 closing = True if "dont_close" not in kwargs else not kwargs["dont_close"] 365 if closing: 366 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
- title: str - plot title, also used to create the save file name
- xlabel: str | None - text label for the x-axis
- ylabel: str | None - label for the y-axis
- pre_tag: str - text before the title in file name
- tag: str - text after the title in the file name (useful for ensuring that same titled charts do not over-write)
- chart_dir: str - location of the chart directory
- file_type: str - specify a file type - eg. 'png' or 'svg'
- lfooter: str - text to display on bottom left of plot
- rfooter: str - text to display of bottom right of plot
- lheader: str - text to display on top left of plot
- rheader: str - text to display of top right of plot
- figsize: tuple[float, float] - figure size in inches - eg. (8, 4)
- show: bool - whether to show the plot or not
- zero_y: bool - ensure y=0 is included in the plot.
- y0: bool - highlight the y=0 line on the plot (if in scope)
- x0: bool - highlights the x=0 line on the plot
- dont_save: bool - dont save the plot to the file system
- dont_close: bool - dont close the plot
- dpi: int - dots per inch for the saved chart
- legend: bool | dict - if dict, use as the arguments to pass to axes.legend(), if True pass the global default arguments to axes.legend()
- axhspan: dict - arguments to pass to axes.axhspan()
- axvspan: dict - arguments to pass to axes.axvspan()
- axhline: dict - arguments to pass to axes.axhline()
- axvline: dict - arguments to pass to axes.axvline()
- ylim: tuple[float, float] - set lower and upper y-axis limits
- xlim: tuple[float, float] - set lower and upper x-axis limits
- preserve_lims: bool - if True, preserve the original axes limits, lims saved at the start, and restored after the tight layout
- remove_legend: bool | None - if True, remove the legend from the plot
- report_kwargs: bool - if True, report the kwargs used in this function
Returns: - None