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/io/sims.py ADDED
@@ -0,0 +1,398 @@
1
+ """SIMS depth-profile parser (.csv/.tsv/.xlsx). Port of parser.importSIMS.
2
+
3
+ Handles two layouts:
4
+ * shared-depth — column 0 is the depth axis, columns 1.. are concentrations;
5
+ * paired — each element has its own (depth, concentration) column pair.
6
+
7
+ For paired files whose elements share an identical depth grid, no interpolation
8
+ is done. Otherwise a union depth grid (finest positive step over the full
9
+ range) is built and each element is linearly interpolated onto it (NaN outside
10
+ its measured range), matching MATLAB's ``interp1(..., 'linear', NaN)``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import math
16
+ import re
17
+ from collections.abc import Sequence
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ import numpy as np
22
+
23
+ from quantized.datastruct import DataStruct
24
+
25
+ __all__ = ["import_sims", "is_sims_file"]
26
+
27
+ _COMMENT_CHARS = "#%"
28
+ _DELIM_CANDIDATES = (",", "\t", ";", " ")
29
+ _EXCEL_EXTS = {".xlsx", ".xls", ".xlsm", ".xlsb", ".ods"}
30
+
31
+ # SIMS exports share .csv/.tsv/.xlsx with generic tables, so detect them by a
32
+ # vendor banner ("SIMS", "Evans Analytical", "Drawn Curves") or, failing that,
33
+ # the structural fingerprint of a depth axis carrying concentration units.
34
+ _SIMS_WORD_RE = re.compile(r"\bsims\b", re.IGNORECASE)
35
+ _SIMS_PHRASE_MARKERS = ("evans analytical", "drawn curves", "secondary ion")
36
+
37
+
38
+ def _sims_signals(text: str) -> bool:
39
+ if _SIMS_WORD_RE.search(text):
40
+ return True
41
+ low = text.lower()
42
+ if any(m in low for m in _SIMS_PHRASE_MARKERS):
43
+ return True
44
+ return "depth" in low and ("atoms/cc" in low or "atoms/cm" in low)
45
+
46
+
47
+ def _excel_preview_text(path: Path, max_rows: int = 8) -> str:
48
+ """First few rows of sheet 0 flattened to a string (for content sniffing)."""
49
+ import openpyxl
50
+
51
+ workbook = openpyxl.load_workbook(path, data_only=True, read_only=True)
52
+ try:
53
+ ws = workbook.worksheets[0]
54
+ cells: list[str] = []
55
+ for i, row in enumerate(ws.iter_rows(values_only=True)):
56
+ if i >= max_rows:
57
+ break
58
+ cells.extend(str(v) for v in row if v is not None)
59
+ finally:
60
+ workbook.close()
61
+ return " ".join(cells)
62
+
63
+
64
+ def is_sims_file(path: Path) -> bool:
65
+ """Content sniffer for ambiguous .csv/.tsv/.xlsx: True for a SIMS depth profile."""
66
+ p = Path(path)
67
+ try:
68
+ if p.suffix.lower() in _EXCEL_EXTS:
69
+ text = _excel_preview_text(p)
70
+ else:
71
+ text = p.read_text(encoding="latin-1", errors="replace")[:4096]
72
+ except Exception: # noqa: BLE001 — a sniffer must never raise; unreadable -> not SIMS
73
+ return False
74
+ return _sims_signals(text)
75
+
76
+
77
+ def _is_numeric(token: str) -> bool:
78
+ try:
79
+ value = float(token)
80
+ except ValueError:
81
+ return False
82
+ return not math.isnan(value)
83
+
84
+
85
+ def _to_float(token: str) -> float:
86
+ stripped = token.strip()
87
+ if not stripped:
88
+ return float("nan")
89
+ try:
90
+ return float(stripped)
91
+ except ValueError:
92
+ return float("nan")
93
+
94
+
95
+ def _read_raw_lines(text: str) -> list[str]:
96
+ out: list[str] = []
97
+ for raw in text.splitlines():
98
+ stripped = raw.strip()
99
+ if not stripped or stripped[0] in _COMMENT_CHARS:
100
+ continue
101
+ out.append(stripped)
102
+ return out
103
+
104
+
105
+ def _detect_delimiter(raw_lines: Sequence[str]) -> str:
106
+ test = raw_lines[:10]
107
+ best, best_score = ",", 0.0
108
+ for ch in _DELIM_CANDIDATES:
109
+ counts = [line.count(ch) for line in test]
110
+ if counts and all(c > 0 for c in counts):
111
+ mean = sum(counts) / len(counts)
112
+ std = (sum((c - mean) ** 2 for c in counts) / len(counts)) ** 0.5
113
+ if std < mean * 0.5 and mean > best_score:
114
+ best, best_score = ch, mean
115
+ return best
116
+
117
+
118
+ def _numeric_score(row: Sequence[str]) -> float:
119
+ if not row:
120
+ return 0.0
121
+ return sum(1 for t in row if _is_numeric(t.strip())) / len(row)
122
+
123
+
124
+ def _detect_layout(tokens: Sequence[Sequence[str]]) -> tuple[int, int]:
125
+ """0-based (header_row, data_start); header walks back past blank rows."""
126
+ scores = [_numeric_score(r) for r in tokens]
127
+ first_data = next((i for i, s in enumerate(scores) if s > 0.5), 0)
128
+ header_row = -1
129
+ if first_data > 0:
130
+ cand = first_data - 1
131
+ while cand >= 0:
132
+ if any(t.strip() for t in tokens[cand]) and scores[cand] < 0.5:
133
+ break
134
+ cand -= 1
135
+ header_row = cand
136
+ return header_row, first_data
137
+
138
+
139
+ def _detect_paired(matrix: np.ndarray) -> bool:
140
+ n_cols = int(matrix.shape[1])
141
+ if n_cols < 4 or n_cols % 2 != 0:
142
+ return False
143
+ n_odd = n_cols // 2
144
+ n_monotonic = 0
145
+ for k in range(n_odd):
146
+ col = matrix[:, 2 * k] # 0-based even = MATLAB odd (depth) columns
147
+ col = col[~np.isnan(col)]
148
+ if col.size >= 2 and bool(np.all(np.diff(col) > 0)):
149
+ n_monotonic += 1
150
+ return bool((n_monotonic / n_odd) >= 0.8)
151
+
152
+
153
+ _BARE_UNIT_RE = re.compile(r"^\(([^)]+)\)$")
154
+ _PAREN_RE = re.compile(r"(.+?)\s*\(([^)]+)\)\s*$")
155
+ _BRACK_RE = re.compile(r"(.+?)\s*\[([^\]]+)\]\s*$")
156
+ _CONC_RE = re.compile(r"^\s*(?:conc(?:entration)?)\s+", re.IGNORECASE)
157
+ _MASS_PREFIX_RE = re.compile(r"^\d+([A-Z][a-z]?)")
158
+ _MASS_TRAIL_RE = re.compile(r"^([A-Z][a-z]?)\d+[+\-]?$")
159
+
160
+
161
+ def _clean_element_names(headers: Sequence[str]) -> tuple[list[str], list[str]]:
162
+ names: list[str] = []
163
+ units: list[str] = []
164
+ for header in headers:
165
+ h = header.strip()
166
+ bare = _BARE_UNIT_RE.match(h)
167
+ if bare:
168
+ names.append("")
169
+ units.append(bare.group(1).strip())
170
+ continue
171
+ unit = ""
172
+ paren = _PAREN_RE.match(h)
173
+ brack = _BRACK_RE.match(h)
174
+ if paren:
175
+ h, unit = paren.group(1).strip(), paren.group(2).strip()
176
+ elif brack:
177
+ h, unit = brack.group(1).strip(), brack.group(2).strip()
178
+ h = _CONC_RE.sub("", h)
179
+ mass_prefix = _MASS_PREFIX_RE.match(h)
180
+ if mass_prefix:
181
+ h = mass_prefix.group(1)
182
+ mass_trail = _MASS_TRAIL_RE.match(h)
183
+ if mass_trail:
184
+ h = mass_trail.group(1)
185
+ h = re.sub(r"[+\-]+$", "", h)
186
+ names.append(h)
187
+ units.append(unit)
188
+ return names, units
189
+
190
+
191
+ def _clean_vendor_element(raw: str) -> str:
192
+ name = re.sub(r"-+>$", "", raw).strip()
193
+ if name and name.isalpha():
194
+ name = name[0].upper() + name[1:].lower()
195
+ return name
196
+
197
+
198
+ def _matlab_colon(a: float, d: float, b: float) -> np.ndarray:
199
+ n = int(math.floor((b - a) / d + 1e-10))
200
+ grid = a + np.arange(n + 1) * d
201
+ if grid[-1] < b:
202
+ grid = np.append(grid, b)
203
+ return grid
204
+
205
+
206
+ def _build_union_grid(
207
+ depths: Sequence[np.ndarray], concs: Sequence[np.ndarray]
208
+ ) -> tuple[np.ndarray, np.ndarray]:
209
+ n_e = len(depths)
210
+ all_same = True
211
+ if n_e > 1:
212
+ ref = depths[0]
213
+ scale = float(np.max(np.abs(ref))) if ref.size else 0.0
214
+ for e in range(1, n_e):
215
+ if depths[e].size != ref.size or np.any(
216
+ np.abs(depths[e] - ref) > np.spacing(scale) * 10
217
+ ):
218
+ all_same = False
219
+ break
220
+ if all_same and depths[0].size:
221
+ union = depths[0].copy()
222
+ return union, np.column_stack([c for c in concs])
223
+
224
+ all_min, all_max, min_step = math.inf, -math.inf, math.inf
225
+ for d in depths:
226
+ if d.size == 0:
227
+ continue
228
+ all_min = min(all_min, float(d.min()))
229
+ all_max = max(all_max, float(d.max()))
230
+ steps = np.diff(d)
231
+ pos = steps[steps > 0]
232
+ if pos.size:
233
+ min_step = min(min_step, float(pos.min()))
234
+
235
+ if math.isinf(min_step) or all_min >= all_max:
236
+ return depths[0].copy(), np.column_stack([c for c in concs])
237
+
238
+ union = _matlab_colon(all_min, min_step, all_max)
239
+ interp = np.full((union.size, n_e), np.nan)
240
+ for e in range(n_e):
241
+ d, c = depths[e], concs[e]
242
+ if d.size < 2:
243
+ if d.size:
244
+ interp[int(np.argmin(np.abs(union - d[0]))), e] = c[0]
245
+ continue
246
+ interp[:, e] = np.interp(union, d, c, left=np.nan, right=np.nan)
247
+ return union, interp
248
+
249
+
250
+ def _detect_depth_unit(col_headers: Sequence[str], header_meta: Sequence[str]) -> str:
251
+ text = " ".join(list(col_headers) + list(header_meta)).lower()
252
+ if "um" in text or "µ" in text or "micron" in text or "micrometer" in text:
253
+ return "um"
254
+ if "nm" in text or "nanometer" in text:
255
+ return "nm"
256
+ if "angstrom" in text or "Å" in " ".join(col_headers):
257
+ return "A"
258
+ return "nm"
259
+
260
+
261
+ def _read_text_tokens(path: Path) -> list[list[str]]:
262
+ raw_lines = _read_raw_lines(path.read_text(encoding="latin-1"))
263
+ if not raw_lines:
264
+ raise ValueError(f"file empty or only comments: {path.name}")
265
+ delim = _detect_delimiter(raw_lines)
266
+ return [line.split(delim) for line in raw_lines]
267
+
268
+
269
+ def _read_excel_tokens(path: Path, sheet: int | str) -> list[list[str]]:
270
+ import openpyxl
271
+
272
+ workbook = openpyxl.load_workbook(path, data_only=True, read_only=True)
273
+ try:
274
+ ws = workbook[sheet] if isinstance(sheet, str) else workbook.worksheets[sheet]
275
+ grid = [list(row) for row in ws.iter_rows(values_only=True)]
276
+ finally:
277
+ workbook.close()
278
+ tokens: list[list[str]] = []
279
+ for row in grid:
280
+ cells: list[str] = []
281
+ for v in row:
282
+ if isinstance(v, str):
283
+ cells.append(v)
284
+ elif isinstance(v, bool) or v is None:
285
+ cells.append("")
286
+ elif isinstance(v, (int, float)):
287
+ cells.append(f"{v:.15g}")
288
+ else:
289
+ cells.append(str(v))
290
+ tokens.append(cells)
291
+ return tokens
292
+
293
+
294
+ def import_sims(
295
+ filepath: str | Path,
296
+ *,
297
+ depth_unit: str = "auto",
298
+ sheet: int | str = 0,
299
+ ) -> DataStruct:
300
+ """Import a SIMS depth profile (paired or shared-depth layout)."""
301
+ path = Path(filepath)
302
+ is_excel = path.suffix.lower() in {".xlsx", ".xls", ".xlsm", ".xlsb", ".ods"}
303
+ tokens = _read_excel_tokens(path, sheet) if is_excel else _read_text_tokens(path)
304
+
305
+ header_row, data_start = _detect_layout(tokens)
306
+ data_rows = tokens[data_start:]
307
+ n_data_cols = max(len(r) for r in data_rows)
308
+ if header_row >= 0:
309
+ col_headers = [c.strip() for c in tokens[header_row]]
310
+ else:
311
+ col_headers = []
312
+ if len(col_headers) < n_data_cols:
313
+ col_headers += [f"Col{k + 1}" for k in range(len(col_headers), n_data_cols)]
314
+ elif len(col_headers) > n_data_cols:
315
+ col_headers = col_headers[:n_data_cols]
316
+ col_headers = [h if h.strip() else f"Col{k + 1}" for k, h in enumerate(col_headers)]
317
+
318
+ header_meta: list[str] = []
319
+ if header_row > 0:
320
+ header_meta = [" ".join(t.strip() for t in tokens[mi]) for mi in range(header_row)]
321
+
322
+ n_cols = len(col_headers)
323
+ matrix = np.full((len(data_rows), n_cols), np.nan)
324
+ for r, row in enumerate(data_rows):
325
+ for c in range(min(len(row), n_cols)):
326
+ matrix[r, c] = _to_float(row[c])
327
+
328
+ empty_mask = np.all(np.isnan(matrix), axis=0)
329
+ matrix = matrix[:, ~empty_mask]
330
+ col_headers = [h for h, drop in zip(col_headers, empty_mask, strict=True) if not drop]
331
+ n_cols = matrix.shape[1]
332
+ if n_cols < 2:
333
+ raise ValueError(f"need >=2 non-empty columns in {path.name}")
334
+
335
+ is_paired = _detect_paired(matrix)
336
+ depths: list[np.ndarray] = []
337
+ concs: list[np.ndarray] = []
338
+ elem_headers: list[str] = []
339
+ if is_paired:
340
+ for p in range(n_cols // 2):
341
+ d_col, c_col = matrix[:, 2 * p], matrix[:, 2 * p + 1]
342
+ valid = ~np.isnan(d_col) & ~np.isnan(c_col)
343
+ depths.append(d_col[valid])
344
+ concs.append(c_col[valid])
345
+ elem_headers.append(col_headers[2 * p + 1])
346
+ else:
347
+ shared = matrix[:, 0]
348
+ for e in range(1, n_cols):
349
+ c_col = matrix[:, e]
350
+ valid = ~np.isnan(shared) & ~np.isnan(c_col)
351
+ depths.append(shared[valid])
352
+ concs.append(c_col[valid])
353
+ elem_headers.append(col_headers[e])
354
+
355
+ elem_names, conc_units = _clean_element_names(elem_headers)
356
+ if is_paired and any(not n for n in elem_names) and header_row > 0:
357
+ _recover_paired_names(elem_names, tokens, header_row, empty_mask)
358
+
359
+ union_depth, interp = _build_union_grid(depths, concs)
360
+ if depth_unit != "auto":
361
+ resolved_unit = depth_unit
362
+ else:
363
+ resolved_unit = _detect_depth_unit(col_headers, header_meta)
364
+
365
+ metadata: dict[str, Any] = {
366
+ "source": str(path),
367
+ "parser_name": "import_sims",
368
+ "x_column_name": "Depth",
369
+ "x_column_unit": resolved_unit,
370
+ "is_paired_layout": is_paired,
371
+ }
372
+ return DataStruct.create(
373
+ union_depth, interp, labels=elem_names, units=conc_units, metadata=metadata
374
+ )
375
+
376
+
377
+ def _recover_paired_names(
378
+ elem_names: list[str],
379
+ tokens: Sequence[Sequence[str]],
380
+ header_row: int,
381
+ empty_mask: np.ndarray,
382
+ ) -> None:
383
+ n_e = len(elem_names)
384
+ width = empty_mask.size
385
+ for mi in range(header_row - 1, -1, -1):
386
+ row = list(tokens[mi])
387
+ row = (row + [""] * width)[:width]
388
+ parts = [cell for cell, drop in zip(row, empty_mask, strict=True) if not drop]
389
+ parts = (parts + [""] * (2 * n_e))[: 2 * n_e]
390
+ odd = [p.strip() for p in parts[0::2]]
391
+ even = [p.strip() for p in parts[1::2]]
392
+ n_even_blank = sum(1 for p in even if not p)
393
+ n_odd_text = sum(1 for p in odd if p and not _is_numeric(p))
394
+ if n_even_blank >= n_e // 2 and n_odd_text >= n_e // 2:
395
+ for p in range(n_e):
396
+ if not elem_names[p] and odd[p]:
397
+ elem_names[p] = _clean_vendor_element(odd[p])
398
+ return