CodonAdaptPy 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1073 @@
1
+ """
2
+ CodonAdaptPy Publication and Interactive Plots
3
+
4
+ This module creates interactive Plotly figures and publication-ready static
5
+ Matplotlib figures for RSCU heatmaps, ENC-GC3 diagnostics, neutrality and PR2
6
+ plots, group distributions, ordinations, dinucleotide profiles, correlation
7
+ matrices, PCA, and sliding-window CAI profiles.
8
+
9
+ Classes:
10
+ - PlotManager: Plot construction and format-aware figure export.
11
+ - PublicationPlotManager: Colorblind-safe static scientific figures.
12
+
13
+ :Created: July 20, 2026
14
+ :Updated: July 20, 2026
15
+ :Author: Naveen Duhan
16
+ :Version: 1.0.0
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import math
22
+ from collections import defaultdict
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ from .exceptions import OptionalDependencyError
27
+ from .models import AnalysisResult
28
+
29
+
30
+ class PlotManager:
31
+ """Build consistent Plotly figures from structured analysis results."""
32
+
33
+ def _plotly(self) -> tuple[Any, Any]:
34
+ """Import Plotly lazily so core analysis has no plotting dependency."""
35
+
36
+ try:
37
+ import plotly.express as px
38
+ import plotly.graph_objects as go
39
+ except ImportError as exc:
40
+ raise OptionalDependencyError("Plots require CodonAdaptPy[plot].") from exc
41
+ return px, go
42
+
43
+ def rscu_heatmap(self, results: list[AnalysisResult]) -> Any:
44
+ """Create a sequence-by-codon RSCU heatmap."""
45
+
46
+ _, go = self._plotly()
47
+ from .genetic_code import GeneticCode
48
+
49
+ code = GeneticCode.from_ncbi(1)
50
+ codons = sorted(
51
+ codon
52
+ for amino_acid, family in code.synonymous_codons.items()
53
+ if amino_acid != "*" and len(family) > 1
54
+ for codon in family
55
+ )
56
+ return go.Figure(
57
+ data=go.Heatmap(
58
+ z=[[result.rscu.get(codon, 0.0) for codon in codons] for result in results],
59
+ x=codons,
60
+ y=[result.identifier for result in results],
61
+ colorscale="Viridis",
62
+ )
63
+ ).update_layout(title="Relative Synonymous Codon Usage", xaxis_title="Codon", yaxis_title="Sequence")
64
+
65
+ def enc_gc3(self, results: list[AnalysisResult]) -> Any:
66
+ """Create observed ENC versus GC3 with the mutation-only expectation."""
67
+
68
+ _, go = self._plotly()
69
+ gc3_grid = [index / 100 for index in range(101)]
70
+ from .metrics.evolutionary import EvolutionaryMetrics
71
+
72
+ figure = go.Figure()
73
+ figure.add_scatter(
74
+ x=[result.metrics["gc3"] for result in results],
75
+ y=[result.metrics["enc"] for result in results],
76
+ mode="markers+text",
77
+ text=[result.identifier for result in results],
78
+ name="Observed",
79
+ )
80
+ figure.add_scatter(
81
+ x=gc3_grid, y=[EvolutionaryMetrics.expected_enc(value) for value in gc3_grid], mode="lines", name="Expected"
82
+ )
83
+ return figure.update_layout(title="ENC-GC3 Plot", xaxis_title="GC3", yaxis_title="Effective Number of Codons")
84
+
85
+ def sliding_cai(self, result: AnalysisResult) -> Any:
86
+ """Create a sliding-window CAI line profile for one sequence."""
87
+
88
+ _, go = self._plotly()
89
+ values = result.sliding_windows.get("cai", [])
90
+ return go.Figure(
91
+ data=go.Scatter(
92
+ x=[item["start_codon"] for item in values], y=[item["cai"] for item in values], mode="lines"
93
+ )
94
+ ).update_layout(
95
+ title=f"Sliding-window CAI: {result.identifier}", xaxis_title="Starting codon", yaxis_title="CAI"
96
+ )
97
+
98
+ def neutrality(self, results: list[AnalysisResult]) -> Any:
99
+ """Create a GC12-versus-GC3 neutrality scatter with linear trend."""
100
+
101
+ px, _ = self._plotly()
102
+ rows = [
103
+ {
104
+ "identifier": result.identifier,
105
+ "GC3": result.metrics["gc3"],
106
+ "GC12": (result.metrics["gc1"] + result.metrics["gc2"]) / 2,
107
+ }
108
+ for result in results
109
+ ]
110
+ return px.scatter(rows, x="GC3", y="GC12", text="identifier", trendline="ols", title="Neutrality Plot")
111
+
112
+ def parity_rule_2(self, results: list[AnalysisResult]) -> Any:
113
+ """Create a PR2 plot centered on the equal-bias expectation."""
114
+
115
+ px, _ = self._plotly()
116
+ rows = [
117
+ {
118
+ "identifier": result.identifier,
119
+ "A3/(A3+T3)": result.metrics["at_bias"],
120
+ "G3/(G3+C3)": result.metrics["gc_bias"],
121
+ }
122
+ for result in results
123
+ ]
124
+ figure = px.scatter(
125
+ rows,
126
+ x="A3/(A3+T3)",
127
+ y="G3/(G3+C3)",
128
+ text="identifier",
129
+ title="Parity Rule 2 Plot",
130
+ )
131
+ figure.add_hline(y=0.5, line_dash="dash")
132
+ figure.add_vline(x=0.5, line_dash="dash")
133
+ return figure
134
+
135
+ def group_distribution(
136
+ self,
137
+ rows: list[dict[str, Any]],
138
+ *,
139
+ metric: str,
140
+ group: str,
141
+ kind: str = "box",
142
+ ) -> Any:
143
+ """Create box, violin, or bar comparisons from flat metric records."""
144
+
145
+ px, _ = self._plotly()
146
+ if kind == "box":
147
+ return px.box(rows, x=group, y=metric, points="all", title=f"{metric} by {group}")
148
+ if kind == "violin":
149
+ return px.violin(rows, x=group, y=metric, box=True, points="all", title=f"{metric} by {group}")
150
+ if kind == "bar":
151
+ return px.bar(rows, x=group, y=metric, title=f"{metric} by {group}")
152
+ raise ValueError("kind must be 'box', 'violin', or 'bar'.")
153
+
154
+ def matrix_heatmap(
155
+ self,
156
+ matrix: list[list[float]],
157
+ *,
158
+ row_labels: list[str],
159
+ column_labels: list[str],
160
+ title: str = "Matrix Heatmap",
161
+ ) -> Any:
162
+ """Create correlation, distance, or other labeled matrix heatmaps."""
163
+
164
+ _, go = self._plotly()
165
+ return go.Figure(
166
+ data=go.Heatmap(z=matrix, x=column_labels, y=row_labels, colorscale="RdBu", zmid=0)
167
+ ).update_layout(title=title)
168
+
169
+ def pca(self, scores: list[list[float]], labels: list[str]) -> Any:
170
+ """Create an interactive PCA score plot from comparative output."""
171
+
172
+ px, _ = self._plotly()
173
+ rows = [
174
+ {"identifier": label, "PC1": score[0], "PC2": score[1] if len(score) > 1 else 0.0}
175
+ for label, score in zip(labels, scores, strict=True)
176
+ ]
177
+ return px.scatter(rows, x="PC1", y="PC2", text="identifier", title="Principal Component Analysis")
178
+
179
+ def save(self, figure: Any, path: str | Path) -> Path:
180
+ """Save interactive HTML or static SVG/PNG/PDF based on suffix."""
181
+
182
+ destination = Path(path)
183
+ if destination.suffix.lower() == ".html":
184
+ figure.write_html(destination, include_plotlyjs="cdn")
185
+ else:
186
+ try:
187
+ figure.write_image(destination)
188
+ except Exception as exc:
189
+ raise OptionalDependencyError("Static plots require a working Kaleido installation.") from exc
190
+ return destination
191
+
192
+
193
+ class PublicationPlotManager:
194
+ """Create journal-ready static figures from CodonAdaptPy outputs.
195
+
196
+ The manager uses the Okabe-Ito colorblind-safe palette, redundant marker
197
+ shapes, outward-offset axes, visible individual observations, and vector-
198
+ compatible Matplotlib artists. Matplotlib is imported lazily so sequence
199
+ analysis remains dependency-free.
200
+ """
201
+
202
+ COLORS = ("#0072B2", "#D55E00", "#009E73", "#CC79A7", "#E69F00", "#56B4E9")
203
+ MARKERS = ("o", "s", "^", "D", "P", "X")
204
+ METRIC_LABELS = {
205
+ "gc": "GC",
206
+ "gc1": "GC1",
207
+ "gc2": "GC2",
208
+ "gc3": "GC3",
209
+ "gc3s": "GC3s",
210
+ "at1": "AT1",
211
+ "at2": "AT2",
212
+ "at3": "AT3",
213
+ "enc": "ENC",
214
+ "cpg_representation": "CpG O/E",
215
+ "upa_representation": "UpA O/E",
216
+ }
217
+
218
+ def _matplotlib(self) -> tuple[Any, Any]:
219
+ """Import Matplotlib lazily and apply consistent publication styling."""
220
+
221
+ try:
222
+ import matplotlib.pyplot as plt
223
+ import numpy as np
224
+ except ImportError as exc:
225
+ raise OptionalDependencyError(
226
+ "Publication plots require CodonAdaptPy[plot] with Matplotlib."
227
+ ) from exc
228
+ plt.rcParams.update(
229
+ {
230
+ "font.family": "sans-serif",
231
+ "font.sans-serif": ["Arial", "Helvetica", "DejaVu Sans"],
232
+ "font.size": 8,
233
+ "axes.labelsize": 9,
234
+ "axes.titlesize": 10,
235
+ "xtick.labelsize": 7,
236
+ "ytick.labelsize": 7,
237
+ "legend.fontsize": 7,
238
+ "pdf.fonttype": 42,
239
+ "svg.fonttype": "none",
240
+ "figure.max_open_warning": 50,
241
+ }
242
+ )
243
+ return plt, np
244
+
245
+ @staticmethod
246
+ def _clean_axes(axis: Any) -> None:
247
+ """Remove chart junk and separate the visible left and bottom spines."""
248
+
249
+ axis.spines["top"].set_visible(False)
250
+ axis.spines["right"].set_visible(False)
251
+ axis.spines["left"].set_position(("outward", 5))
252
+ axis.spines["bottom"].set_position(("outward", 5))
253
+ axis.tick_params(direction="out", length=3, width=0.7)
254
+
255
+ def ordination(
256
+ self,
257
+ scores: list[list[float]],
258
+ labels: list[str],
259
+ groups: list[str],
260
+ *,
261
+ x_label: str = "Axis 1",
262
+ y_label: str = "Axis 2",
263
+ title: str = "Ordination",
264
+ ) -> Any:
265
+ """Plot grouped two-dimensional scores with centroid standard errors.
266
+
267
+ Individual samples are shown with color and shape encoding. Larger
268
+ black-edged points show group centroids, and error bars represent the
269
+ standard error independently along both axes.
270
+ """
271
+
272
+ if not (len(scores) == len(labels) == len(groups)) or not scores:
273
+ raise ValueError("scores, labels, and groups must have equal non-zero lengths.")
274
+ if any(len(score) < 2 for score in scores):
275
+ raise ValueError("Each ordination score must contain at least two axes.")
276
+ plt, np = self._matplotlib()
277
+ figure, axis = plt.subplots(figsize=(7.2, 5.0), constrained_layout=True)
278
+ ordered_groups = sorted({str(group) for group in groups})
279
+ for index, group in enumerate(ordered_groups):
280
+ positions = [position for position, value in enumerate(groups) if str(value) == group]
281
+ values = np.asarray([scores[position][:2] for position in positions], dtype=float)
282
+ color = self.COLORS[index % len(self.COLORS)]
283
+ marker = self.MARKERS[index % len(self.MARKERS)]
284
+ axis.scatter(
285
+ values[:, 0],
286
+ values[:, 1],
287
+ s=28,
288
+ color=color,
289
+ marker=marker,
290
+ alpha=0.78,
291
+ edgecolor="white",
292
+ linewidth=0.45,
293
+ label=f"{group} (n={len(values)})",
294
+ )
295
+ centroid = values.mean(axis=0)
296
+ standard_error = values.std(axis=0, ddof=1) / math.sqrt(len(values)) if len(values) > 1 else [0, 0]
297
+ axis.errorbar(
298
+ centroid[0],
299
+ centroid[1],
300
+ xerr=standard_error[0],
301
+ yerr=standard_error[1],
302
+ fmt=marker,
303
+ markersize=7,
304
+ markerfacecolor=color,
305
+ markeredgecolor="black",
306
+ markeredgewidth=0.8,
307
+ ecolor=color,
308
+ elinewidth=1.0,
309
+ capsize=2.5,
310
+ zorder=4,
311
+ )
312
+ axis.axhline(0, color="#BDBDBD", linewidth=0.6, zorder=0)
313
+ axis.axvline(0, color="#BDBDBD", linewidth=0.6, zorder=0)
314
+ axis.set(xlabel=x_label, ylabel=y_label, title=title)
315
+ axis.legend(frameon=False, title="Genotype")
316
+ self._clean_axes(axis)
317
+ return figure
318
+
319
+ def metric_panels(
320
+ self,
321
+ rows: list[dict[str, Any]],
322
+ *,
323
+ metrics: list[str],
324
+ group: str,
325
+ title: str = "Metric distributions",
326
+ columns: int = 2,
327
+ ) -> Any:
328
+ """Create box-and-point panels for numeric metrics grouped by metadata.
329
+
330
+ Boxes show the median and interquartile range; whiskers follow the
331
+ conventional 1.5-IQR rule. Every observation is overlaid using
332
+ deterministic horizontal jitter, and each category label includes its
333
+ sample size.
334
+ """
335
+
336
+ if not rows or not metrics:
337
+ raise ValueError("rows and metrics must be non-empty.")
338
+ plt, np = self._matplotlib()
339
+ group_names = sorted({str(row[group]) for row in rows})
340
+ panel_columns = min(columns, len(metrics))
341
+ panel_rows = math.ceil(len(metrics) / panel_columns)
342
+ figure, axes = plt.subplots(
343
+ panel_rows,
344
+ panel_columns,
345
+ figsize=(7.2, 2.9 * panel_rows),
346
+ constrained_layout=True,
347
+ squeeze=False,
348
+ )
349
+ random = np.random.default_rng(1)
350
+ for panel_index, metric in enumerate(metrics):
351
+ axis = axes.flat[panel_index]
352
+ values = [
353
+ [float(row[metric]) for row in rows if str(row[group]) == name and row.get(metric) is not None]
354
+ for name in group_names
355
+ ]
356
+ boxes = axis.boxplot(values, patch_artist=True, widths=0.55, showfliers=False)
357
+ for index, patch in enumerate(boxes["boxes"]):
358
+ patch.set_facecolor(self.COLORS[index % len(self.COLORS)])
359
+ patch.set_alpha(0.34)
360
+ patch.set_edgecolor(self.COLORS[index % len(self.COLORS)])
361
+ for index, observations in enumerate(values, start=1):
362
+ jitter = random.uniform(-0.12, 0.12, len(observations))
363
+ axis.scatter(
364
+ np.full(len(observations), index) + jitter,
365
+ observations,
366
+ s=15,
367
+ color=self.COLORS[(index - 1) % len(self.COLORS)],
368
+ alpha=0.68,
369
+ edgecolor="white",
370
+ linewidth=0.3,
371
+ )
372
+ axis.set_xticks(
373
+ range(1, len(group_names) + 1),
374
+ [f"{name}\n(n={len(values[index])})" for index, name in enumerate(group_names)],
375
+ )
376
+ axis.set_xlabel(group)
377
+ display_metric = self.METRIC_LABELS.get(metric, metric.replace("_", " "))
378
+ axis.set_ylabel(display_metric)
379
+ axis.set_title(display_metric)
380
+ self._clean_axes(axis)
381
+ for axis in axes.flat[len(metrics) :]:
382
+ axis.set_visible(False)
383
+ figure.suptitle(title, fontsize=11, fontweight="bold")
384
+ return figure
385
+
386
+ def dinucleotide_heatmap(
387
+ self,
388
+ results: list[AnalysisResult],
389
+ *,
390
+ group_key: str,
391
+ title: str = "Mean dinucleotide observed/expected ratios",
392
+ ) -> Any:
393
+ """Plot mean O/E ratios for all 16 dinucleotides by metadata group."""
394
+
395
+ if not results:
396
+ raise ValueError("results must be non-empty.")
397
+ plt, np = self._matplotlib()
398
+ grouped: dict[str, list[AnalysisResult]] = defaultdict(list)
399
+ for result in results:
400
+ grouped[str(result.metadata.get(group_key, "Unknown"))].append(result)
401
+ group_names = sorted(grouped)
402
+ dinucleotides = [left + right for left in "ACGT" for right in "ACGT"]
403
+ matrix = np.asarray(
404
+ [
405
+ [
406
+ np.mean(
407
+ [
408
+ result.dinucleotide_odds[pair]
409
+ for result in grouped[name]
410
+ if result.dinucleotide_odds[pair] is not None
411
+ ]
412
+ )
413
+ for pair in dinucleotides
414
+ ]
415
+ for name in group_names
416
+ ],
417
+ dtype=float,
418
+ )
419
+ figure, axis = plt.subplots(figsize=(7.2, max(2.6, 0.58 * len(group_names))), constrained_layout=True)
420
+ image = axis.imshow(matrix, cmap="BrBG", vmin=0.65, vmax=1.35, aspect="auto")
421
+ axis.set_xticks(range(len(dinucleotides)), dinucleotides, rotation=45, ha="right")
422
+ axis.set_yticks(range(len(group_names)), [f"{name} (n={len(grouped[name])})" for name in group_names])
423
+ axis.set_xlabel("Dinucleotide")
424
+ axis.set_ylabel("Genotype")
425
+ axis.set_title(title)
426
+ for row_index in range(matrix.shape[0]):
427
+ for column_index in range(matrix.shape[1]):
428
+ axis.text(
429
+ column_index,
430
+ row_index,
431
+ f"{matrix[row_index, column_index]:.2f}",
432
+ ha="center",
433
+ va="center",
434
+ fontsize=5.5,
435
+ )
436
+ colorbar = figure.colorbar(image, ax=axis, fraction=0.025, pad=0.02)
437
+ colorbar.set_label("Observed/expected ratio")
438
+ return figure
439
+
440
+ def rscu_heatmap(
441
+ self,
442
+ results: list[AnalysisResult],
443
+ *,
444
+ group_key: str,
445
+ title: str = "Mean relative synonymous codon usage",
446
+ ) -> Any:
447
+ """Plot group-mean RSCU values for all available sense codons."""
448
+
449
+ if not results:
450
+ raise ValueError("results must be non-empty.")
451
+ plt, np = self._matplotlib()
452
+ grouped: dict[str, list[AnalysisResult]] = defaultdict(list)
453
+ for result in results:
454
+ grouped[str(result.metadata.get(group_key, "Unknown"))].append(result)
455
+ group_names = sorted(grouped)
456
+ codons = sorted({codon for result in results for codon in result.rscu})
457
+ matrix = np.asarray(
458
+ [
459
+ [np.mean([result.rscu.get(codon, 0.0) for result in grouped[name]]) for codon in codons]
460
+ for name in group_names
461
+ ],
462
+ dtype=float,
463
+ )
464
+ figure, axis = plt.subplots(figsize=(10.0, max(2.7, 0.58 * len(group_names))), constrained_layout=True)
465
+ image = axis.imshow(matrix, cmap="viridis", vmin=0.0, vmax=max(2.0, float(matrix.max())), aspect="auto")
466
+ axis.set_xticks(range(len(codons)), codons, rotation=90)
467
+ axis.set_yticks(range(len(group_names)), [f"{name} (n={len(grouped[name])})" for name in group_names])
468
+ axis.set_xlabel("Codon")
469
+ axis.set_ylabel("Genotype")
470
+ axis.set_title(title)
471
+ colorbar = figure.colorbar(image, ax=axis, fraction=0.018, pad=0.02)
472
+ colorbar.set_label("Mean RSCU")
473
+ return figure
474
+
475
+ def evolutionary_diagnostics(
476
+ self,
477
+ results: list[AnalysisResult],
478
+ *,
479
+ group_key: str,
480
+ title: str = "Evolutionary codon-usage diagnostics",
481
+ ) -> Any:
482
+ """Create ENC-GC3, neutrality, and PR2 panels by metadata group."""
483
+
484
+ if not results:
485
+ raise ValueError("results must be non-empty.")
486
+ plt, np = self._matplotlib()
487
+ from .metrics.evolutionary import EvolutionaryMetrics
488
+
489
+ figure, axes = plt.subplots(1, 3, figsize=(10.8, 3.5), constrained_layout=True)
490
+ group_names = sorted({str(result.metadata.get(group_key, "Unknown")) for result in results})
491
+ for index, group in enumerate(group_names):
492
+ selected = [result for result in results if str(result.metadata.get(group_key, "Unknown")) == group]
493
+ color = self.COLORS[index % len(self.COLORS)]
494
+ marker = self.MARKERS[index % len(self.MARKERS)]
495
+ common = {
496
+ "s": 25,
497
+ "color": color,
498
+ "marker": marker,
499
+ "alpha": 0.75,
500
+ "edgecolor": "white",
501
+ "linewidth": 0.4,
502
+ "label": f"{group} (n={len(selected)})",
503
+ }
504
+ axes[0].scatter(
505
+ [result.metrics["gc3s"] for result in selected],
506
+ [result.metrics["enc"] for result in selected],
507
+ **common,
508
+ )
509
+ axes[1].scatter(
510
+ [result.metrics["gc3"] for result in selected],
511
+ [(result.metrics["gc1"] + result.metrics["gc2"]) / 2 for result in selected],
512
+ **common,
513
+ )
514
+ selected_gc3 = np.asarray([result.metrics["gc3"] for result in selected], dtype=float)
515
+ selected_gc12 = np.asarray(
516
+ [(result.metrics["gc1"] + result.metrics["gc2"]) / 2 for result in selected],
517
+ dtype=float,
518
+ )
519
+ if len(selected) >= 2 and np.ptp(selected_gc3) > 0:
520
+ slope, intercept = np.polyfit(selected_gc3, selected_gc12, 1)
521
+ trend = np.linspace(float(selected_gc3.min()), float(selected_gc3.max()), 100)
522
+ axes[1].plot(trend, intercept + slope * trend, color=color, linewidth=1.0, linestyle="--")
523
+ axes[2].scatter(
524
+ [result.metrics["at_bias"] for result in selected],
525
+ [result.metrics["gc_bias"] for result in selected],
526
+ **common,
527
+ )
528
+
529
+ grid = np.linspace(0.01, 0.99, 200)
530
+ axes[0].plot(
531
+ grid,
532
+ [EvolutionaryMetrics.expected_enc(float(value)) for value in grid],
533
+ color="black",
534
+ linewidth=1.0,
535
+ linestyle="--",
536
+ label="Mutation-only expectation",
537
+ )
538
+ axes[2].axhline(0.5, color="black", linewidth=0.8, linestyle="--")
539
+ axes[2].axvline(0.5, color="black", linewidth=0.8, linestyle="--")
540
+ axes[0].set(xlabel="GC3s", ylabel="Effective number of codons", title="ENC-GC3s")
541
+ axes[1].set(xlabel="GC3", ylabel="GC12", title="Neutrality")
542
+ axes[2].set(xlabel="A3/(A3+T3)", ylabel="G3/(G3+C3)", title="PR2")
543
+ for index, axis in enumerate(axes):
544
+ axis.text(-0.16, 1.08, chr(65 + index), transform=axis.transAxes, fontweight="bold", fontsize=11)
545
+ self._clean_axes(axis)
546
+ handles, labels = axes[0].get_legend_handles_labels()
547
+ figure.legend(
548
+ handles,
549
+ labels,
550
+ loc="lower center",
551
+ bbox_to_anchor=(0.5, -0.03),
552
+ ncol=min(4, len(labels)),
553
+ frameon=False,
554
+ )
555
+ figure.suptitle(title, fontsize=11, fontweight="bold")
556
+ return figure
557
+
558
+ def rscu_profiles(
559
+ self,
560
+ results: list[AnalysisResult],
561
+ *,
562
+ group_key: str,
563
+ title: str = "Relative synonymous codon usage profiles",
564
+ ) -> Any:
565
+ """Plot group-mean 59-codon RSCU profiles with standard errors.
566
+
567
+ Codons are arranged by amino-acid family and colored by their third
568
+ nucleotide. Horizontal reference lines identify the conventional
569
+ underrepresentation (0.6) and preference (1.6) thresholds.
570
+ """
571
+
572
+ if not results:
573
+ raise ValueError("results must be non-empty.")
574
+ plt, np = self._matplotlib()
575
+ from .genetic_code import GeneticCode
576
+
577
+ code = GeneticCode.from_ncbi(1)
578
+ codon_rows = [
579
+ (amino_acid, codon)
580
+ for amino_acid, family in sorted(code.synonymous_codons.items())
581
+ if amino_acid != "*" and len(family) > 1
582
+ for codon in sorted(family)
583
+ ]
584
+ groups = sorted({str(result.metadata.get(group_key, "Unknown")) for result in results})
585
+ figure, axes = plt.subplots(
586
+ len(groups),
587
+ 1,
588
+ figsize=(11.0, 2.55 * len(groups)),
589
+ sharex=True,
590
+ constrained_layout=True,
591
+ squeeze=False,
592
+ )
593
+ base_colors = {"A": "#E69F00", "C": "#0072B2", "G": "#009E73", "T": "#CC79A7"}
594
+ positions = np.arange(len(codon_rows))
595
+ for panel, group in enumerate(groups):
596
+ axis = axes[panel, 0]
597
+ selected = [result for result in results if str(result.metadata.get(group_key, "Unknown")) == group]
598
+ means = []
599
+ errors = []
600
+ for _, codon in codon_rows:
601
+ values = np.asarray([result.rscu[codon] for result in selected], dtype=float)
602
+ means.append(float(values.mean()))
603
+ errors.append(float(values.std(ddof=1) / math.sqrt(len(values))) if len(values) > 1 else 0.0)
604
+ axis.bar(
605
+ positions,
606
+ means,
607
+ yerr=errors,
608
+ color=[base_colors[codon[2]] for _, codon in codon_rows],
609
+ edgecolor="white",
610
+ linewidth=0.2,
611
+ error_kw={"elinewidth": 0.45, "capsize": 1.0, "capthick": 0.45},
612
+ )
613
+ axis.axhline(0.6, color="#666666", linestyle=":", linewidth=0.8)
614
+ axis.axhline(1.6, color="#B2182B", linestyle="--", linewidth=0.8)
615
+ axis.set_ylabel("Mean RSCU")
616
+ axis.set_title(f"Genotype {group} (n={len(selected)})", loc="left", fontweight="bold")
617
+ self._clean_axes(axis)
618
+ family_starts: dict[str, int] = {}
619
+ for index, (amino_acid, _) in enumerate(codon_rows):
620
+ family_starts.setdefault(amino_acid, index)
621
+ boundaries = [index - 0.5 for index in family_starts.values() if index]
622
+ for boundary in boundaries:
623
+ axis.axvline(boundary, color="#D9D9D9", linewidth=0.4, zorder=0)
624
+ axes[-1, 0].set_xticks(positions, [codon for _, codon in codon_rows], rotation=90)
625
+ axes[-1, 0].set_xlabel("Codon (bar color indicates third nucleotide)")
626
+ figure.suptitle(title, fontsize=11, fontweight="bold")
627
+ return figure
628
+
629
+ def dinucleotide_profiles(
630
+ self,
631
+ results: list[AnalysisResult],
632
+ *,
633
+ group_key: str,
634
+ title: str = "Dinucleotide observed/expected profiles",
635
+ ) -> Any:
636
+ """Plot all 16 group-mean dinucleotide O/E ratios with SEM bars."""
637
+
638
+ if not results:
639
+ raise ValueError("results must be non-empty.")
640
+ plt, np = self._matplotlib()
641
+ pairs = [left + right for left in "ACGT" for right in "ACGT"]
642
+ groups = sorted({str(result.metadata.get(group_key, "Unknown")) for result in results})
643
+ figure, axis = plt.subplots(figsize=(8.2, 3.9), constrained_layout=True)
644
+ positions = np.arange(len(pairs), dtype=float)
645
+ offsets = np.linspace(-0.18, 0.18, len(groups)) if len(groups) > 1 else np.asarray([0.0])
646
+ for index, (group, offset) in enumerate(zip(groups, offsets, strict=True)):
647
+ selected = [result for result in results if str(result.metadata.get(group_key, "Unknown")) == group]
648
+ means = []
649
+ errors = []
650
+ for pair in pairs:
651
+ values = np.asarray(
652
+ [
653
+ result.dinucleotide_odds[pair]
654
+ for result in selected
655
+ if result.dinucleotide_odds[pair] is not None
656
+ ],
657
+ dtype=float,
658
+ )
659
+ means.append(float(values.mean()))
660
+ errors.append(float(values.std(ddof=1) / math.sqrt(len(values))) if len(values) > 1 else 0.0)
661
+ axis.errorbar(
662
+ positions + offset,
663
+ means,
664
+ yerr=errors,
665
+ color=self.COLORS[index % len(self.COLORS)],
666
+ marker=self.MARKERS[index % len(self.MARKERS)],
667
+ markersize=4,
668
+ linewidth=1.0,
669
+ capsize=2,
670
+ label=f"{group} (n={len(selected)})",
671
+ )
672
+ axis.axhline(1.25, color="#B2182B", linestyle="--", linewidth=0.9, label="Overrepresented (>1.25)")
673
+ axis.axhline(0.78, color="#666666", linestyle=":", linewidth=0.9, label="Underrepresented (<0.78)")
674
+ axis.set_xticks(positions, pairs)
675
+ axis.set(xlabel="Dinucleotide", ylabel="Observed/expected ratio", title=title)
676
+ axis.legend(frameon=False, ncol=3, loc="upper center", bbox_to_anchor=(0.5, -0.18))
677
+ self._clean_axes(axis)
678
+ return figure
679
+
680
+ def composition_overview(
681
+ self,
682
+ results: list[AnalysisResult],
683
+ *,
684
+ group_key: str,
685
+ title: str = "Nucleotide composition and codon bias",
686
+ ) -> Any:
687
+ """Create four panels summarizing composition, positional bias, and ENC."""
688
+
689
+ if not results:
690
+ raise ValueError("results must be non-empty.")
691
+ plt, np = self._matplotlib()
692
+ groups = sorted({str(result.metadata.get(group_key, "Unknown")) for result in results})
693
+ figure, axes = plt.subplots(2, 2, figsize=(8.2, 6.5))
694
+ figure.subplots_adjust(left=0.09, right=0.985, top=0.87, bottom=0.18, hspace=0.50, wspace=0.30)
695
+
696
+ def significance_label(samples: list[list[float]]) -> str:
697
+ """Return an overall Kruskal-Wallis significance label when possible."""
698
+
699
+ non_empty = [sample for sample in samples if sample]
700
+ if len(non_empty) < 2:
701
+ return ""
702
+ try:
703
+ from scipy.stats import kruskal
704
+
705
+ p_value = float(kruskal(*non_empty).pvalue)
706
+ except (ImportError, ValueError):
707
+ return ""
708
+ if p_value < 0.0001:
709
+ return "****"
710
+ if p_value < 0.001:
711
+ return "***"
712
+ if p_value < 0.01:
713
+ return "**"
714
+ if p_value < 0.05:
715
+ return "*"
716
+ return "ns"
717
+
718
+ def grouped_bars(axis: Any, keys: list[str], labels: list[str], ylabel: str) -> None:
719
+ positions = np.arange(len(keys), dtype=float)
720
+ width = 0.72 / len(groups)
721
+ samples_by_key: dict[str, list[list[float]]] = {key: [] for key in keys}
722
+ upper_by_key: dict[str, float] = {key: 0.0 for key in keys}
723
+ for group_index, group in enumerate(groups):
724
+ selected = [result for result in results if str(result.metadata.get(group_key, "Unknown")) == group]
725
+ means = []
726
+ errors = []
727
+ for key in keys:
728
+ values = np.asarray([float(result.metrics[key]) * 100 for result in selected], dtype=float)
729
+ means.append(float(values.mean()))
730
+ errors.append(float(values.std(ddof=1) / math.sqrt(len(values))) if len(values) > 1 else 0.0)
731
+ samples_by_key[key].append(values.tolist())
732
+ upper_by_key[key] = max(upper_by_key[key], means[-1] + errors[-1])
733
+ offset = (group_index - (len(groups) - 1) / 2) * width
734
+ axis.bar(
735
+ positions + offset,
736
+ means,
737
+ width=width,
738
+ yerr=errors,
739
+ color=self.COLORS[group_index % len(self.COLORS)],
740
+ alpha=0.82,
741
+ error_kw={"elinewidth": 0.7, "capsize": 2},
742
+ label=f"{group} (n={len(selected)})",
743
+ )
744
+ span = max(upper_by_key.values(), default=1.0)
745
+ for position, key in zip(positions, keys, strict=True):
746
+ label = significance_label(samples_by_key[key])
747
+ if label:
748
+ axis.text(position, upper_by_key[key] + span * 0.035, label, ha="center", va="bottom", fontsize=7)
749
+ axis.set_xticks(positions, labels)
750
+ axis.set_ylabel(ylabel)
751
+ self._clean_axes(axis)
752
+
753
+ grouped_bars(axes[0, 0], ["a", "t", "g", "c"], ["A", "T", "G", "C"], "Composition (%)")
754
+ axes[0, 0].set_title("Overall nucleotide composition")
755
+ grouped_bars(axes[0, 1], ["at1", "at2", "at3"], ["AT1", "AT2", "AT3"], "AT content (%)")
756
+ axes[0, 1].set_title("Codon-position AT content")
757
+ grouped_bars(axes[1, 0], ["gc", "gc3", "gc3s"], ["GC", "GC3", "GC3s"], "GC content (%)")
758
+ axes[1, 0].set_title("GC composition")
759
+
760
+ random = np.random.default_rng(1)
761
+ enc_axis = axes[1, 1]
762
+ enc_values = [
763
+ [result.metrics["enc"] for result in results if str(result.metadata.get(group_key, "Unknown")) == group]
764
+ for group in groups
765
+ ]
766
+ boxes = enc_axis.boxplot(enc_values, patch_artist=True, widths=0.55, showfliers=False)
767
+ for index, patch in enumerate(boxes["boxes"]):
768
+ patch.set_facecolor(self.COLORS[index % len(self.COLORS)])
769
+ patch.set_alpha(0.34)
770
+ for index, values in enumerate(enc_values, start=1):
771
+ enc_axis.scatter(
772
+ np.full(len(values), index) + random.uniform(-0.11, 0.11, len(values)),
773
+ values,
774
+ color=self.COLORS[(index - 1) % len(self.COLORS)],
775
+ s=16,
776
+ alpha=0.68,
777
+ edgecolor="white",
778
+ linewidth=0.3,
779
+ )
780
+ enc_axis.set_xticks(
781
+ range(1, len(groups) + 1),
782
+ [f"{group}\n(n={len(values)})" for group, values in zip(groups, enc_values, strict=True)],
783
+ )
784
+ enc_axis.set(xlabel="Genotype", ylabel="Effective number of codons", title="Codon usage bias")
785
+ enc_label = significance_label([[float(value) for value in values] for values in enc_values])
786
+ if enc_label:
787
+ enc_upper = max((max(values) for values in enc_values if values), default=1.0)
788
+ enc_annotation_y = enc_upper + max(enc_upper, 1.0) * 0.035
789
+ enc_axis.text(
790
+ (len(groups) + 1) / 2,
791
+ enc_annotation_y,
792
+ enc_label,
793
+ ha="center",
794
+ va="bottom",
795
+ fontsize=7,
796
+ )
797
+ enc_axis.set_ylim(top=enc_annotation_y + max(enc_upper, 1.0) * 0.05)
798
+ self._clean_axes(enc_axis)
799
+ handles, labels = axes[0, 0].get_legend_handles_labels()
800
+ figure.legend(
801
+ handles,
802
+ labels,
803
+ loc="lower center",
804
+ bbox_to_anchor=(0.5, 0.055),
805
+ ncol=len(groups),
806
+ frameon=False,
807
+ )
808
+ figure.suptitle(title, fontsize=11, fontweight="bold")
809
+ figure.text(
810
+ 0.995,
811
+ 0.012,
812
+ "Overall Kruskal-Wallis: ns ≥ 0.05; * < 0.05; ** < 0.01; *** < 0.001; **** < 0.0001",
813
+ ha="right",
814
+ va="bottom",
815
+ fontsize=6,
816
+ color="#4D4D4D",
817
+ )
818
+ for index, axis in enumerate(axes.flat):
819
+ axis.text(-0.14, 1.07, chr(65 + index), transform=axis.transAxes, fontweight="bold", fontsize=11)
820
+ return figure
821
+
822
+ def lda_evaluation(
823
+ self,
824
+ analysis: dict[str, Any],
825
+ groups: list[str],
826
+ *,
827
+ title: str = "Linear discriminant analysis",
828
+ ) -> Any:
829
+ """Plot an LDA projection beside its held-out confusion matrix."""
830
+
831
+ projection = analysis.get("projection", [])
832
+ if not projection or len(projection) != len(groups):
833
+ raise ValueError("LDA projection and groups must have equal non-zero lengths.")
834
+ plt, np = self._matplotlib()
835
+ figure, axes = plt.subplots(1, 2, figsize=(8.2, 3.8), constrained_layout=True)
836
+ classes = [str(value) for value in analysis["classes"]]
837
+ coordinates = np.asarray(projection, dtype=float)
838
+ if coordinates.shape[1] == 1:
839
+ coordinates = np.column_stack([coordinates[:, 0], np.zeros(len(coordinates))])
840
+ for index, group in enumerate(classes):
841
+ selected = np.asarray([position for position, value in enumerate(groups) if str(value) == group])
842
+ axes[0].scatter(
843
+ coordinates[selected, 0],
844
+ coordinates[selected, 1],
845
+ color=self.COLORS[index % len(self.COLORS)],
846
+ marker=self.MARKERS[index % len(self.MARKERS)],
847
+ s=28,
848
+ alpha=0.78,
849
+ edgecolor="white",
850
+ linewidth=0.4,
851
+ label=f"{group} (n={len(selected)})",
852
+ )
853
+ axes[0].axhline(0, color="#BDBDBD", linewidth=0.6)
854
+ axes[0].axvline(0, color="#BDBDBD", linewidth=0.6)
855
+ axes[0].set(xlabel="LD1", ylabel="LD2", title="All-sample projection")
856
+ axes[0].legend(frameon=False, title="Genotype")
857
+ self._clean_axes(axes[0])
858
+
859
+ matrix = np.asarray(analysis["confusion_matrix"], dtype=float)
860
+ image = axes[1].imshow(matrix, cmap="Blues", vmin=0)
861
+ axes[1].set_xticks(range(len(classes)), classes)
862
+ axes[1].set_yticks(range(len(classes)), classes)
863
+ axes[1].set(
864
+ xlabel="Predicted genotype",
865
+ ylabel="True genotype",
866
+ title=f"Held-out accuracy: {analysis['accuracy'] * 100:.1f}%",
867
+ )
868
+ for row in range(matrix.shape[0]):
869
+ for column in range(matrix.shape[1]):
870
+ axes[1].text(column, row, f"{int(matrix[row, column])}", ha="center", va="center", fontsize=9)
871
+ figure.colorbar(image, ax=axes[1], fraction=0.045, pad=0.03, label="Test samples")
872
+ figure.suptitle(title, fontsize=11, fontweight="bold")
873
+ for index, axis in enumerate(axes):
874
+ axis.text(-0.16, 1.08, chr(65 + index), transform=axis.transAxes, fontweight="bold", fontsize=11)
875
+ return figure
876
+
877
+ def host_adaptation(
878
+ self,
879
+ rows: list[dict[str, Any]],
880
+ *,
881
+ metrics: list[str],
882
+ group_key: str,
883
+ host_key: str,
884
+ title: str = "Host adaptation",
885
+ ) -> Any:
886
+ """Plot host metrics by genotype with observations, means, and SEM."""
887
+
888
+ if not rows or not metrics:
889
+ raise ValueError("rows and metrics must be non-empty.")
890
+ plt, np = self._matplotlib()
891
+ groups = sorted({str(row[group_key]) for row in rows})
892
+ hosts = list(dict.fromkeys(str(row[host_key]) for row in rows))
893
+ figure, axes = plt.subplots(1, len(metrics), figsize=(4.4 * len(metrics), 3.8), constrained_layout=True)
894
+ axes = np.atleast_1d(axes)
895
+ random = np.random.default_rng(1)
896
+ host_positions = np.arange(len(hosts), dtype=float)
897
+ offsets = np.linspace(-0.22, 0.22, len(groups)) if len(groups) > 1 else np.asarray([0.0])
898
+ for panel, metric in enumerate(metrics):
899
+ axis = axes[panel]
900
+ for index, (group, offset) in enumerate(zip(groups, offsets, strict=True)):
901
+ means = []
902
+ errors = []
903
+ for host_position, host in enumerate(hosts):
904
+ values = np.asarray(
905
+ [
906
+ float(row[metric])
907
+ for row in rows
908
+ if str(row[group_key]) == group
909
+ and str(row[host_key]) == host
910
+ and row.get(metric) is not None
911
+ ],
912
+ dtype=float,
913
+ )
914
+ means.append(float(values.mean()))
915
+ errors.append(float(values.std(ddof=1) / math.sqrt(len(values))) if len(values) > 1 else 0.0)
916
+ axis.scatter(
917
+ np.full(len(values), host_position + offset) + random.uniform(-0.035, 0.035, len(values)),
918
+ values,
919
+ color=self.COLORS[index % len(self.COLORS)],
920
+ s=10,
921
+ alpha=0.25,
922
+ linewidth=0,
923
+ )
924
+ axis.errorbar(
925
+ host_positions + offset,
926
+ means,
927
+ yerr=errors,
928
+ color=self.COLORS[index % len(self.COLORS)],
929
+ marker=self.MARKERS[index % len(self.MARKERS)],
930
+ linewidth=1.0,
931
+ markersize=5,
932
+ capsize=2,
933
+ label=f"{group}",
934
+ )
935
+ axis.set_xticks(host_positions, hosts, rotation=25, ha="right")
936
+ axis.set(xlabel="Host", ylabel=self.METRIC_LABELS.get(metric.lower(), metric), title=metric)
937
+ self._clean_axes(axis)
938
+ axis.text(-0.15, 1.08, chr(65 + panel), transform=axis.transAxes, fontweight="bold", fontsize=11)
939
+ axes[0].legend(frameon=False, title="Genotype")
940
+ figure.suptitle(title, fontsize=11, fontweight="bold")
941
+ return figure
942
+
943
+ def bland_altman_validation(
944
+ self,
945
+ panels: list[dict[str, Any]],
946
+ *,
947
+ title: str = "Method Validation: Bland-Altman and Residual Agreement",
948
+ ) -> Any:
949
+ """Create multi-panel Bland-Altman and residual agreement plots.
950
+
951
+ Each dictionary in ``panels`` must supply:
952
+ - ``metric``: str metric label (e.g. 'CAI', 'ENC', 'RSCU', 'GC3s')
953
+ - ``reference``: list[float] reference or benchmark values
954
+ - ``tool``: list[float] CodonAdaptPy calculated values
955
+ """
956
+
957
+ if not panels:
958
+ raise ValueError("panels must be non-empty.")
959
+ plt, np = self._matplotlib()
960
+ columns = min(2, len(panels))
961
+ rows = math.ceil(len(panels) / columns)
962
+ figure, axes = plt.subplots(
963
+ rows,
964
+ columns,
965
+ figsize=(7.2, 3.2 * rows),
966
+ constrained_layout=True,
967
+ squeeze=False,
968
+ )
969
+ for index, panel in enumerate(panels):
970
+ axis = axes.flat[index]
971
+ ref = np.asarray(panel["reference"], dtype=float)
972
+ tool = np.asarray(panel["tool"], dtype=float)
973
+ if len(ref) != len(tool) or len(ref) < 3:
974
+ raise ValueError("reference and tool must contain at least three matching observations.")
975
+ means = (ref + tool) / 2.0
976
+ diffs = tool - ref
977
+ bias = float(diffs.mean())
978
+ sd = float(diffs.std(ddof=1)) if len(diffs) > 1 else 0.0
979
+ upper_loa = bias + 1.96 * sd
980
+ lower_loa = bias - 1.96 * sd
981
+
982
+ correlation = np.corrcoef(ref, tool)[0, 1] if len(ref) > 1 else 1.0
983
+ r_squared = float(correlation**2)
984
+
985
+ axis.scatter(means, diffs, s=16, color="#0072B2", alpha=0.7, edgecolor="white", linewidth=0.3)
986
+ axis.axhline(bias, color="#D55E00", linewidth=1.2, linestyle="-", label=f"Mean bias: {bias:+.4f}")
987
+ axis.axhline(upper_loa, color="#009E73", linewidth=1.0, linestyle="--", label=f"+1.96 SD: {upper_loa:+.4f}")
988
+ axis.axhline(lower_loa, color="#009E73", linewidth=1.0, linestyle="--", label=f"-1.96 SD: {lower_loa:+.4f}")
989
+
990
+ metric_name = panel.get("metric", f"Metric {index + 1}")
991
+ axis.set_xlabel(f"Mean ({metric_name})")
992
+ axis.set_ylabel("Difference (CodonAdaptPy - Ref)")
993
+ axis.set_title(f"{metric_name} (R² = {r_squared:.4f}, n = {len(ref)})")
994
+ axis.legend(frameon=False, fontsize=6.5, loc="upper right")
995
+ self._clean_axes(axis)
996
+
997
+ for axis in axes.flat[len(panels) :]:
998
+ axis.set_visible(False)
999
+
1000
+ figure.suptitle(title, fontsize=11, fontweight="bold")
1001
+ for index, axis in enumerate(axes.flat[: len(panels)]):
1002
+ axis.text(-0.15, 1.08, chr(65 + index), transform=axis.transAxes, fontweight="bold", fontsize=11)
1003
+ return figure
1004
+
1005
+ def repeated_cv_distribution(
1006
+ self,
1007
+ lda_output: dict[str, Any],
1008
+ *,
1009
+ title: str = "Repeated Grouped Cross-Validation Distributions",
1010
+ ) -> Any:
1011
+ """Plot fold-level accuracy distributions across repeated CV iterations."""
1012
+
1013
+ fold_results = lda_output.get("fold_results", [])
1014
+ if not fold_results:
1015
+ raise ValueError("lda_output must contain non-empty 'fold_results'.")
1016
+ plt, np = self._matplotlib()
1017
+ figure, axis = plt.subplots(figsize=(6.5, 3.6), constrained_layout=True)
1018
+
1019
+ metrics = [("accuracy", "Accuracy"), ("balanced_accuracy", "Balanced Accuracy"), ("macro_f1", "Macro F1")]
1020
+ positions = np.arange(len(metrics))
1021
+ data = [[float(row[key]) * 100 for row in fold_results] for key, _ in metrics]
1022
+
1023
+ boxes = axis.boxplot(data, positions=positions, patch_artist=True, widths=0.45, showfliers=False)
1024
+ for idx, patch in enumerate(boxes["boxes"]):
1025
+ patch.set_facecolor(self.COLORS[idx % len(self.COLORS)])
1026
+ patch.set_alpha(0.4)
1027
+ patch.set_edgecolor(self.COLORS[idx % len(self.COLORS)])
1028
+
1029
+ random = np.random.default_rng(1)
1030
+ for idx, values in enumerate(data):
1031
+ jitter = random.uniform(-0.1, 0.1, len(values))
1032
+ axis.scatter(
1033
+ positions[idx] + jitter,
1034
+ values,
1035
+ s=14,
1036
+ color=self.COLORS[idx % len(self.COLORS)],
1037
+ alpha=0.6,
1038
+ edgecolor="white",
1039
+ linewidth=0.3,
1040
+ )
1041
+
1042
+ summary = lda_output.get("summary", {})
1043
+ for idx, (key, _metric_label) in enumerate(metrics):
1044
+ if key in summary:
1045
+ mean_val = summary[key]["mean"] * 100
1046
+ sd_val = summary[key]["sd"] * 100
1047
+ axis.text(
1048
+ positions[idx],
1049
+ min([min(d) for d in data]) - 3.0,
1050
+ f"Mean: {mean_val:.1f}%\nSD: ±{sd_val:.1f}%",
1051
+ ha="center",
1052
+ va="top",
1053
+ fontsize=7,
1054
+ )
1055
+
1056
+ axis.set_xticks(positions, [label for _, label in metrics])
1057
+ axis.set_ylabel("Held-Out Performance (%)")
1058
+ folds = lda_output.get("folds", 5)
1059
+ repeats = lda_output.get("repeats", 20)
1060
+ subtitle = f"({folds}-fold Stratified Grouped LDA, {repeats} repeats, n={len(fold_results)} folds)"
1061
+ axis.set_title(f"{title}\n{subtitle}")
1062
+ self._clean_axes(axis)
1063
+ return figure
1064
+
1065
+ def save(self, figure: Any, path: str | Path, *, dpi: int = 600) -> Path:
1066
+ """Save a publication figure as PNG, TIFF, PDF, or SVG."""
1067
+
1068
+ destination = Path(path)
1069
+ if destination.suffix.lower() not in {".png", ".tif", ".tiff", ".pdf", ".svg"}:
1070
+ raise ValueError("Publication figures must use PNG, TIFF, PDF, or SVG.")
1071
+ destination.parent.mkdir(parents=True, exist_ok=True)
1072
+ figure.savefig(destination, dpi=dpi, bbox_inches="tight", facecolor="white")
1073
+ return destination