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 value = kwargs.get(method_name) 278 if value is None or isinstance(value, (bool, dict, Sequence)): 279 _apply_splat(axes, method_name, value) 280 else: 281 print(f"Warning: expected dict or sequence of dicts for {method_name}, but got {type(value)}.") 282 283 284def apply_annotations(axes: Axes, **kwargs: Unpack[FinaliseKwargs]) -> None: 285 """Set figure size and apply chart annotations. 286 287 No-op when axes_only=True: the work here is all figure-level (resize, 288 corner text) and would stomp on other panels in a multi-axes figure. 289 """ 290 if kwargs.get("axes_only"): 291 return 292 fig = axes.figure 293 fig_size = kwargs.get("figsize", get_setting("figsize")) 294 if not isinstance(fig, SubFigure): 295 fig.set_size_inches(*fig_size) 296 297 annotations = { 298 "rfooter": (0.99, 0.001, "right", "bottom"), 299 "lfooter": (0.01, 0.001, "left", "bottom"), 300 "rheader": (0.99, 0.999, "right", "top"), 301 "lheader": (0.01, 0.999, "left", "top"), 302 } 303 304 for annotation in HEADER_FOOTER_KWARGS: 305 if annotation in kwargs: 306 x_pos, y_pos, h_align, v_align = annotations[annotation] 307 fig.text( 308 x_pos, 309 y_pos, 310 str(kwargs.get(annotation, "")), 311 ha=h_align, 312 va=v_align, 313 fontsize=FOOTNOTE_FONTSIZE, 314 fontstyle=FOOTNOTE_FONTSTYLE, 315 color=FOOTNOTE_COLOR, 316 ) 317 318 319def apply_late_kwargs(axes: Axes, **kwargs: Unpack[FinaliseKwargs]) -> None: 320 """Apply settings found in kwargs, after plotting the data.""" 321 apply_splat_kwargs(axes, SPLAT_KWARGS, **kwargs) 322 323 324def apply_kwargs(axes: Axes, **kwargs: Unpack[FinaliseKwargs]) -> None: 325 """Apply settings found in kwargs.""" 326 327 def check_kwargs(name: str) -> bool: 328 return name in kwargs and bool(kwargs.get(name)) 329 330 apply_value_kwargs(axes, VALUE_KWARGS, **kwargs) 331 apply_annotations(axes, **kwargs) 332 333 if check_kwargs("zero_y"): 334 bottom, top = axes.get_ylim() 335 adj = (top - bottom) * ZERO_AXIS_ADJUSTMENT 336 if bottom > -adj: 337 axes.set_ylim(bottom=-adj) 338 if top < adj: 339 axes.set_ylim(top=adj) 340 341 if check_kwargs("y0"): 342 low, high = axes.get_ylim() 343 if low < 0 < high: 344 axes.axhline(y=0, lw=ZERO_LINE_WIDTH, c=ZERO_LINE_COLOR) 345 346 if check_kwargs("x0"): 347 low, high = axes.get_xlim() 348 if low < 0 < high: 349 axes.axvline(x=0, lw=ZERO_LINE_WIDTH, c=ZERO_LINE_COLOR) 350 351 if check_kwargs("axisbelow"): 352 axes.set_axisbelow(True) 353 354 355def save_to_file(fig: Figure, **kwargs: Unpack[FinaliseKwargs]) -> None: 356 """Save the figure to file.""" 357 saving = not kwargs.get("dont_save", False) # save by default 358 if not saving: 359 return 360 361 try: 362 chart_dir = Path(kwargs.get("chart_dir", get_setting("chart_dir"))) 363 364 # Ensure directory exists 365 chart_dir.mkdir(parents=True, exist_ok=True) 366 367 suptitle = kwargs.get("suptitle", "") 368 title = kwargs.get("title", "") 369 pre_tag = kwargs.get("pre_tag", "") 370 tag = kwargs.get("tag", "") 371 name_override = kwargs.get("filename", "") 372 name_title = name_override or suptitle or title 373 file_title = sanitize_filename(name_title or DEFAULT_FILE_TITLE_NAME) 374 file_type = kwargs.get("file_type", get_setting("file_type")).lower() 375 dpi = kwargs.get("dpi", get_setting("dpi")) 376 377 # Construct filename components safely 378 filename_parts = [] 379 if pre_tag: 380 filename_parts.append(sanitize_filename(pre_tag)) 381 filename_parts.append(file_title) 382 if tag: 383 filename_parts.append(sanitize_filename(tag)) 384 385 # Join filename parts and add extension 386 filename = "-".join(filter(None, filename_parts)) 387 filepath = chart_dir / f"{filename}.{file_type}" 388 389 fig.savefig(filepath, dpi=dpi) 390 391 except ( 392 OSError, 393 PermissionError, 394 FileNotFoundError, 395 ValueError, 396 RuntimeError, 397 TypeError, 398 UnicodeError, 399 ) as e: 400 print(f"Error: Could not save plot to file: {e}") 401 402 403# - public functions for finalise_plot() 404 405 406def finalise_plot(axes: Axes, **kwargs: Unpack[FinaliseKwargs]) -> None: 407 """Finalise and save plots to the file system. 408 409 The filename for the saved plot is constructed from the global 410 chart_dir, the plot's title, any specified tag text, and the 411 file_type for the plot. 412 413 Args: 414 axes: Axes - matplotlib axes object - required 415 kwargs: FinaliseKwargs 416 417 """ 418 # --- check the kwargs 419 report_kwargs(caller=ME, **kwargs) 420 validate_kwargs(schema=FinaliseKwargs, caller=ME, **kwargs) 421 422 # --- sanity checks 423 if len(axes.get_children()) < 1: 424 print(f"Warning: {ME}() called with an empty axes, which was ignored.") 425 return 426 427 # --- remember axis-limits should we need to restore thems 428 xlim, ylim = axes.get_xlim(), axes.get_ylim() 429 430 # margins 431 axes.margins(DEFAULT_MARGIN) 432 axes.autoscale(tight=False) # This is problematic ... 433 434 apply_kwargs(axes, **kwargs) 435 436 # tight layout and save the figure 437 fig = axes.figure 438 axes_only = kwargs.get("axes_only", False) 439 if not axes_only and (suptitle := kwargs.get("suptitle")): 440 fig.suptitle(suptitle) 441 if kwargs.get("preserve_lims"): 442 # restore the original limits of the axes 443 axes.set_xlim(xlim) 444 axes.set_ylim(ylim) 445 if not axes_only and not isinstance(fig, SubFigure): 446 fig.tight_layout(pad=TIGHT_LAYOUT_PAD) 447 apply_late_kwargs(axes, **kwargs) 448 # axvspan/axvline in late_kwargs may have widened xlim beyond what 449 # set_labels() last saw; regenerate ticks from the updated view. 450 refresh_period_labels(axes) 451 legend = axes.get_legend() 452 if legend and kwargs.get("remove_legend", False): 453 legend.remove() 454 if not axes_only and not isinstance(fig, SubFigure): 455 save_to_file(fig, **kwargs) 456 457 # show the plot in Jupyter Lab 458 if not axes_only and kwargs.get("show"): 459 plt.show() 460 461 # And close 462 if not axes_only and not kwargs.get("dont_close", False): 463 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 value = kwargs.get(method_name) 279 if value is None or isinstance(value, (bool, dict, Sequence)): 280 _apply_splat(axes, method_name, value) 281 else: 282 print(f"Warning: expected dict or sequence of dicts for {method_name}, but got {type(value)}.")
Set matplotlib elements dynamically using setting_name and splat.
285def apply_annotations(axes: Axes, **kwargs: Unpack[FinaliseKwargs]) -> None: 286 """Set figure size and apply chart annotations. 287 288 No-op when axes_only=True: the work here is all figure-level (resize, 289 corner text) and would stomp on other panels in a multi-axes figure. 290 """ 291 if kwargs.get("axes_only"): 292 return 293 fig = axes.figure 294 fig_size = kwargs.get("figsize", get_setting("figsize")) 295 if not isinstance(fig, SubFigure): 296 fig.set_size_inches(*fig_size) 297 298 annotations = { 299 "rfooter": (0.99, 0.001, "right", "bottom"), 300 "lfooter": (0.01, 0.001, "left", "bottom"), 301 "rheader": (0.99, 0.999, "right", "top"), 302 "lheader": (0.01, 0.999, "left", "top"), 303 } 304 305 for annotation in HEADER_FOOTER_KWARGS: 306 if annotation in kwargs: 307 x_pos, y_pos, h_align, v_align = annotations[annotation] 308 fig.text( 309 x_pos, 310 y_pos, 311 str(kwargs.get(annotation, "")), 312 ha=h_align, 313 va=v_align, 314 fontsize=FOOTNOTE_FONTSIZE, 315 fontstyle=FOOTNOTE_FONTSTYLE, 316 color=FOOTNOTE_COLOR, 317 )
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.
320def apply_late_kwargs(axes: Axes, **kwargs: Unpack[FinaliseKwargs]) -> None: 321 """Apply settings found in kwargs, after plotting the data.""" 322 apply_splat_kwargs(axes, SPLAT_KWARGS, **kwargs)
Apply settings found in kwargs, after plotting the data.
325def apply_kwargs(axes: Axes, **kwargs: Unpack[FinaliseKwargs]) -> None: 326 """Apply settings found in kwargs.""" 327 328 def check_kwargs(name: str) -> bool: 329 return name in kwargs and bool(kwargs.get(name)) 330 331 apply_value_kwargs(axes, VALUE_KWARGS, **kwargs) 332 apply_annotations(axes, **kwargs) 333 334 if check_kwargs("zero_y"): 335 bottom, top = axes.get_ylim() 336 adj = (top - bottom) * ZERO_AXIS_ADJUSTMENT 337 if bottom > -adj: 338 axes.set_ylim(bottom=-adj) 339 if top < adj: 340 axes.set_ylim(top=adj) 341 342 if check_kwargs("y0"): 343 low, high = axes.get_ylim() 344 if low < 0 < high: 345 axes.axhline(y=0, lw=ZERO_LINE_WIDTH, c=ZERO_LINE_COLOR) 346 347 if check_kwargs("x0"): 348 low, high = axes.get_xlim() 349 if low < 0 < high: 350 axes.axvline(x=0, lw=ZERO_LINE_WIDTH, c=ZERO_LINE_COLOR) 351 352 if check_kwargs("axisbelow"): 353 axes.set_axisbelow(True)
Apply settings found in kwargs.
356def save_to_file(fig: Figure, **kwargs: Unpack[FinaliseKwargs]) -> None: 357 """Save the figure to file.""" 358 saving = not kwargs.get("dont_save", False) # save by default 359 if not saving: 360 return 361 362 try: 363 chart_dir = Path(kwargs.get("chart_dir", get_setting("chart_dir"))) 364 365 # Ensure directory exists 366 chart_dir.mkdir(parents=True, exist_ok=True) 367 368 suptitle = kwargs.get("suptitle", "") 369 title = kwargs.get("title", "") 370 pre_tag = kwargs.get("pre_tag", "") 371 tag = kwargs.get("tag", "") 372 name_override = kwargs.get("filename", "") 373 name_title = name_override or suptitle or title 374 file_title = sanitize_filename(name_title or DEFAULT_FILE_TITLE_NAME) 375 file_type = kwargs.get("file_type", get_setting("file_type")).lower() 376 dpi = kwargs.get("dpi", get_setting("dpi")) 377 378 # Construct filename components safely 379 filename_parts = [] 380 if pre_tag: 381 filename_parts.append(sanitize_filename(pre_tag)) 382 filename_parts.append(file_title) 383 if tag: 384 filename_parts.append(sanitize_filename(tag)) 385 386 # Join filename parts and add extension 387 filename = "-".join(filter(None, filename_parts)) 388 filepath = chart_dir / f"{filename}.{file_type}" 389 390 fig.savefig(filepath, dpi=dpi) 391 392 except ( 393 OSError, 394 PermissionError, 395 FileNotFoundError, 396 ValueError, 397 RuntimeError, 398 TypeError, 399 UnicodeError, 400 ) as e: 401 print(f"Error: Could not save plot to file: {e}")
Save the figure to file.
407def finalise_plot(axes: Axes, **kwargs: Unpack[FinaliseKwargs]) -> None: 408 """Finalise and save plots to the file system. 409 410 The filename for the saved plot is constructed from the global 411 chart_dir, the plot's title, any specified tag text, and the 412 file_type for the plot. 413 414 Args: 415 axes: Axes - matplotlib axes object - required 416 kwargs: FinaliseKwargs 417 418 """ 419 # --- check the kwargs 420 report_kwargs(caller=ME, **kwargs) 421 validate_kwargs(schema=FinaliseKwargs, caller=ME, **kwargs) 422 423 # --- sanity checks 424 if len(axes.get_children()) < 1: 425 print(f"Warning: {ME}() called with an empty axes, which was ignored.") 426 return 427 428 # --- remember axis-limits should we need to restore thems 429 xlim, ylim = axes.get_xlim(), axes.get_ylim() 430 431 # margins 432 axes.margins(DEFAULT_MARGIN) 433 axes.autoscale(tight=False) # This is problematic ... 434 435 apply_kwargs(axes, **kwargs) 436 437 # tight layout and save the figure 438 fig = axes.figure 439 axes_only = kwargs.get("axes_only", False) 440 if not axes_only and (suptitle := kwargs.get("suptitle")): 441 fig.suptitle(suptitle) 442 if kwargs.get("preserve_lims"): 443 # restore the original limits of the axes 444 axes.set_xlim(xlim) 445 axes.set_ylim(ylim) 446 if not axes_only and not isinstance(fig, SubFigure): 447 fig.tight_layout(pad=TIGHT_LAYOUT_PAD) 448 apply_late_kwargs(axes, **kwargs) 449 # axvspan/axvline in late_kwargs may have widened xlim beyond what 450 # set_labels() last saw; regenerate ticks from the updated view. 451 refresh_period_labels(axes) 452 legend = axes.get_legend() 453 if legend and kwargs.get("remove_legend", False): 454 legend.remove() 455 if not axes_only and not isinstance(fig, SubFigure): 456 save_to_file(fig, **kwargs) 457 458 # show the plot in Jupyter Lab 459 if not axes_only and kwargs.get("show"): 460 plt.show() 461 462 # And close 463 if not axes_only and not kwargs.get("dont_close", False): 464 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