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