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/qd.py ADDED
@@ -0,0 +1,380 @@
1
+ """Quantum Design VSM / PPMS / MPMS ``.dat`` parser.
2
+
3
+ Port of MATLAB ``parser.importQDVSM`` — reads the standard [Header]/[Data]
4
+ format into a :class:`~quantized.datastruct.DataStruct`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Sequence
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import numpy as np
14
+
15
+ from quantized.datastruct import DataStruct
16
+ from quantized.io.base import NO_COLUMN, parse_col_header, resolve_column
17
+
18
+ __all__ = ["import_mpms", "import_ppms", "import_qd_vsm", "is_ppms_dat", "is_qd_file"]
19
+
20
+ _COMMENT_CHARS = (";", "#", "%")
21
+
22
+ # Shorthand -> canonical QD column name (from importQDVSM's resolveQDColumn map).
23
+ _QD_SHORTHAND: dict[str, str] = {
24
+ "field": "Magnetic Field",
25
+ "moment": "Moment",
26
+ "dc": "Moment",
27
+ "dcmoment": "Moment",
28
+ "acmoment": "AC Moment",
29
+ "acsusceptibility": "AC Susceptibility",
30
+ "acsuscept": "AC Susceptibility",
31
+ "temp": "Temperature",
32
+ "temperature": "Temperature",
33
+ "time": "Time Stamp",
34
+ "stderr": "M. Std. Err.",
35
+ "mass": "Mass",
36
+ "pressure": "Pressure",
37
+ "frequency": "Frequency",
38
+ "amplitude": "Peak Amplitude",
39
+ "range": "Range",
40
+ "motorcurrent": "Motor Current",
41
+ "coilsignal": "Coil Signal",
42
+ }
43
+
44
+
45
+ def is_qd_file(path: Path) -> bool:
46
+ """Content sniffer: a Quantum Design ``.dat`` has [Header] ... [Data]."""
47
+ head = Path(path).read_text(encoding="latin-1", errors="replace")[:4096].lower()
48
+ return "[header]" in head and ("[data]" in head or "byapp" in head)
49
+
50
+
51
+ def _to_float(token: str) -> float:
52
+ token = token.strip()
53
+ if not token:
54
+ return float("nan")
55
+ try:
56
+ return float(token)
57
+ except ValueError:
58
+ return float("nan")
59
+
60
+
61
+ # MPMS3 ``.dat`` files leave the legacy "Moment" column blank and write the
62
+ # signal to "DC Moment Free Ctr" / "DC Moment Fixed Ctr". When the resolved
63
+ # Moment column is entirely empty, fall back to a populated DC-moment column so
64
+ # the data actually plots. (MATLAB importQDVSM lacks this — MPMS3 M(H)/M(T) files
65
+ # import there but the Moment trace is all-NaN; this is a deliberate improvement.)
66
+ _DC_MOMENT_FALLBACKS = ("DC Moment Free Ctr", "DC Moment Fixed Ctr")
67
+
68
+
69
+ def _first_populated(
70
+ col_names: Sequence[str], matrix: np.ndarray, candidates: Sequence[str]
71
+ ) -> int | None:
72
+ for name in candidates:
73
+ if name in col_names:
74
+ i = list(col_names).index(name)
75
+ if i < matrix.shape[1] and np.isfinite(matrix[:, i]).any():
76
+ return i
77
+ return None
78
+
79
+
80
+ def _apply_moment_fallback(
81
+ col_names: Sequence[str], matrix: np.ndarray, y_idx: list[int]
82
+ ) -> list[int]:
83
+ """Swap an all-empty 'Moment' column for a populated DC-moment column."""
84
+ out: list[int] = []
85
+ for idx in y_idx:
86
+ if col_names[idx] == "Moment" and not np.isfinite(matrix[:, idx]).any():
87
+ fb = _first_populated(col_names, matrix, _DC_MOMENT_FALLBACKS)
88
+ out.append(fb if fb is not None else idx)
89
+ else:
90
+ out.append(idx)
91
+ return out
92
+
93
+
94
+ # When the resolved x-axis is constant — e.g. an M-vs-T sweep imported with the
95
+ # field default, where the field is regulated flat — the default plot collapses to
96
+ # a single vertical line. Fall back to a sweep axis that actually varies (in
97
+ # priority order) so the data plots meaningfully by default; the user can still
98
+ # re-pick the x-axis in the UI afterwards.
99
+ _X_SWEEP_FALLBACKS = ("temp", "field", "time")
100
+
101
+
102
+ def _is_constant_axis(col: np.ndarray) -> bool:
103
+ """True if a column has <2 finite points or a negligible (<0.1%) span."""
104
+ finite = col[np.isfinite(col)]
105
+ if finite.size < 2:
106
+ return True
107
+ lo = float(finite.min())
108
+ hi = float(finite.max())
109
+ scale = max(abs(lo), abs(hi), 1.0)
110
+ return (hi - lo) / scale < 1e-3
111
+
112
+
113
+ def _auto_x_index(col_names: Sequence[str], matrix: np.ndarray, x_idx: int) -> int:
114
+ """If the chosen x column is constant, swap to the first varying sweep axis."""
115
+ if not _is_constant_axis(matrix[:, x_idx]):
116
+ return x_idx
117
+ for short in _X_SWEEP_FALLBACKS:
118
+ try:
119
+ cand = resolve_column(short, col_names, _QD_SHORTHAND, "x-axis")
120
+ except (KeyError, IndexError):
121
+ continue
122
+ if cand not in (NO_COLUMN, x_idx) and not _is_constant_axis(matrix[:, cand]):
123
+ return cand
124
+ return x_idx
125
+
126
+
127
+ def import_qd_vsm(
128
+ filepath: str | Path,
129
+ *,
130
+ x_axis: str | int = "field",
131
+ y_axis: str | int | Sequence[str | int] = "moment",
132
+ include_raw: bool = False,
133
+ ) -> DataStruct:
134
+ """Import a QD ``.dat`` file. Defaults to Magnetic Field (x) vs Moment (y)."""
135
+ path = Path(filepath)
136
+ raw_lines = path.read_text(encoding="latin-1").splitlines()
137
+
138
+ header, data_start = _parse_header(raw_lines)
139
+ if data_start < 0:
140
+ raise ValueError(f"[Data] section not found in {path.name}")
141
+
142
+ col_names, col_units = _parse_column_row(raw_lines[data_start])
143
+ matrix = _parse_data_rows(raw_lines[data_start + 1 :], len(col_names))
144
+ if matrix.shape[0] == 0:
145
+ raise ValueError(f"no valid data rows in {path.name}")
146
+
147
+ x_idx = resolve_column(x_axis, col_names, _QD_SHORTHAND, "x-axis")
148
+ if x_idx == NO_COLUMN:
149
+ raise ValueError("x-axis column could not be resolved")
150
+ x_idx = _auto_x_index(col_names, matrix, x_idx)
151
+
152
+ if isinstance(y_axis, str) and y_axis.lower() == "all":
153
+ y_idx = _resolve_all_columns(col_names, matrix, x_idx, include_raw)
154
+ else:
155
+ specs: list[str | int] = [y_axis] if isinstance(y_axis, (str, int)) else list(y_axis)
156
+ y_idx = [resolve_column(s, col_names, _QD_SHORTHAND, "y-axis") for s in specs]
157
+ if not y_idx:
158
+ raise ValueError("no valid data columns resolved")
159
+ y_idx = _apply_moment_fallback(col_names, matrix, y_idx)
160
+
161
+ metadata: dict[str, Any] = {
162
+ "source": str(path),
163
+ "parser_name": "import_qd_vsm",
164
+ "x_column_name": col_names[x_idx],
165
+ "x_column_unit": col_units[x_idx],
166
+ "x_column_index": x_idx,
167
+ "y_column_indices": list(y_idx),
168
+ "all_column_names": col_names,
169
+ "all_column_units": col_units,
170
+ **header,
171
+ }
172
+ return DataStruct.create(
173
+ matrix[:, x_idx],
174
+ matrix[:, y_idx],
175
+ labels=[col_names[i] for i in y_idx],
176
+ units=[col_units[i] for i in y_idx],
177
+ metadata=metadata,
178
+ )
179
+
180
+
181
+ def _parse_header(raw_lines: Sequence[str]) -> tuple[dict[str, Any], int]:
182
+ header: dict[str, Any] = {"instrument": {}}
183
+ in_header = False
184
+ for i, raw in enumerate(raw_lines):
185
+ line = raw.strip()
186
+ if line.lower() == "[header]":
187
+ in_header = True
188
+ continue
189
+ if line.lower() == "[data]":
190
+ return header, i + 1
191
+ if not in_header or line.startswith(";"):
192
+ continue
193
+ parts = line.split(",")
194
+ if len(parts) < 2:
195
+ continue
196
+ key = parts[0].strip().upper()
197
+ if key == "TITLE":
198
+ header["title"] = ",".join(parts[1:]).strip()
199
+ elif key == "BYAPP":
200
+ header["app"] = ",".join(parts[1:]).strip()
201
+ elif key == "INFO" and len(parts) >= 3:
202
+ header["instrument"][parts[2].strip()] = parts[1].strip()
203
+ elif key == "STARTUPAXIS" and len(parts) >= 3:
204
+ axis = parts[1].strip().lower()
205
+ try:
206
+ col = int(float(parts[2]))
207
+ except ValueError:
208
+ col = NO_COLUMN
209
+ header["startup_axis_x" if axis == "x" else "startup_axis_y"] = col
210
+ return header, -1
211
+
212
+
213
+ def _parse_column_row(col_header: str) -> tuple[list[str], list[str]]:
214
+ names: list[str] = []
215
+ units: list[str] = []
216
+ for cell in col_header.split(","):
217
+ name, unit = parse_col_header(cell.strip())
218
+ names.append(name)
219
+ units.append(unit)
220
+ return names, units
221
+
222
+
223
+ def _parse_data_rows(data_lines: Sequence[str], n_cols: int) -> np.ndarray:
224
+ rows: list[list[float]] = []
225
+ for raw in data_lines:
226
+ if not raw.strip():
227
+ continue
228
+ tokens = raw.split(",")
229
+ row = [float("nan")] * n_cols
230
+ for c in range(min(len(tokens), n_cols)):
231
+ row[c] = _to_float(tokens[c])
232
+ rows.append(row)
233
+ if not rows:
234
+ return np.empty((0, n_cols), dtype=float)
235
+ return np.asarray(rows, dtype=float)
236
+
237
+
238
+ def import_mpms(
239
+ filepath: str | Path,
240
+ *,
241
+ x_axis: str | int = "temp",
242
+ y_axis: str | int | Sequence[str | int] = "dcmoment",
243
+ include_raw: bool = False,
244
+ ) -> DataStruct:
245
+ """Import a QD MPMS SQUID ``.dat``.
246
+
247
+ MATLAB's importMPMS delegates to importQDVSM with MPMS defaults (temperature
248
+ vs DC moment) and re-tags the metadata; this mirrors that exactly.
249
+ """
250
+ ds = import_qd_vsm(filepath, x_axis=x_axis, y_axis=y_axis, include_raw=include_raw)
251
+ meta = dict(ds.metadata)
252
+ meta["parser_name"] = "import_mpms"
253
+ meta["instrument_type"] = "MPMS SQUID"
254
+ return DataStruct.create(
255
+ ds.time, ds.values, labels=list(ds.labels), units=list(ds.units), metadata=meta
256
+ )
257
+
258
+
259
+ def is_ppms_dat(path: Path) -> bool:
260
+ """Sniff a plain-CSV PPMS ``.dat``: no [Header]; first data line names a QD column."""
261
+ head = Path(path).read_text(encoding="latin-1", errors="replace")[:2048]
262
+ if "[header]" in head.lower():
263
+ return False
264
+ for line in head.splitlines():
265
+ stripped = line.strip()
266
+ if not stripped or stripped[0] in _COMMENT_CHARS:
267
+ continue
268
+ low = stripped.lower()
269
+ return ("," in stripped or "\t" in stripped) and (
270
+ "magnetic field" in low or "moment" in low or "temperature" in low
271
+ )
272
+ return False
273
+
274
+
275
+ def import_ppms(
276
+ filepath: str | Path,
277
+ *,
278
+ x_axis: str | int = "field",
279
+ y_axis: str | int | Sequence[str | int] = "moment",
280
+ include_raw: bool = False,
281
+ ) -> DataStruct:
282
+ """Import a legacy PPMS/VSM plain-CSV ``.dat`` (no [Header]/[Data] markers)."""
283
+ path = Path(filepath)
284
+ lines = path.read_text(encoding="latin-1").splitlines()
285
+
286
+ header_idx = next(
287
+ (i for i, ln in enumerate(lines) if ln.strip() and ln.strip()[0] not in _COMMENT_CHARS),
288
+ -1,
289
+ )
290
+ if header_idx < 0:
291
+ raise ValueError(f"no header row found in {path.name}")
292
+ header_line = lines[header_idx]
293
+ delim = "\t" if "\t" in header_line else ","
294
+
295
+ raw_headers = [h.strip() for h in header_line.split(delim)]
296
+ first_col = 0
297
+ if raw_headers and (raw_headers[0].lower() == "comment" or raw_headers[0] == ""):
298
+ raw_headers = raw_headers[1:]
299
+ first_col = 1
300
+ col_names: list[str] = []
301
+ col_units: list[str] = []
302
+ for cell in raw_headers:
303
+ name, unit = parse_col_header(cell)
304
+ col_names.append(name)
305
+ col_units.append(unit)
306
+ n_cols = len(col_names)
307
+
308
+ rows: list[list[float]] = []
309
+ for ln in lines[header_idx + 1 :]:
310
+ if not ln.strip():
311
+ continue
312
+ parts = ln.split(delim)
313
+ row = [float("nan")] * n_cols
314
+ for c in range(n_cols):
315
+ src = c + first_col
316
+ if src < len(parts):
317
+ row[c] = _to_float(parts[src])
318
+ if any(not np.isnan(v) for v in row):
319
+ rows.append(row)
320
+ if not rows:
321
+ raise ValueError(f"no valid data rows in {path.name}")
322
+ matrix = np.asarray(rows, dtype=float)
323
+
324
+ # The PPMS sniffer accepts any QD-ish plain CSV (e.g. resistance-vs-temperature
325
+ # or moment-only files), so the default x/y ("field"/"moment") may be absent.
326
+ # Degrade to auto-detection instead of crashing with a KeyError.
327
+ try:
328
+ x_idx = resolve_column(x_axis, col_names, _QD_SHORTHAND, "x-axis")
329
+ except KeyError:
330
+ x_idx = NO_COLUMN
331
+ if x_idx == NO_COLUMN:
332
+ x_idx = 0
333
+ x_idx = _auto_x_index(col_names, matrix, x_idx)
334
+ if isinstance(y_axis, str) and y_axis.lower() == "all":
335
+ y_idx = _resolve_all_columns(col_names, matrix, x_idx, include_raw)
336
+ else:
337
+ specs: list[str | int] = [y_axis] if isinstance(y_axis, (str, int)) else list(y_axis)
338
+ try:
339
+ y_idx = [resolve_column(s, col_names, _QD_SHORTHAND, "y-axis") for s in specs]
340
+ except KeyError:
341
+ y_idx = _resolve_all_columns(col_names, matrix, x_idx, include_raw)
342
+ if not y_idx:
343
+ raise ValueError("no valid data columns resolved")
344
+ y_idx = _apply_moment_fallback(col_names, matrix, y_idx)
345
+
346
+ metadata: dict[str, Any] = {
347
+ "source": str(path),
348
+ "parser_name": "import_ppms",
349
+ "x_column_name": col_names[x_idx],
350
+ "x_column_unit": col_units[x_idx],
351
+ "all_column_names": col_names,
352
+ "all_column_units": col_units,
353
+ }
354
+ return DataStruct.create(
355
+ matrix[:, x_idx],
356
+ matrix[:, y_idx],
357
+ labels=[col_names[i] for i in y_idx],
358
+ units=[col_units[i] for i in y_idx],
359
+ metadata=metadata,
360
+ )
361
+
362
+
363
+ def _resolve_all_columns(
364
+ col_names: Sequence[str],
365
+ matrix: np.ndarray,
366
+ x_idx: int,
367
+ include_raw: bool,
368
+ ) -> list[int]:
369
+ """All numeric columns except x / Comment / Map* with >50% finite values."""
370
+ n_rows = matrix.shape[0]
371
+ idx: list[int] = []
372
+ for c, name in enumerate(col_names):
373
+ if c == x_idx or name == "Comment" or name.startswith("Map"):
374
+ continue
375
+ if not include_raw and ("Raw" in name or "Quad" in name):
376
+ continue
377
+ frac = float(np.count_nonzero(~np.isnan(matrix[:, c]))) / n_rows
378
+ if frac > 0.5:
379
+ idx.append(c)
380
+ return idx
quantized/io/refl1d.py ADDED
@@ -0,0 +1,132 @@
1
+ """refl1d output ``.dat`` parser (profile / refl / slabs / steps).
2
+
3
+ Port of MATLAB parser.importRefl1dDat. ``#``-prefixed header with optional
4
+ ``key: value`` metadata lines and one column-name line ("z (A) rho (1e-6/A2)
5
+ ..."); first column -> time, the rest -> values.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import numpy as np
15
+
16
+ from quantized.datastruct import DataStruct
17
+
18
+ __all__ = ["import_refl1d_dat", "is_refl1d_dat"]
19
+
20
+ _KV_RE = re.compile(r"^(\w[\w\s]*\w|\w+):\s*(.+)$")
21
+ _TOKEN_RE = re.compile(r"\S+(?:\s*\([^)]*\))?")
22
+ _UNIT_RE = re.compile(r"^(.+?)\s*\(([^)]+)\)$")
23
+ # Column-header signals, matched on word boundaries so a prose comment like
24
+ # "rhodium thermometer" or "quick readout" no longer false-positives (the bare
25
+ # substrings "rho"/"q"+"r" did — mis-routing PPMS files into this parser).
26
+ _RHO_RE = re.compile(r"\brho\b")
27
+ _Z_COL_RE = re.compile(r"\bz\s*\(")
28
+ _Q_COL_RE = re.compile(r"\bq\b")
29
+ _R_COL_RE = re.compile(r"\br\b")
30
+
31
+
32
+ def is_refl1d_dat(path: Path) -> bool:
33
+ """Sniff a ``.dat`` as refl1d output: a ``#``-comment column header naming a
34
+ profile (``z``/``rho``) or reflectivity (``Q``/``R``) axis, and not a QD
35
+ ``[Header]`` file. The column header may follow other ``#`` metadata lines
36
+ (e.g. ``# intensity:`` / ``# background:`` in refl-fit exports), so scan every
37
+ comment line rather than only the first non-empty one."""
38
+ head = Path(path).read_text(encoding="latin-1", errors="replace")[:512]
39
+ if "[header]" in head.lower():
40
+ return False
41
+ for line in head.splitlines():
42
+ stripped = line.strip()
43
+ if not stripped.startswith("#"):
44
+ continue
45
+ low = stripped.lower()
46
+ if (
47
+ _RHO_RE.search(low)
48
+ or _Z_COL_RE.search(low)
49
+ or (_Q_COL_RE.search(low) and _R_COL_RE.search(low))
50
+ ):
51
+ return True
52
+ return False
53
+
54
+
55
+ def import_refl1d_dat(filepath: str | Path) -> DataStruct:
56
+ path = Path(filepath)
57
+ lines = path.read_text(encoding="latin-1").splitlines()
58
+
59
+ header_meta: dict[str, Any] = {}
60
+ column_line = ""
61
+ data_start = len(lines)
62
+ for i, raw in enumerate(lines):
63
+ stripped = raw.strip()
64
+ if not stripped.startswith("#"):
65
+ data_start = i
66
+ break
67
+ content = stripped[1:].strip()
68
+ if not content:
69
+ continue
70
+ kv = _KV_RE.match(content)
71
+ if kv:
72
+ key, val = kv.group(1), kv.group(2)
73
+ try:
74
+ header_meta[key] = float(val)
75
+ except ValueError:
76
+ header_meta[key] = val
77
+ else:
78
+ column_line = content
79
+
80
+ labels_all: list[str] = []
81
+ units_all: list[str] = []
82
+ for tok in _TOKEN_RE.findall(column_line):
83
+ unit_match = _UNIT_RE.match(tok.strip())
84
+ if unit_match:
85
+ labels_all.append(unit_match.group(1).strip())
86
+ units_all.append(unit_match.group(2).strip())
87
+ else:
88
+ labels_all.append(tok.strip())
89
+ units_all.append("")
90
+
91
+ rows: list[list[float]] = []
92
+ for raw in lines[data_start:]:
93
+ stripped = raw.strip()
94
+ if not stripped or stripped.startswith("#"):
95
+ continue
96
+ try:
97
+ rows.append([float(t) for t in stripped.split()])
98
+ except ValueError:
99
+ continue
100
+ if not rows:
101
+ raise ValueError(f"no numeric data in {path.name}")
102
+ # Pad/truncate ragged rows to the column count (header if known, else the
103
+ # widest row), filling gaps with NaN — mirrors MATLAB textscan, which yields
104
+ # NaN for missing fields rather than failing on a truncated/disk-cut file.
105
+ target = len(labels_all) if labels_all else max(len(r) for r in rows)
106
+ if any(len(r) != target for r in rows):
107
+ rows = [(r + [float("nan")] * (target - len(r)))[:target] for r in rows]
108
+ matrix = np.asarray(rows, dtype=float)
109
+ n_cols = matrix.shape[1]
110
+ if n_cols < 2:
111
+ raise ValueError(
112
+ f"refl1d .dat needs at least 2 columns (found {n_cols}) in {path.name}"
113
+ )
114
+
115
+ if not labels_all:
116
+ labels_all = [f"Col{j + 1}" for j in range(n_cols)]
117
+ units_all = [""] * n_cols
118
+
119
+ metadata: dict[str, Any] = {
120
+ "source": str(path),
121
+ "parser_name": "import_refl1d_dat",
122
+ "x_column_name": labels_all[0],
123
+ "x_column_unit": units_all[0],
124
+ **header_meta,
125
+ }
126
+ return DataStruct.create(
127
+ matrix[:, 0],
128
+ matrix[:, 1:],
129
+ labels=labels_all[1:n_cols],
130
+ units=units_all[1:n_cols],
131
+ metadata=metadata,
132
+ )