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
  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        if setting == "ylabel" and value is None and axes.get_ylabel():
120            # already set - probably in series_growth_plot() - so skip
121            continue
122        axes.set(**{setting: value})
123
124
125def apply_splat_kwargs(axes: Axes, settings: tuple, **kwargs) -> None:
126    """
127    Set matplotlib elements dynamically using setting_name and splat.
128    This is used for legend, axhspan, axvspan, axhline, and axvline.
129    These can be ignored if not in kwargs, or set to None in kwargs.
130    """
131
132    for method_name in settings:
133        if method_name in kwargs:
134            if method_name == "legend":
135                # special case for legend
136                make_legend(axes, kwargs[method_name])
137                continue
138
139            if kwargs[method_name] is None or kwargs[method_name] is False:
140                continue
141
142            if kwargs[method_name] is True:  # use the global default settings
143                kwargs[method_name] = get_setting(method_name)
144
145            # splat the kwargs to the method
146            if isinstance(kwargs[method_name], dict):
147                method = getattr(axes, method_name)
148                method(**kwargs[method_name])
149            else:
150                print(
151                    f"Warning expected dict argument for {method_name} but got "
152                    + f"{type(kwargs[method_name])}."
153                )
154
155
156def apply_annotations(axes: Axes, **kwargs) -> None:
157    """Set figure size and apply chart annotations."""
158
159    fig = axes.figure
160    fig_size = kwargs.get("figsize", get_setting("figsize"))
161    if not isinstance(fig, mpl.figure.SubFigure):
162        fig.set_size_inches(*fig_size)
163
164    annotations = {
165        "rfooter": (0.99, 0.001, "right", "bottom"),
166        "lfooter": (0.01, 0.001, "left", "bottom"),
167        "rheader": (0.99, 0.999, "right", "top"),
168        "lheader": (0.01, 0.999, "left", "top"),
169    }
170
171    for annotation in annotation_kwargs:
172        if annotation in kwargs:
173            x_pos, y_pos, h_align, v_align = annotations[annotation]
174            fig.text(
175                x_pos,
176                y_pos,
177                kwargs[annotation],
178                ha=h_align,
179                va=v_align,
180                fontsize=8,
181                fontstyle="italic",
182                color="#999999",
183            )
184
185
186def apply_late_kwargs(axes: Axes, **kwargs) -> None:
187    """Apply settings found in kwargs, after plotting the data."""
188    apply_splat_kwargs(axes, splat_kwargs, **kwargs)
189
190
191def apply_kwargs(axes: Axes, **kwargs) -> None:
192    """Apply settings found in kwargs."""
193
194    def check_kwargs(name):
195        return name in kwargs and kwargs[name]
196
197    apply_value_kwargs(axes, value_kwargs, **kwargs)
198    apply_annotations(axes, **kwargs)
199
200    if check_kwargs("zero_y"):
201        bottom, top = axes.get_ylim()
202        adj = (top - bottom) * 0.02
203        if bottom > -adj:
204            axes.set_ylim(bottom=-adj)
205        if top < adj:
206            axes.set_ylim(top=adj)
207
208    if check_kwargs("y0"):
209        low, high = axes.get_ylim()
210        if low < 0 < high:
211            axes.axhline(y=0, lw=0.66, c="#555555")
212
213    if check_kwargs("x0"):
214        low, high = axes.get_xlim()
215        if low < 0 < high:
216            axes.axvline(x=0, lw=0.66, c="#555555")
217
218
219def save_to_file(fig: Figure, **kwargs) -> None:
220    """Save the figure to file."""
221
222    saving = not kwargs.get("dont_save", False)  # save by default
223    if saving:
224        chart_dir = kwargs.get("chart_dir", get_setting("chart_dir"))
225        if not chart_dir.endswith("/"):
226            chart_dir += "/"
227
228        title = kwargs.get("title", "")
229        max_title_len = 150  # avoid overly long file names
230        shorter = title if len(title) < max_title_len else title[:max_title_len]
231        pre_tag = kwargs.get("pre_tag", "")
232        tag = kwargs.get("tag", "")
233        file_title = re.sub(_remove, "-", shorter).lower()
234        file_title = re.sub(_reduce, "-", file_title)
235        file_type = kwargs.get("file_type", get_setting("file_type")).lower()
236        dpi = kwargs.get("dpi", get_setting("dpi"))
237        fig.savefig(f"{chart_dir}{pre_tag}{file_title}-{tag}.{file_type}", dpi=dpi)
238
239
240# - public functions for finalise_plot()
241
242
243def finalise_plot(axes: Axes, **kwargs: Unpack[FinaliseKwargs]) -> None:
244    """
245    A function to finalise and save plots to the file system. The filename
246    for the saved plot is constructed from the global chart_dir, the plot's title,
247    any specified tag text, and the file_type for the plot.
248
249    Arguments:
250    - axes - matplotlib axes object - required
251    - kwargs: FinaliseKwargs
252
253     Returns:
254        - None
255    """
256
257    # --- check the kwargs
258    me = "finalise_plot"
259    report_kwargs(caller=me, **kwargs)
260    validate_kwargs(schema=FinaliseKwargs, caller=me, **kwargs)
261
262    # --- sanity checks
263    if len(axes.get_children()) < 1:
264        print("Warning: finalise_plot() called with empty axes, which was ignored.")
265        return
266
267    # --- remember axis-limits should we need to restore thems
268    xlim, ylim = axes.get_xlim(), axes.get_ylim()
269
270    # margins
271    axes.margins(0.02)
272    axes.autoscale(tight=False)  # This is problematic ...
273
274    apply_kwargs(axes, **kwargs)
275
276    # tight layout and save the figure
277    fig = axes.figure
278    if "preserve_lims" in kwargs and kwargs["preserve_lims"]:
279        # restore the original limits of the axes
280        axes.set_xlim(xlim)
281        axes.set_ylim(ylim)
282    if not isinstance(fig, mpl.figure.SubFigure):  # mypy
283        fig.tight_layout(pad=1.1)
284    apply_late_kwargs(axes, **kwargs)
285    legend = axes.get_legend()
286    if legend and kwargs.get("remove_legend", False):
287        legend.remove()
288    if not isinstance(fig, mpl.figure.SubFigure):  # mypy
289        save_to_file(fig, **kwargs)
290
291    # show the plot in Jupyter Lab
292    if "show" in kwargs and kwargs["show"]:
293        plt.show()
294
295    # And close
296    closing = True if "dont_close" not in kwargs else not kwargs["dont_close"]
297    if closing:
298        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        if setting == "ylabel" and value is None and axes.get_ylabel():
121            # already set - probably in series_growth_plot() - so skip
122            continue
123        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:
126def apply_splat_kwargs(axes: Axes, settings: tuple, **kwargs) -> None:
127    """
128    Set matplotlib elements dynamically using setting_name and splat.
129    This is used for legend, axhspan, axvspan, axhline, and axvline.
130    These can be ignored if not in kwargs, or set to None in kwargs.
131    """
132
133    for method_name in settings:
134        if method_name in kwargs:
135            if method_name == "legend":
136                # special case for legend
137                make_legend(axes, kwargs[method_name])
138                continue
139
140            if kwargs[method_name] is None or kwargs[method_name] is False:
141                continue
142
143            if kwargs[method_name] is True:  # use the global default settings
144                kwargs[method_name] = get_setting(method_name)
145
146            # splat the kwargs to the method
147            if isinstance(kwargs[method_name], dict):
148                method = getattr(axes, method_name)
149                method(**kwargs[method_name])
150            else:
151                print(
152                    f"Warning expected dict argument for {method_name} but got "
153                    + f"{type(kwargs[method_name])}."
154                )

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:
157def apply_annotations(axes: Axes, **kwargs) -> None:
158    """Set figure size and apply chart annotations."""
159
160    fig = axes.figure
161    fig_size = kwargs.get("figsize", get_setting("figsize"))
162    if not isinstance(fig, mpl.figure.SubFigure):
163        fig.set_size_inches(*fig_size)
164
165    annotations = {
166        "rfooter": (0.99, 0.001, "right", "bottom"),
167        "lfooter": (0.01, 0.001, "left", "bottom"),
168        "rheader": (0.99, 0.999, "right", "top"),
169        "lheader": (0.01, 0.999, "left", "top"),
170    }
171
172    for annotation in annotation_kwargs:
173        if annotation in kwargs:
174            x_pos, y_pos, h_align, v_align = annotations[annotation]
175            fig.text(
176                x_pos,
177                y_pos,
178                kwargs[annotation],
179                ha=h_align,
180                va=v_align,
181                fontsize=8,
182                fontstyle="italic",
183                color="#999999",
184            )

