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,163 @@
1
+ """Lake Shore VSM ``.csv``/``.dat`` parser. Port of MATLAB parser.importLakeShore.
2
+
3
+ Auto-detects the column-header row (first comma line with >50% non-numeric
4
+ fields), then defaults to Temperature (x) vs Moment (y).
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_lake_shore", "is_lakeshore_file"]
19
+
20
+ _LS_SHORTHAND: dict[str, str] = {
21
+ "temp": "Temperature",
22
+ "temperature": "Temperature",
23
+ "field": "Magnetic Field",
24
+ "appliedfield": "Magnetic Field",
25
+ "moment": "Moment",
26
+ }
27
+
28
+
29
+ def _is_nan_token(token: str) -> bool:
30
+ try:
31
+ float(token)
32
+ return False
33
+ except ValueError:
34
+ return True
35
+
36
+
37
+ def _to_float(token: str) -> float:
38
+ token = token.strip()
39
+ if not token:
40
+ return float("nan")
41
+ try:
42
+ return float(token)
43
+ except ValueError:
44
+ return float("nan")
45
+
46
+
47
+ def _detect_header_row(lines: Sequence[str]) -> int:
48
+ """First line (of the first 100) with a comma and >50% non-numeric fields."""
49
+ for i, raw in enumerate(lines[:100]):
50
+ line = raw.strip()
51
+ if not line or "," not in line:
52
+ continue
53
+ parts = [p.strip() for p in line.split(",")]
54
+ if len(parts) < 2:
55
+ continue
56
+ text_count = sum(1 for p in parts if _is_nan_token(p))
57
+ if text_count / len(parts) > 0.5:
58
+ return i
59
+ return -1
60
+
61
+
62
+ def import_lake_shore(
63
+ filepath: str | Path,
64
+ *,
65
+ x_axis: str | int = "temp",
66
+ y_axis: str | int | Sequence[str | int] = "moment",
67
+ ) -> DataStruct:
68
+ """Import a Lake Shore VSM file (Temperature vs Moment by default)."""
69
+ path = Path(filepath)
70
+ lines = path.read_text(encoding="latin-1").splitlines()
71
+ header_idx = _detect_header_row(lines)
72
+ if header_idx < 0:
73
+ raise ValueError(f"could not detect column-header row in {path.name}")
74
+
75
+ raw_cols = [c.strip() for c in lines[header_idx].split(",")]
76
+ n_cols = len(raw_cols)
77
+ col_names: list[str] = []
78
+ col_units: list[str] = []
79
+ for cell in raw_cols:
80
+ name, unit = parse_col_header(cell)
81
+ col_names.append(name)
82
+ col_units.append(unit)
83
+
84
+ rows: list[list[float]] = []
85
+ for raw in lines[header_idx + 1 :]:
86
+ if not raw.strip():
87
+ continue # drop blank rows only (MATLAB parity)
88
+ parts = raw.split(",")
89
+ row = [float("nan")] * n_cols
90
+ for c in range(min(len(parts), n_cols)):
91
+ row[c] = _to_float(parts[c])
92
+ rows.append(row)
93
+ if not rows:
94
+ raise ValueError(f"no data rows in {path.name}")
95
+ matrix = np.asarray(rows, dtype=float)
96
+
97
+ x_idx = resolve_column(x_axis, col_names, _LS_SHORTHAND, "x-axis")
98
+ if x_idx == NO_COLUMN:
99
+ raise ValueError("x-axis column could not be resolved")
100
+ if isinstance(y_axis, str) and y_axis.lower() == "all":
101
+ y_idx = [c for c in range(n_cols) if c != x_idx]
102
+ else:
103
+ specs: list[str | int] = [y_axis] if isinstance(y_axis, (str, int)) else list(y_axis)
104
+ # resolve_column raises KeyError for an unresolvable name and returns
105
+ # NO_COLUMN (-1) for empty specs - the latter would silently index the
106
+ # LAST column as the signal (latent while the parser was unregistered;
107
+ # reachable via import_auto since MAIN #7). Normalize both to a clean
108
+ # ValueError like every other unresolved-column path.
109
+ y_idx = []
110
+ unresolved: list[str] = []
111
+ for spec in specs:
112
+ try:
113
+ idx = resolve_column(spec, col_names, _LS_SHORTHAND, "y-axis")
114
+ except KeyError:
115
+ unresolved.append(str(spec))
116
+ continue
117
+ if idx == NO_COLUMN:
118
+ unresolved.append(str(spec))
119
+ else:
120
+ y_idx.append(idx)
121
+ if unresolved:
122
+ raise ValueError(
123
+ "y-axis column(s) could not be resolved: " + ", ".join(unresolved)
124
+ )
125
+ if not y_idx:
126
+ raise ValueError("no y-axis columns resolved")
127
+
128
+ metadata: dict[str, Any] = {
129
+ "source": str(path),
130
+ "parser_name": "import_lake_shore",
131
+ "x_column_name": col_names[x_idx],
132
+ "x_column_unit": col_units[x_idx],
133
+ "all_column_names": col_names,
134
+ "all_column_units": col_units,
135
+ }
136
+ return DataStruct.create(
137
+ matrix[:, x_idx],
138
+ matrix[:, y_idx],
139
+ labels=[col_names[i] for i in y_idx],
140
+ units=[col_units[i] for i in y_idx],
141
+ metadata=metadata,
142
+ )
143
+
144
+
145
+ def is_lakeshore_file(path: Path) -> bool:
146
+ """Content sniffer for ambiguous ``.csv``/``.dat``: True for a Lake Shore
147
+ VSM export — the instrument writes a "Lake Shore" preamble line above the
148
+ column-header row (see the module docstring). Tight on purpose: a generic
149
+ CSV only matches if it self-identifies as Lake Shore in its first 2 KB.
150
+ A sniffer must never raise; unreadable -> not Lake Shore."""
151
+ try:
152
+ text = Path(path).read_text(encoding="latin-1", errors="replace")[:2048]
153
+ except Exception: # noqa: BLE001
154
+ return False
155
+ lower = text.lower()
156
+ # Vendor marker must sit in the PREAMBLE (the instrument titles the file),
157
+ # not merely appear anywhere - a generic CSV mentioning "Lake Shore" in a
158
+ # comment or data column must NOT reroute away from import_csv. And the
159
+ # header region must actually carry a resolvable instrument column.
160
+ head_lines = [ln.strip().lower() for ln in lower.splitlines()[:3]]
161
+ if not any("lake shore" in ln for ln in head_lines):
162
+ return False
163
+ return any(col in lower for col in ("temperature", "moment", "magnetic field"))
quantized/io/ncnr.py ADDED
@@ -0,0 +1,278 @@
1
+ """NCNR reductus reflectometry parser (.refl). Port of parser.importNCNRRefl.
2
+
3
+ Format: ``#``-prefixed JSON-ish header (name / polarization / wavelength /
4
+ columns / units) then whitespace-delimited numeric rows. time = Qz (column 0),
5
+ values = the remaining columns (Intensity, uncertainty, resolution). Rows with
6
+ any non-numeric token are dropped (matches MATLAB's any-NaN filter).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import numpy as np
17
+
18
+ from quantized.datastruct import DataStruct
19
+
20
+ __all__ = ["import_ncnr_dat", "import_ncnr_pnr", "import_ncnr_refl", "is_ncnr_refl"]
21
+
22
+ _HEADER_RE = re.compile(r'#\s*"([^"]+)":\s*(.+)$')
23
+
24
+
25
+ def _loads(text: str) -> Any:
26
+ try:
27
+ return json.loads(text)
28
+ except (json.JSONDecodeError, ValueError):
29
+ return None
30
+
31
+
32
+ def is_ncnr_refl(path: str | Path) -> bool:
33
+ """Sniff a ``.refl`` as NCNR reductus output: its ``#``-comment header carries
34
+ a JSON ``"columns": [...]`` line. A refl1d-exported ``.refl`` instead uses a
35
+ plain ``Q (1/A) R dR`` column line, so it won't match — letting the registry
36
+ route it to the refl1d parser. Scans the header line-by-line (the reductus
37
+ ``"template_data"`` line alone can exceed several KB, so a fixed byte slice
38
+ would miss the later ``"columns"`` key) and stops at the first data row."""
39
+ with Path(path).open(encoding="latin-1", errors="replace") as fh:
40
+ for raw in fh:
41
+ line = raw.rstrip("\n")
42
+ if not line.startswith("#"):
43
+ break # header ended without a "columns" key
44
+ match = _HEADER_RE.match(line)
45
+ if match is not None and match.group(1) == "columns":
46
+ return True
47
+ return False
48
+
49
+
50
+ def import_ncnr_refl(filepath: str | Path) -> DataStruct:
51
+ """Import an NCNR reductus ``.refl`` file (PBR or CANDOR)."""
52
+ path = Path(filepath)
53
+ lines = path.read_text(encoding="latin-1").splitlines()
54
+
55
+ name = ""
56
+ polarization = ""
57
+ wavelength: list[float] = []
58
+ columns: list[str] = []
59
+ units: list[str] = []
60
+ data_start = len(lines)
61
+
62
+ for i, line in enumerate(lines):
63
+ if not line.startswith("#"):
64
+ data_start = i
65
+ break
66
+ match = _HEADER_RE.match(line)
67
+ if match is None:
68
+ continue
69
+ key, valstr = match.group(1), match.group(2).strip()
70
+ if key == "name":
71
+ parsed = _loads(valstr)
72
+ name = parsed if isinstance(parsed, str) else valstr.strip('"')
73
+ elif key == "polarization":
74
+ parsed = _loads(valstr)
75
+ polarization = parsed if isinstance(parsed, str) else valstr.strip('"')
76
+ elif key == "wavelength":
77
+ parsed = _loads(valstr)
78
+ if isinstance(parsed, list):
79
+ wavelength = [float(x) for x in parsed]
80
+ elif isinstance(parsed, (int, float)):
81
+ wavelength = [float(parsed)]
82
+ elif key == "columns":
83
+ parsed = _loads(valstr)
84
+ if isinstance(parsed, list):
85
+ columns = [str(x) for x in parsed]
86
+ elif key == "units":
87
+ parsed = _loads(valstr)
88
+ if isinstance(parsed, list):
89
+ units = [str(x) for x in parsed]
90
+
91
+ if not columns:
92
+ raise ValueError(f"no 'columns' header found in {path.name}")
93
+
94
+ rows: list[list[float]] = []
95
+ for line in lines[data_start:]:
96
+ text = line.strip()
97
+ if not text:
98
+ continue
99
+ try:
100
+ rows.append([float(t) for t in text.split()])
101
+ except ValueError:
102
+ continue # any non-numeric token -> drop the row (MATLAB parity)
103
+ if not rows:
104
+ raise ValueError(f"no numeric data rows in {path.name}")
105
+ matrix = np.asarray(rows, dtype=float)
106
+
107
+ n_cols = len(columns)
108
+ qz = matrix[:, 0]
109
+ values = matrix[:, 1:n_cols]
110
+ labels = columns[1:n_cols]
111
+ out_units = [units[j] if j < len(units) else "" for j in range(1, n_cols)]
112
+
113
+ instrument_type = "PBR (monochromatic)" if len(wavelength) == 1 else "CANDOR (polychromatic)"
114
+ metadata: dict[str, Any] = {
115
+ "source": str(path),
116
+ "parser_name": "import_ncnr_refl",
117
+ "x_column_name": "Qz",
118
+ "x_column_unit": units[0] if units else "1/Ang",
119
+ "name": name,
120
+ "polarization": polarization,
121
+ "instrument_type": instrument_type,
122
+ "wavelengths": wavelength,
123
+ }
124
+ return DataStruct.create(qz, values, labels=labels, units=out_units, metadata=metadata)
125
+
126
+
127
+ # ── Polarized .pnr (tab-delimited, 2 header rows) ────────────────────────────
128
+ _POL_REPLACEMENTS = (
129
+ ("++", "pp"), ("+-", "pm"), ("-+", "mp"), ("--", "mm"), ("+/-", "pm"), ("-/+", "mp"),
130
+ )
131
+
132
+
133
+ def _clean_polarization(label: str) -> str:
134
+ for old, new in _POL_REPLACEMENTS:
135
+ label = label.replace(old, new)
136
+ return label
137
+
138
+
139
+ def import_ncnr_pnr(filepath: str | Path) -> DataStruct:
140
+ """Import an NCNR polarized neutron reflectometry ``.pnr`` (tab-delimited)."""
141
+ path = Path(filepath)
142
+ lines = path.read_text(encoding="latin-1").splitlines()
143
+ if len(lines) < 3:
144
+ raise ValueError(f"{path.name}: too few lines for a .pnr file")
145
+ col_names = lines[0].strip().split("\t")
146
+ units = lines[1].strip().split("\t")
147
+ n_cols = len(col_names)
148
+
149
+ rows: list[list[float]] = []
150
+ for raw in lines[2:]:
151
+ stripped = raw.strip()
152
+ if not stripped:
153
+ continue
154
+ tokens = stripped.split("\t")
155
+ if len(tokens) < n_cols:
156
+ continue
157
+ try:
158
+ rows.append([float(t) for t in tokens[:n_cols]])
159
+ except ValueError:
160
+ continue
161
+ if not rows:
162
+ raise ValueError(f"no numeric data in {path.name}")
163
+ matrix = np.asarray(rows, dtype=float)
164
+
165
+ labels = [_clean_polarization(c) for c in col_names[1:]]
166
+ out_units = [units[j] if j < len(units) else "" for j in range(1, n_cols)]
167
+
168
+ lowered = " ".join(col_names).lower()
169
+ is_nsf = "r++" in lowered or "r--" in lowered
170
+ is_sf = "r+-" in lowered or "r-+" in lowered or "r+/-" in lowered
171
+ if is_nsf and is_sf:
172
+ variant = "combined"
173
+ elif is_nsf:
174
+ variant = "NSF"
175
+ elif is_sf:
176
+ variant = "SF"
177
+ else:
178
+ variant = "unknown"
179
+
180
+ metadata: dict[str, Any] = {
181
+ "source": str(path),
182
+ "parser_name": "import_ncnr_pnr",
183
+ "x_column_name": "Q",
184
+ "x_column_unit": units[0] if units else "1/Ang",
185
+ "variant": variant,
186
+ }
187
+ return DataStruct.create(
188
+ matrix[:, 0], matrix[:, 1:], labels=labels, units=out_units, metadata=metadata
189
+ )
190
+
191
+
192
+ # ── refl1d-fit cross sections (.datA/.datB/.datC/.datD) ──────────────────────
193
+ _NCNR_POL_BY_EXT = {".datA": "++", ".datB": "+-", ".datC": "-+", ".datD": "--"}
194
+ _NCNR_DAT_LABELS = ["dQ", "R", "dR", "theory", "fresnel"]
195
+ _NCNR_DAT_UNITS = ["1/A", "", "", "", ""]
196
+
197
+
198
+ def _safe_float(text: str) -> float:
199
+ try:
200
+ return float(text.strip())
201
+ except ValueError:
202
+ return float("nan")
203
+
204
+
205
+ def import_ncnr_dat(filepath: str | Path) -> DataStruct:
206
+ """Import an NCNR refl1d-fit cross section (.datA/.datB/.datC/.datD)."""
207
+ path = Path(filepath)
208
+ pol = next(
209
+ (v for k, v in _NCNR_POL_BY_EXT.items() if k.lower() == path.suffix.lower()), None
210
+ )
211
+ if pol is None:
212
+ raise ValueError(f"{path.name}: expected extension .datA/.datB/.datC/.datD")
213
+ lines = path.read_text(encoding="latin-1").splitlines()
214
+
215
+ intensity = float("nan")
216
+ background = float("nan")
217
+ data_start = 0
218
+ # Scan the whole comment header (not just the first 5 lines) so intensity /
219
+ # background metadata is captured wherever it sits; the loop still breaks at
220
+ # the column header or first data row.
221
+ for i, line in enumerate(lines):
222
+ if line.startswith("# intensity:"):
223
+ intensity = _safe_float(line.split(":", 1)[1])
224
+ elif line.startswith("# background:"):
225
+ background = _safe_float(line.split(":", 1)[1])
226
+ elif line.startswith("#") and "Q (1/A)" in line:
227
+ data_start = i + 1
228
+ break
229
+ elif not line.startswith("#"):
230
+ data_start = i
231
+ break
232
+
233
+ rows: list[list[float]] = []
234
+ for line in lines[data_start:]:
235
+ stripped = line.strip()
236
+ if not stripped or stripped.startswith("#"):
237
+ continue
238
+ try:
239
+ rows.append([float(t) for t in stripped.split()])
240
+ except ValueError:
241
+ continue
242
+ if not rows:
243
+ raise ValueError(f"no numeric data in {path.name}")
244
+ width = len(rows[0])
245
+ matrix = np.asarray([r for r in rows if len(r) == width], dtype=float)
246
+
247
+ n_val = matrix.shape[1] - 1
248
+ labels: list[str] = []
249
+ units: list[str] = []
250
+ for j in range(n_val):
251
+ labels.append(_NCNR_DAT_LABELS[j] if j < len(_NCNR_DAT_LABELS) else f"col{j + 1}")
252
+ units.append(_NCNR_DAT_UNITS[j] if j < len(_NCNR_DAT_UNITS) else "")
253
+
254
+ metadata: dict[str, Any] = {
255
+ "source": str(path),
256
+ "parser_name": "import_ncnr_dat",
257
+ "x_column_name": "Q",
258
+ "x_column_unit": "1/Ang",
259
+ "polarization": pol,
260
+ }
261
+ if not np.isnan(intensity):
262
+ metadata["intensity"] = intensity
263
+ if not np.isnan(background):
264
+ metadata["background"] = background
265
+ # Plot hints (honoured by the frontend defaults): show the measured
266
+ # reflectivity R and the fit line 'theory' by default, with dR as R's error
267
+ # bars; the resolution 'dQ' and normalisation 'fresnel' columns stay off the
268
+ # plot by default (still toggleable in the Channels card). Keyed by value-
269
+ # column index (0-based, x/Q excluded).
270
+ label_idx = {lab: j for j, lab in enumerate(labels)}
271
+ default_channels = [label_idx[name] for name in ("R", "theory") if name in label_idx]
272
+ if default_channels:
273
+ metadata["default_value_channels"] = default_channels
274
+ if "R" in label_idx and "dR" in label_idx:
275
+ metadata["error_channels"] = {label_idx["R"]: label_idx["dR"]}
276
+ return DataStruct.create(
277
+ matrix[:, 0], matrix[:, 1:], labels=labels, units=units, metadata=metadata
278
+ )
quantized/io/netcdf.py ADDED
@@ -0,0 +1,195 @@
1
+ """NetCDF import (``.nc`` / ``.cdf``) — chromatography (ANDI/AIA) + generic.
2
+
3
+ NetCDF is a self-describing container, so this parser has two layers:
4
+
5
+ * a **reader** that normalizes both on-disk formats to a common view —
6
+ NetCDF-3 "classic"/64-bit (magic ``CDF\\x01``/``\\x02``/``\\x05``) via
7
+ ``scipy.io.netcdf_file``, and NetCDF-4 (HDF5-backed, magic ``\\x89HDF``) via
8
+ ``h5py``. Both are existing quantized deps (no new dependency);
9
+ * an **interpreter** that recognizes the ANDI/AIA Chromatography convention
10
+ (``total_intensity`` vs ``scan_acquisition_time`` = a TIC; or
11
+ ``ordinate_values`` + sampling interval = a single-channel trace) and
12
+ otherwise falls back to a generic "pick the monotonic coordinate as x, the
13
+ rest as channels" heuristic.
14
+
15
+ Pure layer: ndarray/DataStruct in -> out. No fastapi/pydantic imports.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ import numpy as np
25
+
26
+ from quantized.datastruct import DataStruct
27
+
28
+ __all__ = ["import_netcdf", "is_netcdf"]
29
+
30
+ _CDF_MAGICS = (b"CDF\x01", b"CDF\x02", b"CDF\x05")
31
+ _HDF5_MAGIC = b"\x89HDF"
32
+
33
+
34
+ @dataclass
35
+ class _Var:
36
+ data: np.ndarray
37
+ units: str = ""
38
+
39
+
40
+ @dataclass
41
+ class _NcData:
42
+ variables: dict[str, _Var]
43
+ attrs: dict[str, Any] = field(default_factory=dict)
44
+
45
+
46
+ def is_netcdf(path: Path) -> bool:
47
+ """Sniff a file as NetCDF-3 (``CDF``) or NetCDF-4/HDF5 (``\\x89HDF``)."""
48
+ with Path(path).open("rb") as fh:
49
+ head = fh.read(4)
50
+ return head in _CDF_MAGICS or head == _HDF5_MAGIC
51
+
52
+
53
+ def _as_str(value: Any) -> str:
54
+ if isinstance(value, bytes):
55
+ return value.decode("latin-1", "replace").strip()
56
+ return str(value).strip()
57
+
58
+
59
+ def _read_netcdf3(path: Path) -> _NcData:
60
+ from scipy.io import netcdf_file # noqa: PLC0415
61
+
62
+ f = netcdf_file(str(path), "r", mmap=False)
63
+ try:
64
+ variables = {
65
+ name: _Var(np.asarray(var[:], dtype=float) if var.data.dtype.kind in "fiu"
66
+ else np.asarray(var[:]),
67
+ _as_str(getattr(var, "units", b"")))
68
+ for name, var in f.variables.items()
69
+ }
70
+ attrs = {k: _as_str(v) if isinstance(v, bytes) else v
71
+ for k, v in f._attributes.items()} # noqa: SLF001 (scipy's public-in-practice map)
72
+ finally:
73
+ f.close()
74
+ return _NcData(variables, attrs)
75
+
76
+
77
+ def _read_netcdf4(path: Path) -> _NcData:
78
+ import h5py # noqa: PLC0415
79
+
80
+ variables: dict[str, _Var] = {}
81
+ with h5py.File(path, "r") as h:
82
+ def _collect(name: str, obj: Any) -> None:
83
+ if isinstance(obj, h5py.Dataset) and obj.ndim <= 1:
84
+ key = name.rsplit("/", 1)[-1]
85
+ variables[key] = _Var(np.asarray(obj[()]), _as_str(obj.attrs.get("units", "")))
86
+
87
+ h.visititems(_collect)
88
+ attrs = {k: _as_str(v) if isinstance(v, bytes) else v for k, v in h.attrs.items()}
89
+ return _NcData(variables, attrs)
90
+
91
+
92
+ def _numeric_1d(nc: _NcData) -> dict[str, _Var]:
93
+ return {
94
+ n: v for n, v in nc.variables.items()
95
+ if v.data.ndim == 1 and v.data.size > 1 and v.data.dtype.kind in "fiu"
96
+ }
97
+
98
+
99
+ def _is_monotonic(a: np.ndarray) -> bool:
100
+ d = np.diff(a)
101
+ return bool(np.all(d > 0) or np.all(d < 0))
102
+
103
+
104
+ def _andi_axes(
105
+ nc: _NcData,
106
+ ) -> tuple[np.ndarray, np.ndarray, str, str, str, str] | None:
107
+ """Return (x, y, x_name, x_unit, y_label, y_unit) for ANDI/AIA, else None."""
108
+ v = nc.variables
109
+ if "total_intensity" in v and "scan_acquisition_time" in v:
110
+ t, tic = v["scan_acquisition_time"], v["total_intensity"]
111
+ return (np.asarray(t.data, float), np.asarray(tic.data, float),
112
+ "Retention Time", t.units or "seconds", "Total Intensity",
113
+ tic.units or "counts")
114
+ if "ordinate_values" in v:
115
+ y = np.asarray(v["ordinate_values"].data, float)
116
+ interval = _attr_float(nc, "actual_sampling_interval")
117
+ delay = _attr_float(nc, "actual_delay_time", 0.0) or 0.0
118
+ x = delay + np.arange(y.size) * interval if interval is not None \
119
+ else np.arange(y.size, dtype=float)
120
+ return x, y, "Retention Time", "seconds", "Signal", v["ordinate_values"].units or ""
121
+ return None
122
+
123
+
124
+ def _attr_float(nc: _NcData, key: str, default: float | None = None) -> float | None:
125
+ if key in nc.attrs:
126
+ try:
127
+ return float(nc.attrs[key])
128
+ except (TypeError, ValueError):
129
+ return default
130
+ if key in nc.variables and nc.variables[key].data.size == 1:
131
+ return float(np.asarray(nc.variables[key].data).ravel()[0])
132
+ return default
133
+
134
+
135
+ def import_netcdf(filepath: str | Path) -> DataStruct:
136
+ """Import a NetCDF ``.nc``/``.cdf`` into a DataStruct.
137
+
138
+ ANDI/AIA chromatography files yield the total-ion-current (or single
139
+ detector) trace vs retention time; generic files use the monotonic
140
+ coordinate variable as x and the remaining 1-D numeric variables as
141
+ channels.
142
+ """
143
+ path = Path(filepath)
144
+ with path.open("rb") as fh:
145
+ magic = fh.read(4)
146
+ if magic in _CDF_MAGICS:
147
+ nc = _read_netcdf3(path)
148
+ elif magic == _HDF5_MAGIC:
149
+ nc = _read_netcdf4(path)
150
+ else:
151
+ raise ValueError(f"not a NetCDF/HDF5 file (magic {magic!r}): {path.name}")
152
+
153
+ andi = _andi_axes(nc)
154
+ if andi is not None:
155
+ x, y, x_name, x_unit, y_label, y_unit = andi
156
+ return _build(path, x, y[:, None], [y_label], [y_unit], x_name, x_unit, nc, "ANDI")
157
+
158
+ numeric = _numeric_1d(nc)
159
+ if not numeric:
160
+ raise ValueError(f"no 1-D numeric variables to import: {path.name}")
161
+
162
+ names = list(numeric)
163
+ x_key = next((n for n in names if _is_monotonic(numeric[n].data)), names[0])
164
+ x = np.asarray(numeric[x_key].data, float)
165
+ channels = [n for n in names if n != x_key and numeric[n].data.size == x.size]
166
+ if not channels: # a single variable: index it
167
+ return _build(path, np.arange(x.size, dtype=float), x[:, None],
168
+ [_titlecase(x_key)], [numeric[x_key].units], "Index", "", nc, "generic")
169
+
170
+ values = np.column_stack([numeric[n].data for n in channels])
171
+ return _build(path, x, values, [_titlecase(n) for n in channels],
172
+ [numeric[n].units for n in channels], _titlecase(x_key),
173
+ numeric[x_key].units, nc, "generic")
174
+
175
+
176
+ def _titlecase(name: str) -> str:
177
+ return name.replace("_", " ").title()
178
+
179
+
180
+ def _build(
181
+ path: Path, x: np.ndarray, values: np.ndarray, labels: list[str], units: list[str],
182
+ x_name: str, x_unit: str, nc: _NcData, kind: str,
183
+ ) -> DataStruct:
184
+ metadata: dict[str, Any] = {
185
+ "source": str(path),
186
+ "parser_name": "import_netcdf",
187
+ "netcdf_kind": kind,
188
+ "x_column_name": x_name,
189
+ "x_column_unit": x_unit,
190
+ "num_points": int(x.size),
191
+ }
192
+ for k in ("aia_template_revision", "netcdf_revision", "experiment_type", "title"):
193
+ if k in nc.attrs:
194
+ metadata[k] = nc.attrs[k]
195
+ return DataStruct.create(x, values, labels=labels, units=units, metadata=metadata)