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,222 @@
1
+ """Generic delimited-text (CSV/TSV) parser. Port of MATLAB parser.importCSV.
2
+
3
+ Auto-detects delimiter, comment lines, the header row, an optional units row,
4
+ and the data start. By default the first column is the x-axis (time) and the
5
+ remaining numeric columns are values. Named ``delimited`` to avoid shadowing
6
+ the stdlib ``csv`` module.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ import re
13
+ from collections.abc import Sequence
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ import numpy as np
18
+
19
+ from quantized.datastruct import DataStruct
20
+ from quantized.io.base import resolve_column
21
+
22
+ __all__ = ["import_csv"]
23
+
24
+ _COMMENT_CHARS = "#%"
25
+ _NA_TOKENS = {"", "nan", "na", "-", "n/a"}
26
+ _DELIM_CANDIDATES = (",", "\t", ";", " ")
27
+
28
+
29
+ def _is_numeric(token: str) -> bool:
30
+ """True if token parses to a number; NaN counts as non-numeric (str2double parity)."""
31
+ try:
32
+ value = float(token)
33
+ except ValueError:
34
+ return False
35
+ return not math.isnan(value)
36
+
37
+
38
+ def _to_float(token: str) -> float:
39
+ stripped = token.strip()
40
+ if stripped.lower() in _NA_TOKENS:
41
+ return float("nan")
42
+ try:
43
+ return float(stripped)
44
+ except ValueError:
45
+ return float("nan")
46
+
47
+
48
+ def _read_raw_lines(text: str) -> list[str]:
49
+ out: list[str] = []
50
+ for raw in text.splitlines():
51
+ stripped = raw.strip()
52
+ if not stripped or stripped[0] in _COMMENT_CHARS:
53
+ continue
54
+ out.append(stripped)
55
+ return out
56
+
57
+
58
+ def _detect_delimiter(raw_lines: Sequence[str]) -> str:
59
+ test = raw_lines[:10]
60
+ best_delim = ","
61
+ best_score = 0.0
62
+ for ch in _DELIM_CANDIDATES:
63
+ counts = [line.count(ch) for line in test]
64
+ if counts and all(c > 0 for c in counts):
65
+ mean = sum(counts) / len(counts)
66
+ std = (sum((c - mean) ** 2 for c in counts) / len(counts)) ** 0.5
67
+ if std < mean * 0.5 and mean > best_score:
68
+ best_score = mean
69
+ best_delim = ch
70
+ return best_delim
71
+
72
+
73
+ def _extract_units(header: str) -> tuple[str, str]:
74
+ """``'Temp (C)'`` -> ``('C', 'Temp')`` (also ``[...]``). Returns (unit, label)."""
75
+ paren = re.match(r"(.+?)\s*\(([^)]+)\)\s*$", header)
76
+ if paren:
77
+ return paren.group(2).strip(), paren.group(1).strip()
78
+ brack = re.match(r"(.+?)\s*\[([^\]]+)\]\s*$", header)
79
+ if brack:
80
+ return brack.group(2).strip(), brack.group(1).strip()
81
+ return "", header
82
+
83
+
84
+ def _numeric_score(row: Sequence[str]) -> float:
85
+ if not row:
86
+ return 0.0
87
+ return sum(1 for t in row if _is_numeric(t.strip())) / len(row)
88
+
89
+
90
+ def _looks_like_units_row(row: Sequence[str], n_data_cols: int) -> bool:
91
+ n = len(row)
92
+ if n < max(n_data_cols * 0.5, 2):
93
+ return False
94
+ n_unit_like = 0
95
+ n_non_empty = 0
96
+ for cell in row:
97
+ token = cell.strip()
98
+ if not token:
99
+ n_unit_like += 1
100
+ continue
101
+ n_non_empty += 1
102
+ if re.match(r"^[(\[{].*[)\]}]$", token):
103
+ n_unit_like += 1
104
+ elif " " not in token and not _is_numeric(token):
105
+ has_non_alpha = re.search(r"[^a-zA-Z]", token) is not None
106
+ if (has_non_alpha and len(token) <= 10) or (not has_non_alpha and len(token) <= 4):
107
+ n_unit_like += 1
108
+ return n_non_empty > 0 and (n_unit_like / max(n, 1)) >= 0.6
109
+
110
+
111
+ def _detect_layout(tokens: Sequence[Sequence[str]]) -> tuple[int, int, int]:
112
+ """Return 0-based (header_row, data_start, units_row); -1 when absent."""
113
+ scores = [_numeric_score(row) for row in tokens]
114
+ first_data = next((i for i, s in enumerate(scores) if s > 0.5), 0)
115
+ header_row = -1
116
+ units_row = -1
117
+ if (
118
+ first_data >= 2
119
+ and scores[first_data - 1] < 0.5
120
+ and scores[first_data - 2] < 0.5
121
+ and _looks_like_units_row(tokens[first_data - 1], len(tokens[first_data]))
122
+ ):
123
+ units_row = first_data - 1
124
+ header_row = first_data - 2
125
+ elif first_data >= 1 and scores[first_data - 1] < 0.5:
126
+ header_row = first_data - 1
127
+ return header_row, first_data, units_row
128
+
129
+
130
+ def import_csv(
131
+ filepath: str | Path,
132
+ *,
133
+ time_column: int | str = 0,
134
+ data_columns: Sequence[int | str] | None = None,
135
+ ) -> DataStruct:
136
+ """Import a generic delimited text file (first column = x-axis by default)."""
137
+ path = Path(filepath)
138
+ raw_lines = _read_raw_lines(path.read_text(encoding="latin-1"))
139
+ if not raw_lines:
140
+ raise ValueError(f"file empty or only comments: {path.name}")
141
+ delim = _detect_delimiter(raw_lines)
142
+ tokens = [line.split(delim) for line in raw_lines]
143
+
144
+ header_row, data_start, units_row = _detect_layout(tokens)
145
+ n_data_cols = len(tokens[data_start])
146
+ if header_row >= 0:
147
+ col_headers = [c.strip() for c in tokens[header_row]]
148
+ else:
149
+ col_headers = [f"Col{k + 1}" for k in range(n_data_cols)]
150
+ if len(col_headers) < n_data_cols:
151
+ col_headers += [f"Col{k + 1}" for k in range(len(col_headers), n_data_cols)]
152
+ elif len(col_headers) > n_data_cols:
153
+ col_headers = col_headers[:n_data_cols]
154
+ col_headers = [h if h.strip() else f"Col{k + 1}" for k, h in enumerate(col_headers)]
155
+ n_cols = len(col_headers)
156
+
157
+ row_units: list[str] = []
158
+ if units_row >= 0:
159
+ utok = [u.strip() for u in tokens[units_row]]
160
+ for k in range(n_cols):
161
+ cell = utok[k] if k < len(utok) else ""
162
+ row_units.append(re.sub(r"^\s*[(\[](.*?)[)\]]\s*$", r"\1", cell))
163
+
164
+ rows: list[list[float]] = []
165
+ for row in tokens[data_start:]:
166
+ vals = [float("nan")] * n_cols
167
+ for c in range(min(len(row), n_cols)):
168
+ vals[c] = _to_float(row[c])
169
+ rows.append(vals)
170
+ matrix = np.asarray(rows, dtype=float)
171
+ n_rows = matrix.shape[0]
172
+
173
+ if isinstance(time_column, int) and time_column < 0:
174
+ time_idx = -1
175
+ else:
176
+ time_idx = resolve_column(time_column, col_headers)
177
+
178
+ time_vec = np.arange(1, n_rows + 1, dtype=float) if time_idx < 0 else matrix[:, time_idx]
179
+
180
+ if data_columns is None:
181
+ candidates = [c for c in range(n_cols) if c != time_idx]
182
+ data_idx = [
183
+ c
184
+ for c in candidates
185
+ if (np.count_nonzero(~np.isnan(matrix[:, c])) / n_rows) > 0.1
186
+ ]
187
+ else:
188
+ data_idx = [resolve_column(s, col_headers) for s in data_columns]
189
+ if not data_idx:
190
+ raise ValueError(f"no valid data columns in {path.name}")
191
+
192
+ labels: list[str] = []
193
+ units: list[str] = []
194
+ for c in data_idx:
195
+ unit, label = _extract_units(col_headers[c])
196
+ labels.append(label)
197
+ units.append(unit)
198
+ if row_units:
199
+ for i, c in enumerate(data_idx):
200
+ if c < len(row_units) and row_units[c]:
201
+ units[i] = row_units[c]
202
+
203
+ if time_idx >= 0:
204
+ x_unit, x_name = _extract_units(col_headers[time_idx])
205
+ if not x_name:
206
+ x_name = col_headers[time_idx]
207
+ if row_units and time_idx < len(row_units) and row_units[time_idx]:
208
+ x_unit = row_units[time_idx]
209
+ else:
210
+ x_name, x_unit = "Sample Index", ""
211
+
212
+ metadata: dict[str, Any] = {
213
+ "source": str(path),
214
+ "parser_name": "import_csv",
215
+ "x_column_name": x_name,
216
+ "x_column_unit": x_unit,
217
+ "delimiter": delim,
218
+ "all_column_names": col_headers,
219
+ }
220
+ return DataStruct.create(
221
+ time_vec, matrix[:, data_idx], labels=labels, units=units, metadata=metadata
222
+ )
quantized/io/excel.py ADDED
@@ -0,0 +1,135 @@
1
+ """Excel ``.xlsx`` parser. Port of MATLAB parser.importExcel (via openpyxl).
2
+
3
+ Reads a sheet's cell grid, detects the header + data-start rows from a numeric
4
+ shadow matrix, and (by default) takes the first column as the x-axis. Mirrors
5
+ importExcel's logic; the cell grid replaces MATLAB's ``readcell``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import zipfile
11
+ from collections.abc import Sequence
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import numpy as np
16
+ import openpyxl
17
+ from openpyxl.utils.exceptions import InvalidFileException
18
+
19
+ from quantized.datastruct import DataStruct
20
+ from quantized.io.base import resolve_column
21
+ from quantized.io.delimited import _extract_units
22
+
23
+ __all__ = ["import_excel"]
24
+
25
+
26
+ def _cell_to_float(value: Any) -> float:
27
+ if isinstance(value, bool): # bool is an int subclass — not data
28
+ return float("nan")
29
+ if isinstance(value, (int, float)):
30
+ return float(value)
31
+ return float("nan")
32
+
33
+
34
+ def _header_str(value: Any, col: int) -> str:
35
+ if isinstance(value, str):
36
+ return value.strip()
37
+ if not isinstance(value, bool) and isinstance(value, (int, float)):
38
+ return str(value)
39
+ return f"Col{col + 1}"
40
+
41
+
42
+ def import_excel(
43
+ filepath: str | Path,
44
+ *,
45
+ sheet: int | str = 0,
46
+ time_column: int | str = 0,
47
+ data_columns: Sequence[int | str] | None = None,
48
+ ) -> DataStruct:
49
+ """Import an ``.xlsx`` sheet (first column = x-axis by default)."""
50
+ path = Path(filepath)
51
+ try:
52
+ workbook = openpyxl.load_workbook(path, data_only=True, read_only=True)
53
+ except (zipfile.BadZipFile, InvalidFileException, OSError) as exc:
54
+ # An empty / non-ZIP / truncated .xlsx raises BadZipFile or
55
+ # InvalidFileException (neither a ValueError) -> would 500 the import
56
+ # route. Reject cleanly instead.
57
+ raise ValueError(f"{path.name} is not a readable .xlsx workbook: {exc}") from exc
58
+ try:
59
+ worksheet = workbook[sheet] if isinstance(sheet, str) else workbook.worksheets[sheet]
60
+ sheet_name = worksheet.title
61
+ grid: list[list[Any]] = [list(row) for row in worksheet.iter_rows(values_only=True)]
62
+ finally:
63
+ workbook.close()
64
+
65
+ while grid and all(v is None for v in grid[-1]):
66
+ grid.pop()
67
+ if not grid:
68
+ raise ValueError(f"sheet has no data: {path.name}")
69
+ n_cols = max(len(r) for r in grid)
70
+ grid = [r + [None] * (n_cols - len(r)) for r in grid]
71
+ while n_cols > 0 and all(row[n_cols - 1] is None for row in grid):
72
+ n_cols -= 1
73
+ grid = [row[:n_cols] for row in grid]
74
+
75
+ num_mat = np.array([[_cell_to_float(v) for v in row] for row in grid], dtype=float)
76
+ scores = [
77
+ (float(np.count_nonzero(~np.isnan(num_mat[i]))) / n_cols) if n_cols else 0.0
78
+ for i in range(len(grid))
79
+ ]
80
+ first_data = next((i for i, s in enumerate(scores) if s > 0.5), 0)
81
+ header_row = first_data - 1 if first_data >= 1 and scores[first_data - 1] < 0.5 else -1
82
+
83
+ if header_row >= 0:
84
+ col_headers = [_header_str(grid[header_row][c], c) for c in range(n_cols)]
85
+ else:
86
+ col_headers = [f"Col{c + 1}" for c in range(n_cols)]
87
+
88
+ data = num_mat[first_data:]
89
+ keep = [i for i in range(data.shape[0]) if not np.all(np.isnan(data[i]))]
90
+ data = data[keep]
91
+ if data.shape[0] == 0:
92
+ raise ValueError(f"no numeric data rows in {path.name}")
93
+ n_rows = data.shape[0]
94
+
95
+ if isinstance(time_column, int) and time_column < 0:
96
+ time_idx = -1
97
+ else:
98
+ time_idx = resolve_column(time_column, col_headers)
99
+ time_vec = np.arange(1, n_rows + 1, dtype=float) if time_idx < 0 else data[:, time_idx]
100
+
101
+ if data_columns is None:
102
+ candidates = [c for c in range(n_cols) if c != time_idx]
103
+ data_idx = [
104
+ c for c in candidates if (np.count_nonzero(~np.isnan(data[:, c])) / n_rows) > 0.1
105
+ ]
106
+ else:
107
+ data_idx = [resolve_column(s, col_headers) for s in data_columns]
108
+ if not data_idx:
109
+ raise ValueError(f"no valid data columns in {path.name}")
110
+
111
+ labels: list[str] = []
112
+ units: list[str] = []
113
+ for c in data_idx:
114
+ unit, label = _extract_units(col_headers[c])
115
+ labels.append(label)
116
+ units.append(unit)
117
+
118
+ if time_idx >= 0:
119
+ x_unit, x_name = _extract_units(col_headers[time_idx])
120
+ if not x_name:
121
+ x_name = col_headers[time_idx]
122
+ else:
123
+ x_name, x_unit = "Sample Index", ""
124
+
125
+ metadata: dict[str, Any] = {
126
+ "source": str(path),
127
+ "parser_name": "import_excel",
128
+ "x_column_name": x_name,
129
+ "x_column_unit": x_unit,
130
+ "sheet_name": sheet_name,
131
+ "all_column_names": col_headers,
132
+ }
133
+ return DataStruct.create(
134
+ time_vec, data[:, data_idx], labels=labels, units=units, metadata=metadata
135
+ )
quantized/io/hdf5.py ADDED
@@ -0,0 +1,192 @@
1
+ """Self-describing HDF5 exporter. Port of MATLAB ``+utilities/exportHDF5.m``.
2
+
3
+ Writes a unified ``DataStruct`` (plus optional corrected data, correction
4
+ parameters, and peak fits) to a hierarchical HDF5 file with a schema that is
5
+ consistent across data types (VSM, PPMS, XRD, generic CSV/Excel). All parsers
6
+ produce the same ``DataStruct`` layout, so one schema covers everything.
7
+
8
+ Schema overview (v1.0, identical to the MATLAB original)::
9
+
10
+ /raw/ raw data (always written)
11
+ /corrected/ corrected data (optional)
12
+ /corrections/ xOff, yOff, bgSlope, bgInt (optional)
13
+ /peaks/ peak-fit results (optional)
14
+ /metadata/ common metadata attributes
15
+ /metadata/parserSpecific/ instrument-specific attributes
16
+
17
+ String datasets (labels, units, peak status/model) are written as space-padded
18
+ ASCII ``uint8`` matrices with an ``encoding='ASCII_padded_space'`` attribute,
19
+ matching MATLAB so files round-trip across both implementations.
20
+
21
+ Pure layer: ``DataStruct`` in -> ``.h5`` file out. No fastapi/pydantic imports.
22
+ ``h5py`` (BSD-3-Clause) is the only extra dependency; it is imported lazily so
23
+ the rest of ``io/`` works without it installed.
24
+
25
+ The tree-shaping logic lives in :mod:`quantized.io._hdf5_layout` (keeps both
26
+ modules under the 500-line ceiling and makes the layout independently testable).
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from collections.abc import Mapping, Sequence
32
+ from datetime import UTC, datetime
33
+ from pathlib import Path
34
+ from typing import Any
35
+
36
+ import numpy as np
37
+
38
+ from quantized.datastruct import DataStruct
39
+ from quantized.io import _hdf5_layout as layout
40
+
41
+ __all__ = ["write_hdf5"]
42
+
43
+ _VALID_EXTENSIONS = (".h5", ".hdf5")
44
+ _TOOLBOX_NAME = "quantized"
45
+ _HDF5_SCHEMA = "1.0"
46
+
47
+
48
+ def write_hdf5(
49
+ data: DataStruct,
50
+ output_path: str | Path,
51
+ *,
52
+ corr_data: DataStruct | None = None,
53
+ corrections: Mapping[str, float] | None = None,
54
+ include_peaks: bool = False,
55
+ peaks: Sequence[Mapping[str, Any]] | None = None,
56
+ overwrite: bool = True,
57
+ ) -> None:
58
+ """Write ``data`` to a self-describing HDF5 file (port of ``exportHDF5``).
59
+
60
+ Parameters
61
+ ----------
62
+ data:
63
+ Unified ``DataStruct`` (from any parser). Written to ``/raw/``.
64
+ output_path:
65
+ Destination file; must end in ``.h5`` or ``.hdf5``. Its parent
66
+ directory must already exist (mirrors the MATLAB ``isfolder`` check).
67
+ corr_data:
68
+ Corrected ``DataStruct`` (same layout as ``data``); writes
69
+ ``/corrected/`` when given. ``None`` (or ``struct()`` upstream) skips it.
70
+ corrections:
71
+ Mapping with any of ``xOff``/``yOff``/``bgSlope``/``bgInt`` (also
72
+ accepts the quantized snake_case ``x_off``/``y_off``/``bg_slope``/
73
+ ``bg_int``). Writes ``/corrections/`` only when at least one finite,
74
+ value is present (mirrors MATLAB ``hasCorrections``).
75
+ include_peaks:
76
+ Write ``/peaks/`` from ``peaks`` (default ``False``).
77
+ peaks:
78
+ Sequence of peak mappings (``center``/``fwhm``/``height``/``bg``/
79
+ ``xRange``/``status``/``model``). Required when ``include_peaks=True``.
80
+ overwrite:
81
+ Delete an existing file first (default ``True``). When ``False`` and the
82
+ file exists, raise ``FileExistsError`` (mirrors MATLAB ``Overwrite``).
83
+
84
+ Raises
85
+ ------
86
+ ValueError
87
+ Bad file extension.
88
+ FileNotFoundError
89
+ Output directory does not exist.
90
+ FileExistsError
91
+ File exists and ``overwrite=False``.
92
+ ImportError
93
+ ``h5py`` is not installed.
94
+ """
95
+ out = Path(output_path)
96
+ _validate_path(out, overwrite=overwrite)
97
+
98
+ h5py = _import_h5py()
99
+
100
+ norm_corr = _normalize_corrections(corrections)
101
+ has_corr_data = corr_data is not None
102
+ peak_list = list(peaks) if peaks else []
103
+ has_peaks = include_peaks and len(peak_list) > 0
104
+ has_corrections = _has_corrections(norm_corr)
105
+ corrections_applied = has_corrections and any(v != 0.0 for v in norm_corr.values())
106
+
107
+ if out.exists():
108
+ out.unlink()
109
+
110
+ with h5py.File(out, "w") as hf:
111
+ # Root sentinel dataset + root-level attributes.
112
+ hf.create_dataset("file_schema_version", data=np.uint8(1).reshape(1, 1))
113
+ hf.attrs["toolboxName"] = _TOOLBOX_NAME
114
+ hf.attrs["hdf5Schema"] = _HDF5_SCHEMA
115
+ hf.attrs["createdAt"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
116
+ hf.attrs["hasCorrected"] = np.uint8(has_corr_data)
117
+ hf.attrs["hasPeaks"] = np.uint8(has_peaks)
118
+ hf.attrs["correctionsApplied"] = np.uint8(corrections_applied)
119
+
120
+ layout.write_data_group(hf, "raw", data)
121
+
122
+ if has_corr_data:
123
+ assert corr_data is not None # narrowed by has_corr_data
124
+ layout.write_data_group(hf, "corrected", corr_data)
125
+
126
+ if has_corrections:
127
+ layout.write_corrections_group(hf, norm_corr)
128
+
129
+ if has_peaks:
130
+ layout.write_peaks_group(hf, peak_list)
131
+
132
+ layout.write_metadata_group(hf, data.metadata)
133
+
134
+
135
+ # ── helpers ──────────────────────────────────────────────────────────────────
136
+
137
+
138
+ def _validate_path(out: Path, *, overwrite: bool) -> None:
139
+ """Validate extension, parent directory, and overwrite policy."""
140
+ if out.suffix.lower() not in _VALID_EXTENSIONS:
141
+ raise ValueError(
142
+ f"output_path must end in .h5 or .hdf5 (got: {out.suffix!r})"
143
+ )
144
+ parent = out.parent
145
+ if str(parent) not in ("", ".") and not parent.is_dir():
146
+ raise FileNotFoundError(f"Output directory does not exist: {parent}")
147
+ if not overwrite and out.exists():
148
+ raise FileExistsError(
149
+ f"File already exists and overwrite=False: {out}"
150
+ )
151
+
152
+
153
+ def _import_h5py() -> Any:
154
+ """Lazily import ``h5py`` with an actionable error message."""
155
+ try:
156
+ import h5py # noqa: PLC0415
157
+ except ImportError as exc: # pragma: no cover - exercised only without h5py
158
+ raise ImportError(
159
+ "h5py is required for HDF5 export. Install it with: uv add h5py"
160
+ ) from exc
161
+ return h5py
162
+
163
+
164
+ # Map quantized snake_case correction names onto the MATLAB attribute names.
165
+ _CORRECTION_ALIASES: dict[str, str] = {
166
+ "x_off": "xOff",
167
+ "y_off": "yOff",
168
+ "bg_slope": "bgSlope",
169
+ "bg_int": "bgInt",
170
+ }
171
+
172
+
173
+ def _normalize_corrections(
174
+ corrections: Mapping[str, float] | None,
175
+ ) -> dict[str, float]:
176
+ """Coerce a corrections mapping to the four MATLAB-named float keys."""
177
+ if not corrections:
178
+ return {}
179
+ out: dict[str, float] = {}
180
+ for key, val in corrections.items():
181
+ name = _CORRECTION_ALIASES.get(key, key)
182
+ if name in layout.CORRECTION_FIELDS:
183
+ try:
184
+ out[name] = float(val)
185
+ except (TypeError, ValueError):
186
+ continue
187
+ return out
188
+
189
+
190
+ def _has_corrections(norm_corr: Mapping[str, float]) -> bool:
191
+ """True when any normalized correction value is finite (mirrors MATLAB)."""
192
+ return any(np.isfinite(v) for v in norm_corr.values())