Skills › Scientific Agent Skills › scientific-visualization › style_preview.py
style_preview.py
232 lines · skills/scientific-visualization/scripts/style_preview.py at 5f2bc03
1#!/usr/bin/env python3
2"""Generate a deterministic preview for a bundled scientific figure style."""
4from __future__ import annotations
6import argparse
7import math
8import sys
9from typing import Any
11from _common import CliError, emit_json, positive_float
14def build_preview(style_name: str, palette_name: str) -> tuple[Any, dict[str, Any]]:
15 """Create a deterministic multi-panel preview and palette audit."""
16 try:
17 import matplotlib as mpl
18 import matplotlib.pyplot as plt
19 except ImportError as exc:
20 raise CliError(
21 "Matplotlib is required for previews; "
22 "run with --with 'matplotlib==3.11.2'"
23 ) from exc
24 from palette_audit import audit_palette
25 from style_presets import available_palettes, style_context
27 palettes = available_palettes()
28 if palette_name not in palettes:
29 raise CliError(
30 f"unknown palette {palette_name!r}; "
31 f"available: {', '.join(sorted(palettes))}"
32 )
33 colors = palettes[palette_name]
34 if len(colors) < 3:
35 raise CliError("preview palette must contain at least three colors")
36 audit = audit_palette(
37 colors,
38 background="#FFFFFF",
39 role="graphical",
40 name=palette_name,
41 )
43 with style_context(style_name, palette_name=palette_name):
44 fig, axes = plt.subplots(
45 2,
46 2,
47 figsize=(7.0, 5.2),
48 layout="constrained",
49 )
50 x_values = [index / 10.0 for index in range(41)]
51 line_styles = ["-", "--", "-.", ":"]
52 markers = ["o", "s", "^", "D"]
53 for index in range(4):
54 values = [
55 math.sin(value + index * 0.45) + index * 0.35
56 for value in x_values
57 ]
58 axes[0, 0].plot(
59 x_values,
60 values,
61 color=colors[index % len(colors)],
62 linestyle=line_styles[index],
63 marker=markers[index],
64 markevery=8,
65 label=f"Series {index + 1}",
66 )
67 axes[0, 0].axhline(0, color="0.35", linewidth=0.7)
68 axes[0, 0].set(
69 xlabel="Time (hours)",
70 ylabel="Response (a.u.)",
71 title="Color plus redundant encoding",
72 )
73 axes[0, 0].legend(ncols=2)
75 categories = ["Control", "Low", "High"]
76 values = [0.0, 1.4, -0.8]
77 hatches = ["", "///", "xx"]
78 bars = axes[0, 1].bar(
79 categories,
80 values,
81 color=[colors[index % len(colors)] for index in range(3)],
82 edgecolor="black",
83 linewidth=0.7,
84 )
85 for bar, hatch in zip(bars, hatches):
86 bar.set_hatch(hatch)
87 axes[0, 1].axhline(0, color="black", linewidth=0.7)
88 axes[0, 1].set(
89 ylabel="Change from baseline (unit)",
90 title="Signed bars with visible zero",
91 )
93 matrix = [
94 [-2.0, -1.2, -0.4, 0.2, 1.0],
95 [-1.5, -0.8, float("nan"), 0.8, 1.7],
96 [-1.0, -0.2, 0.0, 1.1, 2.0],
97 ]
98 colormap = mpl.colormaps["RdBu_r"].with_extremes(bad="#777777")
99 image = axes[1, 0].imshow(
100 matrix,
101 cmap=colormap,
102 norm=mpl.colors.TwoSlopeNorm(vmin=-2, vcenter=0, vmax=2),
103 aspect="auto",
104 interpolation="nearest",
105 )
106 axes[1, 0].set(
107 xlabel="Sample",
108 ylabel="Feature",
109 title="Centered normalization; gray = missing",
110 )
111 colorbar = fig.colorbar(image, ax=axes[1, 0])
112 colorbar.set_label("Effect (unit)")
114 groups = [
115 [1.0, 1.2, 0.9, 1.1, 1.4, 0.8],
116 [1.4, 1.8, 1.6, 1.5, 2.0, 1.7],
117 [0.7, 0.9, 1.0, 0.6, 0.8, 1.1],
118 ]
119 for index, group in enumerate(groups):
120 offsets = [-0.08, -0.05, -0.02, 0.02, 0.05, 0.08]
121 axes[1, 1].scatter(
122 [index + offset for offset in offsets],
123 group,
124 color=colors[index % len(colors)],
125 edgecolor="black",
126 linewidth=0.4,
127 marker=markers[index],
128 zorder=2,
129 )
130 ordered = sorted(group)
131 median = (ordered[2] + ordered[3]) / 2
132 axes[1, 1].plot(
133 [index - 0.15, index + 0.15],
134 [median, median],
135 color="black",
136 linewidth=1.2,
137 )
138 axes[1, 1].set(
139 xticks=range(3),
140 xticklabels=categories,
141 ylabel="Observed value (unit)",
142 title="Raw observations with median",
143 )
145 for label, ax in zip("ABCD", axes.flat):
146 ax.text(
147 -0.12,
148 1.06,
149 label,
150 transform=ax.transAxes,
151 fontweight="bold",
152 va="top",
153 )
154 fig.suptitle(f"Style preview: {style_name} / {palette_name}")
155 return fig, audit
158def build_parser() -> argparse.ArgumentParser:
159 parser = argparse.ArgumentParser(
160 description=(
161 "Render a deterministic accessible-style preview with line, bar, "
162 "heatmap, missing-data, and raw-observation examples."
163 )
164 )
165 parser.add_argument("--output", required=True, help="output base path")
166 parser.add_argument(
167 "--style",
168 choices=("default", "nature", "science", "cell", "minimal", "presentation"),
169 default="default",
170 )
171 parser.add_argument("--palette", default="okabe_ito_on_white")
172 parser.add_argument(
173 "--formats", default="png,svg", help="comma-separated formats"
174 )
175 parser.add_argument("--dpi", type=positive_float, default=300.0)
176 parser.add_argument("--manifest", action="store_true")
177 parser.add_argument("--force", action="store_true")
178 return parser
181def main(argv: list[str] | None = None) -> int:
182 parser = build_parser()
183 try:
184 args = parser.parse_args(argv)
185 from figure_export import export_figure
187 fig, audit = build_preview(args.style, args.palette)
188 try:
189 report = export_figure(
190 fig,
191 args.output,
192 formats=args.formats.split(","),
193 dpi=args.dpi,
194 overwrite=args.force,
195 provenance={
196 "purpose": "deterministic bundled-style preview",
197 "style": args.style,
198 "palette": args.palette,
199 "data": "synthetic values defined in style_preview.py",
200 "transformations": [
201 "analytical sine offsets",
202 "explicit TwoSlopeNorm(-2, 0, 2)",
203 "median of displayed raw observations",
204 ],
205 "palette_screen": {
206 "background_review_count": audit["contrast_screen"][
207 "review_count"
208 ],
209 "grayscale_review_count": audit["grayscale_screen"][
210 "review_count"
211 ],
212 },
213 },
214 write_manifest=args.manifest,
215 )
216 finally:
217 import matplotlib.pyplot as plt
219 plt.close(fig)
220 report["palette_audit"] = {
221 "contrast_screen": audit["contrast_screen"],
222 "grayscale_screen": audit["grayscale_screen"],
223 "notice": audit["notice"],
224 }
225 emit_json(report)
226 return 0
227 except CliError as exc:
228 parser.exit(2, f"error: {exc}\n")
231if __name__ == "__main__":
232 sys.exit(main())