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,303 @@
1
+ """Headless calculator registry — the scripting/automation entry point for the
2
+ DiraCulator calculator domains (the Python analogue of MATLAB ``api = DiraCulator()``).
3
+
4
+ The ``calc/`` domain modules are already pure and headless; this module gives them
5
+ a **single, discoverable, stable surface**: a name → pure-function catalog so any
6
+ calculation can be listed, described, and invoked by name with a params dict —
7
+ without the GUI, the HTTP routes, or knowing each module's import path.
8
+
9
+ >>> from quantized.calc.registry import call_calculator, list_calculators
10
+ >>> call_calculator("crystal.d_spacing", {"system": "cubic",
11
+ ... "a": 5.4309, "b": 5.4309, "c": 5.4309, "h": 1, "k": 1, "l": 1})["d"]
12
+ 3.135...
13
+
14
+ Discovery:
15
+
16
+ >>> [op["name"] for op in list_calculators(domain="xray")]
17
+ ['xray.bragg_d_spacing', 'xray.bragg_two_theta', 'xray.q_from_two_theta', ...]
18
+ >>> describe_calculator("xray.bragg_d_spacing")["params"] # signature params
19
+ [{'name': 'two_theta', 'required': True, 'default': None}, ...]
20
+
21
+ Pure layer: imports only ``quantized.calc.*`` (no fastapi/pydantic). The thin
22
+ ``routes/calc.py`` adapter exposes the same catalog + call over HTTP; a CLI could
23
+ wrap it identically. Operation names are ``<domain>.<operation>`` and are stable —
24
+ treat them as API.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import inspect
30
+ import math
31
+ from collections.abc import Callable
32
+ from dataclasses import dataclass
33
+ from typing import Any
34
+
35
+ from quantized.calc import (
36
+ constants,
37
+ crystallography,
38
+ diffusion,
39
+ electrical,
40
+ electrochemistry,
41
+ magnetic,
42
+ optics,
43
+ semiconductor,
44
+ sld_formula,
45
+ substrates,
46
+ superconductor,
47
+ thermal,
48
+ thin_film,
49
+ unit_convert,
50
+ vacuum,
51
+ xray,
52
+ )
53
+
54
+ __all__ = [
55
+ "CALCULATORS",
56
+ "CalcOp",
57
+ "call_calculator",
58
+ "describe_calculator",
59
+ "list_calculators",
60
+ ]
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class CalcOp:
65
+ """One headless calculator operation: a stable name → pure function.
66
+
67
+ ``name`` is ``<domain>.<operation>``; ``fn`` is the pure ``calc`` callable it
68
+ dispatches to. The human summary is derived from the function's docstring, so
69
+ it never drifts from the implementation.
70
+ """
71
+
72
+ name: str
73
+ domain: str
74
+ fn: Callable[..., Any]
75
+
76
+ @property
77
+ def summary(self) -> str:
78
+ doc = (self.fn.__doc__ or "").strip()
79
+ return doc.splitlines()[0].strip() if doc else ""
80
+
81
+
82
+ def _ops(domain: str, module: object, names: list[str]) -> list[CalcOp]:
83
+ """Register ``<domain>.<name>`` → ``module.<name>`` for each name."""
84
+ return [CalcOp(f"{domain}.{n}", domain, getattr(module, n)) for n in names]
85
+
86
+
87
+ # The curated catalog. Operation names are stable public API; the second segment
88
+ # is the calc function name (so a name maps predictably to its source), except the
89
+ # two aliased singletons (units.convert / constants.list).
90
+ _OPS: list[CalcOp] = [
91
+ CalcOp("units.convert", "units", unit_convert.unit_convert),
92
+ CalcOp("constants.list", "constants", constants.constants),
93
+ *_ops(
94
+ "xray",
95
+ xray,
96
+ ["bragg_d_spacing", "bragg_two_theta", "q_from_two_theta", "two_theta_from_q", "xray_calc"],
97
+ ),
98
+ *_ops(
99
+ "crystal",
100
+ crystallography,
101
+ ["d_spacing", "cell_volume", "theoretical_density", "plane_spacings"],
102
+ ),
103
+ *_ops("sld", sld_formula, ["sld_from_formula"]),
104
+ *_ops(
105
+ "electrical",
106
+ electrical,
107
+ [
108
+ "resistivity",
109
+ "sheet_resistance",
110
+ "conductivity",
111
+ "mobility",
112
+ "current_density",
113
+ "hall_single_point",
114
+ "hall_analysis",
115
+ "wiedemann_franz",
116
+ ],
117
+ ),
118
+ *_ops("thermal", thermal, ["wiedemann_franz", "debye_temperature", "thermal_diffusivity"]),
119
+ *_ops("diffusion", diffusion, ["arrhenius", "diffusion_length", "fick_flux"]),
120
+ *_ops(
121
+ "optics",
122
+ optics,
123
+ [
124
+ "fresnel_coefficients",
125
+ "critical_angle",
126
+ "brewster_angle",
127
+ "penetration_depth",
128
+ "skin_depth",
129
+ "dielectric_to_refractive",
130
+ "refractive_to_dielectric",
131
+ ],
132
+ ),
133
+ *_ops(
134
+ "vacuum",
135
+ vacuum,
136
+ [
137
+ "mean_free_path",
138
+ "monolayer_time",
139
+ "knudsen_number",
140
+ "pump_down_time",
141
+ "sputter_yield",
142
+ "gas_flow",
143
+ ],
144
+ ),
145
+ *_ops(
146
+ "electrochemistry",
147
+ electrochemistry,
148
+ [
149
+ "nernst_potential",
150
+ "butler_volmer",
151
+ "tafel_slope",
152
+ "ohmic_drop",
153
+ "double_layer_capacitance",
154
+ ],
155
+ ),
156
+ *_ops(
157
+ "substrates",
158
+ substrates,
159
+ ["get_substrate", "list_substrates", "lattice_mismatch", "substrate_table"],
160
+ ),
161
+ *_ops(
162
+ "semiconductor",
163
+ semiconductor,
164
+ [
165
+ "intrinsic_carrier_conc",
166
+ "carrier_concentration",
167
+ "fermi_level",
168
+ "built_in_potential",
169
+ "depletion_width",
170
+ "debye_length",
171
+ "hall_coefficient",
172
+ "mobility_model",
173
+ "thermal_velocity",
174
+ "sheet_carrier_density",
175
+ "diffusion_coeff",
176
+ "diffusion_length",
177
+ "dos_effective_mass",
178
+ "material_presets",
179
+ ],
180
+ ),
181
+ *_ops(
182
+ "superconductor",
183
+ superconductor,
184
+ [
185
+ "london_depth",
186
+ "coherence_length",
187
+ "gl_parameter",
188
+ "critical_fields",
189
+ "depairing_current",
190
+ "bcs_gap",
191
+ "material_presets",
192
+ ],
193
+ ),
194
+ *_ops(
195
+ "thinfilm",
196
+ thin_film,
197
+ [
198
+ "deposition_rate",
199
+ "sputter_rate",
200
+ "kiessig_thickness",
201
+ "stoney_stress",
202
+ "projected_range",
203
+ "multilayer_thermal_conductivity",
204
+ "thermal_mismatch_strain",
205
+ "diffusion_length_thermal",
206
+ "dose_from_current",
207
+ "dose_to_concentration",
208
+ ],
209
+ ),
210
+ *_ops(
211
+ "magnetic",
212
+ magnetic,
213
+ [
214
+ "moment_convert",
215
+ "bohr_magneton_convert",
216
+ "demag_factor",
217
+ "demag_named",
218
+ "curie_weiss_moment",
219
+ "curie_weiss_fit",
220
+ "langevin",
221
+ "magnetization",
222
+ "domain_wall",
223
+ "moment_per_atom",
224
+ ],
225
+ ),
226
+ ]
227
+
228
+ CALCULATORS: dict[str, CalcOp] = {op.name: op for op in _OPS}
229
+
230
+ # Stable-name invariant: no domain accidentally registers two ops under one name.
231
+ assert len(CALCULATORS) == len(_OPS), "duplicate calculator operation name"
232
+
233
+ DOMAINS: tuple[str, ...] = tuple(dict.fromkeys(op.domain for op in _OPS))
234
+
235
+
236
+ def _jsonable(value: Any) -> Any:
237
+ """A signature default reduced to a JSON-safe form (or ``str`` fallback)."""
238
+ if value is None or isinstance(value, bool | int | str):
239
+ return value
240
+ if isinstance(value, float):
241
+ return None if math.isnan(value) else value
242
+ return str(value)
243
+
244
+
245
+ def _param_specs(fn: Callable[..., Any]) -> list[dict[str, Any]]:
246
+ """Positional/keyword params of ``fn`` (name, required, default) for discovery."""
247
+ specs: list[dict[str, Any]] = []
248
+ for name, p in inspect.signature(fn).parameters.items():
249
+ if p.kind in (p.VAR_POSITIONAL, p.VAR_KEYWORD):
250
+ continue
251
+ required = p.default is inspect.Parameter.empty
252
+ specs.append(
253
+ {
254
+ "name": name,
255
+ "required": required,
256
+ "default": None if required else _jsonable(p.default),
257
+ }
258
+ )
259
+ return specs
260
+
261
+
262
+ def list_calculators(domain: str | None = None) -> list[dict[str, Any]]:
263
+ """The catalog: ``{name, domain, summary}`` for every op (optionally one domain)."""
264
+ ops = _OPS if domain is None else [op for op in _OPS if op.domain == domain]
265
+ return [{"name": op.name, "domain": op.domain, "summary": op.summary} for op in ops]
266
+
267
+
268
+ def describe_calculator(name: str) -> dict[str, Any]:
269
+ """Full description of one op: name, domain, summary, and its signature params."""
270
+ op = CALCULATORS.get(name)
271
+ if op is None:
272
+ raise KeyError(_unknown_message(name))
273
+ return {
274
+ "name": op.name,
275
+ "domain": op.domain,
276
+ "summary": op.summary,
277
+ "params": _param_specs(op.fn),
278
+ }
279
+
280
+
281
+ def call_calculator(name: str, params: dict[str, Any] | None = None) -> Any:
282
+ """Invoke a calculator by name with a params dict (``fn(**params)``).
283
+
284
+ Raises ``KeyError`` for an unknown ``name``, and ``ValueError`` for invalid
285
+ parameters (a ``TypeError`` from the call — missing/extra/misnamed args) or a
286
+ domain-validation failure raised by the calc function itself.
287
+ """
288
+ op = CALCULATORS.get(name)
289
+ if op is None:
290
+ raise KeyError(_unknown_message(name))
291
+ kwargs = params or {}
292
+ try:
293
+ return op.fn(**kwargs)
294
+ except TypeError as exc:
295
+ expected = ", ".join(s["name"] for s in _param_specs(op.fn)) or "(none)"
296
+ raise ValueError(f"invalid parameters for {name!r} ({exc}); expected: {expected}") from exc
297
+
298
+
299
+ def _unknown_message(name: str) -> str:
300
+ return (
301
+ f"unknown calculator {name!r}; "
302
+ f"see list_calculators() for the {len(CALCULATORS)} available"
303
+ )
@@ -0,0 +1,119 @@
1
+ """Magnetic relaxation model comparison. Port of utilities.compareRelaxation.
2
+
3
+ Pure calc layer. Fits Arrhenius (lnτ = lnτ₀ + Ea/(kB·T), linear/closed-form) and
4
+ Vogel-Fulcher-Tammann (lnτ = lnτ₀ + Ea/(kB·(T-T₀)), nonlinear Nelder-Mead) to
5
+ relaxation-time vs temperature data, then ranks them by AIC/BIC.
6
+
7
+ Parity note: the Arrhenius fit and its metrics are exact; the VFT fit uses
8
+ Nelder-Mead, so scipy and MATLAB ``fminsearch`` may land at marginally different
9
+ points — the VFT parameters are matched only to a loose tolerance.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import math
15
+ from typing import Any
16
+
17
+ import numpy as np
18
+ from numpy.typing import ArrayLike, NDArray
19
+ from scipy.optimize import minimize
20
+
21
+ __all__ = ["compare_relaxation"]
22
+
23
+ _KB = 8.617333e-5 # Boltzmann constant, eV/K
24
+ _EPS = float(np.finfo(float).eps)
25
+
26
+
27
+ def _compute_metrics(
28
+ y: NDArray[np.float64], residuals: NDArray[np.float64], n_params: int
29
+ ) -> tuple[float, float, float]:
30
+ """Return (AIC, BIC, R²) for a fit. Port of the local computeMetrics helper."""
31
+ n = y.size
32
+ rss = float(np.sum(residuals**2))
33
+ tss = float(np.sum((y - np.mean(y)) ** 2))
34
+ r2 = 1.0 - rss / max(tss, _EPS)
35
+ if rss < _EPS:
36
+ return float("-inf"), float("-inf"), r2
37
+ log_l = n * math.log(rss / n)
38
+ return log_l + 2 * n_params, log_l + n_params * math.log(n), r2
39
+
40
+
41
+ def _vft_rss(
42
+ p: NDArray[np.float64], t: NDArray[np.float64], lntau_obs: NDArray[np.float64], tmin: float
43
+ ) -> float:
44
+ """VFT sum-of-squared residuals with the MATLAB penalty walls."""
45
+ lntau0, ea, t0 = float(p[0]), float(p[1]), float(p[2])
46
+ if t0 >= tmin - 0.5:
47
+ return 1e12 * (1.0 + abs(t0 - tmin + 0.5))
48
+ denom = _KB * (t - t0)
49
+ if np.any(denom <= 0):
50
+ return 1e12
51
+ pred = lntau0 + ea / denom
52
+ return float(np.sum((lntau_obs - pred) ** 2))
53
+
54
+
55
+ def compare_relaxation(
56
+ temperature: ArrayLike, relaxation_time: ArrayLike
57
+ ) -> dict[str, Any]:
58
+ """Compare Arrhenius vs VFT relaxation fits. Port of utilities.compareRelaxation.
59
+
60
+ Returns ``{"arrhenius", "vft", "preferred", "deltaAIC", "deltaBIC"}``. Each
61
+ model sub-dict carries tau0, Ea_eV, R², AIC, BIC (VFT also T0). ``preferred``
62
+ is "VFT" when ΔBIC > 0 (VFT lower BIC), else "Arrhenius".
63
+ """
64
+ t = np.asarray(temperature, dtype=float).ravel()
65
+ tau = np.asarray(relaxation_time, dtype=float).ravel()
66
+ n = t.size
67
+ if n != tau.size:
68
+ raise ValueError("temperature and relaxation_time must have the same length")
69
+ if n < 5:
70
+ raise ValueError("at least 5 data points are required")
71
+ if np.any(tau <= 0):
72
+ raise ValueError("all relaxation times must be positive")
73
+ if np.any(t <= 0):
74
+ raise ValueError("all temperatures must be positive (K)")
75
+
76
+ lntau = np.log(tau)
77
+ xmat = np.column_stack([np.ones(n), 1.0 / t])
78
+ b = np.linalg.lstsq(xmat, lntau, rcond=None)[0]
79
+ aic_a, bic_a, r2_a = _compute_metrics(lntau, lntau - xmat @ b, 2)
80
+ arrhenius = {
81
+ "tau0": math.exp(b[0]),
82
+ "Ea_eV": float(b[1] * _KB),
83
+ "R2": r2_a,
84
+ "AIC": aic_a,
85
+ "BIC": bic_a,
86
+ }
87
+
88
+ tmin = float(t.min())
89
+ p0 = np.array([b[0], b[1] * _KB, max(0.0, tmin * 0.5)])
90
+ res = minimize(
91
+ _vft_rss,
92
+ p0,
93
+ args=(t, lntau, tmin),
94
+ method="Nelder-Mead",
95
+ options={"maxiter": 20000, "maxfev": 50000, "xatol": 1e-10, "fatol": 1e-12},
96
+ )
97
+ p_vft = np.asarray(res.x, dtype=float)
98
+ t0_vft = min(float(p_vft[2]), tmin - 1.0)
99
+ ea_vft = float(p_vft[1])
100
+ lntau_vft_fit = p_vft[0] + ea_vft / (_KB * np.maximum(t - t0_vft, _EPS))
101
+ aic_v, bic_v, r2_v = _compute_metrics(lntau, lntau - lntau_vft_fit, 3)
102
+ vft = {
103
+ "tau0": math.exp(p_vft[0]),
104
+ "Ea_eV": ea_vft,
105
+ "T0": t0_vft,
106
+ "R2": r2_v,
107
+ "AIC": aic_v,
108
+ "BIC": bic_v,
109
+ }
110
+
111
+ delta_aic = aic_a - aic_v
112
+ delta_bic = bic_a - bic_v
113
+ return {
114
+ "arrhenius": arrhenius,
115
+ "vft": vft,
116
+ "preferred": "VFT" if delta_bic > 0 else "Arrhenius",
117
+ "deltaAIC": delta_aic,
118
+ "deltaBIC": delta_bic,
119
+ }
@@ -0,0 +1,253 @@
1
+ """Structured report sheets: the serializable substrate for exports & batch.
2
+
3
+ ORIGIN_GAP_PLAN #36 (W7 contract item). A :class:`ReportSheet` is plain,
4
+ diffable, JSON-round-trippable data (like ``DataStruct``) — never markup. Curve
5
+ fits, peak fits, W5 stats, the peak wizard, and batch runs all emit one (see
6
+ :mod:`quantized.calc.report_emit`); the #37/#38 exporters (docx / pptx / LaTeX /
7
+ HTML) and the future frontend viewer render the SAME schema with no
8
+ per-renderer special cases.
9
+
10
+ Structure — a report is a title + optional source references + an ordered list
11
+ of sections; each section is a title + an ordered list of typed blocks. Blocks
12
+ are a small closed set discriminated by ``"type"``::
13
+
14
+ text {"type": "text", "text": str}
15
+ table {"type": "table", "columns": [str], "rows": [[cell]], "caption"?}
16
+ params {"type": "params", "params": [{name, value, error?, unit?}], "caption"?}
17
+ figure {"type": "figure", "name": str, "image"?: {mime, data}, "caption"?}
18
+
19
+ A table cell is ``str | float | int | None``. Builders validate and normalize
20
+ on the way in (numpy scalars -> python scalars); :func:`validate_report`
21
+ re-checks a decoded dict so a round-tripped or hand-authored report is safe to
22
+ hand to any renderer.
23
+
24
+ Pure layer — no fastapi/pydantic imports (enforced by test_repo_integrity).
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import math
31
+ from collections.abc import Iterable, Mapping, Sequence
32
+ from dataclasses import dataclass, field
33
+ from typing import Any
34
+
35
+ __all__ = [
36
+ "BLOCK_TYPES",
37
+ "ReportSheet",
38
+ "figure_block",
39
+ "params_block",
40
+ "section",
41
+ "source_ref",
42
+ "table_block",
43
+ "text_block",
44
+ "validate_report",
45
+ ]
46
+
47
+ BLOCK_TYPES = ("text", "table", "params", "figure")
48
+
49
+
50
+ def _cell(value: Any) -> str | float | int | None:
51
+ """Coerce a table cell to a JSON scalar (numpy scalars -> python)."""
52
+ if value is None:
53
+ return None
54
+ if isinstance(value, bool): # keep bool out of the int branch
55
+ return str(value)
56
+ if isinstance(value, (int, float)):
57
+ v = float(value)
58
+ return None if not math.isfinite(v) else value
59
+ # numpy scalar or anything else -> try float, else str
60
+ try:
61
+ return float(value)
62
+ except (TypeError, ValueError):
63
+ return str(value)
64
+
65
+
66
+ # ── Block builders ────────────────────────────────────────────────────────
67
+ def text_block(text: str) -> dict[str, Any]:
68
+ """A free-text note block."""
69
+ return {"type": "text", "text": str(text)}
70
+
71
+
72
+ def table_block(
73
+ columns: Sequence[str],
74
+ rows: Iterable[Sequence[Any]],
75
+ *,
76
+ caption: str | None = None,
77
+ ) -> dict[str, Any]:
78
+ """A generic table: column headers + a grid of scalar cells.
79
+
80
+ Covers ANOVA / post-hoc / descriptive-stats / peak tables — anything
81
+ rectangular. Every row must match the column count.
82
+ """
83
+ cols = [str(c) for c in columns]
84
+ out_rows: list[list[str | float | int | None]] = []
85
+ for r in rows:
86
+ r = list(r)
87
+ if len(r) != len(cols):
88
+ raise ValueError(f"row has {len(r)} cells, expected {len(cols)}")
89
+ out_rows.append([_cell(v) for v in r])
90
+ block: dict[str, Any] = {"type": "table", "columns": cols, "rows": out_rows}
91
+ if caption:
92
+ block["caption"] = str(caption)
93
+ return block
94
+
95
+
96
+ def params_block(
97
+ params: Iterable[Mapping[str, Any]],
98
+ *,
99
+ caption: str | None = None,
100
+ ) -> dict[str, Any]:
101
+ """A fitted-parameter block: ordered ``{name, value, error?, unit?}`` rows.
102
+
103
+ Kept distinct from a plain table so renderers can format value ± error to
104
+ the precision implied by the uncertainty (the LaTeX/booktabs path, #38).
105
+ """
106
+ out: list[dict[str, Any]] = []
107
+ for p in params:
108
+ # non-finite value/error -> None/omitted, so to_json() stays valid wire
109
+ # JSON (consistent with _cell / DataStruct's null-at-the-boundary rule).
110
+ val = float(p["value"])
111
+ entry: dict[str, Any] = {
112
+ "name": str(p["name"]),
113
+ "value": val if math.isfinite(val) else None,
114
+ }
115
+ err = p.get("error")
116
+ if err is not None and math.isfinite(float(err)):
117
+ entry["error"] = float(err)
118
+ unit = p.get("unit")
119
+ if unit:
120
+ entry["unit"] = str(unit)
121
+ out.append(entry)
122
+ block: dict[str, Any] = {"type": "params", "params": out}
123
+ if caption:
124
+ block["caption"] = str(caption)
125
+ return block
126
+
127
+
128
+ def figure_block(
129
+ name: str,
130
+ *,
131
+ image: Mapping[str, str] | None = None,
132
+ caption: str | None = None,
133
+ ) -> dict[str, Any]:
134
+ """A figure reference, optionally carrying an embedded image.
135
+
136
+ ``image`` is ``{"mime": "image/png"|"image/svg+xml"|..., "data": <base64>}``
137
+ so an exporter can embed the figure without re-rendering; without it the
138
+ block is a pure reference (``name`` points at a FigureDoc, #12).
139
+ """
140
+ block: dict[str, Any] = {"type": "figure", "name": str(name)}
141
+ if image is not None:
142
+ if "mime" not in image or "data" not in image:
143
+ raise ValueError("figure image needs 'mime' and 'data' keys")
144
+ block["image"] = {"mime": str(image["mime"]), "data": str(image["data"])}
145
+ if caption:
146
+ block["caption"] = str(caption)
147
+ return block
148
+
149
+
150
+ def section(title: str, blocks: Iterable[Mapping[str, Any]]) -> dict[str, Any]:
151
+ """A titled, ordered group of blocks."""
152
+ return {"title": str(title), "blocks": [dict(b) for b in blocks]}
153
+
154
+
155
+ def source_ref(kind: str, ref_id: str, name: str | None = None) -> dict[str, Any]:
156
+ """A pointer back to a source object (dataset / fit / figure id)."""
157
+ ref: dict[str, Any] = {"kind": str(kind), "id": str(ref_id)}
158
+ if name:
159
+ ref["name"] = str(name)
160
+ return ref
161
+
162
+
163
+ # ── Validation ──────────────────────────────────────────────────────────────
164
+ def _validate_block(block: Mapping[str, Any], where: str) -> None:
165
+ if not isinstance(block, Mapping):
166
+ raise ValueError(f"{where}: block must be an object")
167
+ btype = block.get("type")
168
+ if btype not in BLOCK_TYPES:
169
+ raise ValueError(f"{where}: unknown block type {btype!r}")
170
+ if btype == "text":
171
+ if not isinstance(block.get("text"), str):
172
+ raise ValueError(f"{where}: text block needs a string 'text'")
173
+ elif btype == "table":
174
+ cols = block.get("columns")
175
+ rows = block.get("rows")
176
+ if not isinstance(cols, list) or not isinstance(rows, list):
177
+ raise ValueError(f"{where}: table needs list 'columns' and 'rows'")
178
+ for r in rows:
179
+ if not isinstance(r, list) or len(r) != len(cols):
180
+ raise ValueError(f"{where}: every table row must match the column count")
181
+ elif btype == "params":
182
+ ps = block.get("params")
183
+ if not isinstance(ps, list):
184
+ raise ValueError(f"{where}: params block needs a list 'params'")
185
+ for p in ps:
186
+ if not isinstance(p, Mapping) or "name" not in p or "value" not in p:
187
+ raise ValueError(f"{where}: each param needs 'name' and 'value'")
188
+ elif btype == "figure": # noqa: SIM102
189
+ if not isinstance(block.get("name"), str):
190
+ raise ValueError(f"{where}: figure block needs a string 'name'")
191
+
192
+
193
+ def validate_report(payload: Mapping[str, Any]) -> None:
194
+ """Raise ``ValueError`` if ``payload`` is not a well-formed report dict."""
195
+ if not isinstance(payload.get("title"), str):
196
+ raise ValueError("report needs a string 'title'")
197
+ sections = payload.get("sections", [])
198
+ if not isinstance(sections, list):
199
+ raise ValueError("report 'sections' must be a list")
200
+ for si, sec in enumerate(sections):
201
+ if not isinstance(sec, Mapping):
202
+ raise ValueError(f"section {si} must be an object")
203
+ if not isinstance(sec.get("title"), str):
204
+ raise ValueError(f"section {si} needs a string 'title'")
205
+ blocks = sec.get("blocks", [])
206
+ if not isinstance(blocks, list):
207
+ raise ValueError(f"section {si} 'blocks' must be a list")
208
+ for bi, block in enumerate(blocks):
209
+ _validate_block(block, f"section {si} block {bi}")
210
+
211
+
212
+ # ── The report sheet ──────────────────────────────────────────────────────
213
+ @dataclass(frozen=True, slots=True)
214
+ class ReportSheet:
215
+ """An immutable, serializable report: title + source refs + sections."""
216
+
217
+ title: str
218
+ sections: tuple[dict[str, Any], ...] = ()
219
+ source_refs: tuple[dict[str, Any], ...] = ()
220
+ created: str | None = None # ISO string, set by the caller (keeps calc pure/deterministic)
221
+ meta: Mapping[str, Any] = field(default_factory=dict)
222
+
223
+ def to_dict(self) -> dict[str, Any]:
224
+ return {
225
+ "title": self.title,
226
+ "sections": [dict(s) for s in self.sections],
227
+ "source_refs": [dict(r) for r in self.source_refs],
228
+ "created": self.created,
229
+ "meta": dict(self.meta),
230
+ }
231
+
232
+ @classmethod
233
+ def from_dict(cls, payload: Mapping[str, Any]) -> ReportSheet:
234
+ validate_report(payload)
235
+ return cls(
236
+ title=str(payload["title"]),
237
+ sections=tuple(dict(s) for s in payload.get("sections", [])),
238
+ source_refs=tuple(dict(r) for r in payload.get("source_refs", [])),
239
+ created=payload.get("created"),
240
+ meta=dict(payload.get("meta", {})),
241
+ )
242
+
243
+ def to_json(self) -> str:
244
+ return json.dumps(self.to_dict())
245
+
246
+ @classmethod
247
+ def from_json(cls, text: str) -> ReportSheet:
248
+ return cls.from_dict(json.loads(text))
249
+
250
+ def iter_blocks(self) -> Iterable[dict[str, Any]]:
251
+ """Yield every block across all sections (renderer convenience)."""
252
+ for sec in self.sections:
253
+ yield from sec.get("blocks", [])