mgplot.finalise_plot
Functions to finalise and save plots to the file system.
1"""Functions to finalise and save plots to the file system.""" 2 3import re 4import unicodedata 5from collections.abc import Callable, Sequence 6from pathlib import Path 7from typing import Any, Final, NotRequired, Unpack 8 9import matplotlib.pyplot as plt 10from matplotlib.axes import Axes 11from matplotlib.figure import Figure, SubFigure 12from pandas import Period, PeriodIndex 13 14from mgplot.axis_utils import get_period_axes, refresh_period_labels, register_period_axes 15from mgplot.keyword_checking import BaseKwargs, report_kwargs, validate_kwargs 16from mgplot.settings import get_setting 17 18# --- constants 19ME: Final[str] = "finalise_plot" 20MAX_FILENAME_LENGTH: Final[int] = 150 21DEFAULT_MARGIN: Final[float] = 0.02 22TIGHT_LAYOUT_PAD: Final[float] = 1.1 23FOOTNOTE_FONTSIZE: Final[int] = 8 24FOOTNOTE_FONTSTYLE: Final[str] = "italic" 25FOOTNOTE_COLOR: Final[str] = "#999999" 26ZERO_LINE_WIDTH: Final[float] = 0.66 27ZERO_LINE_COLOR: Final[str] = "#555555" 28ZERO_AXIS_ADJUSTMENT: Final[float] = 0.02 29DEFAULT_FILE_TITLE_NAME: Final[str] = "plot" 30 31 32class FinaliseKwargs(BaseKwargs): 33 """Keyword arguments for the finalise_plot function.""" 34 35 # --- value options 36 suptitle: NotRequired[str | None] 37 title: NotRequired[str | None] 38 xlabel: NotRequired[str | None] 39 ylabel: NotRequired[str | None] 40 xlim: NotRequired[tuple[float, float] | None] 41 ylim: NotRequired[tuple[float, float] | None] 42 xticks: NotRequired[list[float] | None] 43 yticks: NotRequired[list[float] | None] 44 xscale: NotRequired[str | None] 45 yscale: NotRequired[str | None] 46 # --- splat options 47 legend: NotRequired[bool | dict[str, Any] | None] 48 axhspan: NotRequired[dict[str, Any] | Sequence[dict[str, Any]] | None] 49 axvspan: NotRequired[dict[str, Any] | Sequence[dict[str, Any]] | None] 50 axhline: NotRequired[dict[str, Any] | Sequence[dict[str, Any]] | None] 51 axvline: NotRequired[dict[str, Any] | Sequence[dict[str, Any]] | None] 52 # --- options for annotations 53 lfooter: NotRequired[str] 54 rfooter: NotRequired[str] 55 lheader: NotRequired[str] 56 rheader: NotRequired[str] 57 # --- file/save options 58 pre_tag: NotRequired[str] 59 tag: NotRequired[str] 60 filename: NotRequired[str] 61 chart_dir: NotRequired[str] 62 file_type: NotRequired[str] 63 dpi: NotRequired[int] 64 figsize: NotRequired[tuple[float, float]] 65 show: NotRequired[bool] 66 # --- other options 67 preserve_lims: NotRequired[bool] 68 remove_legend: NotRequired[bool] 69 zero_y: NotRequired[bool] 70 y0: NotRequired[bool] 71 x0: NotRequired[bool] 72 axisbelow: NotRequired[bool] 73 dont_save: NotRequired[bool] 74 dont_close: NotRequired[bool] 75 axes_only: NotRequired[bool] 76 77 78VALUE_KWARGS = ( 79 "title", 80 "xlabel", 81 "ylabel", 82 "xlim", 83 "ylim", 84 "xticks", 85 "yticks", 86 "xscale", 87 "yscale", 88) 89SPLAT_KWARGS = ( 90 "axhspan", 91 "axvspan", 92 "axhline", 93 "axvline", 94 "legend", # needs to be last in this tuple 95) 96HEADER_FOOTER_KWARGS = ( 97 "lfooter", 98 "rfooter", 99 "lheader", 100 "rheader", 101) 102 103 104def sanitize_filename(filename: str, max_length: int = MAX_FILENAME_LENGTH) -> str: 105 """Convert a string to a safe filename. 106 107 Args: 108 filename: The string to convert to a filename 109 max_length: Maximum length for the filename 110 111 Returns: 112 A safe filename string 113 114 """ 115 if not filename: 116 return "untitled" 117 118 # Normalize unicode characters (e.g., é -> e) 119 filename = unicodedata.normalize("NFKD", filename) 120 121 # Remove non-ASCII characters 122 filename = filename.encode("ascii", "ignore").decode("ascii") 123 124 # Convert to lowercase 125 filename = filename.lower() 126 127 # Replace spaces and other separators with hyphens 128 filename = re.sub(r"[\s\-_]+", "-", filename) 129 130 # Remove unsafe characters, keeping only alphanumeric and hyphens 131 filename = re.sub(r"[^a-z0-9\-]", "", filename) 132 133 # Remove leading/trailing hyphens and collapse multiple hyphens 134 filename = re.sub(r"^-+|-+$", "", filename) 135 filename = re.sub(r"-+", "-", filename) 136 137 # Truncate to max length 138 if len(filename) > max_length: 139 filename = filename[:max_length].rstrip("-") 140 141 # Ensure we have a valid filename 142 return filename or "untitled" 143 144 145def make_legend(axes: Axes, *, legend: None | bool | dict[str, Any]) -> None: 146 """Create a legend for the plot.""" 147 if legend is None or legend is False: 148 return 149 150 if legend is True: # use the global default settings 151 legend = get_setting("legend") 152 153 if isinstance(legend, dict): 154 axes.legend(**legend) 155 return 156 157 print(f"Warning: expected dict argument for legend, but got {type(legend)}.") 158 159 160def apply_value_kwargs(axes: Axes, value_kwargs_: Sequence[str], **kwargs: Unpack[FinaliseKwargs]) -> None: 161 """Set matplotlib elements by name using Axes.set(). 162 163 Tricky: some plotting functions may set the xlabel or ylabel. 164 So ... we will set these if a setting is explicitly provided. If no 165 setting is provided, we will set to None if they are not already set. 166 If they have already been set, we will not change them. 167 168 """ 169 # --- preliminary 170 function: dict[str, Callable[[], str]] = { 171 "xlabel": axes.get_xlabel, 172 "ylabel": axes.get_ylabel, 173 "title": axes.get_title, 174 } 175 176 def fail() -> str: 177 return "" 178 179 # --- loop over potential value settings 180 for setting in value_kwargs_: 181 value = kwargs.get(setting) 182 if setting in kwargs: 183 # deliberately set, so we will action 184 axes.set(**{setting: value}) 185 continue 186 required_to_set = ("title", "xlabel", "ylabel") 187 if setting not in required_to_set: 188 # not set - and not required - so we can skip 189 continue 190 191 # we will set these 'required_to_set' ones 192 # provided they are not already set 193 already_set = function.get(setting, fail)() 194 if already_set and value is None: 195 continue 196 197 # if we get here, we will set the value (implicitly to None) 198 axes.set(**{setting: value}) 199 200 201_SplatValue = bool | dict[str, Any] | Sequence[dict[str, Any]] | None 202 203# Keys in each splat-method's kwargs that are x-axis coordinates — when the 204# plot uses a PeriodIndex the axis is mapped to Period ordinals, so a Period 205# passed here must be converted to its ordinal for matplotlib. 206_PERIOD_COORD_KEYS: Final[dict[str, tuple[str, ...]]] = { 207 "axvline": ("x",), 208 "axvspan": ("xmin", "xmax"), 209} 210 211 212def _convert_period_coords(axes: Axes, method_name: str, item: dict[str, Any]) -> dict[str, Any]: 213 """Return a copy of item with any Period x-coordinates replaced by ordinals. 214 215 If the axes was period-mapped by mgplot, the Period's freq must match the 216 axes' stashed freq — otherwise the ordinals live in different spaces. 217 On an axes with no stash we trust the programmer and just take .ordinal. 218 """ 219 keys = _PERIOD_COORD_KEYS.get(method_name) 220 if not keys: 221 return item 222 stash = get_period_axes(axes) 223 stashed_freq = stash[0] if stash is not None else None 224 converted = dict(item) 225 for key in keys: 226 val = converted.get(key) 227 if isinstance(val, Period): 228 if stashed_freq is not None and val.freqstr != stashed_freq: 229 raise ValueError( 230 f"{method_name} Period freq {val.freqstr!r} does not match axes freq {stashed_freq!r}", 231 ) 232 if stashed_freq is not None: 233 # Widen the stash so later label refresh covers this coordinate, 234 # even if it falls outside the plotted data's ordinal range. 235 register_period_axes(axes, PeriodIndex([val])) 236 converted[key] = val.ordinal 237 return converted 238 239 240def _apply_splat(axes: Axes, method_name: str, value: _SplatValue) -> None: 241 """Apply a single splat kwarg, which may be a dict or sequence of dicts.""" 242 if value is None or value is False: 243 return 244 245 if value is True: # use the global default settings 246 value = get_setting(method_name) 247 248 # normalise to a list of dicts 249 if isinstance(value, dict): 250 value = [value] 251 252 if isinstance(value, Sequence): 253 method = getattr(axes, method_name) 254 for item in value: 255 if isinstance(item, dict): 256 method(**_convert_period_coords(axes, method_name, item)) 257 else: 258 print(f"Warning: expected dict in {method_name} sequence, but got {type(item)}.") 259 else: 260 print(f"Warning: expected dict or sequence of dicts for {method_name}, but got {type(value)}.") 261 262 263def apply_splat_kwargs(axes: Axes, settings: tuple, **kwargs: Unpack[FinaliseKwargs]) -> None: 264 """Set matplotlib elements dynamically using setting_name and splat.""" 265 for method_name in settings: 266 if method_name not in kwargs: 267 continue 268 269 if method_name == "legend": 270 legend_value = kwargs.get(method_name) 271 if isinstance(legend_value, (bool, dict, type(None))): 272 make_legend(axes, legend=legend_value) 273 else: 274 print(f"Warning: expected bool, dict, or None for legend, but got {type(legend_value)}.") 275 continue 276 277 _apply_splat(axes, method_name, kwargs.get(method_name)) 278 279 280def apply_annotations(axes: Axes, **kwargs: Unpack[FinaliseKwargs]) -> None: 281 """Set figure size and apply chart annotations. 282 283 No-op when axes_only=True: the work here is all figure-level (resize, 284 corner text) and would stomp on other panels in a multi-axes figure. 285 """ 286 if kwargs.get("axes_only"): 287 return 288 fig = axes.figure 289 fig_size = kwargs.get("figsize", get_setting("figsize")) 290 if not isinstance(fig, SubFigure): 291 fig.set_size_inches(*fig_size) 292 293 annotations = { 294 "rfooter": (0.99, 0.001, "right", "bottom"), 295 "lfooter": (0.01, 0.001, "left", "bottom"), 296 "rheader": (0.99, 0.999, "right", "top"), 297 "lheader": (0.01, 0.999, "left", "top"), 298 } 299 300 for annotation in HEADER_FOOTER_KWARGS: 301 if annotation in kwargs: 302 x_pos, y_pos, h_align, v_align = annotations[annotation] 303 fig.text( 304 x_pos, 305 y_pos, 306 str(kwargs.get(annotation, "")), 307 ha=h_align, 308 va=v_align, 309 fontsize=FOOTNOTE_FONTSIZE, 310 fontstyle=FOOTNOTE_FONTSTYLE, 311 color=FOOTNOTE_COLOR, 312 ) 313 314 315def apply_late_kwargs(axes: Axes, **kwargs: Unpack[FinaliseKwargs]) -> None: 316 """Apply settings found in kwargs, after plotting the data.""" 317 apply_splat_kwargs(axes, SPLAT_KWARGS, **kwargs) 318 319 320def apply_kwargs(axes: Axes, **kwargs: Unpack[FinaliseKwargs]) -> None: 321 """Apply settings found in kwargs.""" 322 323 def check_kwargs(name: str) -> bool: 324 return name in kwargs and bool(kwargs.get(name)) 325 326 apply_value_kwargs(axes, VALUE_KWARGS, **kwargs) 327 apply_annotations(axes, **kwargs) 328 329 if check_kwargs("zero_y"): 330 bottom, top = axes.get_ylim() 331 adj = (top - bottom) * ZERO_AXIS_ADJUSTMENT 332 if bottom > -adj: 333 axes.set_ylim(bottom=-adj) 334 if top < adj: 335 axes.set_ylim(top=adj) 336 337 if check_kwargs("y0"): 338 low, high = axes.get_ylim() 339 if low < 0 < high: 340 axes.axhline(y=0, lw=ZERO_LINE_WIDTH, c=ZERO_LINE_COLOR) 341 342 if check_kwargs("x0"): 343 low, high = axes.get_xlim() 344 if low < 0 < high: 345 axes.axvline(x=0, lw=ZERO_LINE_WIDTH, c=ZERO_LINE_COLOR) 346 347 if check_kwargs("axisbelow"): 348 axes.set_axisbelow(True) 349 350 351def save_to_file(fig: Figure, **kwargs: Unpack[FinaliseKwargs]) -> None: 352 """Save the figure to file.""" 353 saving = not kwargs.get("dont_save", False) # save by default 354 if not saving: 355 return 356 357 try: 358 chart_dir = Path(kwargs.get("chart_dir", get_setting("chart_dir"))) 359 360 # Ensure directory exists 361 chart_dir.mkdir(parents=True, exist_ok=True) 362 363 suptitle = kwargs.get("suptitle", "") 364 title = kwargs.get("title", "") 365 pre_tag = kwargs.get("pre_tag", "") 366 tag = kwargs.get("tag", "") 367 name_override = kwargs.get("filename", "") 368 name_title = name_override or suptitle or title 369 file_title = sanitize_filename(name_title or DEFAULT_FILE_TITLE_NAME) 370 file_type = kwargs.get("file_type", get_setting("file_type")).lower() 371 dpi = kwargs.get("dpi", get_setting("dpi")) 372 373 # Construct filename components safely 374 filename_parts = [] 375 if pre_tag: 376 filename_parts.append(sanitize_filename(pre_tag)) 377 filename_parts.append(file_title) 378 if tag: 379 filename_parts.append(sanitize_filename(tag)) 380 381 # Join filename parts and add extension 382 filename = "-".join(filter(None, filename_parts)) 383 filepath = chart_dir / f"{filename}.{file_type}" 384 385 fig.savefig(filepath, dpi=dpi) 386 387 except ( 388 OSError, 389 PermissionError, 390 FileNotFoundError, 391 ValueError, 392 RuntimeError, 393 TypeError, 394 UnicodeError, 395 ) as e: 396 print(f"Error: Could not save plot to file: {e}") 397 398 399# - public functions for finalise_plot() 400 401 402def finalise_plot(axes: Axes, **kwargs: Unpack[FinaliseKwargs]) -> None: 403 """Finalise and save plots to the file system. 404 405 The filename for the saved plot is constructed from the global 406 chart_dir, the plot's title, any specified tag text, and the 407 file_type for the plot. 408 409 Args: 410 axes: Axes - matplotlib axes object - required 411 kwargs: FinaliseKwargs 412 413 """ 414 # --- check the kwargs 415 report_kwargs(caller=ME, **kwargs) 416 validate_kwargs(schema=FinaliseKwargs, caller=ME, **kwargs) 417 418 # --- sanity checks 419 if len(axes.get_children()) < 1: 420 print(f"Warning: {ME}() called with an empty axes, which was ignored.") 421 return 422 423 # --- remember axis-limits should we need to restore thems 424 xlim, ylim = axes.get_xlim(), axes.get_ylim() 425 426 # margins 427 axes.margins(DEFAULT_MARGIN) 428 axes.autoscale(tight=False) # This is problematic ... 429 430 apply_kwargs(axes, **kwargs) 431 432 # tight layout and save the figure 433 fig = axes.figure 434 axes_only = kwargs.get("axes_only", False) 435 if not axes_only and (suptitle := kwargs.get("suptitle")): 436 fig.suptitle(suptitle) 437 if kwargs.get("preserve_lims"): 438 # restore the original limits of the axes 439 axes.set_xlim(xlim) 440 axes.set_ylim(ylim) 441 if not axes_only and not isinstance(fig, SubFigure): 442 fig.tight_layout(pad=TIGHT_LAYOUT_PAD) 443 apply_late_kwargs(axes, **kwargs) 444 # axvspan/axvline in late_kwargs may have widened xlim beyond what 445 # set_labels() last saw; regenerate ticks from the updated view. 446 refresh_period_labels(axes) 447 legend = axes.get_legend() 448 if legend and kwargs.get("remove_legend", False): 449 legend.remove() 450 if not axes_only and not isinstance(fig, SubFigure): 451 save_to_file(fig, **kwargs) 452 453 # show the plot in Jupyter Lab 454 if not axes_only and kwargs.get("show"): 455 plt.show() 456 457 # And close 458 if not axes_only and not kwargs.get("dont_close", False): 459 plt.close()
33class FinaliseKwargs(BaseKwargs): 34 """Keyword arguments for the finalise_plot function.""" 35 36 # --- value options 37 suptitle: NotRequired[str | None] 38 title: NotRequired[str | None] 39 xlabel: NotRequired[str | None] 40 ylabel: NotRequired[str | None] 41 xlim: NotRequired[tuple[float, float] | None] 42 ylim: NotRequired[tuple[float, float] | None] 43 xticks: NotRequired[list[float] | None] 44 yticks: NotRequired[list[float] | None] 45 xscale: NotRequired[str | None] 46 yscale: NotRequired[str | None] 47 # --- splat options 48 legend: NotRequired[bool | dict[str, Any] | None] 49 axhspan: NotRequired[dict[str, Any] | Sequence[dict[str, Any]] | None] 50 axvspan: NotRequired[dict[str, Any] | Sequence[dict[str, Any]] | None] 51 axhline: NotRequired[dict[str, Any] | Sequence[dict[str, Any]] | None] 52 axvline: NotRequired[dict[str, Any] | Sequence[dict[str, Any]] | None] 53 # --- options for annotations 54 lfooter: NotRequired[str] 55 rfooter: NotRequired[str] 56 lheader: NotRequired[str] 57 rheader: NotRequired[str] 58 # --- file/save options 59 pre_tag: NotRequired[str] 60 tag: NotRequired[str] 61 filename: NotRequired[str] 62 chart_dir: NotRequired[str] 63 file_type: NotRequired[str] 64 dpi: NotRequired[int] 65 figsize: NotRequired[tuple[float, float]] 66 show: NotRequired[bool] 67 # --- other options 68 preserve_lims: NotRequired[bool] 69 remove_legend: NotRequired[bool] 70 zero_y: NotRequired[bool] 71 y0: NotRequired[bool] 72 x0: NotRequired[bool] 73 axisbelow: NotRequired[bool] 74 dont_save: NotRequired[bool] 75 dont_close: NotRequired[bool] 76 axes_only: NotRequired[bool]
Keyword arguments for the finalise_plot function.
105def sanitize_filename(filename: str, max_length: int = MAX_FILENAME_LENGTH) -> str: 106 """Convert a string to a safe filename. 107 108 Args: 109 filename: The string to convert to a filename 110 max_length: Maximum length for the filename 111 112 Returns: 113 A safe filename string 114 115 """ 116 if not filename: 117 return "untitled" 118 119 # Normalize unicode characters (e.g., é -> e) 120 filename = unicodedata.normalize("NFKD", filename) 121 122 # Remove non-ASCII characters 123 filename = filename.encode("ascii", "ignore").decode("ascii") 124 125 # Convert to lowercase 126 filename = filename.lower() 127 128 # Replace spaces and other separators with hyphens 129 filename = re.sub(r"[\s\-_]+", "-", filename) 130 131 # Remove unsafe characters, keeping only alphanumeric and hyphens 132 filename = re.sub(r"[^a-z0-9\-]", "", filename) 133 134 # Remove leading/trailing hyphens and collapse multiple hyphens 135 filename = re.sub(r"^-+|-+$", "", filename) 136 filename = re.sub(r"-+", "-", filename) 137 138 # Truncate to max length 139 if len(filename) > max_length: 140 filename = filename[:max_length].rstrip("-") 141 142 # Ensure we have a valid filename 143 return filename or "untitled"
Convert a string to a safe filename.
Args: filename: The string to convert to a filename max_length: Maximum length for the filename
Returns: A safe filename string
146def make_legend(axes: Axes, *, legend: None | bool | dict[str, Any]) -> None: 147 """Create a legend for the plot.""" 148 if legend is None or legend is False: 149 return 150 151 if legend is True: # use the global default settings 152 legend = get_setting("legend") 153 154 if isinstance(legend, dict): 155 axes.legend(**legend) 156 return 157 158 print(f"Warning: expected dict argument for legend, but got {type(legend)}.")
Create a legend for the plot.
161def apply_value_kwargs(axes: Axes, value_kwargs_: Sequence[str], **kwargs: Unpack[FinaliseKwargs]) -> None: 162 """Set matplotlib elements by name using Axes.set(). 163 164 Tricky: some plotting functions may set the xlabel or ylabel. 165 So ... we will set these if a setting is explicitly provided. If no 166 setting is provided, we will set to None if they are not already set. 167 If they have already been set, we will not change them. 168 169 """ 170 # --- preliminary 171 function: dict[str, Callable[[], str]] = { 172 "xlabel": axes.get_xlabel, 173 "ylabel": axes.get_ylabel, 174 "title": axes.get_title, 175 } 176 177 def fail() -> str: 178 return "" 179 180 # --- loop over potential value settings 181 for setting in value_kwargs_: 182 value = kwargs.get(setting) 183 if setting in kwargs: 184 # deliberately set, so we will action 185 axes.set(**{setting: value}) 186 continue 187 required_to_set = ("title", "xlabel", "ylabel") 188 if setting not in required_to_set: 189 # not set - and not required - so we can skip 190 continue 191 192 # we will set these 'required_to_set' ones 193 # provided they are not already set 194 already_set = function.get(setting, fail)() 195 if already_set and value is None: 196 continue 197 198 # if we get here, we will set the value (implicitly to None) 199 axes.set(**{setting: value})
Set matplotlib elements by name using Axes.set().
Tricky: some plotting functions may set the xlabel or ylabel. So ... we will set these if a setting is explicitly provided. If no setting is provided, we will set to None if they are not already set. If they have already been set, we will not change them.
264def apply_splat_kwargs(axes: Axes, settings: tuple, **kwargs: Unpack[FinaliseKwargs]) -> None: 265 """Set matplotlib elements dynamically using setting_name and splat.""" 266 for method_name in settings: 267 if method_name not in kwargs: 268 continue 269 270 if method_name == "legend": 271 legend_value = kwargs.get(method_name) 272 if isinstance(legend_value, (bool, dict, type(None))): 273 make_legend(axes, legend=legend_value) 274 else: 275 print(f"Warning: expected bool, dict, or None for legend, but got {type(legend_value)}.") 276 continue 277 278 _apply_splat(axes, method_name, kwargs.get(method_name))
Set matplotlib elements dynamically using setting_name and splat.
281def apply_annotations(axes: Axes, **kwargs: Unpack[FinaliseKwargs]) -> None: 282 """Set figure size and apply chart annotations. 283 284 No-op when axes_only=True: the work here is all figure-level (resize, 285 corner text) and would stomp on other panels in a multi-axes figure. 286 """ 287 if kwargs.get("axes_only"): 288 return 289 fig = axes.figure 290 fig_size = kwargs.get("figsize", get_setting("figsize")) 291 if not isinstance(fig, SubFigure): 292 fig.set_size_inches(*fig_size) 293 294 annotations = { 295 "rfooter": (0.99, 0.001, "right", "bottom"), 296 "lfooter": (0.01, 0.001, "left", "bottom"), 297 "rheader": (0.99, 0.999, "right", "top"), 298 "lheader": (0.01, 0.999, "left", "top"), 299 } 300 301 for annotation in HEADER_FOOTER_KWARGS: 302 if annotation in kwargs: 303 x_pos, y_pos, h_align, v_align = annotations[annotation] 304 fig.text( 305 x_pos, 306 y_pos, 307 str(kwargs.get(annotation, "")), 308 ha=h_align, 309 va=v_align, 310 fontsize=FOOTNOTE_FONTSIZE, 311 fontstyle=FOOTNOTE_FONTSTYLE, 312 color=FOOTNOTE_COLOR, 313 )
Set figure size and apply chart annotations.
No-op when axes_only=True: the work here is all figure-level (resize, corner text) and would stomp on other panels in a multi-axes figure.
316def apply_late_kwargs(axes: Axes, **kwargs: Unpack[FinaliseKwargs]) -> None: 317 """Apply settings found in kwargs, after plotting the data.""" 318 apply_splat_kwargs(axes, SPLAT_KWARGS, **kwargs)
Apply settings found in kwargs, after plotting the data.
321def apply_kwargs(axes: Axes, **kwargs: Unpack[FinaliseKwargs]) -> None: 322 """Apply settings found in kwargs.""" 323 324 def check_kwargs(name: str) -> bool: 325 return name in kwargs and bool(kwargs.get(name)) 326 327 apply_value_kwargs(axes, VALUE_KWARGS, **kwargs) 328 apply_annotations(axes, **kwargs) 329 330 if check_kwargs("zero_y"): 331 bottom, top = axes.get_ylim() 332 adj = (top - bottom) * ZERO_AXIS_ADJUSTMENT 333 if bottom > -adj: 334 axes.set_ylim(bottom=-adj) 335 if top < adj: 336 axes.set_ylim(top=adj) 337 338 if check_kwargs("y0"): 339 low, high = axes.get_ylim() 340 if low < 0 < high: 341 axes.axhline(y=0, lw=ZERO_LINE_WIDTH, c=ZERO_LINE_COLOR) 342 343 if check_kwargs("x0"): 344 low, high = axes.get_xlim() 345 if low < 0 < high: 346 axes.axvline(x=0, lw=ZERO_LINE_WIDTH, c=ZERO_LINE_COLOR) 347 348 if check_kwargs("axisbelow"): 349 axes.set_axisbelow(True)
Apply settings found in kwargs.
352def save_to_file(fig: Figure, **kwargs: Unpack[FinaliseKwargs]) -> None: 353 """Save the figure to file.""" 354 saving = not kwargs.get("dont_save", False) # save by default 355 if not saving: 356 return 357 358 try: 359 chart_dir = Path(kwargs.get("chart_dir", get_setting("chart_dir"))) 360 361 # Ensure directory exists 362 chart_dir.mkdir(parents=True, exist_ok=True) 363 364 suptitle = kwargs.get("suptitle", "") 365 title = kwargs.get("title", "") 366 pre_tag = kwargs.get("pre_tag", "") 367 tag = kwargs.get("tag", "") 368 name_override = kwargs.get("filename", "") 369 name_title = name_override or suptitle or title 370 file_title = sanitize_filename(name_title or DEFAULT_FILE_TITLE_NAME) 371 file_type = kwargs.get("file_type", get_setting("file_type")).lower() 372 dpi = kwargs.get("dpi", get_setting("dpi")) 373 374 # Construct filename components safely 375 filename_parts = [] 376 if pre_tag: 377 filename_parts.append(sanitize_filename(pre_tag)) 378 filename_parts.append(file_title) 379 if tag: 380 filename_parts.append(sanitize_filename(tag)) 381 382 # Join filename parts and add extension 383 filename = "-".join(filter(None, filename_parts)) 384 filepath = chart_dir / f"{filename}.{file_type}" 385 386 fig.savefig(filepath, dpi=dpi) 387 388 except ( 389 OSError, 390 PermissionError, 391 FileNotFoundError, 392 ValueError, 393 RuntimeError, 394 TypeError, 395 UnicodeError, 396 ) as e: 397 print(f"Error: Could not save plot to file: {e}")
Save the figure to file.
403def finalise_plot(axes: Axes, **kwargs: Unpack[FinaliseKwargs]) -> None: 404 """Finalise and save plots to the file system. 405 406 The filename for the saved plot is constructed from the global 407 chart_dir, the plot's title, any specified tag text, and the 408 file_type for the plot. 409 410 Args: 411 axes: Axes - matplotlib axes object - required 412 kwargs: FinaliseKwargs 413 414 """ 415 # --- check the kwargs 416 report_kwargs(caller=ME, **kwargs) 417 validate_kwargs(schema=FinaliseKwargs, caller=ME, **kwargs) 418 419 # --- sanity checks 420 if len(axes.get_children()) < 1: 421 print(f"Warning: {ME}() called with an empty axes, which was ignored.") 422 return 423 424 # --- remember axis-limits should we need to restore thems 425 xlim, ylim = axes.get_xlim(), axes.get_ylim() 426 427 # margins 428 axes.margins(DEFAULT_MARGIN) 429 axes.autoscale(tight=False) # This is problematic ... 430 431 apply_kwargs(axes, **kwargs) 432 433 # tight layout and save the figure 434 fig = axes.figure 435 axes_only = kwargs.get("axes_only", False) 436 if not axes_only and (suptitle := kwargs.get("suptitle")): 437 fig.suptitle(suptitle) 438 if kwargs.get("preserve_lims"): 439 # restore the original limits of the axes 440 axes.set_xlim(xlim) 441 axes.set_ylim(ylim) 442 if not axes_only and not isinstance(fig, SubFigure): 443 fig.tight_layout(pad=TIGHT_LAYOUT_PAD) 444 apply_late_kwargs(axes, **kwargs) 445 # axvspan/axvline in late_kwargs may have widened xlim beyond what 446 # set_labels() last saw; regenerate ticks from the updated view. 447 refresh_period_labels(axes) 448 legend = axes.get_legend() 449 if legend and kwargs.get("remove_legend", False): 450 legend.remove() 451 if not axes_only and not isinstance(fig, SubFigure): 452 save_to_file(fig, **kwargs) 453 454 # show the plot in Jupyter Lab 455 if not axes_only and kwargs.get("show"): 456 plt.show() 457 458 # And close 459 if not axes_only and not kwargs.get("dont_close", False): 460 plt.close()
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.
Args: axes: Axes - matplotlib axes object - required kwargs: FinaliseKwargs