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()
ME: Final[str] = 'finalise_plot'
class FinaliseKwargs(mgplot.keyword_checking.BaseKwargs):
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.

title: NotRequired[str | None]
xlabel: NotRequired[str | None]
ylabel: NotRequired[str | None]
xlim: NotRequired[tuple[float, float] | None]
ylim: NotRequired[tuple[float, float] | None]
xticks: NotRequired[list[float] | None]
yticks: NotRequired[list[float] | None]
x_scale: NotRequired[str | None]
y_scale: NotRequired[str | None]
legend: NotRequired[bool | dict[str, Any] | None]
axhspan: NotRequired[dict[str, Any]]
axvspan: NotRequired[dict[str, Any]]
axhline: NotRequired[dict[str, Any]]
axvline: NotRequired[dict[str, Any]]
lfooter: NotRequired[str]
rfooter: NotRequired[str]
lheader: NotRequired[str]
rheader: NotRequired[str]
pre_tag: NotRequired[str]
tag: NotRequired[str]
chart_dir: NotRequired[str]
file_type: NotRequired[str]
dpi: NotRequired[int]
figsize: NotRequired[tuple[float, float]]
show: NotRequired[bool]
preserve_lims: NotRequired[bool]
remove_legend: NotRequired[bool]
zero_y: NotRequired[bool]
y0: NotRequired[bool]
x0: NotRequired[bool]
dont_save: NotRequired[bool]
dont_close: NotRequired[bool]
value_kwargs = ('title', 'xlabel', 'ylabel', 'xlim', 'ylim', 'xticks', 'yticks', 'x_scale', 'y_scale')
splat_kwargs = ('legend', 'axhspan', 'axvspan', 'axhline', 'axvline')
annotation_kwargs = ('lfooter', 'rfooter', 'lheader', 'rheader')
def make_legend( axes: matplotlib.axes._axes.Axes, legend: None | bool | dict[str, typing.Any]) -> None:
 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.

def apply_value_kwargs( axes: matplotlib.axes._axes.Axes, settings: Sequence[str], **kwargs) -> None:
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().

def apply_splat_kwargs(axes: matplotlib.axes._axes.Axes, settings: tuple, **kwargs) -> None:
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.

def apply_annotations(axes: matplotlib.axes._axes.Axes, **kwargs) -> None:
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.

def apply_late_kwargs(axes: matplotlib.axes._axes.Axes, **kwargs) -> None:
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.

def apply_kwargs(axes: matplotlib.axes._axes.Axes, **kwargs) -> None:
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.

def save_to_file(fig: matplotlib.figure.Figure, **kwargs) -> None:
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.

def finalise_plot( axes: matplotlib.axes._axes.Axes, **kwargs: Unpack[FinaliseKwargs]) -> None:
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