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.
- quantized/__init__.py +11 -0
- quantized/__main__.py +14 -0
- quantized/api.py +220 -0
- quantized/app.py +206 -0
- quantized/calc/__init__.py +8 -0
- quantized/calc/_clipfit.py +76 -0
- quantized/calc/_natural_neighbor.py +219 -0
- quantized/calc/aggregate.py +159 -0
- quantized/calc/backgrounds.py +353 -0
- quantized/calc/baseline.py +349 -0
- quantized/calc/batch_fit.py +148 -0
- quantized/calc/constants.py +27 -0
- quantized/calc/corrections.py +192 -0
- quantized/calc/crystallography.py +400 -0
- quantized/calc/diffusion.py +120 -0
- quantized/calc/electrical.py +248 -0
- quantized/calc/electrochemistry.py +176 -0
- quantized/calc/element_data.json +1 -0
- quantized/calc/element_data.py +59 -0
- quantized/calc/errors.py +246 -0
- quantized/calc/figure.py +417 -0
- quantized/calc/figure_break.py +152 -0
- quantized/calc/figure_categorical.py +156 -0
- quantized/calc/figure_corner.py +229 -0
- quantized/calc/figure_facets.py +127 -0
- quantized/calc/figure_field.py +137 -0
- quantized/calc/figure_hitmap.py +116 -0
- quantized/calc/figure_labels.py +62 -0
- quantized/calc/figure_map.py +287 -0
- quantized/calc/figure_overrides.py +159 -0
- quantized/calc/figure_page.py +266 -0
- quantized/calc/figure_scale.py +125 -0
- quantized/calc/figure_statplots.py +167 -0
- quantized/calc/figure_styles.py +131 -0
- quantized/calc/figure_ternary.py +239 -0
- quantized/calc/figure_ticks.py +217 -0
- quantized/calc/fit_autoguess.py +156 -0
- quantized/calc/fit_bootstrap.py +163 -0
- quantized/calc/fit_bumps.py +258 -0
- quantized/calc/fit_constraints.py +114 -0
- quantized/calc/fit_equation.py +264 -0
- quantized/calc/fit_findxy.py +80 -0
- quantized/calc/fit_models.py +195 -0
- quantized/calc/fit_models_special.py +189 -0
- quantized/calc/fit_odr.py +99 -0
- quantized/calc/fit_scan.py +243 -0
- quantized/calc/fit_stats.py +215 -0
- quantized/calc/fitting.py +199 -0
- quantized/calc/formula.py +90 -0
- quantized/calc/global_curve_fit.py +305 -0
- quantized/calc/global_fit.py +181 -0
- quantized/calc/interp2d.py +260 -0
- quantized/calc/linecut.py +269 -0
- quantized/calc/magnetic.py +414 -0
- quantized/calc/magnetometry.py +464 -0
- quantized/calc/map.py +228 -0
- quantized/calc/mcmc.py +177 -0
- quantized/calc/optics.py +228 -0
- quantized/calc/pawley.py +251 -0
- quantized/calc/peak_batch.py +128 -0
- quantized/calc/peak_fit.py +259 -0
- quantized/calc/peak_integrate.py +104 -0
- quantized/calc/peak_multifit.py +260 -0
- quantized/calc/peak_track.py +134 -0
- quantized/calc/peaks.py +298 -0
- quantized/calc/peakshapes.py +85 -0
- quantized/calc/plotting.py +147 -0
- quantized/calc/processing.py +232 -0
- quantized/calc/qspace.py +48 -0
- quantized/calc/reductions.py +155 -0
- quantized/calc/reductions_fft.py +383 -0
- quantized/calc/refl_sld_presets.json +1 -0
- quantized/calc/reflectivity.py +80 -0
- quantized/calc/registry.py +303 -0
- quantized/calc/relaxation.py +119 -0
- quantized/calc/report.py +253 -0
- quantized/calc/report_emit.py +227 -0
- quantized/calc/resample.py +142 -0
- quantized/calc/rsm.py +91 -0
- quantized/calc/rsm_analyze.py +245 -0
- quantized/calc/semiconductor.py +488 -0
- quantized/calc/sld.py +131 -0
- quantized/calc/sld_formula.py +138 -0
- quantized/calc/spectral.py +357 -0
- quantized/calc/statplots.py +214 -0
- quantized/calc/stats.py +399 -0
- quantized/calc/stats_anova2.py +202 -0
- quantized/calc/stats_anova_ext.py +338 -0
- quantized/calc/stats_dist.py +196 -0
- quantized/calc/stats_glm.py +245 -0
- quantized/calc/stats_multivar.py +289 -0
- quantized/calc/stats_roc.py +157 -0
- quantized/calc/stats_survival.py +261 -0
- quantized/calc/stats_tests.py +380 -0
- quantized/calc/substrates.py +181 -0
- quantized/calc/superconductor.py +359 -0
- quantized/calc/surface_fit.py +290 -0
- quantized/calc/surface_models.py +156 -0
- quantized/calc/thermal.py +119 -0
- quantized/calc/thin_film.py +425 -0
- quantized/calc/unit_convert.py +259 -0
- quantized/calc/units.py +80 -0
- quantized/calc/vacuum.py +290 -0
- quantized/calc/xray.py +169 -0
- quantized/cli.py +214 -0
- quantized/datastruct.py +153 -0
- quantized/io/__init__.py +11 -0
- quantized/io/_hdf5_layout.py +308 -0
- quantized/io/_jcamp_asdf.py +135 -0
- quantized/io/_xrdml_scan.py +291 -0
- quantized/io/base.py +82 -0
- quantized/io/bruker_brml.py +177 -0
- quantized/io/bruker_raw.py +158 -0
- quantized/io/cif.py +266 -0
- quantized/io/consolidated.py +122 -0
- quantized/io/delimited.py +222 -0
- quantized/io/excel.py +135 -0
- quantized/io/hdf5.py +192 -0
- quantized/io/import_filters.py +178 -0
- quantized/io/import_preview.py +262 -0
- quantized/io/jcamp.py +179 -0
- quantized/io/lakeshore.py +163 -0
- quantized/io/ncnr.py +278 -0
- quantized/io/netcdf.py +195 -0
- quantized/io/opus.py +231 -0
- quantized/io/origin.py +346 -0
- quantized/io/origin_com.py +194 -0
- quantized/io/origin_project/__init__.py +221 -0
- quantized/io/origin_project/annotation_marks.py +288 -0
- quantized/io/origin_project/container.py +262 -0
- quantized/io/origin_project/curve_style_color.py +359 -0
- quantized/io/origin_project/figure_geometry.py +108 -0
- quantized/io/origin_project/figure_layers.py +333 -0
- quantized/io/origin_project/figure_text.py +258 -0
- quantized/io/origin_project/figures.py +210 -0
- quantized/io/origin_project/figures_opju.py +440 -0
- quantized/io/origin_project/notes.py +302 -0
- quantized/io/origin_project/opj.py +459 -0
- quantized/io/origin_project/opj_curves.py +297 -0
- quantized/io/origin_project/opj_shapes.py +148 -0
- quantized/io/origin_project/opju.py +146 -0
- quantized/io/origin_project/opju_axis_real_form.py +418 -0
- quantized/io/origin_project/opju_axis_specimen_form.py +167 -0
- quantized/io/origin_project/opju_codec.py +370 -0
- quantized/io/origin_project/opju_curves.py +497 -0
- quantized/io/origin_project/opju_curves_allcols.py +258 -0
- quantized/io/origin_project/opju_figure_curves.py +302 -0
- quantized/io/origin_project/opju_figure_text.py +245 -0
- quantized/io/origin_project/opju_reports.py +129 -0
- quantized/io/origin_project/origin_richtext.py +145 -0
- quantized/io/origin_project/preview.py +132 -0
- quantized/io/origin_project/templates.py +314 -0
- quantized/io/origin_project/tree.py +379 -0
- quantized/io/origin_project/tree_opju.py +228 -0
- quantized/io/origin_project/windows.py +238 -0
- quantized/io/origin_project/windows_opju.py +393 -0
- quantized/io/origin_project/writer.py +156 -0
- quantized/io/origin_project/writer_blocks.py +282 -0
- quantized/io/qd.py +380 -0
- quantized/io/refl1d.py +132 -0
- quantized/io/registry.py +210 -0
- quantized/io/report_export.py +347 -0
- quantized/io/rigaku.py +100 -0
- quantized/io/sims.py +398 -0
- quantized/io/spc.py +311 -0
- quantized/io/xrd_csv.py +308 -0
- quantized/io/xrdml.py +394 -0
- quantized/jobs.py +173 -0
- quantized/plugins/__init__.py +50 -0
- quantized/plugins/contract.py +111 -0
- quantized/plugins/loader.py +394 -0
- quantized/plugins/steps.py +90 -0
- quantized/routes/__init__.py +7 -0
- quantized/routes/_bookcache.py +62 -0
- quantized/routes/_export_common.py +27 -0
- quantized/routes/_payload.py +58 -0
- quantized/routes/_uploadcache.py +59 -0
- quantized/routes/aggregate.py +46 -0
- quantized/routes/baseline.py +210 -0
- quantized/routes/books.py +117 -0
- quantized/routes/calc.py +56 -0
- quantized/routes/corrections.py +78 -0
- quantized/routes/crystallography.py +80 -0
- quantized/routes/diffusion.py +58 -0
- quantized/routes/electrical.py +101 -0
- quantized/routes/electrochemistry.py +83 -0
- quantized/routes/export.py +280 -0
- quantized/routes/export_facets.py +83 -0
- quantized/routes/export_figures.py +471 -0
- quantized/routes/export_page.py +125 -0
- quantized/routes/fitting.py +379 -0
- quantized/routes/fitting_bumps.py +97 -0
- quantized/routes/import_template.py +97 -0
- quantized/routes/import_wizard.py +150 -0
- quantized/routes/jobs_api.py +59 -0
- quantized/routes/magnetic.py +135 -0
- quantized/routes/magnetometry.py +133 -0
- quantized/routes/optics.py +98 -0
- quantized/routes/parsers.py +281 -0
- quantized/routes/peaks.py +184 -0
- quantized/routes/plot.py +103 -0
- quantized/routes/reductions.py +121 -0
- quantized/routes/reference.py +58 -0
- quantized/routes/reflectivity.py +91 -0
- quantized/routes/report_export.py +119 -0
- quantized/routes/rsm.py +136 -0
- quantized/routes/samples.py +32 -0
- quantized/routes/semiconductor.py +207 -0
- quantized/routes/sld.py +42 -0
- quantized/routes/spectral.py +54 -0
- quantized/routes/statplots.py +99 -0
- quantized/routes/stats.py +418 -0
- quantized/routes/stats_design.py +321 -0
- quantized/routes/substrates.py +46 -0
- quantized/routes/superconductor.py +139 -0
- quantized/routes/thermal.py +57 -0
- quantized/routes/thin_film.py +153 -0
- quantized/routes/vacuum.py +113 -0
- quantized/routes/xray.py +32 -0
- quantized/samples/demo_vsm.csv +42 -0
- quantized/server_launch.py +251 -0
- quantized/web/assets/JetBrainsMono-Bold-CUogYd9I.woff2 +0 -0
- quantized/web/assets/JetBrainsMono-Regular-CA-Os4ii.woff2 +0 -0
- quantized/web/assets/index-BHmmCL-x.js +27 -0
- quantized/web/assets/index-BiZzN7J6.css +1 -0
- quantized/web/index.html +13 -0
- quantized/web/loading.html +69 -0
- quantized_lab-0.8.0.dist-info/METADATA +122 -0
- quantized_lab-0.8.0.dist-info/RECORD +233 -0
- quantized_lab-0.8.0.dist-info/WHEEL +4 -0
- quantized_lab-0.8.0.dist-info/entry_points.txt +4 -0
- quantized_lab-0.8.0.dist-info/licenses/LICENSE +201 -0
- 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)
|