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,167 @@
|
|
|
1
|
+
"""Publication rendering for statistical plots: box / violin / Q-Q / histogram.
|
|
2
|
+
|
|
3
|
+
ORIGIN_GAP_PLAN #16 (export half). Pure layer: grouped/1-D data in -> image
|
|
4
|
+
bytes out. matplotlib's ``boxplot`` / ``violinplot`` compute the same stats as
|
|
5
|
+
``calc.statplots`` (linear-interp quartiles + Tukey whiskers; gaussian_kde
|
|
6
|
+
violins), and the Q-Q reference line + histogram binning come straight from
|
|
7
|
+
``calc.statplots``, so the exported figure and the interactive stage show
|
|
8
|
+
identical statistics. Shares ``render_figure``'s style presets and formats.
|
|
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
|
+
from numpy.typing import ArrayLike # noqa: E402
|
|
23
|
+
|
|
24
|
+
from quantized.calc.figure_labels import safe_mathtext_label # noqa: E402
|
|
25
|
+
from quantized.calc.figure_styles import figure_style # noqa: E402
|
|
26
|
+
from quantized.calc.statplots import histogram as _histogram # noqa: E402
|
|
27
|
+
from quantized.calc.statplots import qq_plot as _qq_plot # noqa: E402
|
|
28
|
+
|
|
29
|
+
__all__ = ["STATPLOT_KINDS", "render_statplot_figure"]
|
|
30
|
+
|
|
31
|
+
_FORMATS = ("pdf", "svg", "png", "tiff")
|
|
32
|
+
STATPLOT_KINDS = ("box", "violin", "qq", "probability", "histogram")
|
|
33
|
+
_GROUPED = ("box", "violin")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _clean_groups(groups: list[ArrayLike]) -> list[np.ndarray]:
|
|
37
|
+
out = []
|
|
38
|
+
for g in groups:
|
|
39
|
+
v = np.asarray(g, dtype=float).ravel()
|
|
40
|
+
v = v[np.isfinite(v)]
|
|
41
|
+
if v.size == 0:
|
|
42
|
+
raise ValueError("every group must have at least one finite value")
|
|
43
|
+
out.append(v)
|
|
44
|
+
return out
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def render_statplot_figure(
|
|
48
|
+
kind: str,
|
|
49
|
+
data: list[ArrayLike] | ArrayLike,
|
|
50
|
+
*,
|
|
51
|
+
labels: list[str] | None = None,
|
|
52
|
+
title: str = "",
|
|
53
|
+
x_label: str = "",
|
|
54
|
+
y_label: str = "",
|
|
55
|
+
fmt: str = "pdf",
|
|
56
|
+
style: str = "default",
|
|
57
|
+
dist: str = "norm",
|
|
58
|
+
bins: str | int = "fd",
|
|
59
|
+
fit: str | None = None,
|
|
60
|
+
width_in: float | None = None,
|
|
61
|
+
height_in: float | None = None,
|
|
62
|
+
dpi: int | None = None,
|
|
63
|
+
) -> bytes:
|
|
64
|
+
"""Render a statistical plot to image bytes.
|
|
65
|
+
|
|
66
|
+
- ``box`` / ``violin`` — ``data`` is a list of groups; ``labels`` names them.
|
|
67
|
+
- ``qq`` / ``probability`` — ``data`` is one sample vs ``dist`` quantiles
|
|
68
|
+
with a least-squares reference line.
|
|
69
|
+
- ``histogram`` — one sample with a numpy bin rule (``bins``) and an
|
|
70
|
+
optional distribution-fit overlay (``fit``, e.g. ``"norm"``).
|
|
71
|
+
|
|
72
|
+
``dpi`` defaults to the style preset's calibrated resolution when not
|
|
73
|
+
given (``None``), same as ``calc.figure``'s ``resolved_dpi`` convention;
|
|
74
|
+
the preset's box-tick convention (``xtick.top``/``ytick.right`` mirrored
|
|
75
|
+
when the preset draws a closed box) is honored too.
|
|
76
|
+
"""
|
|
77
|
+
if fmt not in _FORMATS:
|
|
78
|
+
raise ValueError(f"fmt must be one of {_FORMATS}")
|
|
79
|
+
if kind not in STATPLOT_KINDS:
|
|
80
|
+
raise ValueError(f"kind must be one of {STATPLOT_KINDS}")
|
|
81
|
+
# Rich-text labels (GOTO #5): de-math INVALID $...$ so savefig never raises.
|
|
82
|
+
title = safe_mathtext_label(title)
|
|
83
|
+
x_label = safe_mathtext_label(x_label)
|
|
84
|
+
y_label = safe_mathtext_label(y_label)
|
|
85
|
+
labels = [safe_mathtext_label(str(g)) for g in labels] if labels else labels
|
|
86
|
+
st = figure_style(style)
|
|
87
|
+
resolved_dpi = int(dpi) if dpi is not None else int(st.dpi)
|
|
88
|
+
figsize = (width_in or st.fig_width_in, height_in or st.fig_height_in)
|
|
89
|
+
fallback = "DejaVu Serif" if st.font_generic == "serif" else "DejaVu Sans"
|
|
90
|
+
rc: dict[str, Any] = {
|
|
91
|
+
"font.family": st.font_generic,
|
|
92
|
+
f"font.{st.font_generic}": [st.font_name, fallback],
|
|
93
|
+
"font.size": st.font_size,
|
|
94
|
+
"axes.labelsize": st.font_size,
|
|
95
|
+
"axes.titlesize": st.title_font_size,
|
|
96
|
+
# Mirror ticks onto the top/right spines whenever the preset draws a
|
|
97
|
+
# closed box (matches calc.figure's convention; matplotlib's default
|
|
98
|
+
# leaves top/right bare even with the full rectangular border).
|
|
99
|
+
"xtick.top": st.box_on,
|
|
100
|
+
"ytick.right": st.box_on,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
with matplotlib.rc_context(rc): # type: ignore[arg-type]
|
|
104
|
+
fig, ax = plt.subplots(figsize=figsize)
|
|
105
|
+
try:
|
|
106
|
+
_draw_statplot(ax, kind, data, labels, dist, bins, fit, st)
|
|
107
|
+
if title:
|
|
108
|
+
ax.set_title(title)
|
|
109
|
+
if x_label:
|
|
110
|
+
ax.set_xlabel(x_label)
|
|
111
|
+
if y_label:
|
|
112
|
+
ax.set_ylabel(y_label)
|
|
113
|
+
if not st.box_on:
|
|
114
|
+
ax.spines["top"].set_visible(False)
|
|
115
|
+
ax.spines["right"].set_visible(False)
|
|
116
|
+
fig.tight_layout()
|
|
117
|
+
buf = BytesIO()
|
|
118
|
+
fig.savefig(buf, format=fmt, dpi=resolved_dpi)
|
|
119
|
+
return buf.getvalue()
|
|
120
|
+
finally:
|
|
121
|
+
plt.close(fig)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _draw_statplot(
|
|
125
|
+
ax: Any,
|
|
126
|
+
kind: str,
|
|
127
|
+
data: list[ArrayLike] | ArrayLike,
|
|
128
|
+
labels: list[str] | None,
|
|
129
|
+
dist: str,
|
|
130
|
+
bins: str | int,
|
|
131
|
+
fit: str | None,
|
|
132
|
+
st: Any,
|
|
133
|
+
) -> None:
|
|
134
|
+
if kind in _GROUPED:
|
|
135
|
+
if not isinstance(data, list) or not data:
|
|
136
|
+
raise ValueError(f"{kind} needs a non-empty list of groups")
|
|
137
|
+
groups = _clean_groups(data)
|
|
138
|
+
ticks = list(range(1, len(groups) + 1))
|
|
139
|
+
if kind == "box":
|
|
140
|
+
ax.boxplot(groups, tick_labels=labels, showmeans=True)
|
|
141
|
+
else:
|
|
142
|
+
parts = ax.violinplot(groups, positions=ticks, showmeans=True, showextrema=True)
|
|
143
|
+
if labels:
|
|
144
|
+
ax.set_xticks(ticks)
|
|
145
|
+
ax.set_xticklabels(labels)
|
|
146
|
+
del parts
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
sample = np.asarray(data, dtype=float).ravel()
|
|
150
|
+
if kind in ("qq", "probability"):
|
|
151
|
+
q = _qq_plot(sample, dist=dist)
|
|
152
|
+
theo = np.asarray(q["theoretical_quantiles"])
|
|
153
|
+
obs = np.asarray(q["sample_quantiles"])
|
|
154
|
+
ax.scatter(theo, obs, s=12, color=st.accent if hasattr(st, "accent") else None)
|
|
155
|
+
line = q["slope"] * theo + q["intercept"]
|
|
156
|
+
ax.plot(theo, line, color="0.4", linewidth=st.line_width)
|
|
157
|
+
ax.set_xlabel(ax.get_xlabel() or f"Theoretical quantiles ({dist})")
|
|
158
|
+
ax.set_ylabel(ax.get_ylabel() or "Sample quantiles")
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
# histogram
|
|
162
|
+
h = _histogram(sample, bins=bins, density=fit is not None, fit=fit)
|
|
163
|
+
edges = np.asarray(h["edges"])
|
|
164
|
+
ax.hist(sample, bins=edges, density=fit is not None,
|
|
165
|
+
color="0.6", edgecolor="white", linewidth=0.5)
|
|
166
|
+
if fit is not None and "fit" in h:
|
|
167
|
+
ax.plot(h["fit"]["x"], h["fit"]["pdf"], color="0.1", linewidth=st.line_width)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Publication figure-style presets (ported from ``+styles/template.m``).
|
|
2
|
+
|
|
3
|
+
Pure data layer: a named-template table whose parameters (font, sizes, line
|
|
4
|
+
width, figure geometry, grid/box/legend) are transcribed verbatim from the
|
|
5
|
+
MATLAB ``styles.template`` reference so exported figures match journal specs
|
|
6
|
+
(APS, Nature, thesis, report, web, …). ``render_figure`` consumes a resolved
|
|
7
|
+
``FigureStyle``; no matplotlib/fastapi imports here.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
__all__ = ["FigureStyle", "FIGURE_STYLES", "figure_style", "style_names"]
|
|
15
|
+
|
|
16
|
+
_CM_PER_IN = 2.54
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class FigureStyle:
|
|
21
|
+
"""One named figure style. Sizes are in points (fonts/line) or cm (figure)."""
|
|
22
|
+
|
|
23
|
+
name: str
|
|
24
|
+
font_name: str
|
|
25
|
+
font_size: float # axis tick / label size (pt)
|
|
26
|
+
title_font_size: float
|
|
27
|
+
legend_font_size: float
|
|
28
|
+
line_width: float
|
|
29
|
+
line_width_thin: float
|
|
30
|
+
marker_size: float
|
|
31
|
+
fig_width_cm: float
|
|
32
|
+
fig_height_cm: float
|
|
33
|
+
dpi: int
|
|
34
|
+
grid_alpha: float # 0 = no grid
|
|
35
|
+
legend_box: bool
|
|
36
|
+
box_on: bool = True
|
|
37
|
+
tick_dir: str = "in"
|
|
38
|
+
legend_location: str = "best"
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def fig_width_in(self) -> float:
|
|
42
|
+
return self.fig_width_cm / _CM_PER_IN
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def fig_height_in(self) -> float:
|
|
46
|
+
return self.fig_height_cm / _CM_PER_IN
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def font_generic(self) -> str:
|
|
50
|
+
"""Generic family so matplotlib falls back silently if the named font
|
|
51
|
+
(Helvetica/Arial/Times) is absent on the host."""
|
|
52
|
+
return "serif" if "times" in self.font_name.lower() else "sans-serif"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _t(
|
|
56
|
+
name: str,
|
|
57
|
+
font: str,
|
|
58
|
+
fs: float,
|
|
59
|
+
title_fs: float,
|
|
60
|
+
legend_fs: float,
|
|
61
|
+
lw: float,
|
|
62
|
+
lw_thin: float,
|
|
63
|
+
marker: float,
|
|
64
|
+
w_cm: float,
|
|
65
|
+
h_cm: float,
|
|
66
|
+
dpi: int,
|
|
67
|
+
*,
|
|
68
|
+
grid_alpha: float,
|
|
69
|
+
legend_box: bool,
|
|
70
|
+
box_on: bool = True,
|
|
71
|
+
) -> FigureStyle:
|
|
72
|
+
return FigureStyle(
|
|
73
|
+
name=name,
|
|
74
|
+
font_name=font,
|
|
75
|
+
font_size=fs,
|
|
76
|
+
title_font_size=title_fs,
|
|
77
|
+
legend_font_size=legend_fs,
|
|
78
|
+
line_width=lw,
|
|
79
|
+
line_width_thin=lw_thin,
|
|
80
|
+
marker_size=marker,
|
|
81
|
+
fig_width_cm=w_cm,
|
|
82
|
+
fig_height_cm=h_cm,
|
|
83
|
+
dpi=dpi,
|
|
84
|
+
grid_alpha=grid_alpha,
|
|
85
|
+
legend_box=legend_box,
|
|
86
|
+
box_on=box_on,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# Values transcribed verbatim from quantized_matlab/+styles/template.m — do not
|
|
91
|
+
# "fix" them; they are calibrated journal/context specs.
|
|
92
|
+
FIGURE_STYLES: dict[str, FigureStyle] = {
|
|
93
|
+
# Our interactive baseline (matches the legacy render_figure look): the
|
|
94
|
+
# renderer default when no style is requested.
|
|
95
|
+
"default": _t("default", "DejaVu Sans", 10, 11, 9, 1.2, 0.7, 5, 15.24, 10.16, 200,
|
|
96
|
+
grid_alpha=0.25, legend_box=False),
|
|
97
|
+
"aps": _t("aps", "Helvetica", 9, 10, 8, 1.25, 0.75, 4, 8.6, 6.5, 600,
|
|
98
|
+
grid_alpha=0.0, legend_box=False),
|
|
99
|
+
"aps_double": _t("aps_double", "Helvetica", 9, 10, 8, 1.25, 0.75, 4, 17.8, 6.5, 600,
|
|
100
|
+
grid_alpha=0.0, legend_box=False),
|
|
101
|
+
"nature": _t("nature", "Arial", 7, 8, 6, 1.0, 0.5, 3, 8.9, 6.0, 600,
|
|
102
|
+
grid_alpha=0.0, legend_box=False),
|
|
103
|
+
"nature_double": _t("nature_double", "Arial", 7, 8, 6, 1.0, 0.5, 3, 18.3, 6.0, 600,
|
|
104
|
+
grid_alpha=0.0, legend_box=False),
|
|
105
|
+
"thesis": _t("thesis", "Times New Roman", 11, 12, 10, 1.5, 0.75, 5, 15.0, 10.0, 300,
|
|
106
|
+
grid_alpha=0.15, legend_box=True),
|
|
107
|
+
"presentation": _t("presentation", "Arial", 18, 20, 14, 2.5, 1.5, 8, 25.0, 18.0, 150,
|
|
108
|
+
grid_alpha=0.2, legend_box=False),
|
|
109
|
+
"poster": _t("poster", "Arial", 24, 28, 18, 3.0, 2.0, 10, 30.0, 22.0, 150,
|
|
110
|
+
grid_alpha=0.15, legend_box=False),
|
|
111
|
+
"report": _t("report", "Times New Roman", 10, 11, 9, 1.4, 0.7, 5, 12.0, 8.5, 300,
|
|
112
|
+
grid_alpha=0.0, legend_box=True),
|
|
113
|
+
"web": _t("web", "Arial", 13, 15, 11, 2.0, 0.9, 6, 16.0, 10.0, 150,
|
|
114
|
+
grid_alpha=0.12, legend_box=False),
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def style_names() -> list[str]:
|
|
119
|
+
"""Sorted list of available style names (``default`` first)."""
|
|
120
|
+
rest = sorted(n for n in FIGURE_STYLES if n != "default")
|
|
121
|
+
return ["default", *rest]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def figure_style(name: str) -> FigureStyle:
|
|
125
|
+
"""Resolve a style by name; raises ``ValueError`` on an unknown name."""
|
|
126
|
+
try:
|
|
127
|
+
return FIGURE_STYLES[name]
|
|
128
|
+
except KeyError as exc:
|
|
129
|
+
raise ValueError(
|
|
130
|
+
f"unknown style {name!r}; available: {', '.join(style_names())}"
|
|
131
|
+
) from exc
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Publication rendering for ternary diagrams (3-component compositions).
|
|
2
|
+
|
|
3
|
+
ORIGIN_GAP_PLAN #23 / GAP_TIER3_PLAN item 4. Pure layer: a 2-D array of
|
|
4
|
+
three-component rows (a, b, c) in fractional or percentage form -> ternary
|
|
5
|
+
scatter plot in image bytes. Hand-rolled barycentric transform (no
|
|
6
|
+
python-ternary dependency) on plain matplotlib axes for fast export.
|
|
7
|
+
|
|
8
|
+
Rows are normalized to sum to 1 (or all three components treated as fractions):
|
|
9
|
+
if rows don't sum to ~1, each is divided by its row sum; a warning is issued.
|
|
10
|
+
Non-positive rows (any component < 0 after normalization, or all-zero rows)
|
|
11
|
+
raise ValueError.
|
|
12
|
+
|
|
13
|
+
Shares ``calc.figure_styles`` presets and ``calc.figure``'s resolved-dpi
|
|
14
|
+
convention: an explicit ``dpi`` overrides the preset; otherwise the preset's
|
|
15
|
+
calibrated dpi is used.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from io import BytesIO
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import matplotlib
|
|
24
|
+
|
|
25
|
+
matplotlib.use("Agg") # headless
|
|
26
|
+
|
|
27
|
+
import matplotlib.pyplot as plt # noqa: E402
|
|
28
|
+
import numpy as np # 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_ternary_figure"]
|
|
35
|
+
|
|
36
|
+
_FORMATS = ("pdf", "svg", "png", "tiff")
|
|
37
|
+
_FIGURE_SIZE_IN = (8, 7) # inches: ternary is roughly square + colorbar
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def render_ternary_figure(
|
|
41
|
+
data: ArrayLike,
|
|
42
|
+
*,
|
|
43
|
+
labels: tuple[str, str, str] = ("A", "B", "C"),
|
|
44
|
+
values: ArrayLike | None = None,
|
|
45
|
+
fmt: str = "pdf",
|
|
46
|
+
style: str = "default",
|
|
47
|
+
dpi: int | None = None,
|
|
48
|
+
marker_size: float | None = None,
|
|
49
|
+
title: str = "",
|
|
50
|
+
) -> bytes:
|
|
51
|
+
"""Render a ternary diagram (3-component scatter plot).
|
|
52
|
+
|
|
53
|
+
``data`` is an (n, 3) array of compositions (a, b, c) for n samples.
|
|
54
|
+
Each row is normalized so a+b+c=1 (or treated as fractions if already
|
|
55
|
+
summing to ~1). Non-positive components raise ValueError; rows not
|
|
56
|
+
summing to ~1 issue a warning and are divided by their row sum.
|
|
57
|
+
|
|
58
|
+
``labels`` names the three corners (default: A, B, C, positioned at the
|
|
59
|
+
top, bottom-left, and bottom-right respectively). ``values`` (length n)
|
|
60
|
+
colors the scatter points by an optional fourth dimension (e.g. an
|
|
61
|
+
experimental result); if None, all points are the same color.
|
|
62
|
+
|
|
63
|
+
Ternary gridlines are drawn at 10% intervals. ``fmt``, ``style``, and
|
|
64
|
+
``dpi`` follow ``render_figure``'s conventions: ``dpi`` defaults to the
|
|
65
|
+
preset's calibrated resolution when not given.
|
|
66
|
+
|
|
67
|
+
``marker_size`` (default None) scales the scatter point size; None uses
|
|
68
|
+
the preset's default. ``title`` is an optional figure title.
|
|
69
|
+
|
|
70
|
+
Returns image bytes in the requested ``fmt``. Raises ``ValueError`` on
|
|
71
|
+
malformed input (wrong shape, non-positive components, bad format).
|
|
72
|
+
"""
|
|
73
|
+
if fmt not in _FORMATS:
|
|
74
|
+
raise ValueError(f"fmt must be one of {_FORMATS}")
|
|
75
|
+
# Rich-text labels (GOTO #5): de-math INVALID $...$ so savefig never raises.
|
|
76
|
+
title = safe_mathtext_label(title)
|
|
77
|
+
labels = (
|
|
78
|
+
safe_mathtext_label(str(labels[0])),
|
|
79
|
+
safe_mathtext_label(str(labels[1])),
|
|
80
|
+
safe_mathtext_label(str(labels[2])),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
arr = np.asarray(data, dtype=float)
|
|
84
|
+
if arr.ndim != 2 or arr.shape[1] != 3:
|
|
85
|
+
raise ValueError(
|
|
86
|
+
f"data must be an (n, 3) array of 3-component compositions, "
|
|
87
|
+
f"got shape {arr.shape}"
|
|
88
|
+
)
|
|
89
|
+
n = arr.shape[0]
|
|
90
|
+
if n < 1:
|
|
91
|
+
raise ValueError("need at least one composition row")
|
|
92
|
+
|
|
93
|
+
# Normalize: divide each row by its sum, warn if far from 1.
|
|
94
|
+
row_sums = np.sum(arr, axis=1, keepdims=True)
|
|
95
|
+
if not np.all(np.isfinite(row_sums)) or np.any(row_sums == 0):
|
|
96
|
+
raise ValueError("compositions must have finite, non-zero row sums")
|
|
97
|
+
|
|
98
|
+
# Warn if rows don't sum to ~1 (tolerance: 0.001)
|
|
99
|
+
if not np.allclose(row_sums, 1.0, rtol=0.01, atol=1e-3):
|
|
100
|
+
print("Warning: input compositions don't sum to 1.0; normalizing each row.")
|
|
101
|
+
|
|
102
|
+
normalized = arr / row_sums
|
|
103
|
+
|
|
104
|
+
# Check for any non-positive components after normalization.
|
|
105
|
+
if np.any(normalized < 0):
|
|
106
|
+
raise ValueError(
|
|
107
|
+
"all composition components must be non-negative after normalization"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
if values is not None:
|
|
111
|
+
vals = np.asarray(values, dtype=float)
|
|
112
|
+
if vals.shape[0] != n:
|
|
113
|
+
raise ValueError(
|
|
114
|
+
f"values has {vals.shape[0]} entries, data has {n} rows"
|
|
115
|
+
)
|
|
116
|
+
else:
|
|
117
|
+
vals = None
|
|
118
|
+
|
|
119
|
+
st = figure_style(style)
|
|
120
|
+
resolved_dpi = int(dpi) if dpi is not None else int(st.dpi)
|
|
121
|
+
fallback = "DejaVu Serif" if st.font_generic == "serif" else "DejaVu Sans"
|
|
122
|
+
rc: dict[str, Any] = {
|
|
123
|
+
"font.family": st.font_generic,
|
|
124
|
+
f"font.{st.font_generic}": [st.font_name, fallback],
|
|
125
|
+
"font.size": st.font_size,
|
|
126
|
+
"axes.labelsize": st.font_size,
|
|
127
|
+
"axes.titlesize": st.title_font_size,
|
|
128
|
+
"xtick.labelsize": st.font_size - 1,
|
|
129
|
+
"ytick.labelsize": st.font_size - 1,
|
|
130
|
+
"xtick.direction": st.tick_dir,
|
|
131
|
+
"ytick.direction": st.tick_dir,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
with matplotlib.rc_context(rc): # type: ignore[arg-type]
|
|
135
|
+
fig, ax = plt.subplots(figsize=_FIGURE_SIZE_IN)
|
|
136
|
+
try:
|
|
137
|
+
_draw_ternary_scatter(
|
|
138
|
+
ax, normalized, labels=labels, values=vals,
|
|
139
|
+
marker_size=marker_size, style=st,
|
|
140
|
+
)
|
|
141
|
+
if title:
|
|
142
|
+
fig.suptitle(title, fontsize=st.title_font_size)
|
|
143
|
+
fig.tight_layout()
|
|
144
|
+
buf = BytesIO()
|
|
145
|
+
fig.savefig(buf, format=fmt, dpi=resolved_dpi)
|
|
146
|
+
return buf.getvalue()
|
|
147
|
+
finally:
|
|
148
|
+
plt.close(fig)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _barycentric_to_cartesian(a: float, b: float, c: float) -> tuple[float, float]:
|
|
152
|
+
"""Convert barycentric coordinates (a, b, c) summing to 1 to Cartesian (x, y).
|
|
153
|
+
|
|
154
|
+
Triangle vertices: A (top) at (0.5, √3/2), B (bottom-left) at (0, 0),
|
|
155
|
+
C (bottom-right) at (1, 0). The transformation is:
|
|
156
|
+
x = c + 0.5*a
|
|
157
|
+
y = (√3/2)*a
|
|
158
|
+
"""
|
|
159
|
+
sqrt3_2 = np.sqrt(3) / 2
|
|
160
|
+
x = c + 0.5 * a
|
|
161
|
+
y = sqrt3_2 * a
|
|
162
|
+
return x, y
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _draw_ternary_scatter(
|
|
166
|
+
ax: Any,
|
|
167
|
+
normalized: NDArray[np.float64],
|
|
168
|
+
labels: tuple[str, str, str],
|
|
169
|
+
values: NDArray[np.float64] | None,
|
|
170
|
+
marker_size: float | None,
|
|
171
|
+
style: Any,
|
|
172
|
+
) -> None:
|
|
173
|
+
"""Draw ternary triangle, gridlines, labels, and scatter points."""
|
|
174
|
+
# Triangle vertices: (a, b, c) = (1, 0, 0), (0, 1, 0), (0, 0, 1)
|
|
175
|
+
# Cartesian positions: A (top), B (bottom-left), C (bottom-right)
|
|
176
|
+
ax_cart, ay_cart = _barycentric_to_cartesian(1.0, 0.0, 0.0)
|
|
177
|
+
bx_cart, by_cart = _barycentric_to_cartesian(0.0, 1.0, 0.0)
|
|
178
|
+
cx_cart, cy_cart = _barycentric_to_cartesian(0.0, 0.0, 1.0)
|
|
179
|
+
|
|
180
|
+
# Draw triangle boundary
|
|
181
|
+
triangle_x = [ax_cart, bx_cart, cx_cart, ax_cart]
|
|
182
|
+
triangle_y = [ay_cart, by_cart, cy_cart, ay_cart]
|
|
183
|
+
ax.plot(triangle_x, triangle_y, "k-", linewidth=1.5)
|
|
184
|
+
|
|
185
|
+
# Draw gridlines at 10% intervals (0.1, 0.2, ..., 0.9)
|
|
186
|
+
sqrt3_2 = np.sqrt(3) / 2
|
|
187
|
+
for i in range(1, 10):
|
|
188
|
+
frac = i / 10.0
|
|
189
|
+
# Line of constant a (parallel to B-C edge, bottom)
|
|
190
|
+
a_const_x = [_barycentric_to_cartesian(frac, 1.0 - frac, 0.0)[0],
|
|
191
|
+
_barycentric_to_cartesian(frac, 0.0, 1.0 - frac)[0]]
|
|
192
|
+
a_const_y = [_barycentric_to_cartesian(frac, 1.0 - frac, 0.0)[1],
|
|
193
|
+
_barycentric_to_cartesian(frac, 0.0, 1.0 - frac)[1]]
|
|
194
|
+
ax.plot(a_const_x, a_const_y, "gray", linewidth=0.5, alpha=0.5)
|
|
195
|
+
|
|
196
|
+
# Line of constant b (parallel to A-C edge, right side)
|
|
197
|
+
b_const_x = [_barycentric_to_cartesian(1.0 - frac, frac, 0.0)[0],
|
|
198
|
+
_barycentric_to_cartesian(0.0, frac, 1.0 - frac)[0]]
|
|
199
|
+
b_const_y = [_barycentric_to_cartesian(1.0 - frac, frac, 0.0)[1],
|
|
200
|
+
_barycentric_to_cartesian(0.0, frac, 1.0 - frac)[1]]
|
|
201
|
+
ax.plot(b_const_x, b_const_y, "gray", linewidth=0.5, alpha=0.5)
|
|
202
|
+
|
|
203
|
+
# Line of constant c (parallel to A-B edge, left side)
|
|
204
|
+
c_const_x = [_barycentric_to_cartesian(1.0 - frac, 0.0, frac)[0],
|
|
205
|
+
_barycentric_to_cartesian(0.0, 1.0 - frac, frac)[0]]
|
|
206
|
+
c_const_y = [_barycentric_to_cartesian(1.0 - frac, 0.0, frac)[1],
|
|
207
|
+
_barycentric_to_cartesian(0.0, 1.0 - frac, frac)[1]]
|
|
208
|
+
ax.plot(c_const_x, c_const_y, "gray", linewidth=0.5, alpha=0.5)
|
|
209
|
+
|
|
210
|
+
# Plot compositions as scatter points
|
|
211
|
+
coords = [_barycentric_to_cartesian(row[0], row[1], row[2]) for row in normalized]
|
|
212
|
+
x_pts_list, y_pts_list = zip(*coords, strict=True)
|
|
213
|
+
x_pts = np.asarray(x_pts_list, dtype=float)
|
|
214
|
+
y_pts = np.asarray(y_pts_list, dtype=float)
|
|
215
|
+
|
|
216
|
+
ms = marker_size if marker_size is not None else 50
|
|
217
|
+
if values is not None:
|
|
218
|
+
scatter = ax.scatter(x_pts, y_pts, c=values, s=ms, alpha=0.7,
|
|
219
|
+
edgecolors="black", linewidth=0.5, cmap="viridis")
|
|
220
|
+
fig = ax.get_figure()
|
|
221
|
+
fig.colorbar(scatter, ax=ax, label="Value")
|
|
222
|
+
else:
|
|
223
|
+
ax.scatter(x_pts, y_pts, s=ms, alpha=0.7, c="C0",
|
|
224
|
+
edgecolors="black", linewidth=0.5)
|
|
225
|
+
|
|
226
|
+
# Corner labels and tick positioning
|
|
227
|
+
offset = 0.05
|
|
228
|
+
ax.text(ax_cart, ay_cart + offset, labels[0], ha="center", va="bottom",
|
|
229
|
+
fontsize=style.font_size + 2, fontweight="bold")
|
|
230
|
+
ax.text(bx_cart - offset, by_cart - offset, labels[1], ha="right", va="top",
|
|
231
|
+
fontsize=style.font_size + 2, fontweight="bold")
|
|
232
|
+
ax.text(cx_cart + offset, cy_cart - offset, labels[2], ha="left", va="top",
|
|
233
|
+
fontsize=style.font_size + 2, fontweight="bold")
|
|
234
|
+
|
|
235
|
+
# Clean up axes: no ticks, equal aspect, proper limits
|
|
236
|
+
ax.set_xlim(-0.15, 1.15)
|
|
237
|
+
ax.set_ylim(-0.15, sqrt3_2 + 0.15)
|
|
238
|
+
ax.set_aspect("equal")
|
|
239
|
+
ax.axis("off")
|