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,135 @@
1
+ """JCAMP-DX ASDF ordinate decoder for ``(X++(Y..Y))`` XYDATA.
2
+
3
+ ASDF (ASCII Squeezed Difference Form) packs ordinates with four schemes:
4
+
5
+ * **AFFN / PAC** — plain signed numbers (space- or ``+``/``-``-separated).
6
+ * **SQZ** (squeezed) — the leading digit carries the sign as a letter
7
+ (``@``=+0, ``A``-``I``=+1..+9, ``a``-``i``=-1..-9); an *absolute* value.
8
+ * **DIF** (difference) — leading letter (``%``=0, ``J``-``R``=+1..+9,
9
+ ``j``-``r``=-1..-9) encodes the *difference* from the previous ordinate.
10
+ * **DUP** (duplicate) — ``S``-``Z``,``s`` = 1..9 repeat the previous token that
11
+ many times *total* (so ``count-1`` additional copies); after a DIF token the
12
+ difference is re-applied, after an absolute the value is repeated.
13
+
14
+ Cross-line rule: in DIF mode each continuation line's first ordinate repeats
15
+ the previous line's last value as a **Y-value check** — it is verified and
16
+ dropped (the line's abscissa also leads each line and is discarded, since X is
17
+ reconstructed from FIRSTX/LASTX).
18
+
19
+ Algorithm adapted from the JCAMP-DX standard (McDonald & Wilks, 1988) and the
20
+ MIT-licensed ``nzhagen/jcamp`` reference; re-implemented here.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ __all__ = ["DifCheckError", "decode_xydata"]
26
+
27
+ _SQZ = {"@": 0, "A": 1, "B": 2, "C": 3, "D": 4, "E": 5, "F": 6, "G": 7, "H": 8, "I": 9,
28
+ "a": -1, "b": -2, "c": -3, "d": -4, "e": -5, "f": -6, "g": -7, "h": -8, "i": -9}
29
+ _DIF = {"%": 0, "J": 1, "K": 2, "L": 3, "M": 4, "N": 5, "O": 6, "P": 7, "Q": 8, "R": 9,
30
+ "j": -1, "k": -2, "l": -3, "m": -4, "n": -5, "o": -6, "p": -7, "q": -8, "r": -9}
31
+ _DUP = {"S": 1, "T": 2, "U": 3, "V": 4, "W": 5, "X": 6, "Y": 7, "Z": 8, "s": 9}
32
+
33
+ # Token modes.
34
+ _ABS, _DIFF, _DUPL = "ABS", "DIF", "DUP"
35
+
36
+
37
+ class DifCheckError(ValueError):
38
+ """A DIF Y-value check failed (a line's leading value != previous last)."""
39
+
40
+
41
+ def _tokenize(line: str) -> list[tuple[str, float]]:
42
+ """Split one data line into ``(mode, value)`` tokens (X first, then Y's).
43
+
44
+ A new token starts at a sign, a SQZ/DIF/DUP letter, or after a delimiter.
45
+ Digits, ``.`` and exponent chars extend a plain number.
46
+ """
47
+ tokens: list[tuple[str, float]] = []
48
+ cur = ""
49
+ cur_letter = "" # the SQZ/DIF/DUP letter that opened `cur`, if any
50
+
51
+ def flush() -> None:
52
+ nonlocal cur, cur_letter
53
+ if cur == "" and cur_letter == "":
54
+ return
55
+ if cur_letter in _SQZ:
56
+ tokens.append((_ABS, float(f"{_SQZ[cur_letter]}{cur}")))
57
+ elif cur_letter in _DIF:
58
+ tokens.append((_DIFF, float(f"{_DIF[cur_letter]}{cur}")))
59
+ elif cur_letter in _DUP:
60
+ tokens.append((_DUPL, float(int(f"{_DUP[cur_letter]}{cur}"))))
61
+ elif cur not in ("", "+", "-"):
62
+ tokens.append((_ABS, float(cur)))
63
+ cur, cur_letter = "", ""
64
+
65
+ for ch in line:
66
+ if ch in " \t,":
67
+ flush()
68
+ elif ch in _SQZ or ch in _DIF or ch in _DUP:
69
+ flush()
70
+ cur_letter = ch
71
+ elif ch in "+-" and cur[-1:] not in ("e", "E"):
72
+ flush()
73
+ cur = ch
74
+ elif ch.isdigit() or ch in ".eE":
75
+ cur += ch
76
+ # any other char (stray) ends the current token
77
+ elif ch:
78
+ flush()
79
+ flush()
80
+ return tokens
81
+
82
+
83
+ def decode_xydata(
84
+ data_lines: list[str], *, ycheck: bool = True, ytol: float = 1e-6
85
+ ) -> list[float]:
86
+ """Decode ASDF ``(X++(Y..Y))`` lines into a flat list of raw ordinates.
87
+
88
+ Parameters
89
+ ----------
90
+ ycheck
91
+ Verify DIF-mode Y-value checks; raise :class:`DifCheckError` on mismatch.
92
+ ytol
93
+ Absolute tolerance for the Y-value check (raw ordinate units).
94
+ """
95
+ y: list[float] = []
96
+ last = 0.0
97
+ prev_mode, prev_val = _ABS, 0.0
98
+ prev_line_dif = False # did the previous line end in DIF mode?
99
+
100
+ for li, line in enumerate(data_lines):
101
+ toks = _tokenize(line)
102
+ if not toks:
103
+ continue
104
+ ords = toks[1:] # first token is the abscissa (X) -> discard
105
+ line_dif = False
106
+ first = True
107
+ for mode, val in ords:
108
+ if mode == _DUPL:
109
+ for _ in range(int(val) - 1):
110
+ if prev_mode == _DIFF:
111
+ last += prev_val
112
+ line_dif = True
113
+ else:
114
+ last = prev_val
115
+ y.append(last)
116
+ continue
117
+ if mode == _DIFF:
118
+ last += val
119
+ y.append(last)
120
+ prev_mode, prev_val, line_dif = _DIFF, val, True
121
+ else: # absolute (SQZ / AFFN / PAC)
122
+ if first and li > 0 and prev_line_dif:
123
+ # continuation Y-check: must equal the running last value
124
+ if ycheck and abs(val - last) > ytol:
125
+ raise DifCheckError(
126
+ f"line {li + 1}: Y-check {val} != previous last {last}"
127
+ )
128
+ prev_mode, prev_val = _ABS, val
129
+ else:
130
+ last = val
131
+ y.append(last)
132
+ prev_mode, prev_val = _ABS, val
133
+ first = False
134
+ prev_line_dif = line_dif
135
+ return y
@@ -0,0 +1,291 @@
1
+ """XRDML per-scan model + generalized (cloud) 2-D assembly.
2
+
3
+ Split out of ``io/xrdml.py`` (500-line module ceiling). ``_Scan`` is the
4
+ per-scan record collected during the parse; ``_classify_cloud`` /
5
+ ``_build_2d_cloud`` implement the snapshot/coupled RSM layouts that go
6
+ beyond the MATLAB-compatible mesh (see the ``io/xrdml`` module docstring);
7
+ ``_classify_pole`` / ``_build_pole`` implement the pole-figure layout
8
+ (gap #46 residual).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import numpy as np
17
+ from numpy.typing import NDArray
18
+
19
+ from quantized.calc.qspace import compute_qspace
20
+ from quantized.datastruct import DataStruct
21
+
22
+ _SECONDARY_AXES = ("Omega", "Chi", "Phi")
23
+ # Tilt-axis element names PANalytical schemas use for pole-figure cradles:
24
+ # "Psi" on texture-goniometer (Eulerian/chi-less) cradles, "Chi" on older
25
+ # Eulerian cradles. Checked in this order; either satisfies _classify_pole.
26
+ _TILT_AXES = ("Psi", "Chi")
27
+
28
+ class _Scan:
29
+ """Per-scan data collected during the parse (1D concat + 2D classification)."""
30
+
31
+ __slots__ = ("tt_range", "tt_list", "counts", "sec_value", "sec_ranges")
32
+
33
+ def __init__(
34
+ self,
35
+ tt_range: tuple[float, float],
36
+ tt_list: NDArray[np.float64] | None,
37
+ counts: NDArray[np.float64],
38
+ sec_value: float,
39
+ sec_ranges: dict[str, tuple[float, float]],
40
+ ) -> None:
41
+ self.tt_range = tt_range
42
+ self.tt_list = tt_list
43
+ self.counts = counts
44
+ self.sec_value = sec_value
45
+ # (start, end) per present secondary axis — start == end means the
46
+ # axis was held fixed for this scan.
47
+ self.sec_ranges = sec_ranges
48
+
49
+ def two_theta(self) -> NDArray[np.float64]:
50
+ """This scan's per-pixel 2theta vector (listPositions or linspace)."""
51
+ if self.tt_list is not None and self.tt_list.size == self.counts.size:
52
+ return self.tt_list
53
+ return np.linspace(self.tt_range[0], self.tt_range[1], self.counts.size)
54
+
55
+ def axis_vector(self, axis: str) -> NDArray[np.float64]:
56
+ """The secondary axis as a per-pixel vector: constant when fixed,
57
+ linspace(start, end) when the axis moved during the scan (coupled)."""
58
+ start, end = self.sec_ranges[axis]
59
+ if start == end:
60
+ return np.full(self.counts.size, start)
61
+ return np.linspace(start, end, self.counts.size)
62
+
63
+
64
+ def _classify_cloud(scans: list[_Scan]) -> tuple[str, str] | None:
65
+ """Generalized 2-D detection BEYOND the MATLAB-compatible mesh (`_is_2d`).
66
+
67
+ Returns ``(axis, kind)`` or ``None``. Tried only after `_is_2d` fails, so
68
+ reaching the snapshot branch implies the per-scan 2theta windows differ.
69
+ Requires >= 3 scans (a 2-range 1-D file must never classify as a map).
70
+
71
+ - ``snapshot``: an axis held fixed within EVERY scan whose value varies
72
+ across scans (PIXcel3D "Scanning snapshot": omega fixed per frame while
73
+ both omega and the 2theta window step frame-to-frame).
74
+ - ``coupled``: all scans share one 2theta window while an axis sweeps
75
+ WITHIN each scan (start != end) at a stepped offset across scans
76
+ (schema-1.0 Omega-2Theta RSMs — a sheared mesh).
77
+ """
78
+ if len(scans) < 3:
79
+ return None
80
+ for axis in _SECONDARY_AXES:
81
+ ranges = [s.sec_ranges.get(axis) for s in scans]
82
+ if any(r is None for r in ranges):
83
+ continue
84
+ rr = [r for r in ranges if r is not None] # narrow for the type checker
85
+ if all(r[0] == r[1] for r in rr):
86
+ vals = [r[0] for r in rr]
87
+ if max(vals) - min(vals) > 1e-6:
88
+ return axis, "snapshot"
89
+ s0, e0 = scans[0].tt_range
90
+ tt_same = all(
91
+ abs(s.tt_range[0] - s0) < 1e-4 and abs(s.tt_range[1] - e0) < 1e-4 for s in scans
92
+ )
93
+ if tt_same:
94
+ for axis in _SECONDARY_AXES:
95
+ ranges = [s.sec_ranges.get(axis) for s in scans]
96
+ if any(r is None for r in ranges):
97
+ continue
98
+ rr = [r for r in ranges if r is not None]
99
+ if all(r[0] != r[1] for r in rr):
100
+ mids = [0.5 * (r[0] + r[1]) for r in rr]
101
+ if max(mids) - min(mids) > 1e-6:
102
+ return axis, "coupled"
103
+ return None
104
+
105
+
106
+ def _classify_pole(scans: list[_Scan]) -> tuple[str, float] | None:
107
+ """Detect a PANalytical pole-figure layout.
108
+
109
+ A pole figure holds 2Theta fixed at the Bragg condition for one
110
+ reflection, sweeps Phi (azimuthal, typically ~360 deg) WITHIN every
111
+ scan, and steps a tilt axis -- "Psi" (texture cradles) or "Chi" (older
112
+ Eulerian cradles) -- fixed within each scan but varying ACROSS scans.
113
+
114
+ Returns ``(tilt_axis, two_theta_deg)`` or ``None``. Must run BEFORE
115
+ ``_is_2d``/``_classify_cloud``: a Chi-named tilt axis alone already
116
+ satisfies the generic ``snapshot`` cloud pattern (fixed-per-scan,
117
+ varying across scans) and would silently classify as a mesh/snapshot
118
+ RSM while dropping the Phi sweep entirely; a Psi-named one is invisible
119
+ to both classifiers (Psi is not one of the axes they inspect), so it
120
+ would otherwise fall through to the flat 1-D path.
121
+ """
122
+ if len(scans) < 2:
123
+ return None
124
+ s0, e0 = scans[0].tt_range
125
+ if abs(s0 - e0) > 1e-6:
126
+ return None # 2Theta itself sweeps -> not a fixed-reflection pole figure
127
+ tt_same = all(
128
+ abs(s.tt_range[0] - s0) < 1e-4 and abs(s.tt_range[1] - e0) < 1e-4 for s in scans
129
+ )
130
+ if not tt_same:
131
+ return None
132
+
133
+ phi_ranges = [s.sec_ranges.get("Phi") for s in scans]
134
+ if any(r is None for r in phi_ranges):
135
+ return None
136
+ phi_rr = [r for r in phi_ranges if r is not None]
137
+ if not all(r[0] != r[1] for r in phi_rr):
138
+ return None # Phi must sweep WITHIN every scan
139
+
140
+ for tilt_axis in _TILT_AXES:
141
+ ranges = [s.sec_ranges.get(tilt_axis) for s in scans]
142
+ if any(r is None for r in ranges):
143
+ continue
144
+ rr = [r for r in ranges if r is not None]
145
+ if not all(r[0] == r[1] for r in rr):
146
+ continue # tilt axis must be fixed WITHIN every scan
147
+ vals = [r[0] for r in rr]
148
+ if max(vals) - min(vals) > 1e-6: # and step ACROSS scans
149
+ return tilt_axis, s0
150
+ return None
151
+
152
+
153
+ def _build_pole(
154
+ scans: list[_Scan],
155
+ tilt_axis: str,
156
+ two_theta_deg: float,
157
+ path: Path,
158
+ intensity: str,
159
+ counting_time: float,
160
+ intensity_tag: str,
161
+ att: dict[str, Any],
162
+ ) -> DataStruct:
163
+ """Assemble a pole-figure point cloud: per-point (Phi, Psi, Intensity).
164
+
165
+ Every scan is an azimuthal Phi sweep at one fixed tilt. Unlike the RSM
166
+ mesh/cloud kinds, 2Theta does not vary point-to-point here -- it is the
167
+ single Bragg condition for the whole measurement, so it is recorded as
168
+ scalar metadata (``two_theta_deg``) rather than a column. The tilt axis
169
+ is normalized to the "Psi" label in the output regardless of whether
170
+ the source XML called it "Psi" or "Chi" (``tilt_axis_source`` keeps the
171
+ original element name), so downstream code has one consistent name.
172
+ Stereographic/polar projection is a later view (ORIGIN_GAP_PLAN #46) --
173
+ this is the flat psi x phi map that feeds it.
174
+ """
175
+ order = sorted(range(len(scans)), key=lambda i: scans[i].sec_ranges[tilt_axis][0])
176
+ phi = np.concatenate([scans[i].axis_vector("Phi") for i in order])
177
+ psi = np.concatenate(
178
+ [np.full(scans[i].counts.size, scans[i].sec_ranges[tilt_axis][0]) for i in order]
179
+ )
180
+ counts = np.concatenate([scans[i].counts for i in order])
181
+
182
+ vals, unit = _apply_intensity(counts, intensity, counting_time)
183
+ values = np.column_stack([phi, psi, np.asarray(vals, dtype=float)])
184
+ labels = ["Phi", "Psi", "Intensity"]
185
+ units = ["deg", "deg", unit]
186
+
187
+ pix_counts = {s.counts.size for s in scans}
188
+ n_pix = pix_counts.pop() if len(pix_counts) == 1 else None
189
+ metadata: dict[str, Any] = {
190
+ "source": str(path),
191
+ "parser_name": "import_xrdml",
192
+ "x_column_name": "Phi",
193
+ "x_column_unit": "deg",
194
+ "num_points": int(values.shape[0]),
195
+ "counting_time": counting_time,
196
+ "intensity_tag": intensity_tag,
197
+ "is2D": True,
198
+ "mesh_kind": "pole",
199
+ # Index grid (frames x pixels) only when every scan has the same
200
+ # Phi-sample count; None = ragged (rare, but no reason to require it).
201
+ "map_shape": [len(scans), int(n_pix)] if n_pix is not None else None,
202
+ "axis1_name": "Psi",
203
+ "axis2_name": "Phi",
204
+ "two_theta_deg": float(two_theta_deg),
205
+ "tilt_axis_source": tilt_axis,
206
+ }
207
+ metadata.update(att)
208
+ return DataStruct.create(
209
+ np.arange(values.shape[0], dtype=float),
210
+ values,
211
+ labels=labels,
212
+ units=units,
213
+ metadata=metadata,
214
+ )
215
+
216
+
217
+ def _build_2d_cloud(
218
+ scans: list[_Scan],
219
+ sec_name: str,
220
+ mesh_kind: str,
221
+ path: Path,
222
+ intensity: str,
223
+ counting_time: float,
224
+ wavelength: float,
225
+ intensity_tag: str,
226
+ att: dict[str, Any],
227
+ ) -> DataStruct:
228
+ """Assemble a generalized RSM point cloud (snapshot / coupled layouts).
229
+
230
+ Unlike `_build_2d` there is no shared 2theta vector: every scan contributes
231
+ its own per-pixel 2theta (and, for coupled scans, per-pixel omega), so the
232
+ output is a true scattered cloud. Same column schema as the mesh path.
233
+ """
234
+
235
+ def _mid(s: _Scan) -> float:
236
+ r = s.sec_ranges[sec_name]
237
+ return 0.5 * (r[0] + r[1])
238
+
239
+ order = sorted(range(len(scans)), key=lambda i: _mid(scans[i]))
240
+ tt = np.concatenate([scans[i].two_theta() for i in order])
241
+ sec = np.concatenate([scans[i].axis_vector(sec_name) for i in order])
242
+ counts = np.concatenate([scans[i].counts for i in order])
243
+
244
+ vals, unit = _apply_intensity(counts, intensity, counting_time)
245
+ columns = [tt, sec, np.asarray(vals, dtype=float)]
246
+ labels = ["2Theta", sec_name, "Intensity"]
247
+ units = ["deg", "deg", unit]
248
+ if sec_name == "Omega" and np.isfinite(wavelength) and wavelength > 0:
249
+ qx, qz = compute_qspace(tt, sec, wavelength)
250
+ columns += [np.asarray(qx, dtype=float).ravel(), np.asarray(qz, dtype=float).ravel()]
251
+ labels += ["Qx", "Qz"]
252
+ units += ["Ang^-1", "Ang^-1"]
253
+
254
+ pix_counts = {s.counts.size for s in scans}
255
+ n_pix = pix_counts.pop() if len(pix_counts) == 1 else None
256
+ values = np.column_stack(columns)
257
+ metadata: dict[str, Any] = {
258
+ "source": str(path),
259
+ "parser_name": "import_xrdml",
260
+ "x_column_name": "2-Theta",
261
+ "x_column_unit": "deg",
262
+ "num_points": int(values.shape[0]),
263
+ "counting_time": counting_time,
264
+ "intensity_tag": intensity_tag,
265
+ "is2D": True,
266
+ "mesh_kind": mesh_kind,
267
+ # Index grid (frames x pixels) only when every frame has the same
268
+ # pixel count; None = ragged cloud (grid cuts unavailable, scattered
269
+ # rendering + segment cuts still work).
270
+ "map_shape": [len(scans), int(n_pix)] if n_pix is not None else None,
271
+ "axis1_name": sec_name,
272
+ "axis2_name": "2Theta",
273
+ "wavelength_a": float(wavelength) if np.isfinite(wavelength) else None,
274
+ }
275
+ metadata.update(att)
276
+ return DataStruct.create(
277
+ np.arange(values.shape[0], dtype=float),
278
+ values,
279
+ labels=labels,
280
+ units=units,
281
+ metadata=metadata,
282
+ )
283
+
284
+
285
+ def _apply_intensity(
286
+ counts: NDArray[np.float64], intensity: str, counting_time: float
287
+ ) -> tuple[NDArray[np.float64], str]:
288
+ """counts -> (cps or counts, unit). cps needs a positive counting time."""
289
+ if intensity == "cps" and not np.isnan(counting_time) and counting_time > 0:
290
+ return np.asarray(counts / counting_time, dtype=float), "cps"
291
+ return counts, "counts"
quantized/io/base.py ADDED
@@ -0,0 +1,82 @@
1
+ """Shared, pure parsing primitives used across io/ parsers.
2
+
3
+ Ports of the MATLAB helpers parseColHeader + resolveColumnShorthand.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ from collections.abc import Mapping, Sequence
10
+
11
+ __all__ = ["NO_COLUMN", "parse_col_header", "resolve_column"]
12
+
13
+ NO_COLUMN = -1
14
+
15
+ _HEADER_UNIT_RE = re.compile(r"^(.+?)\s*\(([^)]+)\)\s*$")
16
+
17
+
18
+ def parse_col_header(raw: str) -> tuple[str, str]:
19
+ """Split ``'Magnetic Field (Oe)'`` -> ``('Magnetic Field', 'Oe')``.
20
+
21
+ No parenthesised unit -> ``(trimmed name, '')``. Mirrors parseColHeader.
22
+ """
23
+ name = raw.strip()
24
+ if not name:
25
+ return "", ""
26
+ match = _HEADER_UNIT_RE.match(name)
27
+ if match:
28
+ return match.group(1).strip(), match.group(2).strip()
29
+ return name, ""
30
+
31
+
32
+ def _match_column(needle: str, col_names: Sequence[str]) -> int | None:
33
+ """Exact (case-insensitive) match, else partial; shortest matching name wins."""
34
+ lowered = needle.lower()
35
+ for i, name in enumerate(col_names):
36
+ if name.lower() == lowered:
37
+ return i
38
+ matches = [i for i, name in enumerate(col_names) if lowered in name.lower()]
39
+ if not matches:
40
+ return None
41
+ return min(matches, key=lambda i: len(col_names[i]))
42
+
43
+
44
+ def resolve_column(
45
+ spec: int | str,
46
+ col_names: Sequence[str],
47
+ shorthand_map: Mapping[str, str] | None = None,
48
+ label: str = "column",
49
+ ) -> int:
50
+ """Resolve a column spec to a 0-based index (mirrors resolveColumnShorthand).
51
+
52
+ Order: int index -> empty (``NO_COLUMN``) -> [shorthand target, then the
53
+ literal spec], each tried exact (ci) then partial (ci, shortest name wins)
54
+ -> ``KeyError``. The literal-spec fallback lets ``"field"`` resolve a column
55
+ named literally ``Field`` (MPMS-classic naming) when the shorthand target
56
+ ``"Magnetic Field"`` is absent.
57
+ """
58
+ if isinstance(spec, int):
59
+ if 0 <= spec < len(col_names):
60
+ return spec
61
+ raise IndexError(f"{label} index {spec} out of range (0-{len(col_names) - 1})")
62
+
63
+ needle = spec.strip()
64
+ if not needle:
65
+ return NO_COLUMN
66
+
67
+ # Try the shorthand target first (keeps current behaviour), then the literal
68
+ # spec as a fallback so non-canonical column names still resolve.
69
+ candidates: list[str] = []
70
+ if shorthand_map:
71
+ for short, target in shorthand_map.items():
72
+ if needle.lower() == short.lower():
73
+ candidates.append(target)
74
+ break
75
+ candidates.append(needle)
76
+
77
+ for cand in candidates:
78
+ hit = _match_column(cand, col_names)
79
+ if hit is not None:
80
+ return hit
81
+
82
+ raise KeyError(f"cannot resolve {label} '{spec}'. Available: {list(col_names)}")
@@ -0,0 +1,177 @@
1
+ """Bruker ``.brml`` XRD parser (DIFFRAC.EVA / DIFFRAC.MEASUREMENT).
2
+
3
+ A ``.brml`` is a ZIP archive of XML documents. The scan lives in
4
+ ``Experiment<i>/RawData<j>.xml``; a 1-D line scan has exactly one such file,
5
+ while a reciprocal-space map (RSM) has one per scan line (hundreds).
6
+
7
+ RawData structure (namespaces declared for ``xsi:type`` only; elements are
8
+ un-prefixed, so ElementTree tags are plain)::
9
+
10
+ <RawData>
11
+ <DataRoutes><DataRoute RouteFlag="Measured">
12
+ <ScanInformation>
13
+ <MeasurementPoints>2001</MeasurementPoints>
14
+ <ScanAxes>
15
+ <ScanAxisInfo AxisId="TwoTheta" Unit="deg">
16
+ <Start>44</Start><Stop>48</Stop><Increment>0.002</Increment>
17
+ <Datum>plannedTime,measuredTime,TwoTheta,Theta,...,Counts</Datum>
18
+ ...
19
+
20
+ Each ``<Datum>`` is a comma-separated row; the *first* scan axis (2theta) is
21
+ the abscissa and the *last* field is the recorded counter (intensity). This
22
+ parser handles the 1-D case (item #42); an RSM raises a clear error pointing
23
+ at the map path rather than silently returning one line.
24
+
25
+ Sample files ``FAIRmat_2thomega.brml`` / ``FAIRmat_RSM.brml`` (Apache-2.0,
26
+ FAIRmat pynxtools-xrd corpus) seed the parity tests.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import xml.etree.ElementTree as ET # noqa: S405 (matches io/xrdml.py; trusted local files)
32
+ import zipfile
33
+ from pathlib import Path
34
+ from typing import Any
35
+
36
+ import numpy as np
37
+
38
+ from quantized.datastruct import DataStruct
39
+
40
+ __all__ = ["import_bruker_brml", "is_bruker_brml"]
41
+
42
+ _UNIT_MAP = {"°": "deg", "deg": "deg", "Degree": "deg", "": ""}
43
+
44
+
45
+ def _raw_data_members(names: list[str]) -> list[str]:
46
+ """ZIP members matching ``Experiment*/RawData*.xml`` (the scan documents)."""
47
+ out = []
48
+ for n in names:
49
+ parts = n.replace("\\", "/").split("/")
50
+ if len(parts) >= 2 and parts[-1].startswith("RawData") and parts[-1].endswith(".xml"):
51
+ out.append(n)
52
+ return out
53
+
54
+
55
+ def is_bruker_brml(path: Path) -> bool:
56
+ """Sniff a file as a Bruker ``.brml`` (a ZIP with a RawData scan document)."""
57
+ p = Path(path)
58
+ if not zipfile.is_zipfile(p):
59
+ return False
60
+ with zipfile.ZipFile(p) as zf:
61
+ names = zf.namelist()
62
+ return any(n.endswith("experimentCollection.xml") for n in names) or bool(
63
+ _raw_data_members(names)
64
+ )
65
+
66
+
67
+ def _first(elem: ET.Element | None, tag: str) -> ET.Element | None:
68
+ return None if elem is None else elem.find(tag)
69
+
70
+
71
+ def _axis_range(axis: ET.Element) -> tuple[float, float]:
72
+ start = _first(axis, "Start")
73
+ stop = _first(axis, "Stop")
74
+ lo = float(start.text) if start is not None and start.text else float("nan")
75
+ hi = float(stop.text) if stop is not None and stop.text else float("nan")
76
+ return lo, hi
77
+
78
+
79
+ def import_bruker_brml(filepath: str | Path) -> DataStruct:
80
+ """Import a 1-D Bruker ``.brml`` line scan (2theta vs intensity).
81
+
82
+ Returns
83
+ -------
84
+ DataStruct
85
+ ``time`` = the primary scan axis (typically 2theta, deg), one
86
+ ``Intensity`` channel (counts).
87
+
88
+ Raises
89
+ ------
90
+ ValueError
91
+ If the archive is not a ``.brml``, holds no scan, or is a multi-scan
92
+ RSM (which this 1-D parser does not stitch).
93
+ """
94
+ path = Path(filepath)
95
+ if not zipfile.is_zipfile(path):
96
+ raise ValueError(f"not a ZIP archive (expected a .brml): {path.name}")
97
+
98
+ with zipfile.ZipFile(path) as zf:
99
+ members = _raw_data_members(zf.namelist())
100
+ if not members:
101
+ raise ValueError(f"no RawData scan document in archive: {path.name}")
102
+ if len(members) > 1:
103
+ raise ValueError(
104
+ f"multi-scan .brml detected ({len(members)} scans) — reciprocal-space "
105
+ f"maps are not supported by the 1-D parser: {path.name}"
106
+ )
107
+ xml_text = zf.read(members[0]).decode("utf-8", "replace")
108
+
109
+ root = ET.fromstring(xml_text) # noqa: S314 (trusted local file, matches xrdml)
110
+
111
+ routes = root.findall(".//DataRoute")
112
+ route = next((r for r in routes if r.get("RouteFlag") == "Measured"), None)
113
+ route = route if route is not None else (routes[0] if routes else None)
114
+ if route is None:
115
+ raise ValueError(f"no DataRoute in scan document: {path.name}")
116
+
117
+ scan_info = _first(route, "ScanInformation")
118
+ axes = scan_info.findall(".//ScanAxisInfo") if scan_info is not None else []
119
+ if not axes:
120
+ raise ValueError(f"no scan axis in scan document: {path.name}")
121
+ primary = axes[0]
122
+ axis_name = primary.get("VisibleName") or primary.get("AxisName") or "2-Theta"
123
+ axis_unit = _UNIT_MAP.get(primary.get("Unit", ""), primary.get("Unit", "") or "")
124
+ x_lo, x_hi = _axis_range(primary)
125
+
126
+ rows: list[list[float]] = []
127
+ for datum in route.findall(".//Datum"):
128
+ if not datum.text:
129
+ continue
130
+ try:
131
+ rows.append([float(v) for v in datum.text.split(",")])
132
+ except ValueError:
133
+ continue
134
+ if not rows:
135
+ raise ValueError(f"scan document has no data points: {path.name}")
136
+
137
+ n_cols = min(len(r) for r in rows)
138
+ data = np.array([r[:n_cols] for r in rows], dtype=float)
139
+
140
+ # The last column is the recorded counter (intensity). The abscissa is the
141
+ # column matching the primary axis Start->Stop range; fall back to
142
+ # reconstructing it from Start + i*Increment if no column matches.
143
+ intensity = data[:, -1]
144
+ x = _resolve_abscissa(data[:, :-1], x_lo, x_hi, primary, len(rows))
145
+
146
+ metadata: dict[str, Any] = {
147
+ "source": str(path),
148
+ "parser_name": "import_bruker_brml",
149
+ "x_column_name": axis_name,
150
+ "x_column_unit": axis_unit,
151
+ "num_points": len(rows),
152
+ "start_angle": x_lo,
153
+ "end_angle": x_hi,
154
+ "scan_axes": [a.get("VisibleName") or a.get("AxisName") for a in axes],
155
+ }
156
+ return DataStruct.create(
157
+ x, intensity, labels=["Intensity"], units=["counts"], metadata=metadata
158
+ )
159
+
160
+
161
+ def _resolve_abscissa(
162
+ axis_cols: np.ndarray, x_lo: float, x_hi: float, primary: ET.Element, n: int
163
+ ) -> np.ndarray:
164
+ """Pick the Datum column that spans the primary axis range, else rebuild it."""
165
+ if axis_cols.size and np.isfinite(x_lo) and np.isfinite(x_hi):
166
+ # score each candidate column by how well its ends match Start/Stop
167
+ errs = np.abs(axis_cols[0, :] - x_lo) + np.abs(axis_cols[-1, :] - x_hi)
168
+ best = int(np.argmin(errs))
169
+ span = abs(x_hi - x_lo)
170
+ if errs[best] <= max(1e-6, 0.01 * span):
171
+ return np.asarray(axis_cols[:, best], dtype=float)
172
+ inc = _first(primary, "Increment")
173
+ if np.isfinite(x_lo) and inc is not None and inc.text:
174
+ return np.asarray(x_lo + np.arange(n) * float(inc.text), dtype=float)
175
+ if np.isfinite(x_lo) and np.isfinite(x_hi) and n > 1:
176
+ return np.asarray(np.linspace(x_lo, x_hi, n), dtype=float)
177
+ return np.asarray(axis_cols[:, 0] if axis_cols.size else np.arange(n), dtype=float)