Set figure size and apply chart annotations.

def apply_late_kwargs(axes: matplotlib.axes._axes.Axes, **kwargs) -> None:
187def apply_late_kwargs(axes: Axes, **kwargs) -> None:
188    """Apply settings found in kwargs, after plotting the data."""
189    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:
192def apply_kwargs(axes: Axes, **kwargs) -> None:
193    """Apply settings found in kwargs."""
194
195    def check_kwargs(name):
196        return name in kwargs and kwargs[name]
197
198    apply_value_kwargs(axes, value_kwargs, **kwargs)
199    apply_annotations(axes, **kwargs)
200
201    if check_kwargs("zero_y"):
202        bottom, top = axes.get_ylim()
203        adj = (top - bottom) * 0.02
204        if bottom > -adj:
205            axes.set_ylim(bottom=-adj)
206        if top < adj:
207            axes.set_ylim(top=adj)
208
209    if check_kwargs("y0"):
210        low, high = axes.get_ylim()
211        if low < 0 < high:
212            axes.axhline(y=0, lw=0.66, c="#555555")
213
214    if check_kwargs("x0"):
215        low, high = axes.get_xlim()
216        if low < 0 < high:
217            axes.axvline(x=0, lw=0.66, c="#555555")

Apply settings found in kwargs.

