quantized-lab 0.8.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.
- quantized/__init__.py +11 -0
- quantized/__main__.py +14 -0
- quantized/api.py +220 -0
- quantized/app.py +206 -0
- quantized/calc/__init__.py +8 -0
- quantized/calc/_clipfit.py +76 -0
- quantized/calc/_natural_neighbor.py +219 -0
- quantized/calc/aggregate.py +159 -0
- quantized/calc/backgrounds.py +353 -0
- quantized/calc/baseline.py +349 -0
- quantized/calc/batch_fit.py +148 -0
- quantized/calc/constants.py +27 -0
- quantized/calc/corrections.py +192 -0
- quantized/calc/crystallography.py +400 -0
- quantized/calc/diffusion.py +120 -0
- quantized/calc/electrical.py +248 -0
- quantized/calc/electrochemistry.py +176 -0
- quantized/calc/element_data.json +1 -0
- quantized/calc/element_data.py +59 -0
- quantized/calc/errors.py +246 -0
- quantized/calc/figure.py +417 -0
- quantized/calc/figure_break.py +152 -0
- quantized/calc/figure_categorical.py +156 -0
- quantized/calc/figure_corner.py +229 -0
- quantized/calc/figure_facets.py +127 -0
- quantized/calc/figure_field.py +137 -0
- quantized/calc/figure_hitmap.py +116 -0
- quantized/calc/figure_labels.py +62 -0
- quantized/calc/figure_map.py +287 -0
- quantized/calc/figure_overrides.py +159 -0
- quantized/calc/figure_page.py +266 -0
- quantized/calc/figure_scale.py +125 -0
- quantized/calc/figure_statplots.py +167 -0
- quantized/calc/figure_styles.py +131 -0
- quantized/calc/figure_ternary.py +239 -0
- quantized/calc/figure_ticks.py +217 -0
- quantized/calc/fit_autoguess.py +156 -0
- quantized/calc/fit_bootstrap.py +163 -0
- quantized/calc/fit_bumps.py +258 -0
- quantized/calc/fit_constraints.py +114 -0
- quantized/calc/fit_equation.py +264 -0
- quantized/calc/fit_findxy.py +80 -0
- quantized/calc/fit_models.py +195 -0
- quantized/calc/fit_models_special.py +189 -0
- quantized/calc/fit_odr.py +99 -0
- quantized/calc/fit_scan.py +243 -0
- quantized/calc/fit_stats.py +215 -0
- quantized/calc/fitting.py +199 -0
- quantized/calc/formula.py +90 -0
- quantized/calc/global_curve_fit.py +305 -0
- quantized/calc/global_fit.py +181 -0
- quantized/calc/interp2d.py +260 -0
- quantized/calc/linecut.py +269 -0
- quantized/calc/magnetic.py +414 -0
- quantized/calc/magnetometry.py +464 -0
- quantized/calc/map.py +228 -0
- quantized/calc/mcmc.py +177 -0
- quantized/calc/optics.py +228 -0
- quantized/calc/pawley.py +251 -0
- quantized/calc/peak_batch.py +128 -0
- quantized/calc/peak_fit.py +259 -0
- quantized/calc/peak_integrate.py +104 -0
- quantized/calc/peak_multifit.py +260 -0
- quantized/calc/peak_track.py +134 -0
- quantized/calc/peaks.py +298 -0
- quantized/calc/peakshapes.py +85 -0
- quantized/calc/plotting.py +147 -0
- quantized/calc/processing.py +232 -0
- quantized/calc/qspace.py +48 -0
- quantized/calc/reductions.py +155 -0
- quantized/calc/reductions_fft.py +383 -0
- quantized/calc/refl_sld_presets.json +1 -0
- quantized/calc/reflectivity.py +80 -0
- quantized/calc/registry.py +303 -0
- quantized/calc/relaxation.py +119 -0
- quantized/calc/report.py +253 -0
- quantized/calc/report_emit.py +227 -0
- quantized/calc/resample.py +142 -0
- quantized/calc/rsm.py +91 -0
- quantized/calc/rsm_analyze.py +245 -0
- quantized/calc/semiconductor.py +488 -0
- quantized/calc/sld.py +131 -0
- quantized/calc/sld_formula.py +138 -0
- quantized/calc/spectral.py +357 -0
- quantized/calc/statplots.py +214 -0
- quantized/calc/stats.py +399 -0
- quantized/calc/stats_anova2.py +202 -0
- quantized/calc/stats_anova_ext.py +338 -0
- quantized/calc/stats_dist.py +196 -0
- quantized/calc/stats_glm.py +245 -0
- quantized/calc/stats_multivar.py +289 -0
- quantized/calc/stats_roc.py +157 -0
- quantized/calc/stats_survival.py +261 -0
- quantized/calc/stats_tests.py +380 -0
- quantized/calc/substrates.py +181 -0
- quantized/calc/superconductor.py +359 -0
- quantized/calc/surface_fit.py +290 -0
- quantized/calc/surface_models.py +156 -0
- quantized/calc/thermal.py +119 -0
- quantized/calc/thin_film.py +425 -0
- quantized/calc/unit_convert.py +259 -0
- quantized/calc/units.py +80 -0
- quantized/calc/vacuum.py +290 -0
- quantized/calc/xray.py +169 -0
- quantized/cli.py +214 -0
- quantized/datastruct.py +153 -0
- quantized/io/__init__.py +11 -0
- quantized/io/_hdf5_layout.py +308 -0
- quantized/io/_jcamp_asdf.py +135 -0
- quantized/io/_xrdml_scan.py +291 -0
- quantized/io/base.py +82 -0
- quantized/io/bruker_brml.py +177 -0
- quantized/io/bruker_raw.py +158 -0
- quantized/io/cif.py +266 -0
- quantized/io/consolidated.py +122 -0
- quantized/io/delimited.py +222 -0
- quantized/io/excel.py +135 -0
- quantized/io/hdf5.py +192 -0
- quantized/io/import_filters.py +178 -0
- quantized/io/import_preview.py +262 -0
- quantized/io/jcamp.py +179 -0
- quantized/io/lakeshore.py +163 -0
- quantized/io/ncnr.py +278 -0
- quantized/io/netcdf.py +195 -0
- quantized/io/opus.py +231 -0
- quantized/io/origin.py +346 -0
- quantized/io/origin_com.py +194 -0
- quantized/io/origin_project/__init__.py +221 -0
- quantized/io/origin_project/annotation_marks.py +288 -0
- quantized/io/origin_project/container.py +262 -0
- quantized/io/origin_project/curve_style_color.py +359 -0
- quantized/io/origin_project/figure_geometry.py +108 -0
- quantized/io/origin_project/figure_layers.py +333 -0
- quantized/io/origin_project/figure_text.py +258 -0
- quantized/io/origin_project/figures.py +210 -0
- quantized/io/origin_project/figures_opju.py +440 -0
- quantized/io/origin_project/notes.py +302 -0
- quantized/io/origin_project/opj.py +459 -0
- quantized/io/origin_project/opj_curves.py +297 -0
- quantized/io/origin_project/opj_shapes.py +148 -0
- quantized/io/origin_project/opju.py +146 -0
- quantized/io/origin_project/opju_axis_real_form.py +418 -0
- quantized/io/origin_project/opju_axis_specimen_form.py +167 -0
- quantized/io/origin_project/opju_codec.py +370 -0
- quantized/io/origin_project/opju_curves.py +497 -0
- quantized/io/origin_project/opju_curves_allcols.py +258 -0
- quantized/io/origin_project/opju_figure_curves.py +302 -0
- quantized/io/origin_project/opju_figure_text.py +245 -0
- quantized/io/origin_project/opju_reports.py +129 -0
- quantized/io/origin_project/origin_richtext.py +145 -0
- quantized/io/origin_project/preview.py +132 -0
- quantized/io/origin_project/templates.py +314 -0
- quantized/io/origin_project/tree.py +379 -0
- quantized/io/origin_project/tree_opju.py +228 -0
- quantized/io/origin_project/windows.py +238 -0
- quantized/io/origin_project/windows_opju.py +393 -0
- quantized/io/origin_project/writer.py +156 -0
- quantized/io/origin_project/writer_blocks.py +282 -0
- quantized/io/qd.py +380 -0
- quantized/io/refl1d.py +132 -0
- quantized/io/registry.py +210 -0
- quantized/io/report_export.py +347 -0
- quantized/io/rigaku.py +100 -0
- quantized/io/sims.py +398 -0
- quantized/io/spc.py +311 -0
- quantized/io/xrd_csv.py +308 -0
- quantized/io/xrdml.py +394 -0
- quantized/jobs.py +173 -0
- quantized/plugins/__init__.py +50 -0
- quantized/plugins/contract.py +111 -0
- quantized/plugins/loader.py +394 -0
- quantized/plugins/steps.py +90 -0
- quantized/routes/__init__.py +7 -0
- quantized/routes/_bookcache.py +62 -0
- quantized/routes/_export_common.py +27 -0
- quantized/routes/_payload.py +58 -0
- quantized/routes/_uploadcache.py +59 -0
- quantized/routes/aggregate.py +46 -0
- quantized/routes/baseline.py +210 -0
- quantized/routes/books.py +117 -0
- quantized/routes/calc.py +56 -0
- quantized/routes/corrections.py +78 -0
- quantized/routes/crystallography.py +80 -0
- quantized/routes/diffusion.py +58 -0
- quantized/routes/electrical.py +101 -0
- quantized/routes/electrochemistry.py +83 -0
- quantized/routes/export.py +280 -0
- quantized/routes/export_facets.py +83 -0
- quantized/routes/export_figures.py +471 -0
- quantized/routes/export_page.py +125 -0
- quantized/routes/fitting.py +379 -0
- quantized/routes/fitting_bumps.py +97 -0
- quantized/routes/import_template.py +97 -0
- quantized/routes/import_wizard.py +150 -0
- quantized/routes/jobs_api.py +59 -0
- quantized/routes/magnetic.py +135 -0
- quantized/routes/magnetometry.py +133 -0
- quantized/routes/optics.py +98 -0
- quantized/routes/parsers.py +281 -0
- quantized/routes/peaks.py +184 -0
- quantized/routes/plot.py +103 -0
- quantized/routes/reductions.py +121 -0
- quantized/routes/reference.py +58 -0
- quantized/routes/reflectivity.py +91 -0
- quantized/routes/report_export.py +119 -0
- quantized/routes/rsm.py +136 -0
- quantized/routes/samples.py +32 -0
- quantized/routes/semiconductor.py +207 -0
- quantized/routes/sld.py +42 -0
- quantized/routes/spectral.py +54 -0
- quantized/routes/statplots.py +99 -0
- quantized/routes/stats.py +418 -0
- quantized/routes/stats_design.py +321 -0
- quantized/routes/substrates.py +46 -0
- quantized/routes/superconductor.py +139 -0
- quantized/routes/thermal.py +57 -0
- quantized/routes/thin_film.py +153 -0
- quantized/routes/vacuum.py +113 -0
- quantized/routes/xray.py +32 -0
- quantized/samples/demo_vsm.csv +42 -0
- quantized/server_launch.py +251 -0
- quantized/web/assets/JetBrainsMono-Bold-CUogYd9I.woff2 +0 -0
- quantized/web/assets/JetBrainsMono-Regular-CA-Os4ii.woff2 +0 -0
- quantized/web/assets/index-BHmmCL-x.js +27 -0
- quantized/web/assets/index-BiZzN7J6.css +1 -0
- quantized/web/index.html +13 -0
- quantized/web/loading.html +69 -0
- quantized_lab-0.8.0.dist-info/METADATA +122 -0
- quantized_lab-0.8.0.dist-info/RECORD +233 -0
- quantized_lab-0.8.0.dist-info/WHEEL +4 -0
- quantized_lab-0.8.0.dist-info/entry_points.txt +4 -0
- quantized_lab-0.8.0.dist-info/licenses/LICENSE +201 -0
- quantized_lab-0.8.0.dist-info/licenses/NOTICE +11 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Publication rendering for a manually-broken x-axis (ORIGIN_GAP_PLAN #21).
|
|
2
|
+
|
|
3
|
+
Split out of ``calc.figure`` purely to stay under the 500-line god-module
|
|
4
|
+
ceiling (`_render_impl` calls ``render_breaks_impl`` when its ``overrides``
|
|
5
|
+
carry ``x_breaks``); the behavioural contract is still
|
|
6
|
+
``calc.figure``'s — see ``_validate_overrides``'s ``x_breaks`` checks and
|
|
7
|
+
``_render_impl``'s dispatch. Pure layer: data in -> image bytes out.
|
|
8
|
+
|
|
9
|
+
Renders one matplotlib panel per contiguous x-range between the (sorted,
|
|
10
|
+
validated) break pairs, sharing the y scale (``sharey``), with a diagonal
|
|
11
|
+
break glyph at each seam and the touching inner spines hidden — the paneled
|
|
12
|
+
representation the plan's RESOLVED decision calls for (never a
|
|
13
|
+
discontinuous-tick trick that lies about slope). Each panel plots the FULL
|
|
14
|
+
series and clips its own view via ``set_xlim``, so no data slicing is needed.
|
|
15
|
+
|
|
16
|
+
Scoped deliberately smaller than ``_render_impl``'s single-axes path: the
|
|
17
|
+
full ``_apply_overrides`` sweep (legend/spines/limits/margins) targets ONE
|
|
18
|
+
axes and a broken figure has several, so breaks combine with the plot itself
|
|
19
|
+
+ title/labels/basic legend/grid only — not the rest of gap #11's property
|
|
20
|
+
panel. Also not compatible with the figure-hitmap collector (`collect_map`),
|
|
21
|
+
which harvests pixel boxes off a single axes. Same scope limit for MAIN
|
|
22
|
+
#13/#14: a `series_styles` entry's `fill`/`color_by` keys are silently
|
|
23
|
+
ignored here (each panel draws a plain line) -- fill-under/-between and
|
|
24
|
+
colour-mapped scatter are single-axes features, like the rest of gap #11.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from collections.abc import Mapping, Sequence
|
|
30
|
+
from io import BytesIO
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
import matplotlib
|
|
34
|
+
|
|
35
|
+
matplotlib.use("Agg") # headless
|
|
36
|
+
|
|
37
|
+
import matplotlib.pyplot as plt # noqa: E402
|
|
38
|
+
import numpy as np # noqa: E402
|
|
39
|
+
from numpy.typing import ArrayLike, NDArray # noqa: E402
|
|
40
|
+
|
|
41
|
+
from quantized.calc.figure import _plot_kwargs # noqa: E402
|
|
42
|
+
from quantized.calc.figure_scale import apply_axis_scale, resolve_axis_scale # noqa: E402
|
|
43
|
+
from quantized.calc.figure_ticks import apply_tick_formats # noqa: E402
|
|
44
|
+
|
|
45
|
+
__all__ = ["render_breaks_impl"]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def render_breaks_impl(
|
|
49
|
+
x: NDArray[np.float64],
|
|
50
|
+
series: Sequence[tuple[str, ArrayLike]],
|
|
51
|
+
*,
|
|
52
|
+
breaks: list[tuple[float, float]],
|
|
53
|
+
x_log: bool,
|
|
54
|
+
y_log: bool,
|
|
55
|
+
x_scale: str | None = None,
|
|
56
|
+
y_scale: str | None = None,
|
|
57
|
+
title: str,
|
|
58
|
+
x_label: str,
|
|
59
|
+
y_label: str,
|
|
60
|
+
fmt: str,
|
|
61
|
+
st: Any,
|
|
62
|
+
ov: Mapping[str, Any],
|
|
63
|
+
dpi: int,
|
|
64
|
+
figsize: tuple[float, float],
|
|
65
|
+
series_styles: Sequence[Mapping[str, Any] | None] | None,
|
|
66
|
+
x_fmt: Mapping[str, Any] | None = None,
|
|
67
|
+
y_fmt: Mapping[str, Any] | None = None,
|
|
68
|
+
) -> bytes:
|
|
69
|
+
"""Render ``series`` against ``x`` with the x-axis elided over each
|
|
70
|
+
``[lo, hi]`` pair in ``breaks`` (already sorted/validated non-overlapping
|
|
71
|
+
by ``calc.figure._validate_overrides``). ``x_fmt``/``y_fmt`` (MAIN #24)
|
|
72
|
+
are applied to EVERY panel's axes (see ``figure_ticks.apply_tick_formats``);
|
|
73
|
+
a shared y-axis (``sharey``) still draws tick labels only on panel 0
|
|
74
|
+
(matplotlib's own ``sharey`` + this module's ``tick_params(left=False)``
|
|
75
|
+
on the rest), so ``y_fmt`` only visibly affects that panel, but is
|
|
76
|
+
applied uniformly for simplicity/consistency."""
|
|
77
|
+
finite = x[np.isfinite(x)]
|
|
78
|
+
xlo = float(finite.min()) if finite.size else 0.0
|
|
79
|
+
xhi = float(finite.max()) if finite.size else 1.0
|
|
80
|
+
bounds: list[tuple[float, float]] = []
|
|
81
|
+
lo = xlo
|
|
82
|
+
for b0, b1 in breaks:
|
|
83
|
+
bounds.append((lo, b0))
|
|
84
|
+
lo = b1
|
|
85
|
+
bounds.append((lo, xhi))
|
|
86
|
+
n = len(bounds)
|
|
87
|
+
widths = [max(hi - lo, 1e-9) for lo, hi in bounds]
|
|
88
|
+
|
|
89
|
+
fig, axes_obj = plt.subplots(
|
|
90
|
+
1, n, sharey=True, figsize=figsize, gridspec_kw={"width_ratios": widths, "wspace": 0.06}
|
|
91
|
+
)
|
|
92
|
+
axes = [axes_obj] if n == 1 else list(axes_obj)
|
|
93
|
+
try:
|
|
94
|
+
handles: list[Any] = []
|
|
95
|
+
labels_out: list[str] = []
|
|
96
|
+
for i, ax in enumerate(axes):
|
|
97
|
+
for si, (label, y) in enumerate(series):
|
|
98
|
+
spec = series_styles[si] if series_styles and si < len(series_styles) else None
|
|
99
|
+
kw = _plot_kwargs(st.line_width, st.marker_size, spec)
|
|
100
|
+
ax.plot(x, np.asarray(y, dtype=float), label=label, **kw)
|
|
101
|
+
lo, hi = bounds[i]
|
|
102
|
+
ax.set_xlim(lo, hi)
|
|
103
|
+
apply_axis_scale(ax, "x", resolve_axis_scale(x_scale, x_log))
|
|
104
|
+
apply_axis_scale(ax, "y", resolve_axis_scale(y_scale, y_log))
|
|
105
|
+
apply_tick_formats(ax, x_fmt, y_fmt)
|
|
106
|
+
if i == 0:
|
|
107
|
+
handles, labels_out = ax.get_legend_handles_labels()
|
|
108
|
+
if i > 0:
|
|
109
|
+
ax.spines["left"].set_visible(False)
|
|
110
|
+
ax.tick_params(left=False)
|
|
111
|
+
if i < n - 1:
|
|
112
|
+
ax.spines["right"].set_visible(False)
|
|
113
|
+
if not st.box_on:
|
|
114
|
+
ax.spines["top"].set_visible(False)
|
|
115
|
+
if st.grid_alpha > 0:
|
|
116
|
+
ax.grid(True, alpha=st.grid_alpha)
|
|
117
|
+
|
|
118
|
+
# Diagonal break glyphs (matplotlib's standard broken-axis recipe):
|
|
119
|
+
# short strokes angled across each seam, on both the outgoing panel's
|
|
120
|
+
# right edge and the incoming panel's left edge.
|
|
121
|
+
d = 0.4
|
|
122
|
+
glyph_kw = {
|
|
123
|
+
"marker": [(-1, -d), (1, d)],
|
|
124
|
+
"markersize": 8,
|
|
125
|
+
"linestyle": "none",
|
|
126
|
+
"color": "k",
|
|
127
|
+
"mec": "k",
|
|
128
|
+
"mew": 1,
|
|
129
|
+
"clip_on": False,
|
|
130
|
+
}
|
|
131
|
+
for i in range(n - 1):
|
|
132
|
+
axes[i].plot([1], [0], transform=axes[i].transAxes, **glyph_kw)
|
|
133
|
+
axes[i].plot([1], [1], transform=axes[i].transAxes, **glyph_kw)
|
|
134
|
+
axes[i + 1].plot([0], [0], transform=axes[i + 1].transAxes, **glyph_kw)
|
|
135
|
+
axes[i + 1].plot([0], [1], transform=axes[i + 1].transAxes, **glyph_kw)
|
|
136
|
+
|
|
137
|
+
if title:
|
|
138
|
+
fig.suptitle(title)
|
|
139
|
+
if x_label:
|
|
140
|
+
fig.supxlabel(x_label)
|
|
141
|
+
if y_label:
|
|
142
|
+
axes[0].set_ylabel(y_label)
|
|
143
|
+
if len(series) > 1 and "legend" not in ov and handles:
|
|
144
|
+
axes[-1].legend(
|
|
145
|
+
handles, labels_out, frameon=st.legend_box, fontsize=st.legend_font_size,
|
|
146
|
+
loc=st.legend_location,
|
|
147
|
+
)
|
|
148
|
+
buf = BytesIO()
|
|
149
|
+
fig.savefig(buf, format=fmt, dpi=dpi)
|
|
150
|
+
return buf.getvalue()
|
|
151
|
+
finally:
|
|
152
|
+
plt.close(fig)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Publication rendering for grouped/stacked bar (categorical) plots.
|
|
2
|
+
|
|
3
|
+
ORIGIN_GAP_PLAN #20 (export half). Pure layer: a category x series matrix in
|
|
4
|
+
-> image bytes out — the SAME matrix shape the interactive stat stage's "bar"
|
|
5
|
+
mode computes locally (frontend `lib/barlayout.buildBarMatrix`: mean per
|
|
6
|
+
category/series, SEM for the error bar), so the exported figure matches the
|
|
7
|
+
on-screen bars. Shares ``render_figure``'s style presets and formats
|
|
8
|
+
(``figure_styles.figure_style``), matching ``figure_statplots.py``'s template.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from io import BytesIO
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import matplotlib
|
|
17
|
+
|
|
18
|
+
matplotlib.use("Agg") # headless
|
|
19
|
+
|
|
20
|
+
import matplotlib.pyplot as plt # noqa: E402
|
|
21
|
+
import numpy as np # noqa: E402
|
|
22
|
+
|
|
23
|
+
from quantized.calc.figure_labels import safe_mathtext_label # noqa: E402
|
|
24
|
+
from quantized.calc.figure_styles import figure_style # noqa: E402
|
|
25
|
+
|
|
26
|
+
__all__ = ["render_categorical_figure"]
|
|
27
|
+
|
|
28
|
+
_FORMATS = ("pdf", "svg", "png", "tiff")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _to_matrix(
|
|
32
|
+
values: list[list[float]], n_groups: int, n_series: int, name: str
|
|
33
|
+
) -> np.ndarray:
|
|
34
|
+
arr = np.asarray(values, dtype=float)
|
|
35
|
+
if arr.shape != (n_groups, n_series):
|
|
36
|
+
raise ValueError(f"{name} must have shape ({n_groups}, {n_series}), got {arr.shape}")
|
|
37
|
+
return arr
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _to_error_matrix(
|
|
41
|
+
errors: list[list[float | None]] | None, n_groups: int, n_series: int
|
|
42
|
+
) -> np.ndarray | None:
|
|
43
|
+
if errors is None:
|
|
44
|
+
return None
|
|
45
|
+
if len(errors) != n_groups:
|
|
46
|
+
raise ValueError(f"errors must have {n_groups} rows, got {len(errors)}")
|
|
47
|
+
out = np.full((n_groups, n_series), np.nan)
|
|
48
|
+
for gi, row in enumerate(errors):
|
|
49
|
+
if len(row) != n_series:
|
|
50
|
+
raise ValueError(f"errors row {gi} must have {n_series} entries, got {len(row)}")
|
|
51
|
+
for si, e in enumerate(row):
|
|
52
|
+
if e is not None:
|
|
53
|
+
out[gi, si] = float(e)
|
|
54
|
+
return out
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def render_categorical_figure(
|
|
58
|
+
groups: list[str],
|
|
59
|
+
series: list[str],
|
|
60
|
+
values: list[list[float]],
|
|
61
|
+
errors: list[list[float | None]] | None = None,
|
|
62
|
+
*,
|
|
63
|
+
stacked: bool = False,
|
|
64
|
+
title: str = "",
|
|
65
|
+
x_label: str = "",
|
|
66
|
+
y_label: str = "",
|
|
67
|
+
fmt: str = "pdf",
|
|
68
|
+
style: str = "default",
|
|
69
|
+
width_in: float | None = None,
|
|
70
|
+
height_in: float | None = None,
|
|
71
|
+
dpi: int = 200,
|
|
72
|
+
) -> bytes:
|
|
73
|
+
"""Render a grouped or stacked bar chart to image bytes.
|
|
74
|
+
|
|
75
|
+
``values[g][s]`` is the bar height (mean) for category ``groups[g]``,
|
|
76
|
+
series ``series[s]``; ``errors[g][s]`` (optional, ``None`` entries allowed
|
|
77
|
+
= no whisker for that bar) is its SEM. ``stacked=False`` clusters series
|
|
78
|
+
side by side within each category; ``stacked=True`` draws one bar per
|
|
79
|
+
category with series stacked bottom-to-top (only the topmost segment's
|
|
80
|
+
error bar is drawn, matching the interactive stat stage's convention —
|
|
81
|
+
a stacked bar's lower segments' own spread isn't visually meaningful once
|
|
82
|
+
summed).
|
|
83
|
+
"""
|
|
84
|
+
if fmt not in _FORMATS:
|
|
85
|
+
raise ValueError(f"fmt must be one of {_FORMATS}")
|
|
86
|
+
if not groups:
|
|
87
|
+
raise ValueError("groups must be non-empty")
|
|
88
|
+
if not series:
|
|
89
|
+
raise ValueError("series must be non-empty")
|
|
90
|
+
# Rich-text labels (GOTO #5): de-math INVALID $...$ so savefig never raises.
|
|
91
|
+
title = safe_mathtext_label(title)
|
|
92
|
+
x_label = safe_mathtext_label(x_label)
|
|
93
|
+
y_label = safe_mathtext_label(y_label)
|
|
94
|
+
groups = [safe_mathtext_label(str(g)) for g in groups]
|
|
95
|
+
series = [safe_mathtext_label(str(s)) for s in series]
|
|
96
|
+
n_groups, n_series = len(groups), len(series)
|
|
97
|
+
vals = _to_matrix(values, n_groups, n_series, "values")
|
|
98
|
+
errs = _to_error_matrix(errors, n_groups, n_series)
|
|
99
|
+
|
|
100
|
+
st = figure_style(style)
|
|
101
|
+
figsize = (width_in or st.fig_width_in, height_in or st.fig_height_in)
|
|
102
|
+
fallback = "DejaVu Serif" if st.font_generic == "serif" else "DejaVu Sans"
|
|
103
|
+
rc: dict[str, Any] = {
|
|
104
|
+
"font.family": st.font_generic,
|
|
105
|
+
f"font.{st.font_generic}": [st.font_name, fallback],
|
|
106
|
+
"font.size": st.font_size,
|
|
107
|
+
"axes.labelsize": st.font_size,
|
|
108
|
+
"axes.titlesize": st.title_font_size,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
with matplotlib.rc_context(rc): # type: ignore[arg-type]
|
|
112
|
+
fig, ax = plt.subplots(figsize=figsize)
|
|
113
|
+
try:
|
|
114
|
+
x = np.arange(n_groups, dtype=float)
|
|
115
|
+
if stacked:
|
|
116
|
+
bottom = np.zeros(n_groups)
|
|
117
|
+
for si in range(n_series):
|
|
118
|
+
yerr = errs[:, si] if errs is not None and si == n_series - 1 else None
|
|
119
|
+
ax.bar(
|
|
120
|
+
x, vals[:, si], 0.68, bottom=bottom, yerr=yerr, capsize=3,
|
|
121
|
+
label=series[si],
|
|
122
|
+
)
|
|
123
|
+
bottom = bottom + np.nan_to_num(vals[:, si])
|
|
124
|
+
else:
|
|
125
|
+
width = 0.8 / n_series
|
|
126
|
+
for si in range(n_series):
|
|
127
|
+
offset = (si - (n_series - 1) / 2) * width
|
|
128
|
+
yerr = errs[:, si] if errs is not None else None
|
|
129
|
+
ax.bar(
|
|
130
|
+
x + offset, vals[:, si], width * 0.85, yerr=yerr, capsize=3,
|
|
131
|
+
label=series[si],
|
|
132
|
+
)
|
|
133
|
+
ax.set_xticks(x)
|
|
134
|
+
ax.set_xticklabels(groups)
|
|
135
|
+
ax.axhline(0, color="0.3", linewidth=0.8) # baseline, visible for mixed-sign data
|
|
136
|
+
if title:
|
|
137
|
+
ax.set_title(title)
|
|
138
|
+
if x_label:
|
|
139
|
+
ax.set_xlabel(x_label)
|
|
140
|
+
if y_label:
|
|
141
|
+
ax.set_ylabel(y_label)
|
|
142
|
+
if not st.box_on:
|
|
143
|
+
ax.spines["top"].set_visible(False)
|
|
144
|
+
ax.spines["right"].set_visible(False)
|
|
145
|
+
if n_series > 1:
|
|
146
|
+
ax.legend( # type: ignore[call-overload]
|
|
147
|
+
frameon=st.legend_box, fontsize=st.legend_font_size, loc=st.legend_location,
|
|
148
|
+
)
|
|
149
|
+
if st.grid_alpha > 0:
|
|
150
|
+
ax.grid(True, alpha=st.grid_alpha, axis="y")
|
|
151
|
+
fig.tight_layout()
|
|
152
|
+
buf = BytesIO()
|
|
153
|
+
fig.savefig(buf, format=fmt, dpi=dpi)
|
|
154
|
+
return buf.getvalue()
|
|
155
|
+
finally:
|
|
156
|
+
plt.close(fig)
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Publication rendering for MCMC/bootstrap posterior corner (pairs) plots.
|
|
2
|
+
|
|
3
|
+
ORIGIN_GAP_PLAN #29 residual. Pure layer: an ``(n_samples, k)`` array of
|
|
4
|
+
joint parameter draws in -> image bytes out. matplotlib only -- no
|
|
5
|
+
``corner.py`` dependency, deliberately, since the whole grid is a few hundred
|
|
6
|
+
lines of plain axes. Diagonal panels are 1-D marginal histograms; panels
|
|
7
|
+
below the diagonal are 2-D density histograms of each parameter pair; the
|
|
8
|
+
upper triangle is left blank -- the conventional "corner plot" layout (see
|
|
9
|
+
Foreman-Mackey's ``corner`` package for the reference look this mirrors).
|
|
10
|
+
Shares ``calc.figure_styles`` presets and ``calc.figure``'s resolved-dpi
|
|
11
|
+
convention (an explicit ``dpi`` overrides the preset; otherwise the preset's
|
|
12
|
+
calibrated dpi is used), so corner exports match the rest of the publication
|
|
13
|
+
export pipeline.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections.abc import Sequence
|
|
19
|
+
from io import BytesIO
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import matplotlib
|
|
23
|
+
|
|
24
|
+
matplotlib.use("Agg") # headless
|
|
25
|
+
|
|
26
|
+
import matplotlib.pyplot as plt # noqa: E402
|
|
27
|
+
import numpy as np # noqa: E402
|
|
28
|
+
from matplotlib.ticker import MaxNLocator # noqa: E402
|
|
29
|
+
from numpy.typing import ArrayLike, NDArray # noqa: E402
|
|
30
|
+
|
|
31
|
+
from quantized.calc.figure_labels import safe_mathtext_label # noqa: E402
|
|
32
|
+
from quantized.calc.figure_styles import figure_style # noqa: E402
|
|
33
|
+
|
|
34
|
+
__all__ = ["render_corner_figure"]
|
|
35
|
+
|
|
36
|
+
_FORMATS = ("pdf", "svg", "png", "tiff")
|
|
37
|
+
_PANEL_IN = 2.1 # per-parameter panel edge length (inches) at the default size
|
|
38
|
+
_MAX_TICKS = 4
|
|
39
|
+
_TRUTH_COLOR = "firebrick"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def render_corner_figure(
|
|
43
|
+
samples: ArrayLike,
|
|
44
|
+
param_names: Sequence[str],
|
|
45
|
+
*,
|
|
46
|
+
truths: Sequence[float] | None = None,
|
|
47
|
+
title: str = "",
|
|
48
|
+
fmt: str = "pdf",
|
|
49
|
+
style: str = "default",
|
|
50
|
+
dpi: int | None = None,
|
|
51
|
+
bins: str | int = "fd",
|
|
52
|
+
width_in: float | None = None,
|
|
53
|
+
height_in: float | None = None,
|
|
54
|
+
) -> bytes:
|
|
55
|
+
"""Render a pairwise posterior/bootstrap corner (pairs) plot.
|
|
56
|
+
|
|
57
|
+
``samples`` is ``(n_samples, k)`` joint parameter draws -- e.g. the
|
|
58
|
+
``samples`` array from :func:`calc.fit_bootstrap.fit_posterior` or
|
|
59
|
+
``bootstrap_fit(..., return_samples=True)``'s ``boot_samples``.
|
|
60
|
+
``param_names`` names the ``k`` columns (must match ``samples.shape[1]``).
|
|
61
|
+
|
|
62
|
+
Diagonal panels are 1-D marginal histograms (``bins`` -- a count or a
|
|
63
|
+
numpy binning rule, e.g. ``"fd"``); panels below the diagonal are 2-D
|
|
64
|
+
density histograms of each parameter pair; the upper triangle is left
|
|
65
|
+
blank. ``truths`` (length ``k``), when given, draws a reference dashed
|
|
66
|
+
line (diagonal panels) / crosshair (off-diagonal panels) at each
|
|
67
|
+
parameter's fitted value.
|
|
68
|
+
|
|
69
|
+
``fmt`` / ``style`` / ``width_in`` / ``height_in`` match ``render_figure``
|
|
70
|
+
(default panel geometry scales with ``k`` -- corner grids need more
|
|
71
|
+
canvas than a single-series plot -- unless explicit sizes are given).
|
|
72
|
+
``dpi`` defaults to the preset's calibrated resolution when not given
|
|
73
|
+
(``None``), same as ``calc.figure``'s ``resolved_dpi``.
|
|
74
|
+
|
|
75
|
+
Raises ``ValueError`` on shape mismatches (``param_names``/``truths``
|
|
76
|
+
length vs. the sample columns) or too few finite joint samples.
|
|
77
|
+
"""
|
|
78
|
+
if fmt not in _FORMATS:
|
|
79
|
+
raise ValueError(f"fmt must be one of {_FORMATS}")
|
|
80
|
+
|
|
81
|
+
arr = np.asarray(samples, dtype=float)
|
|
82
|
+
if arr.ndim != 2:
|
|
83
|
+
raise ValueError(
|
|
84
|
+
f"samples must be a 2-D (n_samples, n_params) array, got ndim={arr.ndim}"
|
|
85
|
+
)
|
|
86
|
+
_n_samples, k = arr.shape
|
|
87
|
+
if k < 1:
|
|
88
|
+
raise ValueError("samples needs at least one parameter column")
|
|
89
|
+
names = [safe_mathtext_label(str(nm)) for nm in param_names]
|
|
90
|
+
# (Rich-text labels, GOTO #5: de-math INVALID $...$ so savefig never raises.)
|
|
91
|
+
if len(names) != k:
|
|
92
|
+
raise ValueError(f"param_names has {len(names)} entries, samples has {k} columns")
|
|
93
|
+
if truths is not None and len(truths) != k:
|
|
94
|
+
raise ValueError(f"truths has {len(truths)} entries, samples has {k} columns")
|
|
95
|
+
|
|
96
|
+
finite = arr[np.all(np.isfinite(arr), axis=1)]
|
|
97
|
+
if finite.shape[0] < 2:
|
|
98
|
+
raise ValueError("need at least 2 finite joint samples to render a corner plot")
|
|
99
|
+
tr = np.asarray(truths, dtype=float) if truths is not None else None
|
|
100
|
+
|
|
101
|
+
title = safe_mathtext_label(title)
|
|
102
|
+
st = figure_style(style)
|
|
103
|
+
resolved_dpi = int(dpi) if dpi is not None else int(st.dpi)
|
|
104
|
+
figsize = (width_in or _PANEL_IN * k, height_in or _PANEL_IN * k)
|
|
105
|
+
fallback = "DejaVu Serif" if st.font_generic == "serif" else "DejaVu Sans"
|
|
106
|
+
# Shrink tick labels a touch as the grid grows so k up to ~6 stays readable.
|
|
107
|
+
tick_fs = max(st.font_size - max(k - 2, 0), 6.0)
|
|
108
|
+
rc: dict[str, Any] = {
|
|
109
|
+
"font.family": st.font_generic,
|
|
110
|
+
f"font.{st.font_generic}": [st.font_name, fallback],
|
|
111
|
+
"font.size": tick_fs,
|
|
112
|
+
"axes.labelsize": st.font_size,
|
|
113
|
+
"axes.titlesize": st.title_font_size,
|
|
114
|
+
"xtick.labelsize": tick_fs,
|
|
115
|
+
"ytick.labelsize": tick_fs,
|
|
116
|
+
"xtick.direction": st.tick_dir,
|
|
117
|
+
"ytick.direction": st.tick_dir,
|
|
118
|
+
}
|
|
119
|
+
ranges = [_pad_range(finite[:, i]) for i in range(k)]
|
|
120
|
+
|
|
121
|
+
with matplotlib.rc_context(rc): # type: ignore[arg-type]
|
|
122
|
+
fig, axes = plt.subplots(k, k, figsize=figsize, squeeze=False)
|
|
123
|
+
try:
|
|
124
|
+
for row in range(k):
|
|
125
|
+
for col in range(k):
|
|
126
|
+
ax = axes[row][col]
|
|
127
|
+
if col > row:
|
|
128
|
+
ax.axis("off")
|
|
129
|
+
continue
|
|
130
|
+
truth_col = float(tr[col]) if tr is not None else None
|
|
131
|
+
if col == row:
|
|
132
|
+
_draw_marginal(ax, finite[:, col], bins, ranges[col], truth_col)
|
|
133
|
+
else:
|
|
134
|
+
truth_row = float(tr[row]) if tr is not None else None
|
|
135
|
+
_draw_pair(
|
|
136
|
+
ax, finite[:, col], finite[:, row], bins,
|
|
137
|
+
ranges[col], ranges[row], truth_col, truth_row,
|
|
138
|
+
)
|
|
139
|
+
_style_panel(ax, row, col, k, names, st)
|
|
140
|
+
if title:
|
|
141
|
+
fig.suptitle(title, fontsize=st.title_font_size)
|
|
142
|
+
fig.tight_layout(rect=(0.0, 0.0, 1.0, 0.96))
|
|
143
|
+
else:
|
|
144
|
+
fig.tight_layout()
|
|
145
|
+
buf = BytesIO()
|
|
146
|
+
fig.savefig(buf, format=fmt, dpi=resolved_dpi)
|
|
147
|
+
return buf.getvalue()
|
|
148
|
+
finally:
|
|
149
|
+
plt.close(fig)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _pad_range(v: NDArray[np.float64]) -> tuple[float, float]:
|
|
153
|
+
"""Axis limits for one parameter: data range + 5% pad (a small fixed
|
|
154
|
+
window around a degenerate, all-equal column)."""
|
|
155
|
+
lo, hi = float(np.min(v)), float(np.max(v))
|
|
156
|
+
if hi <= lo:
|
|
157
|
+
pad = abs(lo) * 0.05 or 0.5
|
|
158
|
+
return lo - pad, hi + pad
|
|
159
|
+
pad = 0.05 * (hi - lo)
|
|
160
|
+
return lo - pad, hi + pad
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _bin_edges(v: NDArray[np.float64], bins: str | int) -> NDArray[np.float64]:
|
|
164
|
+
"""Bin edges from a count or a numpy binning rule, clipped to a readable
|
|
165
|
+
panel range (too few bins looks sparse, too many looks noisy at panel
|
|
166
|
+
size)."""
|
|
167
|
+
edges = np.asarray(np.histogram_bin_edges(v, bins=bins), dtype=float)
|
|
168
|
+
n_bins = edges.size - 1
|
|
169
|
+
if n_bins < 4:
|
|
170
|
+
edges = np.asarray(np.histogram_bin_edges(v, bins=4), dtype=float)
|
|
171
|
+
elif n_bins > 60:
|
|
172
|
+
edges = np.asarray(np.histogram_bin_edges(v, bins=60), dtype=float)
|
|
173
|
+
return edges
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _draw_marginal(
|
|
177
|
+
ax: Any,
|
|
178
|
+
v: NDArray[np.float64],
|
|
179
|
+
bins: str | int,
|
|
180
|
+
rng: tuple[float, float],
|
|
181
|
+
truth: float | None,
|
|
182
|
+
) -> None:
|
|
183
|
+
edges = _bin_edges(v, bins)
|
|
184
|
+
ax.hist(v, bins=edges, density=True, color="0.6", edgecolor="white", linewidth=0.5)
|
|
185
|
+
if truth is not None and np.isfinite(truth):
|
|
186
|
+
ax.axvline(truth, color=_TRUTH_COLOR, linestyle="--", linewidth=1.2)
|
|
187
|
+
ax.set_xlim(rng)
|
|
188
|
+
ax.set_yticks([])
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _draw_pair(
|
|
192
|
+
ax: Any,
|
|
193
|
+
xv: NDArray[np.float64],
|
|
194
|
+
yv: NDArray[np.float64],
|
|
195
|
+
bins: str | int,
|
|
196
|
+
x_rng: tuple[float, float],
|
|
197
|
+
y_rng: tuple[float, float],
|
|
198
|
+
truth_x: float | None,
|
|
199
|
+
truth_y: float | None,
|
|
200
|
+
) -> None:
|
|
201
|
+
x_edges = _bin_edges(xv, bins)
|
|
202
|
+
y_edges = _bin_edges(yv, bins)
|
|
203
|
+
ax.hist2d(xv, yv, bins=[x_edges, y_edges], cmap="Greys")
|
|
204
|
+
if truth_x is not None and np.isfinite(truth_x):
|
|
205
|
+
ax.axvline(truth_x, color=_TRUTH_COLOR, linestyle="--", linewidth=1.2)
|
|
206
|
+
if truth_y is not None and np.isfinite(truth_y):
|
|
207
|
+
ax.axhline(truth_y, color=_TRUTH_COLOR, linestyle="--", linewidth=1.2)
|
|
208
|
+
ax.set_xlim(x_rng)
|
|
209
|
+
ax.set_ylim(y_rng)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _style_panel(ax: Any, row: int, col: int, k: int, names: list[str], st: Any) -> None:
|
|
213
|
+
"""Shared axis cosmetics: labels only on the outer edges, thinned tick
|
|
214
|
+
counts, and the preset's box-on spine treatment."""
|
|
215
|
+
ax.xaxis.set_major_locator(MaxNLocator(nbins=_MAX_TICKS, prune="both"))
|
|
216
|
+
if col != row:
|
|
217
|
+
ax.yaxis.set_major_locator(MaxNLocator(nbins=_MAX_TICKS, prune="both"))
|
|
218
|
+
if row == k - 1:
|
|
219
|
+
ax.set_xlabel(names[col])
|
|
220
|
+
plt.setp(ax.get_xticklabels(), rotation=45, ha="right")
|
|
221
|
+
else:
|
|
222
|
+
ax.tick_params(labelbottom=False)
|
|
223
|
+
if col == 0 and row > 0:
|
|
224
|
+
ax.set_ylabel(names[row])
|
|
225
|
+
elif col != 0:
|
|
226
|
+
ax.tick_params(labelleft=False)
|
|
227
|
+
if not st.box_on:
|
|
228
|
+
ax.spines["top"].set_visible(False)
|
|
229
|
+
ax.spines["right"].set_visible(False)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Publication rendering for faceted (small-multiples) plots.
|
|
2
|
+
|
|
3
|
+
ORIGIN_GAP_PLAN #21 (export half, faceting). Pure layer: N independently
|
|
4
|
+
pre-split panels sharing scales/labels -> image bytes out. Mirrors the
|
|
5
|
+
interactive facet splitter (frontend ``lib/facet.facetPayloads``): each panel
|
|
6
|
+
is already the (label, x, series) data for one categorical level — this
|
|
7
|
+
module only lays them out and draws them, matching ``calc.figure``'s style
|
|
8
|
+
presets (``figure_styles.figure_style``) and per-series line kwargs
|
|
9
|
+
(``calc.figure._plot_kwargs``).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from io import BytesIO
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import matplotlib
|
|
18
|
+
|
|
19
|
+
matplotlib.use("Agg") # headless
|
|
20
|
+
|
|
21
|
+
import matplotlib.pyplot as plt # noqa: E402
|
|
22
|
+
import numpy as np # noqa: E402
|
|
23
|
+
|
|
24
|
+
from quantized.calc.figure import _plot_kwargs # noqa: E402
|
|
25
|
+
from quantized.calc.figure_labels import safe_mathtext_label # noqa: E402
|
|
26
|
+
from quantized.calc.figure_styles import figure_style # noqa: E402
|
|
27
|
+
|
|
28
|
+
__all__ = ["render_facets_figure"]
|
|
29
|
+
|
|
30
|
+
_FORMATS = ("pdf", "svg", "png", "tiff")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def render_facets_figure(
|
|
34
|
+
panels: list[dict[str, Any]],
|
|
35
|
+
*,
|
|
36
|
+
x_log: bool = False,
|
|
37
|
+
y_log: bool = False,
|
|
38
|
+
title: str = "",
|
|
39
|
+
x_label: str = "",
|
|
40
|
+
y_label: str = "",
|
|
41
|
+
fmt: str = "pdf",
|
|
42
|
+
style: str = "default",
|
|
43
|
+
width_in: float | None = None,
|
|
44
|
+
height_in: float | None = None,
|
|
45
|
+
dpi: int = 200,
|
|
46
|
+
) -> bytes:
|
|
47
|
+
"""Render one small-multiples panel per facet level.
|
|
48
|
+
|
|
49
|
+
Each ``panels[i]`` is ``{"label": str, "x": [...], "series": [{"label":
|
|
50
|
+
str, "y": [...]}]}`` — a pre-split slice of data for one categorical
|
|
51
|
+
level (see the frontend's ``lib/facet.facetPayloads``). Panels tile into
|
|
52
|
+
as-square-as-possible rows/columns and share x/y scales (``sharex`` /
|
|
53
|
+
``sharey``) so magnitudes stay comparable across levels; each panel
|
|
54
|
+
carries its own facet-level title, and unused trailing grid cells (when
|
|
55
|
+
the panel count isn't a perfect rows*cols rectangle) are hidden.
|
|
56
|
+
"""
|
|
57
|
+
if fmt not in _FORMATS:
|
|
58
|
+
raise ValueError(f"fmt must be one of {_FORMATS}")
|
|
59
|
+
if not panels:
|
|
60
|
+
raise ValueError("panels must be non-empty")
|
|
61
|
+
# Rich-text labels (GOTO #5): de-math INVALID $...$ so savefig never raises.
|
|
62
|
+
title = safe_mathtext_label(title)
|
|
63
|
+
x_label = safe_mathtext_label(x_label)
|
|
64
|
+
y_label = safe_mathtext_label(y_label)
|
|
65
|
+
|
|
66
|
+
st = figure_style(style)
|
|
67
|
+
n = len(panels)
|
|
68
|
+
cols = int(np.ceil(np.sqrt(n)))
|
|
69
|
+
rows = int(np.ceil(n / cols))
|
|
70
|
+
figsize = (
|
|
71
|
+
width_in or st.fig_width_in * cols * 0.8,
|
|
72
|
+
height_in or st.fig_height_in * rows * 0.8,
|
|
73
|
+
)
|
|
74
|
+
fallback = "DejaVu Serif" if st.font_generic == "serif" else "DejaVu Sans"
|
|
75
|
+
rc: dict[str, Any] = {
|
|
76
|
+
"font.family": st.font_generic,
|
|
77
|
+
f"font.{st.font_generic}": [st.font_name, fallback],
|
|
78
|
+
"font.size": st.font_size,
|
|
79
|
+
"axes.labelsize": st.font_size,
|
|
80
|
+
"axes.titlesize": st.font_size,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
with matplotlib.rc_context(rc): # type: ignore[arg-type]
|
|
84
|
+
fig, axes_grid = plt.subplots(
|
|
85
|
+
rows, cols, figsize=figsize, sharex=True, sharey=True, squeeze=False,
|
|
86
|
+
)
|
|
87
|
+
try:
|
|
88
|
+
flat = [ax for row in axes_grid for ax in row]
|
|
89
|
+
for i, panel in enumerate(panels):
|
|
90
|
+
ax = flat[i]
|
|
91
|
+
x = np.asarray(panel.get("x", []), dtype=float)
|
|
92
|
+
series = panel.get("series", [])
|
|
93
|
+
for si, s in enumerate(series):
|
|
94
|
+
kw = _plot_kwargs(st.line_width, st.marker_size, None)
|
|
95
|
+
ax.plot(
|
|
96
|
+
x, np.asarray(s.get("y", []), dtype=float),
|
|
97
|
+
label=safe_mathtext_label(str(s.get("label", f"s{si}"))), **kw,
|
|
98
|
+
)
|
|
99
|
+
ax.set_title(
|
|
100
|
+
safe_mathtext_label(str(panel.get("label", ""))), fontsize=st.font_size
|
|
101
|
+
)
|
|
102
|
+
if x_log:
|
|
103
|
+
ax.set_xscale("log")
|
|
104
|
+
if y_log:
|
|
105
|
+
ax.set_yscale("log")
|
|
106
|
+
if not st.box_on:
|
|
107
|
+
ax.spines["top"].set_visible(False)
|
|
108
|
+
ax.spines["right"].set_visible(False)
|
|
109
|
+
if st.grid_alpha > 0:
|
|
110
|
+
ax.grid(True, alpha=st.grid_alpha)
|
|
111
|
+
if len(series) > 1:
|
|
112
|
+
ax.legend(fontsize=max(6.0, st.legend_font_size - 2), frameon=st.legend_box)
|
|
113
|
+
for j in range(n, len(flat)):
|
|
114
|
+
flat[j].set_visible(False)
|
|
115
|
+
|
|
116
|
+
if title:
|
|
117
|
+
fig.suptitle(title)
|
|
118
|
+
if x_label:
|
|
119
|
+
fig.supxlabel(x_label)
|
|
120
|
+
if y_label:
|
|
121
|
+
fig.supylabel(y_label)
|
|
122
|
+
fig.tight_layout()
|
|
123
|
+
buf = BytesIO()
|
|
124
|
+
fig.savefig(buf, format=fmt, dpi=dpi)
|
|
125
|
+
return buf.getvalue()
|
|
126
|
+
finally:
|
|
127
|
+
plt.close(fig)
|