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