def save_to_file(fig: matplotlib.figure.Figure, **kwargs) -> None:
220def save_to_file(fig: Figure, **kwargs) -> None:
221    """Save the figure to file."""
222
223    saving = not kwargs.get("dont_save", False)  # save by default
224    if saving:
225        chart_dir = kwargs.get("chart_dir", get_setting("chart_dir"))
226        if not chart_dir.endswith("/"):
227            chart_dir += "/"
228
229        title = kwargs.get("title", "")
230        max_title_len = 150  # avoid overly long file names
231        shorter = title if len(title) < max_title_len else title[:max_title_len]
232        pre_tag = kwargs.get("pre_tag", "")
233        tag = kwargs.get("tag", "")
234        file_title = re.sub(_remove, "-", shorter).lower()
235        file_title = re.sub(_reduce, "-", file_title)
236        file_type = kwargs.get("file_type", get_setting("file_type")).lower()
237        dpi = kwargs.get("dpi", get_setting("dpi"))
238        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:
244def finalise_plot(axes: Axes, **kwargs: Unpack[FinaliseKwargs]) -> None:
245    """
246    A function to finalise and save plots to the file system. The filename
247    for the saved plot is constructed from the global chart_dir, the plot's title,
248    any specified tag text, and the file_type for the plot.
249
250    Arguments:
251    - axes - matplotlib axes object - required
252    - kwargs: FinaliseKwargs
253
254     Returns:
255        - None
256    """
257
258    # --- check the kwargs
259    me = "finalise_plot"
260    report_kwargs(caller=me, **kwargs)
261    validate_kwargs(schema=FinaliseKwargs, caller=me, **kwargs)
262
263    # --- sanity checks
264    if len(axes.get_children()) < 1:
265        print("Warning: finalise_plot() called with empty axes, which was ignored.")
266        return
267
268    # --- remember axis-limits should we need to restore thems
269    xlim, ylim = axes.get_xlim(), axes.get_ylim()
270
271    # margins
272    axes.margins(0.02)
273    axes.autoscale(tight=False)  # This is problematic ...
274
275    apply_kwargs(axes, **kwargs)
276
277    # tight layout and save the figure
278    fig = axes.figure
279    if "preserve_lims" in kwargs and kwargs["preserve_lims"]:
280        # restore the original limits of the axes
281        axes.set_xlim(xlim)
282        axes.set_ylim(ylim)
283    if not isinstance(fig, mpl.figure.SubFigure):  # mypy
284        fig.tight_layout(pad=1.1)
285    apply_late_kwargs(axes, **kwargs)
286    legend = axes.get_legend()
287    if legend and kwargs.get("remove_legend", False):
288        legend.remove()
289    if not isinstance(fig, mpl.figure.SubFigure):  # mypy
290        save_to_file(fig, **kwargs)
291
292    # show the plot in Jupyter Lab
293    if "show" in kwargs and kwargs["show"]:
294        plt.show()
295
296    # And close
297    closing = True if "dont_close" not in kwargs else not kwargs["dont_close"]
298    if closing:
299        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