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
quantized/cli.py ADDED
@@ -0,0 +1,214 @@
1
+ """``qz`` — launch the quantized app (API + built SPA) and open the browser.
2
+
3
+ qz serve http://127.0.0.1:8000, open a browser tab, and
4
+ exit when the last tab closes (app-like run model;
5
+ pre-set QZ_AUTO_SHUTDOWN=0 to opt out)
6
+ qz --port 9000 use a different port
7
+ qz --no-browser don't open a browser (headless / CI; never auto-exits)
8
+ qz --dev Vite dev server (HMR) + auto-reloading backend
9
+ qz --desktop native window (pywebview; pip install quantized[desktop])
10
+ qz --calc calculator-only view (DiraCulator materials calculators),
11
+ opens /?view=calc; combine with --desktop for a small
12
+ native window titled "DiraCulator" (also: `diraculator`
13
+ console-script alias runs this by default)
14
+ qz plugin list list discovered plugins and what they contribute
15
+ qz plugin enable <name> re-enable a previously disabled plugin
16
+ qz plugin disable <name> disable a plugin without deleting it
17
+
18
+ If the requested port is busy and ``--port`` was NOT given explicitly, qz
19
+ falls back to a free ephemeral port automatically (the main app is often
20
+ already running on 8000) and prints a note. An explicit ``--port`` that's
21
+ busy still errors as before.
22
+
23
+ The UI is served from the Vite build output (``src/quantized/web``). On a bare
24
+ dev checkout that directory is absent — build it once with
25
+ ``cd frontend && npm run build``, or just double-click the ``run`` launcher,
26
+ which builds on first use.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import argparse
32
+ import os
33
+ import sys
34
+ from pathlib import Path
35
+
36
+ import uvicorn
37
+
38
+ from quantized.server_launch import _open_when_healthy, _resolve_port, _run_desktop, _run_dev
39
+
40
+ # Same resolution as quantized.app._WEB_DIR (kept as a separate constant here
41
+ # rather than importing app.py, so this pre-uvicorn check stays cheap — it
42
+ # doesn't need to pull in FastAPI and every router just to print a warning).
43
+ _WEB_DIR = Path(__file__).parent / "web"
44
+
45
+
46
+ def main(argv: list[str] | None = None) -> None:
47
+ """Dispatch: ``qz plugin ...`` to the plugin CLI, else launch the server."""
48
+ args = list(sys.argv[1:] if argv is None else argv)
49
+ if args and args[0] == "plugin":
50
+ _plugin_command(args[1:])
51
+ return
52
+ _serve(args)
53
+
54
+
55
+ def main_calc(argv: list[str] | None = None) -> None:
56
+ """``diraculator`` console-script entry point — the same as ``qz --calc``.
57
+
58
+ Injects ``--calc`` ahead of the caller's args and delegates to ``main``;
59
+ ``--desktop``/``--port``/etc. all still compose normally (e.g.
60
+ ``diraculator --desktop``)."""
61
+ args = list(sys.argv[1:] if argv is None else argv)
62
+ main(["--calc", *args])
63
+
64
+
65
+ def _serve(argv: list[str]) -> None:
66
+ """Parse serve args, (optionally) schedule the browser, and run the server."""
67
+ parser = argparse.ArgumentParser(prog="qz", description="Launch the quantized app.")
68
+ parser.add_argument("--host", default="127.0.0.1", help="bind host (default 127.0.0.1)")
69
+ parser.add_argument(
70
+ "--port",
71
+ type=int,
72
+ default=None,
73
+ help="bind port (default 8000; falls back to a free port if that's "
74
+ "busy and --port wasn't given explicitly)",
75
+ )
76
+ parser.add_argument(
77
+ "--no-browser", action="store_true", help="do not open a browser tab"
78
+ )
79
+ parser.add_argument(
80
+ "--calc",
81
+ action="store_true",
82
+ help="calculator-only view (DiraCulator) — opens /?view=calc",
83
+ )
84
+ mode = parser.add_mutually_exclusive_group()
85
+ mode.add_argument(
86
+ "--dev", action="store_true", help="Vite dev server (HMR) + reloading backend"
87
+ )
88
+ mode.add_argument(
89
+ "--desktop", action="store_true", help="native window (pywebview)"
90
+ )
91
+ args = parser.parse_args(argv)
92
+
93
+ explicit_port = args.port is not None
94
+ port = _resolve_port(args.host, args.port if explicit_port else 8000, explicit=explicit_port)
95
+ calc_path = "/?view=calc" if args.calc else ""
96
+
97
+ if args.dev:
98
+ _run_dev(args.host, port)
99
+ return
100
+ if args.desktop:
101
+ if args.calc:
102
+ _run_desktop(
103
+ args.host, port, title="DiraCulator", width=520, height=680, path=calc_path
104
+ )
105
+ else:
106
+ _run_desktop(args.host, port)
107
+ return
108
+
109
+ url = f"http://{args.host}:{port}{calc_path}"
110
+ if not _WEB_DIR.is_dir():
111
+ print(
112
+ f"[qz] UI not built ({_WEB_DIR} missing). "
113
+ "Run: cd frontend && npm run build (or use the run launcher)."
114
+ )
115
+
116
+ if not args.no_browser:
117
+ # App-like run model: the SPA holds /api/ws open per tab, and app.py
118
+ # exits when the last tab closes (past a refresh grace window) — armed
119
+ # here, before uvicorn imports quantized.app by string, so the module-
120
+ # level env read sees it. setdefault so an explicit QZ_AUTO_SHUTDOWN=0
121
+ # in the user's environment wins. Headless (--no-browser) never arms.
122
+ os.environ.setdefault("QZ_AUTO_SHUTDOWN", "1")
123
+ # Open the tab once /api/health answers (a fixed delay races the cold
124
+ # numpy/scipy/matplotlib import); daemon thread, so Ctrl+C still exits.
125
+ _open_when_healthy(url, args.host, port)
126
+
127
+ print(f"[qz] quantized -> {url} (press Ctrl+C to stop)")
128
+ uvicorn.run("quantized.app:app", host=args.host, port=port, log_level="warning")
129
+
130
+
131
+ def _plugin_command(argv: list[str]) -> None:
132
+ """``qz plugin ...`` — inspect and manage installed/drop-in plugins.
133
+
134
+ Subcommands: ``list`` (default — also runs when no subcommand is given),
135
+ ``enable <name>``, ``disable <name>``. ``<name>`` is the source
136
+ identifier shown in the first column of ``qz plugin list`` (the drop-in
137
+ module's file stem, or the entry-point name for a packaged plugin).
138
+ Enable/disable persist the ``disabled`` list in
139
+ ``<config_dir>/plugins.json`` (see :mod:`quantized.plugins.loader`); a
140
+ disabled plugin is never imported, so it can be parked without deleting
141
+ it.
142
+ """
143
+ parser = argparse.ArgumentParser(prog="qz plugin", description="Inspect quantized plugins.")
144
+ sub = parser.add_subparsers(dest="action")
145
+ sub.add_parser("list", help="list discovered plugins and what they contribute")
146
+ enable_parser = sub.add_parser("enable", help="re-enable a disabled plugin")
147
+ enable_parser.add_argument("name", help="plugin source identifier (see 'qz plugin list')")
148
+ disable_parser = sub.add_parser("disable", help="disable a plugin without deleting it")
149
+ disable_parser.add_argument("name", help="plugin source identifier (see 'qz plugin list')")
150
+ args = parser.parse_args(argv)
151
+
152
+ if args.action == "enable":
153
+ _plugin_set_enabled(args.name, enabled=True)
154
+ elif args.action == "disable":
155
+ _plugin_set_enabled(args.name, enabled=False)
156
+ else:
157
+ _plugin_list()
158
+
159
+
160
+ def _plugin_list() -> None:
161
+ """Print each discovered plugin: identifier, name/version, contributions, status."""
162
+ from quantized.plugins import load_plugins
163
+
164
+ infos = load_plugins()
165
+ if not infos:
166
+ print("No plugins discovered.")
167
+ print(" Drop a .py module into the plugins directory, or install a package")
168
+ print(" exposing the 'quantized.plugins' entry point. See docs/plugins.md.")
169
+ return
170
+ for info in infos:
171
+ head = info.source
172
+ if info.name:
173
+ head += f" ({info.name} v{info.version})"
174
+ print(f"{head} [{info.status}]")
175
+ if info.parsers:
176
+ print(f" parsers: {', '.join(info.parsers)}")
177
+ if info.fit_models:
178
+ print(f" fit models: {', '.join(info.fit_models)}")
179
+ if info.steps:
180
+ print(f" steps: {', '.join(info.steps)}")
181
+ if info.error:
182
+ print(f" ! {info.error}")
183
+
184
+
185
+ def _plugin_set_enabled(name: str, *, enabled: bool) -> None:
186
+ """``qz plugin enable|disable <name>``: flip ``name``'s disabled state.
187
+
188
+ ``name`` is validated against the currently discoverable plugins (a
189
+ disabled plugin still shows up here — only a nonexistent source is
190
+ rejected) so a typo fails loudly with the known names, instead of
191
+ silently writing a dead entry to ``plugins.json``. Reloads afterwards so
192
+ the change (and a following ``qz plugin list``) reflects it immediately.
193
+ """
194
+ from quantized.plugins import disable_plugin, enable_plugin, load_plugins
195
+
196
+ known = {info.source for info in load_plugins()}
197
+ if name not in known:
198
+ print(f"[qz] unknown plugin {name!r}.")
199
+ if known:
200
+ print(f" known plugins: {', '.join(sorted(known))}")
201
+ else:
202
+ print(" no plugins discovered. Run 'qz plugin list' for details.")
203
+ sys.exit(1)
204
+
205
+ if enabled:
206
+ enable_plugin(name)
207
+ else:
208
+ disable_plugin(name)
209
+ load_plugins()
210
+ print(f"[qz] plugin {name!r} {'enabled' if enabled else 'disabled'}.")
211
+
212
+
213
+ if __name__ == "__main__":
214
+ main()
@@ -0,0 +1,153 @@
1
+ """The canonical data contract: ``DataStruct``.
2
+
3
+ Every parser returns one, and every consumer (corrections, fitting, plotting,
4
+ export) reads one. Mirrors the MATLAB ``parser.createDataStruct`` contract:
5
+
6
+ time (N,) independent variable / x-axis
7
+ values (N, M) data matrix — N samples, M channels
8
+ labels (M,) channel names (deduplicated: 'A','A' -> 'A','A (2)')
9
+ units (M,) channel units ('' when unknown)
10
+ metadata immutable mapping of import metadata
11
+
12
+ Pure layer — no fastapi/pydantic imports (enforced by test_repo_integrity).
13
+ The instance is frozen and its arrays are read-only, honouring the
14
+ "raw data is preserved, never mutated in place" rule: compute on copies.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ from collections.abc import Mapping, Sequence
21
+ from dataclasses import dataclass, field
22
+ from types import MappingProxyType
23
+ from typing import Any
24
+
25
+ import numpy as np
26
+ from numpy.typing import ArrayLike, NDArray
27
+
28
+ __all__ = ["DataStruct"]
29
+
30
+
31
+ def _deduplicate(labels: tuple[str, ...]) -> tuple[str, ...]:
32
+ """Append ' (2)', ' (3)', ... to repeated labels (matches MATLAB)."""
33
+ seen: dict[str, int] = {}
34
+ out: list[str] = []
35
+ for lbl in labels:
36
+ if lbl in seen:
37
+ seen[lbl] += 1
38
+ out.append(f"{lbl} ({seen[lbl]})")
39
+ else:
40
+ seen[lbl] = 1
41
+ out.append(lbl)
42
+ return tuple(out)
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class DataStruct:
47
+ """Immutable, parser-agnostic dataset. Build via :meth:`create`."""
48
+
49
+ time: NDArray[np.float64]
50
+ values: NDArray[np.float64]
51
+ labels: tuple[str, ...] = ()
52
+ units: tuple[str, ...] = ()
53
+ metadata: Mapping[str, Any] = field(default_factory=dict)
54
+
55
+ def __post_init__(self) -> None:
56
+ time = np.asarray(self.time, dtype=float).ravel()
57
+ values = np.asarray(self.values, dtype=float)
58
+ if values.ndim == 1:
59
+ values = (
60
+ values.reshape(-1, 1)
61
+ if values.size
62
+ else np.empty((time.shape[0], 0), dtype=float)
63
+ )
64
+ if values.ndim != 2:
65
+ raise ValueError(f"values must be 2-D, got {values.ndim}-D")
66
+
67
+ n = time.shape[0]
68
+ if values.shape[0] != n:
69
+ raise ValueError(
70
+ f"time length ({n}) must equal values row count ({values.shape[0]})"
71
+ )
72
+ m = values.shape[1]
73
+
74
+ labels = tuple(self.labels) if self.labels else tuple(f"ch{i + 1}" for i in range(m))
75
+ units = tuple(self.units) if self.units else tuple("" for _ in range(m))
76
+ if len(labels) != m:
77
+ raise ValueError(f"expected {m} labels for {m} columns, got {len(labels)}")
78
+ if len(units) != m:
79
+ raise ValueError(f"expected {m} units for {m} columns, got {len(units)}")
80
+ labels = _deduplicate(labels)
81
+
82
+ time.flags.writeable = False
83
+ values.flags.writeable = False
84
+
85
+ object.__setattr__(self, "time", time)
86
+ object.__setattr__(self, "values", values)
87
+ object.__setattr__(self, "labels", labels)
88
+ object.__setattr__(self, "units", units)
89
+ object.__setattr__(self, "metadata", MappingProxyType(dict(self.metadata)))
90
+
91
+ # ── Construction ──────────────────────────────────────────────────────
92
+ @classmethod
93
+ def create(
94
+ cls,
95
+ time: ArrayLike,
96
+ values: ArrayLike,
97
+ *,
98
+ labels: Sequence[str] | None = None,
99
+ units: Sequence[str] | None = None,
100
+ metadata: Mapping[str, Any] | None = None,
101
+ ) -> DataStruct:
102
+ """Mirror of MATLAB ``createDataStruct``. Accepts array-likes."""
103
+ return cls(
104
+ time=np.asarray(time, dtype=float),
105
+ values=np.asarray(values, dtype=float),
106
+ labels=tuple(labels) if labels is not None else (),
107
+ units=tuple(units) if units is not None else (),
108
+ metadata=dict(metadata) if metadata is not None else {},
109
+ )
110
+
111
+ # ── Shape helpers ─────────────────────────────────────────────────────
112
+ @property
113
+ def n_points(self) -> int:
114
+ return int(self.time.shape[0])
115
+
116
+ @property
117
+ def n_channels(self) -> int:
118
+ return int(self.values.shape[1])
119
+
120
+ def column(self, key: int | str) -> NDArray[np.float64]:
121
+ """Return one channel's data (read-only) by index or label."""
122
+ idx = key if isinstance(key, int) else self.labels.index(key)
123
+ return self.values[:, idx]
124
+
125
+ # ── Serialization (route boundary) ────────────────────────────────────
126
+ # NOTE: JSON here is Python-round-trippable (NaN/Inf survive via the
127
+ # stdlib json's non-standard tokens). The HTTP boundary (M1 #5) will map
128
+ # non-finite floats to null for valid wire JSON — that's a routes concern.
129
+ def to_dict(self) -> dict[str, Any]:
130
+ return {
131
+ "time": self.time.tolist(),
132
+ "values": self.values.tolist(),
133
+ "labels": list(self.labels),
134
+ "units": list(self.units),
135
+ "metadata": dict(self.metadata),
136
+ }
137
+
138
+ @classmethod
139
+ def from_dict(cls, payload: Mapping[str, Any]) -> DataStruct:
140
+ return cls.create(
141
+ time=payload["time"],
142
+ values=payload["values"],
143
+ labels=payload.get("labels"),
144
+ units=payload.get("units"),
145
+ metadata=payload.get("metadata"),
146
+ )
147
+
148
+ def to_json(self) -> str:
149
+ return json.dumps(self.to_dict())
150
+
151
+ @classmethod
152
+ def from_json(cls, text: str) -> DataStruct:
153
+ return cls.from_dict(json.loads(text))
@@ -0,0 +1,11 @@
1
+ """Pure I/O library: parsers, the single registry, export writers, session I/O.
2
+
3
+ Pure layer — MUST NOT import fastapi / pydantic / starlette / quantized.routes
4
+ (enforced by tests/test_repo_integrity.py). Data in → DataStruct out.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from quantized.io.registry import import_auto, resolve_parser
10
+
11
+ __all__ = ["import_auto", "resolve_parser"]
@@ -0,0 +1,308 @@
1
+ """Tree-building helpers for the HDF5 exporter (:mod:`quantized.io.hdf5`).
2
+
3
+ Split out of ``hdf5.py`` to keep both modules under the 500-line ceiling and
4
+ to make the tree-shaping logic independently testable. Pure layer: ndarray /
5
+ ``DataStruct`` / ``h5py`` group in -> datasets/attributes written. No
6
+ fastapi/pydantic imports.
7
+
8
+ Schema (mirrors MATLAB ``+utilities/exportHDF5.m`` v1.0)::
9
+
10
+ /file_schema_version uint8 [1,1] = 1 (root sentinel)
11
+ / (root attrs) toolboxName, hdf5Schema, createdAt,
12
+ hasCorrected, hasPeaks, correctionsApplied
13
+ /raw/ time, values, labels, units, nRows (+attrs)
14
+ /corrected/ (optional, same layout as /raw/)
15
+ /corrections/ xOff, yOff, bgSlope, bgInt (double scalars)
16
+ /peaks/ count, center, fwhm, height, bg,
17
+ xRange_lo, xRange_hi, status, model
18
+ /metadata/ schema_version (+ common attrs)
19
+ /metadata/parserSpecific/ schema_version (+ flattened struct attrs)
20
+
21
+ String datasets (labels, units, datetime time, peak status/model) are written
22
+ as space-padded ASCII ``uint8`` matrices, one string per row, exactly like the
23
+ MATLAB original; the companion ``encoding`` attribute documents the format.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from collections.abc import Mapping, Sequence
29
+ from typing import TYPE_CHECKING, Any
30
+
31
+ import numpy as np
32
+ from numpy.typing import NDArray
33
+
34
+ from quantized.datastruct import DataStruct
35
+
36
+ if TYPE_CHECKING: # pragma: no cover - h5py only needed at write time
37
+ import h5py
38
+
39
+ __all__ = [
40
+ "CORRECTION_FIELDS",
41
+ "META_COMMON_KEYS",
42
+ "PEAK_NUMERIC_FIELDS",
43
+ "encode_padded_ascii",
44
+ "first_meta",
45
+ "write_corrections_group",
46
+ "write_data_group",
47
+ "write_metadata_group",
48
+ "write_peaks_group",
49
+ "write_struct_attrs",
50
+ ]
51
+
52
+ # MATLAB writes exactly these four correction scalars, in this order, always
53
+ # defaulting absent ones to 0.0 (see exportHDF5.m corrFieldNames).
54
+ CORRECTION_FIELDS: tuple[str, ...] = ("xOff", "yOff", "bgSlope", "bgInt")
55
+
56
+ # Common /metadata attributes, mapped MATLAB-attr-name -> candidate metadata
57
+ # keys (quantized snake_case first, then MATLAB camelCase, mirroring xrd_csv).
58
+ META_COMMON_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
59
+ ("parserName", ("parser_name", "parserName")),
60
+ ("xColumnName", ("x_column_name", "xColumnName")),
61
+ ("xColumnUnit", ("x_column_unit", "xColumnUnit")),
62
+ ("source", ("source", "sourceFile")),
63
+ )
64
+
65
+ # Peak numeric fields written as parallel [P,1] double vectors (NaN fallback).
66
+ PEAK_NUMERIC_FIELDS: tuple[str, ...] = ("center", "fwhm", "height", "bg")
67
+
68
+
69
+ def first_meta(meta: Mapping[str, Any], keys: Sequence[str]) -> Any:
70
+ """First present, non-empty value among ``keys`` (or ``None``).
71
+
72
+ Mirrors the dual-provenance lookup in ``xrd_csv._meta_get`` (accepts both
73
+ quantized snake_case and MATLAB camelCase keys, plus a nested
74
+ ``parser_specific`` / ``parserSpecific`` mapping).
75
+ """
76
+ sources: list[Mapping[str, Any]] = [meta]
77
+ for nested_key in ("parser_specific", "parserSpecific"):
78
+ nested = meta.get(nested_key)
79
+ if isinstance(nested, Mapping):
80
+ sources.append(nested)
81
+ for src in sources:
82
+ for key in keys:
83
+ if key in src:
84
+ val = src[key]
85
+ if val is None or (isinstance(val, str) and val == ""):
86
+ continue
87
+ return val
88
+ return None
89
+
90
+
91
+ def encode_padded_ascii(strings: Sequence[str]) -> NDArray[np.uint8]:
92
+ """Encode ``strings`` as an ``[M, maxLen]`` space-padded ASCII uint8 matrix.
93
+
94
+ One string per row, right-padded with ASCII space (0x20). An empty input
95
+ becomes a single empty row (``[1, 1]`` of spaces), matching MATLAB's
96
+ ``writeCellStrDataset`` (which substitutes ``{''}`` for an empty cell and a
97
+ minimum column count of 1). Non-ASCII codepoints are replaced (``?``) so
98
+ each character maps to exactly one byte, matching MATLAB ``uint8(char)``
99
+ behaviour for ASCII labels.
100
+ """
101
+ rows = [str(s) for s in strings]
102
+ if not rows:
103
+ rows = [""]
104
+ encoded = [r.encode("ascii", errors="replace") for r in rows]
105
+ max_len = max((len(b) for b in encoded), default=1)
106
+ max_len = max(max_len, 1)
107
+ mat = np.full((len(encoded), max_len), ord(" "), dtype=np.uint8)
108
+ for i, b in enumerate(encoded):
109
+ if b:
110
+ mat[i, : len(b)] = np.frombuffer(b, dtype=np.uint8)
111
+ return mat
112
+
113
+
114
+ def _write_padded_ascii(
115
+ group: h5py.Group,
116
+ name: str,
117
+ strings: Sequence[str],
118
+ *,
119
+ count_attr: bool = True,
120
+ ) -> None:
121
+ """Create a padded-ASCII dataset + ``encoding`` (and optional ``count``)."""
122
+ mat = encode_padded_ascii(strings)
123
+ dset = group.create_dataset(name, data=mat)
124
+ dset.attrs["encoding"] = "ASCII_padded_space"
125
+ if count_attr:
126
+ dset.attrs["count"] = np.int32(len(strings) if strings else 1)
127
+
128
+
129
+ def write_data_group(parent: h5py.Group, group_path: str, d: DataStruct) -> None:
130
+ """Write ``time``, ``values``, ``labels``, ``units`` (+attrs) under a group.
131
+
132
+ ``group_path`` is relative to ``parent`` (e.g. ``"raw"`` or ``"corrected"``).
133
+ quantized's ``DataStruct.time`` is always numeric float64 (datetime axes are
134
+ converted to epoch seconds on import), so ``timeIsDatetime`` is always 0
135
+ here — the datetime branch in MATLAB has no quantized analogue.
136
+ """
137
+ grp = parent.require_group(group_path)
138
+ n = int(d.time.shape[0])
139
+ m = int(d.values.shape[1])
140
+
141
+ grp.create_dataset("time", data=d.time.astype(np.float64).reshape(n, 1))
142
+ grp["time"].attrs["timeIsDatetime"] = np.uint8(0)
143
+
144
+ grp.create_dataset("values", data=d.values.astype(np.float64).reshape(n, m))
145
+
146
+ _write_padded_ascii(grp, "labels", list(d.labels))
147
+ _write_padded_ascii(grp, "units", list(d.units))
148
+
149
+ # nRows sentinel dataset (carries group attrs in the MATLAB original).
150
+ grp.create_dataset("nRows", data=np.int32(n).reshape(1, 1))
151
+ grp.attrs["nChannels"] = np.int32(m)
152
+ grp.attrs["timeIsDatetime"] = np.uint8(0)
153
+
154
+
155
+ def write_corrections_group(
156
+ parent: h5py.Group, corrections: Mapping[str, float]
157
+ ) -> None:
158
+ """Write the four correction scalars as ``[1,1]`` double datasets."""
159
+ grp = parent.require_group("corrections")
160
+ for field in CORRECTION_FIELDS:
161
+ val = float(corrections.get(field, 0.0))
162
+ grp.create_dataset(field, data=np.float64(val).reshape(1, 1))
163
+
164
+
165
+ def write_peaks_group(parent: h5py.Group, peaks: Sequence[Mapping[str, Any]]) -> None:
166
+ """Write parallel peak datasets into ``/peaks/`` (mirrors MATLAB)."""
167
+ grp = parent.require_group("peaks")
168
+ p = len(peaks)
169
+ grp.create_dataset("count", data=np.uint32(p).reshape(1, 1))
170
+
171
+ for field in PEAK_NUMERIC_FIELDS:
172
+ vec = np.array([_safe_num(pk.get(field)) for pk in peaks], dtype=np.float64)
173
+ grp.create_dataset(field, data=vec.reshape(p, 1))
174
+
175
+ x_lo = np.full(p, np.nan)
176
+ x_hi = np.full(p, np.nan)
177
+ for i, pk in enumerate(peaks):
178
+ xr = pk.get("xRange", pk.get("x_range"))
179
+ if xr is not None and len(xr) == 2:
180
+ x_lo[i] = float(xr[0])
181
+ x_hi[i] = float(xr[1])
182
+ grp.create_dataset("xRange_lo", data=x_lo.reshape(p, 1))
183
+ grp.create_dataset("xRange_hi", data=x_hi.reshape(p, 1))
184
+
185
+ status = [_safe_str(pk.get("status"), "unknown") for pk in peaks]
186
+ model = [_safe_str(pk.get("model"), "") for pk in peaks]
187
+ _write_padded_ascii(grp, "status", status)
188
+ _write_padded_ascii(grp, "model", model)
189
+
190
+
191
+ def write_metadata_group(parent: h5py.Group, meta: Mapping[str, Any]) -> None:
192
+ """Write common ``/metadata`` attrs and the ``parserSpecific`` sub-group."""
193
+ grp = parent.require_group("metadata")
194
+ grp.create_dataset("schema_version", data=np.uint8(1).reshape(1, 1))
195
+
196
+ for attr_name, keys in META_COMMON_KEYS:
197
+ val = first_meta(meta, keys)
198
+ grp.attrs[attr_name] = "" if val is None else str(val)
199
+
200
+ # quantized time axis is always numeric.
201
+ grp.attrs["timeIsDatetime"] = np.uint8(0)
202
+
203
+ import_date = first_meta(meta, ("import_date", "importDate"))
204
+ if import_date is not None:
205
+ grp.attrs["importDate"] = str(import_date)
206
+
207
+ parser_specific = _parser_specific(meta)
208
+ if parser_specific is not None:
209
+ ps = grp.require_group("parserSpecific")
210
+ ps.create_dataset("schema_version", data=np.uint8(1).reshape(1, 1))
211
+ write_struct_attrs(ps, parser_specific, "")
212
+
213
+
214
+ def write_struct_attrs(
215
+ group: h5py.Group, mapping: Mapping[str, Any], prefix: str
216
+ ) -> None:
217
+ """Flatten ``mapping`` into HDF5 attributes (port of ``writeStructAttrs``).
218
+
219
+ Scalars -> numeric attr; NaN -> ``"NaN"``; short numeric vectors -> array
220
+ attr; str/bool/None handled; one level of nested-dict flattening with an
221
+ ``<field>_`` prefix; lists of strings joined with ``|`` (+ ``__delim``);
222
+ anything else records ``<name>__type``.
223
+ """
224
+ for field, val in mapping.items():
225
+ attr_name = f"{prefix}{field}"
226
+ if isinstance(val, bool):
227
+ group.attrs[attr_name] = np.uint8(val)
228
+ elif isinstance(val, (int, float, np.integer, np.floating)):
229
+ fval = float(val)
230
+ group.attrs[attr_name] = "NaN" if np.isnan(fval) else fval
231
+ elif isinstance(val, str):
232
+ group.attrs[attr_name] = val
233
+ elif val is None:
234
+ group.attrs[f"{attr_name}__type"] = "NoneType"
235
+ elif isinstance(val, Mapping) and not prefix:
236
+ write_struct_attrs(group, val, f"{field}_")
237
+ elif _is_str_list(val):
238
+ items = [str(x) for x in val]
239
+ if 0 < len(items) <= 128:
240
+ group.attrs[attr_name] = "|".join(items)
241
+ group.attrs[f"{attr_name}__delim"] = "|"
242
+ else:
243
+ group.attrs[f"{attr_name}__type"] = "cell"
244
+ elif _is_num_vector(val):
245
+ arr = np.asarray(val, dtype=np.float64).ravel()
246
+ if 1 < arr.size <= 64:
247
+ group.attrs[attr_name] = arr
248
+ else:
249
+ group.attrs[f"{attr_name}__type"] = "double"
250
+ else:
251
+ group.attrs[f"{attr_name}__type"] = type(val).__name__
252
+
253
+
254
+ # ── small value helpers ─────────────────────────────────────────────────────
255
+
256
+
257
+ def _parser_specific(meta: Mapping[str, Any]) -> Mapping[str, Any] | None:
258
+ """Return the parserSpecific mapping.
259
+
260
+ quantized parsers keep instrument-specific fields flat in ``metadata``
261
+ (alongside the common keys) rather than nested. To mirror the MATLAB
262
+ ``/metadata/parserSpecific`` group we take an explicit nested mapping when
263
+ present, else the leftover flat keys (everything not a recognised common
264
+ key).
265
+ """
266
+ for key in ("parser_specific", "parserSpecific"):
267
+ nested = meta.get(key)
268
+ if isinstance(nested, Mapping):
269
+ return nested
270
+ common = {k for _, keys in META_COMMON_KEYS for k in keys}
271
+ common |= {"import_date", "importDate", "parser_specific", "parserSpecific"}
272
+ leftover = {k: v for k, v in meta.items() if k not in common}
273
+ return leftover or None
274
+
275
+
276
+ def _safe_num(val: Any) -> float:
277
+ if val is None:
278
+ return float("nan")
279
+ try:
280
+ if hasattr(val, "__len__") and not isinstance(val, str):
281
+ return float(val[0]) if len(val) else float("nan")
282
+ return float(val)
283
+ except (TypeError, ValueError):
284
+ return float("nan")
285
+
286
+
287
+ def _safe_str(val: Any, default: str) -> str:
288
+ if val is None or (isinstance(val, str) and val == ""):
289
+ return default
290
+ return str(val)
291
+
292
+
293
+ def _is_str_list(val: Any) -> bool:
294
+ return (
295
+ isinstance(val, (list, tuple))
296
+ and len(val) > 0
297
+ and all(isinstance(x, str) for x in val)
298
+ )
299
+
300
+
301
+ def _is_num_vector(val: Any) -> bool:
302
+ if not isinstance(val, (list, tuple, np.ndarray)):
303
+ return False
304
+ try:
305
+ arr = np.asarray(val, dtype=np.float64)
306
+ except (TypeError, ValueError):
307
+ return False
308
+ return arr.ndim == 1 and arr.size > 0