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,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)