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,159 @@
1
+ """Figure property overrides (gap #11). Split out of ``calc.figure`` purely to
2
+ stay under the 500-line god-module ceiling (the same reason ``figure_break.py``
3
+ and ``figure_labels.py`` exist separately) -- the behavioural contract is still
4
+ ``calc.figure``'s; ``_render_impl`` calls ``_validate_overrides`` up front and
5
+ ``draw_series_axes`` calls ``_apply_overrides`` as its last step. Pure layer:
6
+ mapping in -> mutates the passed matplotlib Figure/Axes, no return value.
7
+
8
+ The one config object behind the property panels: every export property the
9
+ UI exposes lands in ``overrides``, patching the preset per-figure. Plain dict
10
+ (calc stays pydantic-free); unknown keys are ignored so old clients keep
11
+ working.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Mapping
17
+ from typing import Any
18
+
19
+ from quantized.calc.figure_labels import safe_mathtext_label
20
+
21
+ __all__ = ["_apply_overrides", "_validate_overrides"]
22
+
23
+ _LEGEND_LOCS = frozenset({
24
+ "best", "upper right", "upper left", "lower left", "lower right",
25
+ "right", "center left", "center right", "lower center", "upper center",
26
+ "center", "outside right", "outside top", "custom",
27
+ })
28
+
29
+
30
+ def _validate_overrides(ov: Mapping[str, Any]) -> None:
31
+ """Raise ``ValueError`` on invalid override values (bad keys are ignored)."""
32
+ legend = ov.get("legend")
33
+ if legend is not None:
34
+ loc = legend.get("loc")
35
+ if loc is not None and loc not in _LEGEND_LOCS:
36
+ raise ValueError(f"legend loc must be one of {sorted(_LEGEND_LOCS)}")
37
+ ticks = ov.get("ticks")
38
+ if ticks is not None:
39
+ tdir = ticks.get("dir")
40
+ if tdir is not None and tdir not in ("in", "out"):
41
+ raise ValueError("ticks dir must be 'in' or 'out'")
42
+ for key in ("x_lim", "y_lim"):
43
+ lim = ov.get(key)
44
+ if lim is not None and (not isinstance(lim, (list, tuple)) or len(lim) != 2):
45
+ raise ValueError(f"{key} must be a [lo, hi] pair (null member = auto)")
46
+ margins = ov.get("margins")
47
+ if margins is not None:
48
+ for side in ("left", "right", "top", "bottom"):
49
+ v = margins.get(side)
50
+ if v is not None and not 0.0 <= float(v) <= 1.0:
51
+ raise ValueError("margins are figure fractions in [0, 1]")
52
+ breaks = ov.get("x_breaks")
53
+ if breaks is not None:
54
+ if not isinstance(breaks, (list, tuple)) or len(breaks) == 0:
55
+ raise ValueError("x_breaks must be a non-empty list of [lo, hi] pairs")
56
+ prev_hi: float | None = None
57
+ for b in breaks:
58
+ if not isinstance(b, (list, tuple)) or len(b) != 2:
59
+ raise ValueError("each x_breaks entry must be a [lo, hi] pair")
60
+ lo, hi = float(b[0]), float(b[1])
61
+ if not lo < hi:
62
+ raise ValueError("each x_breaks entry must have lo < hi")
63
+ if prev_hi is not None and lo < prev_hi:
64
+ raise ValueError("x_breaks entries must be sorted and non-overlapping")
65
+ prev_hi = hi
66
+
67
+
68
+ def _apply_overrides(
69
+ fig: Any, ax: Any, st: Any, ov: Mapping[str, Any], *, n_series: int
70
+ ) -> None:
71
+ """Apply the post-plot override properties (legend / ticks / spines /
72
+ limits / margins / grid / annotations). rc-level properties (fonts, tick
73
+ direction/length) are folded into the rc context by the caller."""
74
+ legend = ov.get("legend")
75
+ if legend is not None:
76
+ show = legend.get("show")
77
+ if (show is None and n_series > 1) or show:
78
+ frame = bool(legend.get("frame", st.legend_box))
79
+ loc = str(legend.get("loc", "best"))
80
+ kw: dict[str, Any] = {"frameon": frame, "fontsize": st.legend_font_size}
81
+ if loc == "outside right":
82
+ kw.update(loc="center left", bbox_to_anchor=(1.02, 0.5))
83
+ elif loc == "outside top":
84
+ kw.update(loc="lower center", bbox_to_anchor=(0.5, 1.02), ncols=max(1, n_series))
85
+ elif loc == "custom":
86
+ # #14 drag-to-place: anchor is a figure-fraction (fx, fy).
87
+ anchor = legend.get("anchor") or (0.5, 0.5)
88
+ kw.update(
89
+ loc="center",
90
+ bbox_to_anchor=(float(anchor[0]), float(anchor[1])),
91
+ bbox_transform=fig.transFigure,
92
+ )
93
+ else:
94
+ kw["loc"] = loc
95
+ ax.legend(**kw)
96
+ elif ax.get_legend() is not None:
97
+ ax.get_legend().remove()
98
+
99
+ ticks = ov.get("ticks")
100
+ if ticks is not None and ticks.get("minor"):
101
+ ax.minorticks_on()
102
+
103
+ spines = ov.get("spines")
104
+ if spines is not None:
105
+ for side in ("top", "right", "left", "bottom"):
106
+ if side in spines:
107
+ ax.spines[side].set_visible(bool(spines[side]))
108
+
109
+ for key, setter in (("x_lim", ax.set_xlim), ("y_lim", ax.set_ylim)):
110
+ lim = ov.get(key)
111
+ if lim is not None:
112
+ lo, hi = lim
113
+ setter(
114
+ None if lo is None else float(lo),
115
+ None if hi is None else float(hi),
116
+ )
117
+
118
+ if "grid" in ov:
119
+ ax.grid(bool(ov["grid"]), alpha=st.grid_alpha or 0.3)
120
+
121
+ for ann in ov.get("annotations", []):
122
+ # MAIN #18: a per-annotation `size` (the pointer tool's corner-handle
123
+ # font-size resize, screen px) wins over the property panel's global
124
+ # font_size override -- matches the screen, where each annotation's
125
+ # OWN size (Annotation.size) always overrides the plot's base font.
126
+ size = ann.get("size")
127
+ ann_kw: dict[str, Any] = {}
128
+ x, y = float(ann.get("x", 0.0)), float(ann.get("y", 0.0))
129
+ if ann.get("anchor") == "page":
130
+ # MAIN #21: page-anchored on screen (CANVAS fractions, x
131
+ # rightward/y DOWNWARD -- see Annotation.anchor's doc) -> figure
132
+ # fraction placement (x rightward/y UPWARD) rather than
133
+ # axes-data coords, so the label stays pinned to the same page
134
+ # position independent of the axes' data range. The Y AXIS IS
135
+ # FLIPPED between the two conventions -- canvas y=0 is the top,
136
+ # figure fraction y=0 is the bottom -- so `1 - y` is required,
137
+ # not optional. Canvas fractions and matplotlib figure fractions
138
+ # are NOT geometrically identical (the canvas includes the axes
139
+ # margins at a different proportion than matplotlib's own
140
+ # figure margins) -- this is deliberately Origin-parity-in-
141
+ # spirit (pin to the page, stay put through zoom/pan), not
142
+ # pixel-identical placement between screen and export.
143
+ ann_kw["xycoords"] = "figure fraction"
144
+ y = 1.0 - y
145
+ ax.annotate(
146
+ safe_mathtext_label(str(ann.get("text", ""))),
147
+ xy=(x, y),
148
+ fontsize=float(size) if size else float(ov.get("font_size", st.font_size)),
149
+ **ann_kw,
150
+ )
151
+
152
+ margins = ov.get("margins")
153
+ if margins is not None:
154
+ fig.subplots_adjust(
155
+ left=margins.get("left"),
156
+ right=None if margins.get("right") is None else 1.0 - float(margins["right"]),
157
+ top=None if margins.get("top") is None else 1.0 - float(margins["top"]),
158
+ bottom=margins.get("bottom"),
159
+ )
@@ -0,0 +1,266 @@
1
+ """Multi-panel figure page composition (GOTO #4): N different plots -> ONE page.
2
+
3
+ Pure layer: panel data in -> image bytes out. Composes pre-built panels (each
4
+ the same ``(x, series)`` payload ``calc.figure`` renders singly) onto a single
5
+ matplotlib page: a rows x cols grid with optional per-panel row/col spans,
6
+ journal-style panel labels ("(a)", "(b)", ... -- auto-generated in row-major
7
+ placement order, or overridden per panel), and ONE style preset applied
8
+ page-wide. This is the "Figure 1(a)-(d)" workflow with zero external
9
+ post-processing: PDF / SVG vector by default, PNG / TIFF raster at a chosen
10
+ DPI (the low-DPI PNG render doubles as the composer UI's preview image).
11
+
12
+ The per-axes rendering body is shared with the single-figure renderer via
13
+ ``figure.draw_series_axes``, so a panel on a page looks exactly like its
14
+ single-figure export. Every user-supplied string (panel titles, axis labels,
15
+ series labels, panel labels) is routed through the GOTO #5 rich-text guard
16
+ (``figure_labels.safe_mathtext_label``): valid ``$...$`` mathtext renders,
17
+ invalid markup degrades to literal text -- an export must never error on a
18
+ label. Two single-figure overrides are page-incompatible and rejected with a
19
+ clear ``ValueError`` (-> 422 at the route): per-panel ``x_breaks`` (the break
20
+ renderer owns its own figure) and per-panel ``margins`` (page layout is
21
+ constrained-layout, figure-level).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from collections.abc import Mapping, Sequence
27
+ from dataclasses import dataclass
28
+ from io import BytesIO
29
+ from typing import Any
30
+
31
+ import matplotlib
32
+
33
+ matplotlib.use("Agg") # headless: render to a buffer, never to a display
34
+
35
+ import matplotlib.pyplot as plt # noqa: E402 (must follow matplotlib.use)
36
+ import numpy as np # noqa: E402
37
+ from numpy.typing import ArrayLike # noqa: E402
38
+
39
+ from quantized.calc.figure import draw_series_axes, style_rc # noqa: E402
40
+ from quantized.calc.figure_labels import safe_mathtext_label # noqa: E402
41
+ from quantized.calc.figure_overrides import _validate_overrides # noqa: E402
42
+ from quantized.calc.figure_styles import FigureStyle, figure_style # noqa: E402
43
+
44
+ __all__ = ["PagePanel", "panel_label", "render_figure_page"]
45
+
46
+ _FORMATS = ("pdf", "svg", "png", "tiff")
47
+ _LABEL_POSITIONS = ("nw", "ne", "outside")
48
+ # Auto-label formats, keyed by the rendered form of the FIRST panel:
49
+ # (wrap template, uppercase letters?). "none" suppresses auto labels entirely.
50
+ _LABEL_TEMPLATES: dict[str, tuple[str, bool]] = {
51
+ "(a)": ("({})", False),
52
+ "a)": ("{})", False),
53
+ "a.": ("{}.", False),
54
+ "(A)": ("({})", True),
55
+ "A)": ("{})", True),
56
+ "A.": ("{}.", True),
57
+ }
58
+ # Grid cap: a journal page never needs more; guards absurd allocations.
59
+ _MAX_GRID = 8
60
+
61
+
62
+ def _letters(index: int) -> str:
63
+ """0 -> "a", 25 -> "z", 26 -> "aa", ... (spreadsheet-style rollover)."""
64
+ out = ""
65
+ n = index
66
+ while True:
67
+ out = chr(ord("a") + n % 26) + out
68
+ n = n // 26 - 1
69
+ if n < 0:
70
+ return out
71
+
72
+
73
+ def panel_label(index: int, label_format: str = "(a)") -> str:
74
+ """The auto-generated label for the ``index``-th panel (0-based, row-major
75
+ placement order): ``panel_label(1, "(a)") == "(b)"``. ``"none"`` returns
76
+ an empty string (no labels). Raises ``ValueError`` on an unknown format
77
+ or a negative index."""
78
+ if index < 0:
79
+ raise ValueError("panel index must be >= 0")
80
+ if label_format == "none":
81
+ return ""
82
+ try:
83
+ template, upper = _LABEL_TEMPLATES[label_format]
84
+ except KeyError as exc:
85
+ allowed = (*_LABEL_TEMPLATES, "none")
86
+ raise ValueError(f"label_format must be one of {allowed}") from exc
87
+ letters = _letters(index)
88
+ return template.format(letters.upper() if upper else letters)
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class PagePanel:
93
+ """One panel of a figure page: the same ``(x, series)`` payload
94
+ ``calc.figure`` renders singly, plus its grid placement. ``label=None``
95
+ means auto ("(a)", "(b)", ... in row-major placement order); ``label=""``
96
+ suppresses the label on this panel only."""
97
+
98
+ x: ArrayLike
99
+ series: Sequence[tuple[str, ArrayLike]]
100
+ row: int
101
+ col: int
102
+ row_span: int = 1
103
+ col_span: int = 1
104
+ title: str = ""
105
+ x_label: str = ""
106
+ y_label: str = ""
107
+ x_log: bool = False
108
+ y_log: bool = False
109
+ # MAIN #12: linear/log/reciprocal, source of truth when set; x_log/y_log
110
+ # are the back-compat fallback (see figure_scale.resolve_axis_scale).
111
+ x_scale: str | None = None
112
+ y_scale: str | None = None
113
+ # MAIN #24: tick-label number format ({"mode": ..., "digits": ...},
114
+ # AxisFormat-shaped) -- each panel carries its OWN, mirroring the screen
115
+ # (a figure page composes several independently-configured plot views).
116
+ x_fmt: Mapping[str, Any] | None = None
117
+ y_fmt: Mapping[str, Any] | None = None
118
+ series_styles: Sequence[Mapping[str, Any] | None] | None = None
119
+ overrides: Mapping[str, Any] | None = None
120
+ label: str | None = None
121
+
122
+
123
+ def _validate_page(rows: int, cols: int, panels: Sequence[PagePanel]) -> None:
124
+ """Raise ``ValueError`` on an invalid page spec: bad grid, empty page,
125
+ out-of-bounds or overlapping panels, page-incompatible overrides."""
126
+ if rows < 1 or cols < 1:
127
+ raise ValueError("page grid must have at least 1 row and 1 column")
128
+ if rows > _MAX_GRID or cols > _MAX_GRID:
129
+ raise ValueError(f"page grid is capped at {_MAX_GRID}x{_MAX_GRID}")
130
+ if not panels:
131
+ raise ValueError("page must contain at least one panel")
132
+ occupied: dict[tuple[int, int], int] = {}
133
+ for n, p in enumerate(panels):
134
+ if p.row_span < 1 or p.col_span < 1:
135
+ raise ValueError(f"panel {n}: row_span and col_span must be >= 1")
136
+ if p.row < 0 or p.col < 0 or p.row + p.row_span > rows or p.col + p.col_span > cols:
137
+ raise ValueError(
138
+ f"panel {n} does not fit the {rows}x{cols} grid (row={p.row} "
139
+ f"col={p.col} row_span={p.row_span} col_span={p.col_span})"
140
+ )
141
+ for r in range(p.row, p.row + p.row_span):
142
+ for c in range(p.col, p.col + p.col_span):
143
+ other = occupied.get((r, c))
144
+ if other is not None:
145
+ raise ValueError(f"panels {other} and {n} overlap at grid cell ({r}, {c})")
146
+ occupied[(r, c)] = n
147
+ ov = dict(p.overrides or {})
148
+ if "x_breaks" in ov:
149
+ raise ValueError(f"panel {n}: x_breaks is not supported on a figure page")
150
+ if "margins" in ov:
151
+ raise ValueError(
152
+ f"panel {n}: margins are page-level on a figure page; "
153
+ "remove the per-panel margins override"
154
+ )
155
+ _validate_overrides(ov)
156
+
157
+
158
+ def _place_label(ax: Any, text: str, pos: str, st: FigureStyle) -> None:
159
+ """Draw one panel label. ``nw``/``ne`` sit inside the axes at the top
160
+ corner; ``outside`` uses matplotlib's LEFT title slot above the axes,
161
+ which coexists with the panel's own (center) title -- the standard
162
+ journal placement."""
163
+ if not text:
164
+ return
165
+ size = float(st.title_font_size)
166
+ if pos == "outside":
167
+ ax.set_title(text, loc="left", fontweight="bold", fontsize=size)
168
+ elif pos == "ne":
169
+ ax.text(
170
+ 0.97, 0.96, text, transform=ax.transAxes,
171
+ ha="right", va="top", fontweight="bold", fontsize=size,
172
+ )
173
+ else: # "nw"
174
+ ax.text(
175
+ 0.03, 0.96, text, transform=ax.transAxes,
176
+ ha="left", va="top", fontweight="bold", fontsize=size,
177
+ )
178
+
179
+
180
+ def render_figure_page(
181
+ panels: Sequence[PagePanel],
182
+ *,
183
+ rows: int,
184
+ cols: int,
185
+ fmt: str = "pdf",
186
+ style: str = "default",
187
+ width_in: float | None = None,
188
+ height_in: float | None = None,
189
+ dpi: int | None = None,
190
+ label_format: str = "(a)",
191
+ label_pos: str = "nw",
192
+ ) -> bytes:
193
+ """Compose ``panels`` onto one rows x cols page and render to image bytes.
194
+
195
+ ``fmt`` is ``pdf`` / ``svg`` (vector, the default convention) or ``png`` /
196
+ ``tiff`` (raster at ``dpi``; ``None`` = the preset's calibrated dpi).
197
+ ``style`` names a publication preset applied page-wide (fonts, line
198
+ widths, box/ticks/grid). Page size: ``width_in`` defaults to the preset's
199
+ figure width -- the journal-column convention the preset encodes (``aps``
200
+ 8.6 cm ~ 3.39 in single column; ``aps_double`` 17.8 cm ~ 7.0 in double
201
+ column, the APS sizes) -- and ``height_in`` defaults so each grid cell
202
+ keeps the preset's own aspect ratio. ``label_format`` / ``label_pos``
203
+ control the auto panel labels (see :func:`panel_label`, :func:`_place_label`);
204
+ a panel's explicit ``label`` wins over the auto sequence. Raises
205
+ ``ValueError`` on any invalid spec (unknown format/style/label options,
206
+ empty grid, out-of-bounds or overlapping panels).
207
+ """
208
+ if fmt not in _FORMATS:
209
+ raise ValueError(f"fmt must be one of {_FORMATS}")
210
+ if label_pos not in _LABEL_POSITIONS:
211
+ raise ValueError(f"label_pos must be one of {_LABEL_POSITIONS}")
212
+ if label_format != "none" and label_format not in _LABEL_TEMPLATES:
213
+ allowed = (*_LABEL_TEMPLATES, "none")
214
+ raise ValueError(f"label_format must be one of {allowed}")
215
+ _validate_page(rows, cols, panels)
216
+ st = figure_style(style)
217
+ resolved_dpi = int(dpi) if dpi is not None else int(st.dpi)
218
+
219
+ w = float(width_in) if width_in is not None else st.fig_width_in
220
+ h = (
221
+ float(height_in)
222
+ if height_in is not None
223
+ else w * (st.fig_height_in / st.fig_width_in) * (rows / cols)
224
+ )
225
+ if w <= 0 or h <= 0:
226
+ raise ValueError("width_in and height_in must be positive")
227
+
228
+ # Row-major placement order defines the auto-label sequence.
229
+ ordered = sorted(panels, key=lambda p: (p.row, p.col))
230
+ # (matplotlib's RcParams Literal-key type is impractical with the dynamic
231
+ # font.<generic> key -- same targeted ignore as calc.figure.)
232
+ with matplotlib.rc_context(style_rc(st, {})): # type: ignore[arg-type]
233
+ fig = plt.figure(figsize=(w, h), layout="constrained")
234
+ try:
235
+ gs = fig.add_gridspec(rows, cols)
236
+ for idx, p in enumerate(ordered):
237
+ ax = fig.add_subplot(
238
+ gs[p.row : p.row + p.row_span, p.col : p.col + p.col_span]
239
+ )
240
+ # Rich-text guard (GOTO #5) on every user string; see figure.py.
241
+ series = [(safe_mathtext_label(label), y) for label, y in p.series]
242
+ draw_series_axes(
243
+ fig,
244
+ ax,
245
+ np.asarray(p.x, dtype=float),
246
+ series,
247
+ st=st,
248
+ ov=dict(p.overrides or {}),
249
+ x_log=p.x_log,
250
+ y_log=p.y_log,
251
+ x_scale=p.x_scale,
252
+ y_scale=p.y_scale,
253
+ title=safe_mathtext_label(p.title),
254
+ x_label=safe_mathtext_label(p.x_label),
255
+ y_label=safe_mathtext_label(p.y_label),
256
+ series_styles=p.series_styles,
257
+ x_fmt=p.x_fmt,
258
+ y_fmt=p.y_fmt,
259
+ )
260
+ text = p.label if p.label is not None else panel_label(idx, label_format)
261
+ _place_label(ax, safe_mathtext_label(text), label_pos, st)
262
+ buf = BytesIO()
263
+ fig.savefig(buf, format=fmt, dpi=resolved_dpi)
264
+ return buf.getvalue()
265
+ finally:
266
+ plt.close(fig)
@@ -0,0 +1,125 @@
1
+ """Axis-scale resolution + the reciprocal (1/x) transform for matplotlib
2
+ export (MAIN #12 -- Arrhenius-style plots: ln(rho) or log tau vs 1/T). Pure
3
+ layer. Split out of ``calc.figure`` purely to stay under the 500-line
4
+ god-module ceiling (mirrors ``figure_break``/``figure_overrides``).
5
+
6
+ matplotlib has no built-in reciprocal scale, so this applies one via
7
+ ``Axes.set_xscale("function", functions=(f, finv))`` -- matplotlib's own
8
+ documented custom-scale hook (``matplotlib.scale.FuncScale``) -- with the
9
+ self-inverse transform ``f(v) = 1/v`` (``f(f(v)) == v`` for ``v != 0``, so one
10
+ function serves as both the forward and inverse transform) plus a tick
11
+ locator that places ticks at "nice" values evenly spaced IN 1/x SPACE,
12
+ returned in the ORIGINAL x units. That mirrors the screen-side reciprocal
13
+ scale (``frontend/src/lib/uplotOpts.ts``'s ``reciprocalTransform`` /
14
+ ``reciprocalAxisSplits``): the axis POSITIONS by 1/x, but tick LABELS still
15
+ read the natural variable (e.g. T in Kelvin) -- Origin's "Reciprocal" axis
16
+ type convention.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from typing import Any
22
+
23
+ import numpy as np
24
+ from matplotlib.ticker import Locator
25
+ from numpy.typing import NDArray
26
+
27
+ __all__ = ["apply_axis_scale", "reciprocal_tick_values", "resolve_axis_scale"]
28
+
29
+ _SCALES = ("linear", "log", "reciprocal")
30
+
31
+
32
+ def resolve_axis_scale(explicit: str | None, legacy_log: bool) -> str:
33
+ """MAIN #12 back-compat bridge: an explicit ``x_scale``/``y_scale`` (a
34
+ new caller) wins when it's one of the 3 valid tokens; otherwise fall back
35
+ to the old ``x_log``/``y_log`` boolean (``True`` -> ``"log"``, ``False``
36
+ -> ``"linear"``) -- the same convention as the frontend's
37
+ ``lib/plotview.ts``'s ``scaleFromLog``."""
38
+ if explicit in _SCALES:
39
+ return explicit
40
+ return "log" if legacy_log else "linear"
41
+
42
+
43
+ def _reciprocal(v: NDArray[np.float64] | float) -> NDArray[np.float64]:
44
+ """``1/v``, self-inverse. Non-positive input degrades to ``NaN`` --
45
+ matplotlib omits a NaN point from the drawn view rather than erroring --
46
+ the SAME domain restriction the log scale already has (physically apt
47
+ for the Arrhenius case: T in Kelvin is always positive)."""
48
+ arr = np.asarray(v, dtype=float)
49
+ out = np.full_like(arr, np.nan)
50
+ pos = arr > 0
51
+ out[pos] = 1.0 / arr[pos]
52
+ return out
53
+
54
+
55
+ def _nice_step(raw: float) -> float:
56
+ """A nice round step >= raw, snapped to 1/2/5/10 x 10^n (mirrors the
57
+ frontend's ``niceLinearStep``)."""
58
+ if raw <= 0 or not np.isfinite(raw):
59
+ return 1.0
60
+ mag = float(10.0 ** np.floor(np.log10(raw)))
61
+ norm = raw / mag
62
+ snapped = 1.0 if norm < 1.5 else 2.0 if norm < 3.0 else 5.0 if norm < 7.0 else 10.0
63
+ return snapped * mag
64
+
65
+
66
+ def reciprocal_tick_values(vmin: float, vmax: float, target: int = 5) -> list[float]:
67
+ """Reciprocal-axis tick positions for a ``[vmin, vmax]`` data-space range:
68
+ "nice" values evenly spaced in 1/x space, mapped back to ORIGINAL x units
69
+ (mirrors the frontend's ``reciprocalAxisSplits``). Degenerate ranges
70
+ (non-positive, or inverted/zero-width) return ``[]``."""
71
+ if not (vmin > 0) or not (vmax > vmin):
72
+ return []
73
+ r0, r1 = 1.0 / vmin, 1.0 / vmax
74
+ lo, hi = min(r0, r1), max(r0, r1)
75
+ if not (hi > lo):
76
+ return [vmin, vmax]
77
+ step = _nice_step((hi - lo) / max(1, target))
78
+ eps = 1e-9
79
+ n0 = int(np.ceil(lo / step - eps))
80
+ n1 = int(np.floor(hi / step + eps))
81
+ out: list[float] = []
82
+ for n in range(n0, n1 + 1):
83
+ r = n * step
84
+ if r == 0:
85
+ continue # 1/0 is undefined -- skip the (rare) exact-zero tick
86
+ v = float(np.round(1.0 / r, 10))
87
+ if vmin * (1 - eps) <= v <= vmax * (1 + eps):
88
+ out.append(v)
89
+ return sorted(out)
90
+
91
+
92
+ class _ReciprocalLocator(Locator):
93
+ """Matplotlib tick locator for a reciprocal-scaled axis: ticks land at
94
+ "nice" 1/x-evenly-spaced values, returned in ORIGINAL x units (see
95
+ :func:`reciprocal_tick_values`). ``self.axis`` is set by matplotlib when
96
+ the locator is attached via ``set_major_locator``."""
97
+
98
+ def tick_values(self, vmin: float, vmax: float) -> list[float]:
99
+ return reciprocal_tick_values(min(vmin, vmax), max(vmin, vmax))
100
+
101
+ def __call__(self) -> list[float]:
102
+ if self.axis is None:
103
+ return []
104
+ vmin, vmax = self.axis.get_view_interval()
105
+ return self.tick_values(vmin, vmax)
106
+
107
+
108
+ def apply_axis_scale(ax: Any, axis: str, scale: str) -> None:
109
+ """Apply ``scale`` (``"linear"``/``"log"``/``"reciprocal"``) to ``ax``'s
110
+ x or y axis (``axis`` is ``"x"`` or ``"y"``). ``"linear"`` is a no-op
111
+ (matplotlib's own default). ``"reciprocal"`` uses the ``"function"``
112
+ scale (matplotlib's documented custom-scale hook,
113
+ :class:`matplotlib.scale.FuncScale`) with the self-inverse 1/x transform
114
+ plus :class:`_ReciprocalLocator` for reciprocal-spaced ticks."""
115
+ set_scale = ax.set_xscale if axis == "x" else ax.set_yscale
116
+ if scale == "log":
117
+ set_scale("log")
118
+ return
119
+ if scale == "reciprocal":
120
+ set_scale("function", functions=(_reciprocal, _reciprocal))
121
+ locator = _ReciprocalLocator()
122
+ if axis == "x":
123
+ ax.xaxis.set_major_locator(locator)
124
+ else:
125
+ ax.yaxis.set_major_locator(locator)