← Files Scientific Visuals & TablesARCHIVED FILE
skills/scientific-visual-table-style/templates/openai_visuals.py
40.3 KB · Oct 4, 2026 · 12:32 UTC
"""Reusable OpenAI-inspired quantitative-visual helpers.
This module encodes a restrained, publication-oriented visual system distilled
from first-party OpenAI quantitative visuals. It is not an official brand
package. The helpers prioritize scientific fidelity, final-size legibility,
direct labeling, sparse axes, and grayscale-plus-one-accent styling.
Typical usage
-------------
>>> import matplotlib.pyplot as plt
>>> from openai_visuals import configure_matplotlib, new_figure, style_axes
>>> configure_matplotlib()
>>> fig, ax = new_figure(width_mm=85, height_mm=55)
>>> ax.plot([1, 2, 3], [0.3, 0.5, 0.7])
>>> style_axes(ax, ygrid=True)
>>> fig.savefig("figure.svg")
"""
from __future__ import annotations
import json
import math
import os
import tempfile
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any, Iterable, Literal, Mapping, MutableMapping, Sequence
def _ensure_writable_matplotlib_config() -> None:
"""Use a writable Matplotlib cache in read-only shared-skill environments."""
if os.environ.get("MPLCONFIGDIR"):
return
default = Path.home() / ".config" / "matplotlib"
try:
default.mkdir(parents=True, exist_ok=True)
except OSError:
pass
if default.is_dir() and os.access(default, os.W_OK):
return
fallback = Path(tempfile.gettempdir()) / f"openai_visual_mplconfig_{os.getuid()}"
fallback.mkdir(parents=True, exist_ok=True)
os.environ["MPLCONFIGDIR"] = str(fallback)
_ensure_writable_matplotlib_config()
import matplotlib as mpl
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.axes import Axes
from matplotlib.container import BarContainer
from matplotlib.figure import Figure
from matplotlib.lines import Line2D
from matplotlib.ticker import MaxNLocator
MM_PER_INCH = 25.4
Direction = Literal["higher", "lower", "neutral", "toward_zero"]
Preference = Literal["higher", "lower"]
Severity = Literal["error", "warning", "info"]
@dataclass(frozen=True)
class Palette:
"""Operational grayscale-plus-accent palette.
These values are OpenAI-inspired defaults, not official brand colors.
"""
ink: str = "#111111"
ink_secondary: str = "#404040"
muted_text: str = "#6F6F6F"
baseline: str = "#8D8D88"
baseline_light: str = "#B6B6B0"
hairline: str = "#D8D8D4"
grid: str = "#E8E8E5"
subtle_fill: str = "#F5F5F2"
accent: str = "#1F6F5F"
accent_tint: str = "#DDE9E5"
risk: str = "#B5473C"
warning: str = "#9A681A"
white: str = "#FFFFFF"
warm_white: str = "#FBFBF9"
@dataclass(frozen=True)
class Theme:
"""Typography and geometry defaults for manuscript-scale figures."""
palette: Palette = field(default_factory=Palette)
font_family: tuple[str, ...] = (
"Arial",
"Helvetica",
"Inter",
"Liberation Sans",
"DejaVu Sans",
)
body_size_pt: float = 8.5
title_size_pt: float = 10.0
axis_label_size_pt: float = 8.5
tick_size_pt: float = 8.0
annotation_size_pt: float = 8.0
line_width_pt: float = 1.15
focal_line_width_pt: float = 1.65
axis_line_width_pt: float = 0.7
error_line_width_pt: float = 0.8
marker_size_pt: float = 4.8
major_tick_length_pt: float = 3.0
major_tick_width_pt: float = 0.6
grid_line_width_pt: float = 0.55
grid_alpha: float = 1.0
DEFAULT_PALETTE = Palette()
DEFAULT_THEME = Theme()
@dataclass(frozen=True)
class AuditIssue:
"""One programmatic figure-audit finding."""
severity: Severity
code: str
message: str
axes_index: int | None = None
@dataclass(frozen=True)
class AuditReport:
"""Structured result of :func:`audit_matplotlib_figure`."""
passed: bool
min_font_size_pt: float
issues: tuple[AuditIssue, ...]
figure_width_in: float
figure_height_in: float
def to_dict(self) -> dict[str, Any]:
return {
"passed": self.passed,
"min_font_size_pt": self.min_font_size_pt,
"figure_width_in": self.figure_width_in,
"figure_height_in": self.figure_height_in,
"issues": [asdict(issue) for issue in self.issues],
}
def to_json(self, *, indent: int = 2) -> str:
return json.dumps(self.to_dict(), indent=indent)
@dataclass(frozen=True)
class SeriesStyle:
"""Visual encoding for one series."""
color: str
linewidth: float
alpha: float = 1.0
linestyle: str = "-"
marker: str | None = None
zorder: float = 2.0
GENERIC_TITLES = {
"result",
"results",
"performance",
"comparison",
"comparisons",
"evaluation",
"evaluations",
"benchmark",
"benchmarks",
}
def mm_to_inches(value_mm: float) -> float:
"""Convert millimetres to inches."""
if value_mm <= 0:
raise ValueError("value_mm must be positive")
return value_mm / MM_PER_INCH
def relative_luminance(color: str | Sequence[float]) -> float:
"""Return WCAG relative luminance for a Matplotlib-compatible color."""
r, g, b, _ = mpl.colors.to_rgba(color)
def linearize(channel: float) -> float:
return channel / 12.92 if channel <= 0.04045 else ((channel + 0.055) / 1.055) ** 2.4
return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b)
def contrast_ratio(foreground: str, background: str) -> float:
"""Return WCAG contrast ratio between two colors."""
l1 = relative_luminance(foreground)
l2 = relative_luminance(background)
lighter = max(l1, l2)
darker = min(l1, l2)
return (lighter + 0.05) / (darker + 0.05)
def configure_matplotlib(theme: Theme = DEFAULT_THEME) -> None:
"""Install the shared restrained visual theme into Matplotlib rcParams.
Call this once before creating figures. User or venue constraints may
override individual values after this call.
"""
p = theme.palette
mpl.rcParams.update(
{
"font.family": "sans-serif",
"font.sans-serif": list(theme.font_family),
"font.size": theme.body_size_pt,
"font.weight": "regular",
"text.color": p.ink,
"axes.facecolor": p.white,
"axes.edgecolor": p.ink_secondary,
"axes.labelcolor": p.ink_secondary,
"axes.labelsize": theme.axis_label_size_pt,
"axes.labelweight": "regular",
"axes.titlesize": theme.title_size_pt,
"axes.titleweight": "semibold",
"axes.titlecolor": p.ink,
"axes.titlelocation": "left",
"axes.linewidth": theme.axis_line_width_pt,
"axes.spines.top": False,
"axes.spines.right": False,
"axes.axisbelow": True,
"axes.grid": False,
"axes.unicode_minus": True,
"xtick.color": p.ink_secondary,
"ytick.color": p.ink_secondary,
"xtick.labelsize": theme.tick_size_pt,
"ytick.labelsize": theme.tick_size_pt,
"xtick.direction": "out",
"ytick.direction": "out",
"xtick.major.size": theme.major_tick_length_pt,
"ytick.major.size": theme.major_tick_length_pt,
"xtick.major.width": theme.major_tick_width_pt,
"ytick.major.width": theme.major_tick_width_pt,
"grid.color": p.grid,
"grid.linewidth": theme.grid_line_width_pt,
"grid.alpha": theme.grid_alpha,
"lines.color": p.ink,
"lines.linewidth": theme.line_width_pt,
"lines.markersize": theme.marker_size_pt,
"lines.solid_capstyle": "round",
"lines.dash_capstyle": "round",
"patch.edgecolor": "none",
"patch.linewidth": 0.0,
"legend.frameon": False,
"legend.fontsize": theme.tick_size_pt,
"legend.labelcolor": p.ink_secondary,
"figure.facecolor": p.white,
"figure.edgecolor": p.white,
"figure.dpi": 120,
"savefig.facecolor": p.white,
"savefig.edgecolor": p.white,
"savefig.dpi": 300,
"savefig.transparent": False,
"svg.fonttype": "none",
"pdf.fonttype": 42,
"ps.fonttype": 42,
"mathtext.default": "regular",
}
)
def new_figure(
*,
width_mm: float = 85.0,
height_mm: float | None = None,
aspect_ratio: float = 1.60,
nrows: int = 1,
ncols: int = 1,
sharex: bool | Literal["all", "row", "col", "none"] = False,
sharey: bool | Literal["all", "row", "col", "none"] = False,
constrained_layout: bool = True,
squeeze: bool = True,
theme: Theme = DEFAULT_THEME,
**subplot_kwargs: Any,
) -> tuple[Figure, Any]:
"""Create a final-size figure using millimetre dimensions.
Parameters are deliberately explicit so figures are not designed at a
large notebook size and shrunk later.
"""
configure_matplotlib(theme)
if width_mm <= 0:
raise ValueError("width_mm must be positive")
if height_mm is None:
if aspect_ratio <= 0:
raise ValueError("aspect_ratio must be positive")
height_mm = width_mm / aspect_ratio
if height_mm <= 0:
raise ValueError("height_mm must be positive")
if nrows < 1 or ncols < 1:
raise ValueError("nrows and ncols must be >= 1")
fig, axes = plt.subplots(
nrows=nrows,
ncols=ncols,
figsize=(mm_to_inches(width_mm), mm_to_inches(height_mm)),
sharex=sharex,
sharey=sharey,
constrained_layout=constrained_layout,
squeeze=squeeze,
**subplot_kwargs,
)
return fig, axes
def metric_axis_label(
name: str,
unit: str | None = None,
direction: Direction | None = None,
*,
scale: str | None = None,
) -> str:
"""Build a concise metric label with unit, direction, and scale."""
if not name.strip():
raise ValueError("name must be non-empty")
label = name.strip()
qualifiers: list[str] = []
if unit:
qualifiers.append(unit.strip())
if scale and scale != "linear":
qualifiers.append(f"{scale} scale")
if qualifiers:
label += f" ({', '.join(qualifiers)})"
direction_map = {
"higher": "higher is better",
"lower": "lower is better",
"neutral": "",
"toward_zero": "closer to zero is better",
None: "",
}
direction_text = direction_map[direction]
if direction_text:
label += f" · {direction_text}"
return label
def style_axes(
ax: Axes,
*,
xgrid: bool = False,
ygrid: bool = False,
zero_x: bool = False,
zero_y: bool = False,
max_major_ticks: int | None = 5,
hide_spines: Iterable[str] = ("top", "right"),
theme: Theme = DEFAULT_THEME,
) -> Axes:
"""Apply sparse OpenAI-inspired axis styling to an existing axes."""
p = theme.palette
ax.set_facecolor(p.white)
ax.set_axisbelow(True)
for name in hide_spines:
if name in ax.spines:
ax.spines[name].set_visible(False)
for name in ("left", "bottom"):
if name in ax.spines:
ax.spines[name].set_color(p.ink_secondary)
ax.spines[name].set_linewidth(theme.axis_line_width_pt)
ax.grid(False)
if xgrid:
ax.grid(True, axis="x", color=p.grid, linewidth=theme.grid_line_width_pt)
if ygrid:
ax.grid(True, axis="y", color=p.grid, linewidth=theme.grid_line_width_pt)
if zero_x:
ax.axvline(0, color=p.hairline, linewidth=theme.axis_line_width_pt, zorder=0)
if zero_y:
ax.axhline(0, color=p.hairline, linewidth=theme.axis_line_width_pt, zorder=0)
if max_major_ticks is not None:
if max_major_ticks < 2:
raise ValueError("max_major_ticks must be >= 2 or None")
if ax.get_xscale() == "linear" and not _axis_is_categorical(ax, "x"):
ax.xaxis.set_major_locator(MaxNLocator(nbins=max_major_ticks, min_n_ticks=3))
if ax.get_yscale() == "linear" and not _axis_is_categorical(ax, "y"):
ax.yaxis.set_major_locator(MaxNLocator(nbins=max_major_ticks, min_n_ticks=3))
return ax
def _axis_is_categorical(ax: Axes, axis: Literal["x", "y"]) -> bool:
axis_object = ax.xaxis if axis == "x" else ax.yaxis
formatter_name = axis_object.get_major_formatter().__class__.__name__
locator_name = axis_object.get_major_locator().__class__.__name__
categorical_formatters = {"StrCategoryFormatter", "FixedFormatter"}
categorical_locators = {"StrCategoryLocator", "FixedLocator", "IndexLocator"}
return formatter_name in categorical_formatters or locator_name in categorical_locators
def format_number(
value: float,
*,
decimals: int | None = None,
significant: int = 3,
percent: bool = False,
unit: str = "",
missing: str = "—",
) -> str:
"""Format a value with controlled precision and a consistent missing symbol."""
if value is None or not np.isfinite(value):
return missing
numeric = float(value)
if percent:
numeric *= 100.0
if decimals is None:
decimals = 1
formatted = f"{numeric:.{decimals}f}%"
elif decimals is not None:
formatted = f"{numeric:,.{decimals}f}"
else:
if numeric == 0:
formatted = "0"
elif abs(numeric) >= 1e4 or abs(numeric) < 1e-3:
formatted = f"{numeric:.{max(significant - 1, 0)}e}"
else:
formatted = f"{numeric:.{significant}g}"
return f"{formatted}{unit}" if unit else formatted
def build_series_styles(
names: Sequence[str],
*,
focal: str | None = None,
baselines: Iterable[str] = (),
theme: Theme = DEFAULT_THEME,
) -> dict[str, SeriesStyle]:
"""Return a stable grayscale-plus-accent style mapping for series names."""
if len(set(names)) != len(names):
raise ValueError("series names must be unique")
baseline_set = set(baselines)
line_styles = ("-", "--", "-.", ":")
markers = (None, "o", "s", "^", "D", "v", "P")
styles: dict[str, SeriesStyle] = {}
non_special_index = 0
for name in names:
if name == focal:
styles[name] = SeriesStyle(
color=theme.palette.accent,
linewidth=theme.focal_line_width_pt,
alpha=1.0,
linestyle="-",
marker=None,
zorder=4.0,
)
elif name in baseline_set:
styles[name] = SeriesStyle(
color=theme.palette.baseline,
linewidth=theme.line_width_pt,
alpha=0.95,
linestyle=line_styles[non_special_index % len(line_styles)],
marker=markers[(non_special_index + 1) % len(markers)],
zorder=1.5,
)
non_special_index += 1
else:
color = theme.palette.ink_secondary if non_special_index == 0 else theme.palette.baseline
styles[name] = SeriesStyle(
color=color,
linewidth=theme.line_width_pt,
alpha=1.0,
linestyle=line_styles[non_special_index % len(line_styles)],
marker=markers[non_special_index % len(markers)],
zorder=2.5,
)
non_special_index += 1
return styles
def annotate_bar_values(
ax: Axes,
bars: BarContainer,
*,
formatter: str | callable = "{:.1f}",
orientation: Literal["vertical", "horizontal"] | None = None,
padding_pt: float = 3.0,
color: str | None = None,
fontsize: float | None = None,
) -> None:
"""Direct-label a bar container without a legend lookup.
Labels are placed outside positive bars and inside/outside negative bars in
the natural direction. The caller remains responsible for axis headroom.
"""
if orientation is None:
orientation = getattr(bars, "orientation", "vertical")
if orientation not in {"vertical", "horizontal"}:
raise ValueError("orientation must be 'vertical' or 'horizontal'")
def render(value: float) -> str:
if callable(formatter):
return str(formatter(value))
return str(formatter).format(value)
for patch in bars.patches:
if orientation == "vertical":
value = patch.get_height()
x = patch.get_x() + patch.get_width() / 2.0
y = patch.get_y() + value
va = "bottom" if value >= 0 else "top"
offset = (0, padding_pt if value >= 0 else -padding_pt)
ax.annotate(
render(value),
xy=(x, y),
xytext=offset,
textcoords="offset points",
ha="center",
va=va,
color=color,
fontsize=fontsize,
clip_on=False,
)
else:
value = patch.get_width()
x = patch.get_x() + value
y = patch.get_y() + patch.get_height() / 2.0
ha = "left" if value >= 0 else "right"
offset = (padding_pt if value >= 0 else -padding_pt, 0)
ax.annotate(
render(value),
xy=(x, y),
xytext=offset,
textcoords="offset points",
ha=ha,
va="center",
color=color,
fontsize=fontsize,
clip_on=False,
)
def label_line_ends(
ax: Axes,
lines: Sequence[Line2D] | None = None,
*,
labels: Sequence[str] | None = None,
x_offset_pt: float = 4.0,
y_offsets_pt: Mapping[str, float] | None = None,
fontsize: float | None = None,
) -> None:
"""Direct-label the final finite point of each line.
Use ``y_offsets_pt`` to resolve known collisions deterministically.
"""
selected = list(lines) if lines is not None else list(ax.get_lines())
if labels is not None and len(labels) != len(selected):
raise ValueError("labels must have the same length as lines")
offsets = dict(y_offsets_pt or {})
for index, line in enumerate(selected):
x_data = np.asarray(line.get_xdata(), dtype=float)
y_data = np.asarray(line.get_ydata(), dtype=float)
finite = np.isfinite(x_data) & np.isfinite(y_data)
if not finite.any():
continue
last = np.flatnonzero(finite)[-1]
label = labels[index] if labels is not None else line.get_label()
if not label or label.startswith("_"):
continue
ax.annotate(
label,
xy=(x_data[last], y_data[last]),
xytext=(x_offset_pt, offsets.get(label, 0.0)),
textcoords="offset points",
ha="left",
va="center",
color=line.get_color(),
fontsize=fontsize,
fontweight="semibold" if line.get_linewidth() > DEFAULT_THEME.line_width_pt else "regular",
clip_on=False,
)
def pareto_mask(
x: Sequence[float],
y: Sequence[float],
*,
x_preference: Preference = "lower",
y_preference: Preference = "higher",
) -> np.ndarray:
"""Return a boolean mask for nondominated finite points.
A point is dominated when another point is at least as good on both axes
and strictly better on at least one axis, according to the preferences.
"""
x_arr = np.asarray(x, dtype=float)
y_arr = np.asarray(y, dtype=float)
if x_arr.shape != y_arr.shape:
raise ValueError("x and y must have identical shapes")
if x_arr.ndim != 1:
raise ValueError("x and y must be one-dimensional")
if x_preference not in {"higher", "lower"} or y_preference not in {"higher", "lower"}:
raise ValueError("preferences must be 'higher' or 'lower'")
finite = np.isfinite(x_arr) & np.isfinite(y_arr)
result = np.zeros_like(finite, dtype=bool)
indices = np.flatnonzero(finite)
for i in indices:
dominated = False
for j in indices:
if i == j:
continue
x_at_least = x_arr[j] >= x_arr[i] if x_preference == "higher" else x_arr[j] <= x_arr[i]
y_at_least = y_arr[j] >= y_arr[i] if y_preference == "higher" else y_arr[j] <= y_arr[i]
x_strict = x_arr[j] > x_arr[i] if x_preference == "higher" else x_arr[j] < x_arr[i]
y_strict = y_arr[j] > y_arr[i] if y_preference == "higher" else y_arr[j] < y_arr[i]
if x_at_least and y_at_least and (x_strict or y_strict):
dominated = True
break
result[i] = not dominated
return result
def plot_horizontal_comparison(
ax: Axes,
labels: Sequence[str],
values: Sequence[float],
*,
focal_label: str | None = None,
baseline_labels: Iterable[str] = (),
errors: Sequence[float] | None = None,
value_formatter: str | callable = "{:.1f}",
title: str | None = None,
xlabel: str | None = None,
sort: Literal["ascending", "descending", "none"] = "none",
theme: Theme = DEFAULT_THEME,
) -> BarContainer:
"""Create a restrained horizontal model/category comparison."""
if len(labels) != len(values):
raise ValueError("labels and values must have equal length")
if errors is not None and len(errors) != len(values):
raise ValueError("errors and values must have equal length")
if len(set(labels)) != len(labels):
raise ValueError("labels must be unique")
labels_arr = np.asarray(labels, dtype=object)
values_arr = np.asarray(values, dtype=float)
errors_arr = None if errors is None else np.asarray(errors, dtype=float)
order = np.arange(len(labels_arr))
if sort == "ascending":
order = np.argsort(values_arr, kind="stable")
elif sort == "descending":
order = np.argsort(-values_arr, kind="stable")
elif sort != "none":
raise ValueError("sort must be 'ascending', 'descending', or 'none'")
labels_arr = labels_arr[order]
values_arr = values_arr[order]
if errors_arr is not None:
errors_arr = errors_arr[order]
baseline_set = set(baseline_labels)
colors = []
for label in labels_arr:
if label == focal_label:
colors.append(theme.palette.accent)
elif label in baseline_set:
colors.append(theme.palette.baseline_light)
else:
colors.append(theme.palette.ink_secondary)
y = np.arange(len(labels_arr))
bars = ax.barh(
y,
values_arr,
xerr=errors_arr,
color=colors,
ecolor=theme.palette.ink_secondary,
error_kw={"elinewidth": theme.error_line_width_pt, "capsize": 2.0, "capthick": theme.error_line_width_pt},
)
ax.set_yticks(y, labels_arr)
ax.invert_yaxis()
if title:
ax.set_title(title, loc="left", pad=8)
if xlabel:
ax.set_xlabel(xlabel)
style_axes(ax, xgrid=True, ygrid=False, max_major_ticks=5, theme=theme)
x_min, x_max = ax.get_xlim()
if np.nanmin(values_arr) >= 0 and x_min > 0:
ax.set_xlim(left=0)
annotate_bar_values(
ax,
bars,
formatter=value_formatter,
orientation="horizontal",
color=theme.palette.ink_secondary,
fontsize=theme.annotation_size_pt,
)
return bars
def plot_line_series(
ax: Axes,
x: Sequence[float],
series: Mapping[str, Sequence[float]],
*,
focal: str | None = None,
baselines: Iterable[str] = (),
direct_labels: bool = True,
title: str | None = None,
xlabel: str | None = None,
ylabel: str | None = None,
theme: Theme = DEFAULT_THEME,
) -> dict[str, Line2D]:
"""Plot a small family of ordered series with stable emphasis."""
x_arr = np.asarray(x, dtype=float)
styles = build_series_styles(list(series), focal=focal, baselines=baselines, theme=theme)
lines: dict[str, Line2D] = {}
for name, values in series.items():
y_arr = np.asarray(values, dtype=float)
if y_arr.shape != x_arr.shape:
raise ValueError(f"series {name!r} does not match x shape")
style = styles[name]
(line,) = ax.plot(
x_arr,
y_arr,
label=name,
color=style.color,
linewidth=style.linewidth,
alpha=style.alpha,
linestyle=style.linestyle,
marker=style.marker,
zorder=style.zorder,
)
lines[name] = line
if title:
ax.set_title(title, loc="left", pad=8)
if xlabel:
ax.set_xlabel(xlabel)
if ylabel:
ax.set_ylabel(ylabel)
style_axes(ax, ygrid=True, xgrid=False, max_major_ticks=5, theme=theme)
if direct_labels:
label_line_ends(ax, list(lines.values()), labels=list(lines), fontsize=theme.annotation_size_pt)
else:
ax.legend(loc="best")
return lines
def plot_dot_intervals(
ax: Axes,
labels: Sequence[str],
estimates: Sequence[float],
lower: Sequence[float],
upper: Sequence[float],
*,
focal_label: str | None = None,
reference: float | None = 0.0,
title: str | None = None,
xlabel: str | None = None,
theme: Theme = DEFAULT_THEME,
) -> None:
"""Plot horizontal point estimates and intervals without bar-area bias."""
arrays = [np.asarray(v, dtype=float) for v in (estimates, lower, upper)]
if any(len(arr) != len(labels) for arr in arrays):
raise ValueError("all numeric arrays must match labels")
est, lo, hi = arrays
if np.any(lo > est) or np.any(est > hi):
raise ValueError("intervals must satisfy lower <= estimate <= upper")
y = np.arange(len(labels))
colors = [theme.palette.accent if label == focal_label else theme.palette.ink_secondary for label in labels]
for yi, estimate, lower_i, upper_i, color in zip(y, est, lo, hi, colors):
ax.plot([lower_i, upper_i], [yi, yi], color=color, linewidth=theme.error_line_width_pt, zorder=2)
ax.scatter([estimate], [yi], color=color, s=theme.marker_size_pt**2, zorder=3)
if reference is not None:
ax.axvline(reference, color=theme.palette.hairline, linewidth=theme.axis_line_width_pt, zorder=0)
ax.set_yticks(y, labels)
ax.invert_yaxis()
if title:
ax.set_title(title, loc="left", pad=8)
if xlabel:
ax.set_xlabel(xlabel)
style_axes(ax, xgrid=True, ygrid=False, max_major_ticks=5, theme=theme)
def plot_pareto_frontier(
ax: Axes,
x: Sequence[float],
y: Sequence[float],
labels: Sequence[str],
*,
x_preference: Preference = "lower",
y_preference: Preference = "higher",
focal_labels: Iterable[str] = (),
connect_frontier: bool = True,
annotate: bool = True,
title: str | None = None,
xlabel: str | None = None,
ylabel: str | None = None,
theme: Theme = DEFAULT_THEME,
) -> np.ndarray:
"""Plot a computed Pareto frontier with direct labels and muted dominated points."""
x_arr = np.asarray(x, dtype=float)
y_arr = np.asarray(y, dtype=float)
if x_arr.shape != y_arr.shape or len(labels) != len(x_arr):
raise ValueError("x, y, and labels must have identical length")
frontier = pareto_mask(x_arr, y_arr, x_preference=x_preference, y_preference=y_preference)
focal_set = set(focal_labels)
dominated = ~frontier & np.isfinite(x_arr) & np.isfinite(y_arr)
ax.scatter(
x_arr[dominated],
y_arr[dominated],
color=theme.palette.baseline_light,
s=theme.marker_size_pt**2,
alpha=0.9,
zorder=1,
)
frontier_indices = np.flatnonzero(frontier)
frontier_colors = [theme.palette.accent if labels[i] in focal_set else theme.palette.ink for i in frontier_indices]
ax.scatter(
x_arr[frontier_indices],
y_arr[frontier_indices],
color=frontier_colors,
s=(theme.marker_size_pt + 0.8) ** 2,
zorder=3,
)
if connect_frontier and len(frontier_indices) > 1:
order = np.argsort(x_arr[frontier_indices])
ordered = frontier_indices[order]
ax.plot(
x_arr[ordered],
y_arr[ordered],
color=theme.palette.hairline,
linewidth=theme.axis_line_width_pt,
zorder=0,
)
if annotate:
for index in range(len(labels)):
if frontier[index] or labels[index] in focal_set:
color = theme.palette.accent if labels[index] in focal_set else theme.palette.ink_secondary
ax.annotate(
labels[index],
xy=(x_arr[index], y_arr[index]),
xytext=(4, 4),
textcoords="offset points",
fontsize=theme.annotation_size_pt,
color=color,
ha="left",
va="bottom",
clip_on=False,
)
if title:
ax.set_title(title, loc="left", pad=8)
if xlabel:
ax.set_xlabel(xlabel)
if ylabel:
ax.set_ylabel(ylabel)
style_axes(ax, xgrid=True, ygrid=True, max_major_ticks=5, theme=theme)
return frontier
def _visible_text_artists(fig: Figure) -> list[mpl.text.Text]:
texts: list[mpl.text.Text] = []
for artist in fig.findobj(match=mpl.text.Text):
if artist.get_visible() and artist.get_text().strip():
texts.append(artist)
return texts
def _bar_orientation(container: BarContainer) -> str | None:
orientation = getattr(container, "orientation", None)
return orientation if orientation in {"vertical", "horizontal"} else None
def audit_matplotlib_figure(
fig: Figure,
*,
min_font_size_pt: float = 8.0,
max_salient_lines: int = 7,
require_white_canvas: bool = True,
theme: Theme = DEFAULT_THEME,
) -> AuditReport:
"""Run deterministic preflight checks on a Matplotlib figure.
This audit cannot judge scientific correctness, label collisions, or the
five-second message test. It is a supplement to final-size visual review.
"""
if min_font_size_pt <= 0:
raise ValueError("min_font_size_pt must be positive")
fig.canvas.draw()
issues: list[AuditIssue] = []
width_in, height_in = fig.get_size_inches()
if width_in <= 0 or height_in <= 0:
issues.append(AuditIssue("error", "invalid_size", "Figure dimensions must be positive."))
if require_white_canvas:
face = mpl.colors.to_hex(fig.get_facecolor(), keep_alpha=False).upper()
allowed = {
mpl.colors.to_hex(theme.palette.white).upper(),
mpl.colors.to_hex(theme.palette.warm_white).upper(),
}
if face not in allowed:
issues.append(
AuditIssue(
"warning",
"nonwhite_canvas",
f"Figure canvas is {face}; the default contract uses white or warm white.",
)
)
renderer = fig.canvas.get_renderer()
figure_bbox = fig.bbox
inactive_tick_text_ids: set[int] = set()
for ax in fig.axes:
for axis_object, limits in ((ax.xaxis, ax.get_xlim()), (ax.yaxis, ax.get_ylim())):
lower, upper = sorted(float(value) for value in limits)
tolerance = max(1e-12, abs(upper - lower) * 1e-9)
for tick in [*axis_object.get_major_ticks(), *axis_object.get_minor_ticks()]:
try:
location = float(tick.get_loc())
except (TypeError, ValueError):
continue
if not np.isfinite(location) or location < lower - tolerance or location > upper + tolerance:
inactive_tick_text_ids.add(id(tick.label1))
inactive_tick_text_ids.add(id(tick.label2))
for text in _visible_text_artists(fig):
if id(text) in inactive_tick_text_ids:
continue
size = float(text.get_fontsize())
try:
bbox = text.get_window_extent(renderer=renderer)
tolerance_px = 1.0
intersects_canvas = not (
bbox.x1 < figure_bbox.x0
or bbox.x0 > figure_bbox.x1
or bbox.y1 < figure_bbox.y0
or bbox.y0 > figure_bbox.y1
)
if intersects_canvas and (
bbox.x0 < figure_bbox.x0 - tolerance_px
or bbox.y0 < figure_bbox.y0 - tolerance_px
or bbox.x1 > figure_bbox.x1 + tolerance_px
or bbox.y1 > figure_bbox.y1 + tolerance_px
):
snippet = text.get_text().replace("\n", " ")[:60]
issues.append(
AuditIssue(
"error",
"clipped_text",
f"Text {snippet!r} extends outside the figure canvas; revise dimensions, wrapping, or margins.",
)
)
except (RuntimeError, ValueError):
pass
if size < min_font_size_pt - 1e-6:
snippet = text.get_text().replace("\n", " ")[:60]
issues.append(
AuditIssue(
"error",
"small_text",
f"Text {snippet!r} is {size:.2f} pt, below the {min_font_size_pt:.2f} pt minimum.",
)
)
try:
color = mpl.colors.to_hex(text.get_color(), keep_alpha=False)
if contrast_ratio(color, theme.palette.white) < 3.0:
issues.append(
AuditIssue(
"warning",
"low_text_contrast",
f"Text {text.get_text()[:40]!r} has low contrast ({contrast_ratio(color, theme.palette.white):.2f}:1) on white.",
)
)
except (ValueError, TypeError):
pass
for axes_index, ax in enumerate(fig.axes):
title = ax.get_title().strip().lower()
if title in GENERIC_TITLES:
issues.append(
AuditIssue(
"warning",
"generic_title",
f"Axes title {ax.get_title()!r} is generic; use a specific descriptive or claim-led title.",
axes_index,
)
)
for spine_name in ("top", "right"):
if spine_name in ax.spines and ax.spines[spine_name].get_visible():
issues.append(
AuditIssue(
"warning",
"extra_spine",
f"The {spine_name} spine is visible.",
axes_index,
)
)
if len(ax.get_lines()) > max_salient_lines:
issues.append(
AuditIssue(
"warning",
"too_many_lines",
f"Axes contains {len(ax.get_lines())} line artists; consider small multiples or stronger hierarchy.",
axes_index,
)
)
legend = ax.get_legend()
if legend is not None:
if legend.get_frame_on():
issues.append(
AuditIssue(
"warning",
"legend_frame",
"Legend frame is visible; remove it unless required for overlap contrast.",
axes_index,
)
)
if len(legend.get_texts()) > 6:
issues.append(
AuditIssue(
"warning",
"large_legend",
f"Legend has {len(legend.get_texts())} entries; consider direct labels or facets.",
axes_index,
)
)
for axis_name, scale, label in (
("x", ax.get_xscale(), ax.get_xlabel()),
("y", ax.get_yscale(), ax.get_ylabel()),
):
if scale != "linear" and "log" not in label.lower() and scale not in label.lower():
issues.append(
AuditIssue(
"warning",
"unlabeled_scale",
f"{axis_name}-axis uses {scale!r} scale but the label does not state it.",
axes_index,
)
)
for container in ax.containers:
if not isinstance(container, BarContainer) or not container.patches:
continue
orientation = _bar_orientation(container)
if orientation == "vertical":
y_min, y_max = ax.get_ylim()
data_values = np.asarray(getattr(container, "datavalues", []), dtype=float)
if data_values.size and np.nanmin(data_values) >= 0 and not (y_min <= 0 <= y_max):
issues.append(
AuditIssue(
"error",
"truncated_bar_axis",
"Positive vertical bars are shown on an axis that excludes zero.",
axes_index,
)
)
elif orientation == "horizontal":
x_min, x_max = ax.get_xlim()
data_values = np.asarray(getattr(container, "datavalues", []), dtype=float)
if data_values.size and np.nanmin(data_values) >= 0 and not (x_min <= 0 <= x_max):
issues.append(
AuditIssue(
"error",
"truncated_bar_axis",
"Positive horizontal bars are shown on an axis that excludes zero.",
axes_index,
)
)
visible_grid = [
line
for line in [*ax.get_xgridlines(), *ax.get_ygridlines()]
if line.get_visible()
]
for grid_line in visible_grid:
try:
if relative_luminance(grid_line.get_color()) < 0.75:
issues.append(
AuditIssue(
"warning",
"dark_grid",
"A visible gridline is darker than the restrained grid contract.",
axes_index,
)
)
break
except (ValueError, TypeError):
pass
passed = not any(issue.severity == "error" for issue in issues)
return AuditReport(
passed=passed,
min_font_size_pt=min_font_size_pt,
issues=tuple(issues),
figure_width_in=float(width_in),
figure_height_in=float(height_in),
)
def save_figure(
fig: Figure,
output_stem: str | Path,
*,
formats: Sequence[str] = ("svg", "pdf", "png"),
png_dpi: int = 300,
transparent: bool = False,
audit: bool = True,
min_font_size_pt: float = 8.0,
raise_on_audit_error: bool = True,
metadata: Mapping[str, str] | None = None,
) -> tuple[list[Path], AuditReport | None]:
"""Audit and save a figure in deterministic vector/raster formats.
The exact figure dimensions are preserved. ``bbox_inches='tight'`` is not
used because it changes output geometry; use constrained layout and inspect
the final render instead.
"""
stem = Path(output_stem)
stem.parent.mkdir(parents=True, exist_ok=True)
normalized_formats = [fmt.lower().lstrip(".") for fmt in formats]
allowed = {"svg", "pdf", "png"}
invalid = sorted(set(normalized_formats) - allowed)
if invalid:
raise ValueError(f"unsupported formats: {invalid}; allowed: {sorted(allowed)}")
if png_dpi < 150:
raise ValueError("png_dpi must be at least 150; use >=300 for manuscript figures")
report = audit_matplotlib_figure(fig, min_font_size_pt=min_font_size_pt) if audit else None
if report is not None and not report.passed and raise_on_audit_error:
raise ValueError(f"figure audit failed:\n{report.to_json()}")
saved: list[Path] = []
for fmt in normalized_formats:
path = stem.with_suffix(f".{fmt}")
kwargs: MutableMapping[str, Any] = {
"format": fmt,
"transparent": transparent,
"metadata": dict(metadata or {}),
}
if fmt == "png":
kwargs["dpi"] = png_dpi
fig.savefig(path, **kwargs)
saved.append(path)
return saved, report
__all__ = [
"AuditIssue",
"AuditReport",
"DEFAULT_PALETTE",
"DEFAULT_THEME",
"Palette",
"SeriesStyle",
"Theme",
"annotate_bar_values",
"audit_matplotlib_figure",
"build_series_styles",
"configure_matplotlib",
"contrast_ratio",
"format_number",
"label_line_ends",
"metric_axis_label",
"mm_to_inches",
"new_figure",
"pareto_mask",
"plot_dot_intervals",
"plot_horizontal_comparison",
"plot_line_series",
"plot_pareto_frontier",
"relative_luminance",
"save_figure",
]
SHA-256: 05450d6faaca6e6ba02fd60f3c84ac20ff8cb5216e084fb0e160ca5f225d05be