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.
- quantized/__init__.py +11 -0
- quantized/__main__.py +14 -0
- quantized/api.py +220 -0
- quantized/app.py +206 -0
- quantized/calc/__init__.py +8 -0
- quantized/calc/_clipfit.py +76 -0
- quantized/calc/_natural_neighbor.py +219 -0
- quantized/calc/aggregate.py +159 -0
- quantized/calc/backgrounds.py +353 -0
- quantized/calc/baseline.py +349 -0
- quantized/calc/batch_fit.py +148 -0
- quantized/calc/constants.py +27 -0
- quantized/calc/corrections.py +192 -0
- quantized/calc/crystallography.py +400 -0
- quantized/calc/diffusion.py +120 -0
- quantized/calc/electrical.py +248 -0
- quantized/calc/electrochemistry.py +176 -0
- quantized/calc/element_data.json +1 -0
- quantized/calc/element_data.py +59 -0
- quantized/calc/errors.py +246 -0
- quantized/calc/figure.py +417 -0
- quantized/calc/figure_break.py +152 -0
- quantized/calc/figure_categorical.py +156 -0
- quantized/calc/figure_corner.py +229 -0
- quantized/calc/figure_facets.py +127 -0
- quantized/calc/figure_field.py +137 -0
- quantized/calc/figure_hitmap.py +116 -0
- quantized/calc/figure_labels.py +62 -0
- quantized/calc/figure_map.py +287 -0
- quantized/calc/figure_overrides.py +159 -0
- quantized/calc/figure_page.py +266 -0
- quantized/calc/figure_scale.py +125 -0
- quantized/calc/figure_statplots.py +167 -0
- quantized/calc/figure_styles.py +131 -0
- quantized/calc/figure_ternary.py +239 -0
- quantized/calc/figure_ticks.py +217 -0
- quantized/calc/fit_autoguess.py +156 -0
- quantized/calc/fit_bootstrap.py +163 -0
- quantized/calc/fit_bumps.py +258 -0
- quantized/calc/fit_constraints.py +114 -0
- quantized/calc/fit_equation.py +264 -0
- quantized/calc/fit_findxy.py +80 -0
- quantized/calc/fit_models.py +195 -0
- quantized/calc/fit_models_special.py +189 -0
- quantized/calc/fit_odr.py +99 -0
- quantized/calc/fit_scan.py +243 -0
- quantized/calc/fit_stats.py +215 -0
- quantized/calc/fitting.py +199 -0
- quantized/calc/formula.py +90 -0
- quantized/calc/global_curve_fit.py +305 -0
- quantized/calc/global_fit.py +181 -0
- quantized/calc/interp2d.py +260 -0
- quantized/calc/linecut.py +269 -0
- quantized/calc/magnetic.py +414 -0
- quantized/calc/magnetometry.py +464 -0
- quantized/calc/map.py +228 -0
- quantized/calc/mcmc.py +177 -0
- quantized/calc/optics.py +228 -0
- quantized/calc/pawley.py +251 -0
- quantized/calc/peak_batch.py +128 -0
- quantized/calc/peak_fit.py +259 -0
- quantized/calc/peak_integrate.py +104 -0
- quantized/calc/peak_multifit.py +260 -0
- quantized/calc/peak_track.py +134 -0
- quantized/calc/peaks.py +298 -0
- quantized/calc/peakshapes.py +85 -0
- quantized/calc/plotting.py +147 -0
- quantized/calc/processing.py +232 -0
- quantized/calc/qspace.py +48 -0
- quantized/calc/reductions.py +155 -0
- quantized/calc/reductions_fft.py +383 -0
- quantized/calc/refl_sld_presets.json +1 -0
- quantized/calc/reflectivity.py +80 -0
- quantized/calc/registry.py +303 -0
- quantized/calc/relaxation.py +119 -0
- quantized/calc/report.py +253 -0
- quantized/calc/report_emit.py +227 -0
- quantized/calc/resample.py +142 -0
- quantized/calc/rsm.py +91 -0
- quantized/calc/rsm_analyze.py +245 -0
- quantized/calc/semiconductor.py +488 -0
- quantized/calc/sld.py +131 -0
- quantized/calc/sld_formula.py +138 -0
- quantized/calc/spectral.py +357 -0
- quantized/calc/statplots.py +214 -0
- quantized/calc/stats.py +399 -0
- quantized/calc/stats_anova2.py +202 -0
- quantized/calc/stats_anova_ext.py +338 -0
- quantized/calc/stats_dist.py +196 -0
- quantized/calc/stats_glm.py +245 -0
- quantized/calc/stats_multivar.py +289 -0
- quantized/calc/stats_roc.py +157 -0
- quantized/calc/stats_survival.py +261 -0
- quantized/calc/stats_tests.py +380 -0
- quantized/calc/substrates.py +181 -0
- quantized/calc/superconductor.py +359 -0
- quantized/calc/surface_fit.py +290 -0
- quantized/calc/surface_models.py +156 -0
- quantized/calc/thermal.py +119 -0
- quantized/calc/thin_film.py +425 -0
- quantized/calc/unit_convert.py +259 -0
- quantized/calc/units.py +80 -0
- quantized/calc/vacuum.py +290 -0
- quantized/calc/xray.py +169 -0
- quantized/cli.py +214 -0
- quantized/datastruct.py +153 -0
- quantized/io/__init__.py +11 -0
- quantized/io/_hdf5_layout.py +308 -0
- quantized/io/_jcamp_asdf.py +135 -0
- quantized/io/_xrdml_scan.py +291 -0
- quantized/io/base.py +82 -0
- quantized/io/bruker_brml.py +177 -0
- quantized/io/bruker_raw.py +158 -0
- quantized/io/cif.py +266 -0
- quantized/io/consolidated.py +122 -0
- quantized/io/delimited.py +222 -0
- quantized/io/excel.py +135 -0
- quantized/io/hdf5.py +192 -0
- quantized/io/import_filters.py +178 -0
- quantized/io/import_preview.py +262 -0
- quantized/io/jcamp.py +179 -0
- quantized/io/lakeshore.py +163 -0
- quantized/io/ncnr.py +278 -0
- quantized/io/netcdf.py +195 -0
- quantized/io/opus.py +231 -0
- quantized/io/origin.py +346 -0
- quantized/io/origin_com.py +194 -0
- quantized/io/origin_project/__init__.py +221 -0
- quantized/io/origin_project/annotation_marks.py +288 -0
- quantized/io/origin_project/container.py +262 -0
- quantized/io/origin_project/curve_style_color.py +359 -0
- quantized/io/origin_project/figure_geometry.py +108 -0
- quantized/io/origin_project/figure_layers.py +333 -0
- quantized/io/origin_project/figure_text.py +258 -0
- quantized/io/origin_project/figures.py +210 -0
- quantized/io/origin_project/figures_opju.py +440 -0
- quantized/io/origin_project/notes.py +302 -0
- quantized/io/origin_project/opj.py +459 -0
- quantized/io/origin_project/opj_curves.py +297 -0
- quantized/io/origin_project/opj_shapes.py +148 -0
- quantized/io/origin_project/opju.py +146 -0
- quantized/io/origin_project/opju_axis_real_form.py +418 -0
- quantized/io/origin_project/opju_axis_specimen_form.py +167 -0
- quantized/io/origin_project/opju_codec.py +370 -0
- quantized/io/origin_project/opju_curves.py +497 -0
- quantized/io/origin_project/opju_curves_allcols.py +258 -0
- quantized/io/origin_project/opju_figure_curves.py +302 -0
- quantized/io/origin_project/opju_figure_text.py +245 -0
- quantized/io/origin_project/opju_reports.py +129 -0
- quantized/io/origin_project/origin_richtext.py +145 -0
- quantized/io/origin_project/preview.py +132 -0
- quantized/io/origin_project/templates.py +314 -0
- quantized/io/origin_project/tree.py +379 -0
- quantized/io/origin_project/tree_opju.py +228 -0
- quantized/io/origin_project/windows.py +238 -0
- quantized/io/origin_project/windows_opju.py +393 -0
- quantized/io/origin_project/writer.py +156 -0
- quantized/io/origin_project/writer_blocks.py +282 -0
- quantized/io/qd.py +380 -0
- quantized/io/refl1d.py +132 -0
- quantized/io/registry.py +210 -0
- quantized/io/report_export.py +347 -0
- quantized/io/rigaku.py +100 -0
- quantized/io/sims.py +398 -0
- quantized/io/spc.py +311 -0
- quantized/io/xrd_csv.py +308 -0
- quantized/io/xrdml.py +394 -0
- quantized/jobs.py +173 -0
- quantized/plugins/__init__.py +50 -0
- quantized/plugins/contract.py +111 -0
- quantized/plugins/loader.py +394 -0
- quantized/plugins/steps.py +90 -0
- quantized/routes/__init__.py +7 -0
- quantized/routes/_bookcache.py +62 -0
- quantized/routes/_export_common.py +27 -0
- quantized/routes/_payload.py +58 -0
- quantized/routes/_uploadcache.py +59 -0
- quantized/routes/aggregate.py +46 -0
- quantized/routes/baseline.py +210 -0
- quantized/routes/books.py +117 -0
- quantized/routes/calc.py +56 -0
- quantized/routes/corrections.py +78 -0
- quantized/routes/crystallography.py +80 -0
- quantized/routes/diffusion.py +58 -0
- quantized/routes/electrical.py +101 -0
- quantized/routes/electrochemistry.py +83 -0
- quantized/routes/export.py +280 -0
- quantized/routes/export_facets.py +83 -0
- quantized/routes/export_figures.py +471 -0
- quantized/routes/export_page.py +125 -0
- quantized/routes/fitting.py +379 -0
- quantized/routes/fitting_bumps.py +97 -0
- quantized/routes/import_template.py +97 -0
- quantized/routes/import_wizard.py +150 -0
- quantized/routes/jobs_api.py +59 -0
- quantized/routes/magnetic.py +135 -0
- quantized/routes/magnetometry.py +133 -0
- quantized/routes/optics.py +98 -0
- quantized/routes/parsers.py +281 -0
- quantized/routes/peaks.py +184 -0
- quantized/routes/plot.py +103 -0
- quantized/routes/reductions.py +121 -0
- quantized/routes/reference.py +58 -0
- quantized/routes/reflectivity.py +91 -0
- quantized/routes/report_export.py +119 -0
- quantized/routes/rsm.py +136 -0
- quantized/routes/samples.py +32 -0
- quantized/routes/semiconductor.py +207 -0
- quantized/routes/sld.py +42 -0
- quantized/routes/spectral.py +54 -0
- quantized/routes/statplots.py +99 -0
- quantized/routes/stats.py +418 -0
- quantized/routes/stats_design.py +321 -0
- quantized/routes/substrates.py +46 -0
- quantized/routes/superconductor.py +139 -0
- quantized/routes/thermal.py +57 -0
- quantized/routes/thin_film.py +153 -0
- quantized/routes/vacuum.py +113 -0
- quantized/routes/xray.py +32 -0
- quantized/samples/demo_vsm.csv +42 -0
- quantized/server_launch.py +251 -0
- quantized/web/assets/JetBrainsMono-Bold-CUogYd9I.woff2 +0 -0
- quantized/web/assets/JetBrainsMono-Regular-CA-Os4ii.woff2 +0 -0
- quantized/web/assets/index-BHmmCL-x.js +27 -0
- quantized/web/assets/index-BiZzN7J6.css +1 -0
- quantized/web/index.html +13 -0
- quantized/web/loading.html +69 -0
- quantized_lab-0.8.0.dist-info/METADATA +122 -0
- quantized_lab-0.8.0.dist-info/RECORD +233 -0
- quantized_lab-0.8.0.dist-info/WHEEL +4 -0
- quantized_lab-0.8.0.dist-info/entry_points.txt +4 -0
- quantized_lab-0.8.0.dist-info/licenses/LICENSE +201 -0
- 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()
|
quantized/datastruct.py
ADDED
|
@@ -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))
|
quantized/io/__init__.py
ADDED
|
@@ -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
|