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