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.
Files changed (233) hide show
  1. quantized/__init__.py +11 -0
  2. quantized/__main__.py +14 -0
  3. quantized/api.py +220 -0
  4. quantized/app.py +206 -0
  5. quantized/calc/__init__.py +8 -0
  6. quantized/calc/_clipfit.py +76 -0
  7. quantized/calc/_natural_neighbor.py +219 -0
  8. quantized/calc/aggregate.py +159 -0
  9. quantized/calc/backgrounds.py +353 -0
  10. quantized/calc/baseline.py +349 -0
  11. quantized/calc/batch_fit.py +148 -0
  12. quantized/calc/constants.py +27 -0
  13. quantized/calc/corrections.py +192 -0
  14. quantized/calc/crystallography.py +400 -0
  15. quantized/calc/diffusion.py +120 -0
  16. quantized/calc/electrical.py +248 -0
  17. quantized/calc/electrochemistry.py +176 -0
  18. quantized/calc/element_data.json +1 -0
  19. quantized/calc/element_data.py +59 -0
  20. quantized/calc/errors.py +246 -0
  21. quantized/calc/figure.py +417 -0
  22. quantized/calc/figure_break.py +152 -0
  23. quantized/calc/figure_categorical.py +156 -0
  24. quantized/calc/figure_corner.py +229 -0
  25. quantized/calc/figure_facets.py +127 -0
  26. quantized/calc/figure_field.py +137 -0
  27. quantized/calc/figure_hitmap.py +116 -0
  28. quantized/calc/figure_labels.py +62 -0
  29. quantized/calc/figure_map.py +287 -0
  30. quantized/calc/figure_overrides.py +159 -0
  31. quantized/calc/figure_page.py +266 -0
  32. quantized/calc/figure_scale.py +125 -0
  33. quantized/calc/figure_statplots.py +167 -0
  34. quantized/calc/figure_styles.py +131 -0
  35. quantized/calc/figure_ternary.py +239 -0
  36. quantized/calc/figure_ticks.py +217 -0
  37. quantized/calc/fit_autoguess.py +156 -0
  38. quantized/calc/fit_bootstrap.py +163 -0
  39. quantized/calc/fit_bumps.py +258 -0
  40. quantized/calc/fit_constraints.py +114 -0
  41. quantized/calc/fit_equation.py +264 -0
  42. quantized/calc/fit_findxy.py +80 -0
  43. quantized/calc/fit_models.py +195 -0
  44. quantized/calc/fit_models_special.py +189 -0
  45. quantized/calc/fit_odr.py +99 -0
  46. quantized/calc/fit_scan.py +243 -0
  47. quantized/calc/fit_stats.py +215 -0
  48. quantized/calc/fitting.py +199 -0
  49. quantized/calc/formula.py +90 -0
  50. quantized/calc/global_curve_fit.py +305 -0
  51. quantized/calc/global_fit.py +181 -0
  52. quantized/calc/interp2d.py +260 -0
  53. quantized/calc/linecut.py +269 -0
  54. quantized/calc/magnetic.py +414 -0
  55. quantized/calc/magnetometry.py +464 -0
  56. quantized/calc/map.py +228 -0
  57. quantized/calc/mcmc.py +177 -0
  58. quantized/calc/optics.py +228 -0
  59. quantized/calc/pawley.py +251 -0
  60. quantized/calc/peak_batch.py +128 -0
  61. quantized/calc/peak_fit.py +259 -0
  62. quantized/calc/peak_integrate.py +104 -0
  63. quantized/calc/peak_multifit.py +260 -0
  64. quantized/calc/peak_track.py +134 -0
  65. quantized/calc/peaks.py +298 -0
  66. quantized/calc/peakshapes.py +85 -0
  67. quantized/calc/plotting.py +147 -0
  68. quantized/calc/processing.py +232 -0
  69. quantized/calc/qspace.py +48 -0
  70. quantized/calc/reductions.py +155 -0
  71. quantized/calc/reductions_fft.py +383 -0
  72. quantized/calc/refl_sld_presets.json +1 -0
  73. quantized/calc/reflectivity.py +80 -0
  74. quantized/calc/registry.py +303 -0
  75. quantized/calc/relaxation.py +119 -0
  76. quantized/calc/report.py +253 -0
  77. quantized/calc/report_emit.py +227 -0
  78. quantized/calc/resample.py +142 -0
  79. quantized/calc/rsm.py +91 -0
  80. quantized/calc/rsm_analyze.py +245 -0
  81. quantized/calc/semiconductor.py +488 -0
  82. quantized/calc/sld.py +131 -0
  83. quantized/calc/sld_formula.py +138 -0
  84. quantized/calc/spectral.py +357 -0
  85. quantized/calc/statplots.py +214 -0
  86. quantized/calc/stats.py +399 -0
  87. quantized/calc/stats_anova2.py +202 -0
  88. quantized/calc/stats_anova_ext.py +338 -0
  89. quantized/calc/stats_dist.py +196 -0
  90. quantized/calc/stats_glm.py +245 -0
  91. quantized/calc/stats_multivar.py +289 -0
  92. quantized/calc/stats_roc.py +157 -0
  93. quantized/calc/stats_survival.py +261 -0
  94. quantized/calc/stats_tests.py +380 -0
  95. quantized/calc/substrates.py +181 -0
  96. quantized/calc/superconductor.py +359 -0
  97. quantized/calc/surface_fit.py +290 -0
  98. quantized/calc/surface_models.py +156 -0
  99. quantized/calc/thermal.py +119 -0
  100. quantized/calc/thin_film.py +425 -0
  101. quantized/calc/unit_convert.py +259 -0
  102. quantized/calc/units.py +80 -0
  103. quantized/calc/vacuum.py +290 -0
  104. quantized/calc/xray.py +169 -0
  105. quantized/cli.py +214 -0
  106. quantized/datastruct.py +153 -0
  107. quantized/io/__init__.py +11 -0
  108. quantized/io/_hdf5_layout.py +308 -0
  109. quantized/io/_jcamp_asdf.py +135 -0
  110. quantized/io/_xrdml_scan.py +291 -0
  111. quantized/io/base.py +82 -0
  112. quantized/io/bruker_brml.py +177 -0
  113. quantized/io/bruker_raw.py +158 -0
  114. quantized/io/cif.py +266 -0
  115. quantized/io/consolidated.py +122 -0
  116. quantized/io/delimited.py +222 -0
  117. quantized/io/excel.py +135 -0
  118. quantized/io/hdf5.py +192 -0
  119. quantized/io/import_filters.py +178 -0
  120. quantized/io/import_preview.py +262 -0
  121. quantized/io/jcamp.py +179 -0
  122. quantized/io/lakeshore.py +163 -0
  123. quantized/io/ncnr.py +278 -0
  124. quantized/io/netcdf.py +195 -0
  125. quantized/io/opus.py +231 -0
  126. quantized/io/origin.py +346 -0
  127. quantized/io/origin_com.py +194 -0
  128. quantized/io/origin_project/__init__.py +221 -0
  129. quantized/io/origin_project/annotation_marks.py +288 -0
  130. quantized/io/origin_project/container.py +262 -0
  131. quantized/io/origin_project/curve_style_color.py +359 -0
  132. quantized/io/origin_project/figure_geometry.py +108 -0
  133. quantized/io/origin_project/figure_layers.py +333 -0
  134. quantized/io/origin_project/figure_text.py +258 -0
  135. quantized/io/origin_project/figures.py +210 -0
  136. quantized/io/origin_project/figures_opju.py +440 -0
  137. quantized/io/origin_project/notes.py +302 -0
  138. quantized/io/origin_project/opj.py +459 -0
  139. quantized/io/origin_project/opj_curves.py +297 -0
  140. quantized/io/origin_project/opj_shapes.py +148 -0
  141. quantized/io/origin_project/opju.py +146 -0
  142. quantized/io/origin_project/opju_axis_real_form.py +418 -0
  143. quantized/io/origin_project/opju_axis_specimen_form.py +167 -0
  144. quantized/io/origin_project/opju_codec.py +370 -0
  145. quantized/io/origin_project/opju_curves.py +497 -0
  146. quantized/io/origin_project/opju_curves_allcols.py +258 -0
  147. quantized/io/origin_project/opju_figure_curves.py +302 -0
  148. quantized/io/origin_project/opju_figure_text.py +245 -0
  149. quantized/io/origin_project/opju_reports.py +129 -0
  150. quantized/io/origin_project/origin_richtext.py +145 -0
  151. quantized/io/origin_project/preview.py +132 -0
  152. quantized/io/origin_project/templates.py +314 -0
  153. quantized/io/origin_project/tree.py +379 -0
  154. quantized/io/origin_project/tree_opju.py +228 -0
  155. quantized/io/origin_project/windows.py +238 -0
  156. quantized/io/origin_project/windows_opju.py +393 -0
  157. quantized/io/origin_project/writer.py +156 -0
  158. quantized/io/origin_project/writer_blocks.py +282 -0
  159. quantized/io/qd.py +380 -0
  160. quantized/io/refl1d.py +132 -0
  161. quantized/io/registry.py +210 -0
  162. quantized/io/report_export.py +347 -0
  163. quantized/io/rigaku.py +100 -0
  164. quantized/io/sims.py +398 -0
  165. quantized/io/spc.py +311 -0
  166. quantized/io/xrd_csv.py +308 -0
  167. quantized/io/xrdml.py +394 -0
  168. quantized/jobs.py +173 -0
  169. quantized/plugins/__init__.py +50 -0
  170. quantized/plugins/contract.py +111 -0
  171. quantized/plugins/loader.py +394 -0
  172. quantized/plugins/steps.py +90 -0
  173. quantized/routes/__init__.py +7 -0
  174. quantized/routes/_bookcache.py +62 -0
  175. quantized/routes/_export_common.py +27 -0
  176. quantized/routes/_payload.py +58 -0
  177. quantized/routes/_uploadcache.py +59 -0
  178. quantized/routes/aggregate.py +46 -0
  179. quantized/routes/baseline.py +210 -0
  180. quantized/routes/books.py +117 -0
  181. quantized/routes/calc.py +56 -0
  182. quantized/routes/corrections.py +78 -0
  183. quantized/routes/crystallography.py +80 -0
  184. quantized/routes/diffusion.py +58 -0
  185. quantized/routes/electrical.py +101 -0
  186. quantized/routes/electrochemistry.py +83 -0
  187. quantized/routes/export.py +280 -0
  188. quantized/routes/export_facets.py +83 -0
  189. quantized/routes/export_figures.py +471 -0
  190. quantized/routes/export_page.py +125 -0
  191. quantized/routes/fitting.py +379 -0
  192. quantized/routes/fitting_bumps.py +97 -0
  193. quantized/routes/import_template.py +97 -0
  194. quantized/routes/import_wizard.py +150 -0
  195. quantized/routes/jobs_api.py +59 -0
  196. quantized/routes/magnetic.py +135 -0
  197. quantized/routes/magnetometry.py +133 -0
  198. quantized/routes/optics.py +98 -0
  199. quantized/routes/parsers.py +281 -0
  200. quantized/routes/peaks.py +184 -0
  201. quantized/routes/plot.py +103 -0
  202. quantized/routes/reductions.py +121 -0
  203. quantized/routes/reference.py +58 -0
  204. quantized/routes/reflectivity.py +91 -0
  205. quantized/routes/report_export.py +119 -0
  206. quantized/routes/rsm.py +136 -0
  207. quantized/routes/samples.py +32 -0
  208. quantized/routes/semiconductor.py +207 -0
  209. quantized/routes/sld.py +42 -0
  210. quantized/routes/spectral.py +54 -0
  211. quantized/routes/statplots.py +99 -0
  212. quantized/routes/stats.py +418 -0
  213. quantized/routes/stats_design.py +321 -0
  214. quantized/routes/substrates.py +46 -0
  215. quantized/routes/superconductor.py +139 -0
  216. quantized/routes/thermal.py +57 -0
  217. quantized/routes/thin_film.py +153 -0
  218. quantized/routes/vacuum.py +113 -0
  219. quantized/routes/xray.py +32 -0
  220. quantized/samples/demo_vsm.csv +42 -0
  221. quantized/server_launch.py +251 -0
  222. quantized/web/assets/JetBrainsMono-Bold-CUogYd9I.woff2 +0 -0
  223. quantized/web/assets/JetBrainsMono-Regular-CA-Os4ii.woff2 +0 -0
  224. quantized/web/assets/index-BHmmCL-x.js +27 -0
  225. quantized/web/assets/index-BiZzN7J6.css +1 -0
  226. quantized/web/index.html +13 -0
  227. quantized/web/loading.html +69 -0
  228. quantized_lab-0.8.0.dist-info/METADATA +122 -0
  229. quantized_lab-0.8.0.dist-info/RECORD +233 -0
  230. quantized_lab-0.8.0.dist-info/WHEEL +4 -0
  231. quantized_lab-0.8.0.dist-info/entry_points.txt +4 -0
  232. quantized_lab-0.8.0.dist-info/licenses/LICENSE +201 -0
  233. 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")