```{python}
#| code-fold: true
## Insert code here
from __future__ import annotations
import copy
from typing import Dict, List, Optional, Sequence
import numpy as np
import matplotlib.pyplot as plt
from matplotlib import cm
def create_figure(
number_rows: int = 1,
number_columns: int = 1,
subplot_size: Sequence[float] = (3.25, 3.25),
margin_subplot_size: Sequence[float] = (0.6, 0.6),
margin_figure_size: Sequence[float] = (0.8, 1.2, 0.4, 0.6),
figure_title: str = "",
):
"""
Initialize a figure with deterministic subplot geometry for publication plots and animations.
This utility intentionally avoids plt.tight_layout() because automatic layout adjustments can
produce frame-to-frame subplot drift in animations. Instead, absolute subplot and margin sizes
are used to keep axis boxes spatially stable across renders.
Parameters
----------
number_rows : int
Number of subplot rows.
number_columns : int
Number of subplot columns.
subplot_size : sequence of float
(width_in, height_in) for each subplot panel.
margin_subplot_size : sequence of float
(horizontal_gap_in, vertical_gap_in) between adjacent subplots.
margin_figure_size : sequence of float
(left_in, right_in, top_in, bottom_in) figure margins in inches.
figure_title : str
Optional figure title.
Returns
-------
tuple
(fig, axs) where axs is always returned as a 2D NumPy array for consistent indexing.
"""
figure_width = (
number_columns * subplot_size[0]
+ margin_figure_size[0]
+ margin_figure_size[1]
+ (number_columns - 1) * margin_subplot_size[0]
)
figure_height = (
number_rows * subplot_size[1]
+ margin_figure_size[2]
+ margin_figure_size[3]
+ (number_rows - 1) * margin_subplot_size[1]
)
fig, axs = plt.subplots(
nrows=number_rows,
ncols=number_columns,
figsize=(figure_width, figure_height),
edgecolor=(0, 0, 0, 0),
linewidth=3,
dpi=150,
)
# Convert inch margins/gaps to figure-relative fractions for subplots_adjust().
fig.subplots_adjust(
left=margin_figure_size[0] / figure_width,
right=1 - (margin_figure_size[1] / figure_width),
top=1 - (margin_figure_size[2] / figure_height),
bottom=margin_figure_size[3] / figure_height,
wspace=margin_subplot_size[0] / subplot_size[0],
hspace=margin_subplot_size[1] / subplot_size[1],
)
# Ensure axs is always 2D for predictable indexing: axs[row, col].
if number_rows == 1 and number_columns == 1:
axs = np.array([[axs]])
elif number_rows == 1 and number_columns > 1:
axs = np.asarray(axs).reshape(1, number_columns)
elif number_rows > 1 and number_columns == 1:
axs = np.asarray(axs).reshape(number_rows, 1)
fig.suptitle(figure_title, color=(0, 0, 0, 1), fontname="Calibri", size=12)
return fig, axs
def format_subplot_1d(
ax,
plots: Optional[List[Dict]] = None,
plots_yright: Optional[List[Dict]] = None,
plots_errorbar: Optional[List[Dict]] = None,
plots_errorbar_yright: Optional[List[Dict]] = None,
axvlines: Optional[List[Dict]] = None,
axhlines: Optional[List[Dict]] = None,
x_axis_label: Optional[Dict] = None,
y_axis_label: Optional[Dict] = None,
yright_axis_label: Optional[Dict] = None,
xscale: str = "linear",
yscale: str = "linear",
yrightscale: str = "linear",
xlim: Optional[Dict] = None,
ylim: Optional[Dict] = None,
yrightlim: Optional[Dict] = None,
border_colors: Sequence = ((0, 0, 0, 1), (0, 0, 0, 1), (0, 0, 0, 1), (0, 0, 0, 1)),
):
"""
Format a single 1D subplot with optional primary and secondary y-axis data.
All plotting inputs are dictionaries of Matplotlib kwargs plus required coordinate fields.
For example, each element in `plots` should include keys `x` and `y`, plus optional style keys.
Parameters
----------
ax : matplotlib.axes.Axes
Target axis.
plots, plots_yright, plots_errorbar, plots_errorbar_yright : list of dict, optional
Plot specification dictionaries for left and right y axes.
axvlines, axhlines : list of dict, optional
Matplotlib axvline/axhline keyword dictionaries.
x_axis_label, y_axis_label, yright_axis_label : dict, optional
Label dictionaries. Use keys:
- x_axis_label: {'xlabel': 'Text', ...matplotlib kwargs...}
- y_axis_label: {'ylabel': 'Text', ...matplotlib kwargs...}
- yright_axis_label: {'ylabel': 'Text', ...matplotlib kwargs...}
xscale, yscale, yrightscale : str
Axis scales, typically 'linear' or 'log'.
xlim, ylim, yrightlim : dict, optional
Axis limit dictionaries forwarded to set_xlim/set_ylim.
border_colors : sequence
RGBA colors for [left, right, top, bottom] spines.
Returns
-------
dict
Dictionary with references to plotted axes: {'ax': ax, 'axright': axright or None}.
"""
plots = [] if plots is None else plots
plots_yright = [] if plots_yright is None else plots_yright
plots_errorbar = [] if plots_errorbar is None else plots_errorbar
plots_errorbar_yright = [] if plots_errorbar_yright is None else plots_errorbar_yright
axvlines = [] if axvlines is None else axvlines
axhlines = [] if axhlines is None else axhlines
xlim = {"left": None, "right": None, "auto": False} if xlim is None else xlim
ylim = {"bottom": None, "top": None, "auto": False} if ylim is None else ylim
yrightlim = (
{"bottom": None, "top": None, "auto": False} if yrightlim is None else yrightlim
)
yright_conditions = bool(plots_yright or plots_errorbar_yright)
axright = ax.twinx() if yright_conditions else None
plot_settings_default = {
"marker": "o",
"markersize": 0,
"markerfacecolor": (0, 0, 0, 1),
"markeredgecolor": (0, 0, 0, 1),
"markeredgewidth": 1,
"color": (0, 0, 0, 1),
"linestyle": "solid",
"linewidth": 2,
}
def render_plots(target_ax, line_specs, errorbar_specs):
for spec_group in [line_specs, errorbar_specs]:
plot_colors = cm.rainbow(np.linspace(1, 0, len(spec_group))) if len(spec_group) > 0 else []
if len(spec_group) == 1:
plot_colors = [(0, 0, 0, 1)]
for idx, user_settings in enumerate(spec_group):
settings = {
**plot_settings_default,
"markerfacecolor": plot_colors[idx],
"color": plot_colors[idx],
**user_settings,
}
x = settings.pop("x")
y = settings.pop("y")
target_ax.plot(x, y, **settings)
render_plots(ax, plots, plots_errorbar)
if yright_conditions and axright is not None:
render_plots(axright, plots_yright, plots_errorbar_yright)
line_settings_default = {
**plot_settings_default,
"color": (0.5, 0.5, 0.5, 1),
"linestyle": "dotted",
}
for line_type in [axvlines, axhlines]:
plot_colors = cm.rainbow(np.linspace(1, 0, len(line_type))) if len(line_type) > 0 else []
if len(line_type) == 1:
plot_colors = [(0.5, 0.5, 0.5, 1)]
for idx, line_settings_user in enumerate(line_type):
line_settings = {
**line_settings_default,
"markerfacecolor": plot_colors[idx],
"color": plot_colors[idx],
**line_settings_user,
}
if line_type is axvlines:
ax.axvline(**line_settings)
else:
ax.axhline(**line_settings)
if x_axis_label:
x_axis_label_copy = copy.deepcopy(x_axis_label)
xlabel = x_axis_label_copy.pop("xlabel")
ax.set_xlabel(xlabel, **x_axis_label_copy)
if y_axis_label:
y_axis_label_copy = copy.deepcopy(y_axis_label)
ylabel = y_axis_label_copy.pop("ylabel")
ax.set_ylabel(ylabel, **y_axis_label_copy)
if yright_axis_label and axright is not None:
yright_axis_label_copy = copy.deepcopy(yright_axis_label)
yrightlabel = yright_axis_label_copy.pop("ylabel")
axright.set_ylabel(yrightlabel, **yright_axis_label_copy)
ax.set_xscale(xscale)
ax.set_yscale(yscale)
if yright_conditions and axright is not None:
axright.set_yscale(yrightscale)
ax.set_xlim(**xlim)
ax.set_ylim(**ylim)
if yright_conditions and axright is not None:
axright.set_ylim(**yrightlim)
def border_formatting(target_ax, axes_tick_params=(3, 0)):
for idx, border in enumerate(["left", "right", "top", "bottom"]):
target_ax.spines[border].set_linewidth(2)
target_ax.spines[border].set_color(border_colors[idx])
for idx_axis, axis in enumerate(["x", "y"]):
target_ax.tick_params(
axis=axis,
colors=border_colors[axes_tick_params[idx_axis]],
width=2,
)
target_ax.tick_params(axis="both", which="major", labelsize=10)
border_formatting(ax)
if yright_conditions and axright is not None:
border_formatting(axright, axes_tick_params=(3, 1))
return {"ax": ax, "axright": axright}
```