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
quantized/io/xrdml.py
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"""PANalytical XRDML parser. Port of MATLAB parser.importXRDML.
|
|
2
|
+
|
|
3
|
+
**1D scans** (the common case): x = 2theta reconstructed by
|
|
4
|
+
``linspace(start, end, N)``; intensity = counts, optionally divided by
|
|
5
|
+
``commonCountingTime`` to cps (the default). Multi-scan files concatenate
|
|
6
|
+
Completed scans in appendNumber order. Returns ``[Intensity]`` vs 2theta.
|
|
7
|
+
|
|
8
|
+
**2D area-detector (RSM)** — three layouts, detected automatically and all
|
|
9
|
+
returned as a *scattered* multi-column DataStruct (``[2Theta, <axis1>,
|
|
10
|
+
Intensity]`` + ``[Qx, Qz]`` when the secondary axis is Omega and a wavelength
|
|
11
|
+
is present) so the 2-D map viewer (``calc/map``) can render any of them.
|
|
12
|
+
``metadata.is2D`` / ``map_shape`` record the ``(N_frames, M_pixels)`` index
|
|
13
|
+
grid; ``metadata.mesh_kind`` records the layout:
|
|
14
|
+
|
|
15
|
+
- ``"mesh"`` — the classic scanning-line RSM (schema 1.3): every scan shares
|
|
16
|
+
one 2theta range while a secondary motor (Omega / Chi / Phi) is fixed per
|
|
17
|
+
scan and steps between scans. (This is the only layout the MATLAB reference
|
|
18
|
+
detects; its output is byte-compatible here.)
|
|
19
|
+
- ``"snapshot"`` — PIXcel3D-style area snapshots (schema 2.x, e.g. "Scanning
|
|
20
|
+
snapshot equatorial"): the secondary motor is fixed per scan, but the
|
|
21
|
+
2theta window ALSO moves scan-to-scan, so there is no shared 2theta vector
|
|
22
|
+
— each frame contributes its own 2theta pixels at its omega.
|
|
23
|
+
- ``"coupled"`` — schema-1.0-era RSMs: each scan is a coupled Omega-2Theta
|
|
24
|
+
sweep (omega varies WITHIN the scan) at a stepped omega offset; the point
|
|
25
|
+
cloud is a sheared mesh.
|
|
26
|
+
- ``"pole"`` — pole figures (gap #46 residual): fixed 2Theta (one Bragg
|
|
27
|
+
reflection), Phi swept WITHIN every scan (azimuthal), a tilt axis ("Psi"
|
|
28
|
+
or "Chi", normalized to "Psi" in the output) stepped ACROSS scans. Not a
|
|
29
|
+
reciprocal-space map (no Qx/Qz); ``labels`` is ``[Phi, Psi, Intensity]``
|
|
30
|
+
and 2Theta is scalar metadata (``two_theta_deg``), not a column.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import warnings
|
|
36
|
+
import xml.etree.ElementTree as ET
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Any
|
|
39
|
+
|
|
40
|
+
import numpy as np
|
|
41
|
+
from numpy.typing import NDArray
|
|
42
|
+
|
|
43
|
+
from quantized.calc.qspace import compute_qspace
|
|
44
|
+
from quantized.datastruct import DataStruct
|
|
45
|
+
from quantized.io._xrdml_scan import (
|
|
46
|
+
_apply_intensity,
|
|
47
|
+
_build_2d_cloud,
|
|
48
|
+
_build_pole,
|
|
49
|
+
_classify_cloud,
|
|
50
|
+
_classify_pole,
|
|
51
|
+
_Scan,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
__all__ = ["import_xrdml"]
|
|
55
|
+
|
|
56
|
+
# "Psi" is captured here (not just in _xrdml_scan's cloud-only tuple) so a
|
|
57
|
+
# pole-figure tilt axis using that naming shows up in each scan's sec_ranges
|
|
58
|
+
# for _classify_pole to see -- see the mesh_kind="pole" note above.
|
|
59
|
+
_SECONDARY_AXES = ("Omega", "Chi", "Phi", "Psi")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _local(tag: str) -> str:
|
|
63
|
+
"""Strip the XML namespace: '{uri}counts' -> 'counts'."""
|
|
64
|
+
return tag.rsplit("}", 1)[-1]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _find_all(elem: ET.Element, name: str) -> list[ET.Element]:
|
|
68
|
+
return [e for e in elem.iter() if _local(e.tag) == name]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _find_first(elem: ET.Element, name: str) -> ET.Element | None:
|
|
72
|
+
for e in elem.iter():
|
|
73
|
+
if _local(e.tag) == name:
|
|
74
|
+
return e
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _text_float(elem: ET.Element | None) -> float:
|
|
79
|
+
if elem is None or elem.text is None:
|
|
80
|
+
return float("nan")
|
|
81
|
+
return float(elem.text.strip())
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _wavelength(root: ET.Element) -> float:
|
|
85
|
+
"""K-Alpha1 wavelength in Angstrom (NaN when absent)."""
|
|
86
|
+
return _text_float(_find_first(root, "kAlpha1"))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _axis_positions(
|
|
90
|
+
dp: ET.Element, axis: str
|
|
91
|
+
) -> tuple[tuple[float, float] | None, NDArray[np.float64] | None]:
|
|
92
|
+
"""For ``<positions axis="...">`` return ``(range, list)``.
|
|
93
|
+
|
|
94
|
+
``range`` is ``(start, end)`` from start/end positions, ``(c, c)`` from a
|
|
95
|
+
commonPosition, or ``(first, last)`` from listPositions; ``None`` if the axis
|
|
96
|
+
is absent. ``list`` is the explicit listPositions array (or ``None``). Mirrors
|
|
97
|
+
MATLAB ``rxPositions``.
|
|
98
|
+
"""
|
|
99
|
+
for pos in _find_all(dp, "positions"):
|
|
100
|
+
if pos.get("axis") != axis:
|
|
101
|
+
continue
|
|
102
|
+
lp = _find_first(pos, "listPositions")
|
|
103
|
+
if lp is not None and lp.text:
|
|
104
|
+
arr = np.asarray([float(v) for v in lp.text.split()], dtype=float)
|
|
105
|
+
return (float(arr[0]), float(arr[-1])), arr
|
|
106
|
+
sp = _find_first(pos, "startPosition")
|
|
107
|
+
ep = _find_first(pos, "endPosition")
|
|
108
|
+
if sp is not None and ep is not None:
|
|
109
|
+
return (_text_float(sp), _text_float(ep)), None
|
|
110
|
+
cp = _find_first(pos, "commonPosition")
|
|
111
|
+
if cp is not None:
|
|
112
|
+
c = _text_float(cp)
|
|
113
|
+
return (c, c), None
|
|
114
|
+
return None, None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _attenuation_factors(dp: ET.Element) -> NDArray[np.float64] | None:
|
|
118
|
+
"""Per-pixel ``<beamAttenuationFactors>`` for a dataPoints block (or None)."""
|
|
119
|
+
el = _find_first(dp, "beamAttenuationFactors")
|
|
120
|
+
if el is None or el.text is None:
|
|
121
|
+
return None
|
|
122
|
+
return np.asarray([float(v) for v in el.text.split()], dtype=float)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _attenuator_meta(root: ET.Element) -> tuple[float, str, float]:
|
|
126
|
+
"""Instrument ``<beamAttenuator>`` (factor, material, activateLevel)."""
|
|
127
|
+
att = _find_first(root, "beamAttenuator")
|
|
128
|
+
if att is None:
|
|
129
|
+
return float("nan"), "", float("nan")
|
|
130
|
+
mat = _find_first(att, "material")
|
|
131
|
+
material = mat.text.strip() if (mat is not None and mat.text) else ""
|
|
132
|
+
return (
|
|
133
|
+
_text_float(_find_first(att, "factor")),
|
|
134
|
+
material,
|
|
135
|
+
_text_float(_find_first(att, "activateLevel")),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def import_xrdml(filepath: str | Path, *, intensity: str = "cps") -> DataStruct:
|
|
140
|
+
"""Import a PANalytical ``.xrdml`` (1D scan or 2D RSM mesh). Default = cps."""
|
|
141
|
+
if intensity not in ("cps", "counts"):
|
|
142
|
+
raise ValueError(f'intensity must be "cps" or "counts", got "{intensity}"')
|
|
143
|
+
path = Path(filepath)
|
|
144
|
+
text = path.read_text(encoding="latin-1")
|
|
145
|
+
# Strip a UTF-8 BOM: Windows instrument software emits one, and decoded
|
|
146
|
+
# as latin-1 it becomes "" — an invalid token to the XML parser.
|
|
147
|
+
if text.startswith("\xef\xbb\xbf"):
|
|
148
|
+
text = text[3:]
|
|
149
|
+
try:
|
|
150
|
+
root = ET.fromstring(text)
|
|
151
|
+
except ET.ParseError as exc:
|
|
152
|
+
# Empty/truncated/non-XML content raises ParseError (a SyntaxError, not a
|
|
153
|
+
# ValueError) -> would escape the import route as a 500. Reject cleanly.
|
|
154
|
+
raise ValueError(f"{path.name} is not valid XRDML (XML parse failed): {exc}") from exc
|
|
155
|
+
|
|
156
|
+
scans_xml = _find_all(root, "scan")
|
|
157
|
+
if not scans_xml:
|
|
158
|
+
raise ValueError(f"no <scan> element in {path.name}")
|
|
159
|
+
# appendNumber is usually an int but some exporters write "1.0"; float()
|
|
160
|
+
# accepts both and preserves ordering (int() raised on float strings).
|
|
161
|
+
scans_xml.sort(key=lambda s: float(s.get("appendNumber", "1")))
|
|
162
|
+
|
|
163
|
+
wavelength = _wavelength(root)
|
|
164
|
+
att_factor, att_material, att_level = _attenuator_meta(root)
|
|
165
|
+
n_att_corrected = 0
|
|
166
|
+
counting_time = float("nan")
|
|
167
|
+
counting_times_all: list[float] = []
|
|
168
|
+
intensity_tag = "counts"
|
|
169
|
+
sec_name: str | None = None
|
|
170
|
+
collected: list[_Scan] = []
|
|
171
|
+
|
|
172
|
+
for scan in scans_xml:
|
|
173
|
+
status = scan.get("status")
|
|
174
|
+
if status is not None and status.lower() != "completed":
|
|
175
|
+
continue
|
|
176
|
+
dp = _find_first(scan, "dataPoints")
|
|
177
|
+
if dp is None:
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
ct = _text_float(_find_first(dp, "commonCountingTime"))
|
|
181
|
+
if not np.isnan(ct):
|
|
182
|
+
counting_times_all.append(ct)
|
|
183
|
+
if np.isnan(counting_time):
|
|
184
|
+
counting_time = ct
|
|
185
|
+
|
|
186
|
+
counts_elem = _find_first(dp, "counts")
|
|
187
|
+
if counts_elem is None:
|
|
188
|
+
counts_elem = _find_first(dp, "intensities")
|
|
189
|
+
if counts_elem is not None:
|
|
190
|
+
intensity_tag = "intensities"
|
|
191
|
+
if counts_elem is None or counts_elem.text is None:
|
|
192
|
+
continue
|
|
193
|
+
counts = np.asarray([float(x) for x in counts_elem.text.split()], dtype=float)
|
|
194
|
+
if counts.size < 1:
|
|
195
|
+
continue
|
|
196
|
+
|
|
197
|
+
# Beam-attenuation correction: per-pixel factors (>1) restore the true
|
|
198
|
+
# intensity where the attenuator was engaged. Matches importXRDML.
|
|
199
|
+
baf = _attenuation_factors(dp)
|
|
200
|
+
if baf is not None:
|
|
201
|
+
if baf.size == counts.size:
|
|
202
|
+
if bool(np.any(np.abs(baf - 1.0) > 1e-6)):
|
|
203
|
+
n_att_corrected += 1
|
|
204
|
+
counts = np.asarray(counts * baf, dtype=float)
|
|
205
|
+
elif baf.size == 1 and abs(float(baf[0]) - 1.0) > 1e-6:
|
|
206
|
+
n_att_corrected += 1
|
|
207
|
+
counts = np.asarray(counts * float(baf[0]), dtype=float)
|
|
208
|
+
|
|
209
|
+
tt_range, tt_list = _axis_positions(dp, "2Theta")
|
|
210
|
+
if tt_range is None:
|
|
211
|
+
continue
|
|
212
|
+
|
|
213
|
+
# Secondary axes: record every present Omega/Chi/Phi range for the 2-D
|
|
214
|
+
# classification. sec_name keeps the MATLAB-compatible behaviour
|
|
215
|
+
# (first axis found fixed within a scan) for the classic-mesh path.
|
|
216
|
+
sec_ranges: dict[str, tuple[float, float]] = {}
|
|
217
|
+
for axis in _SECONDARY_AXES:
|
|
218
|
+
rng, _ = _axis_positions(dp, axis)
|
|
219
|
+
if rng is not None:
|
|
220
|
+
sec_ranges[axis] = rng
|
|
221
|
+
if sec_name is None:
|
|
222
|
+
for axis in _SECONDARY_AXES:
|
|
223
|
+
rng2 = sec_ranges.get(axis)
|
|
224
|
+
if rng2 is not None and rng2[0] == rng2[1]:
|
|
225
|
+
sec_name = axis
|
|
226
|
+
break
|
|
227
|
+
sec_value = float("nan")
|
|
228
|
+
if sec_name is not None:
|
|
229
|
+
rng3 = sec_ranges.get(sec_name)
|
|
230
|
+
if rng3 is not None and rng3[0] == rng3[1]:
|
|
231
|
+
sec_value = rng3[0]
|
|
232
|
+
|
|
233
|
+
collected.append(_Scan(tt_range, tt_list, counts, sec_value, sec_ranges))
|
|
234
|
+
|
|
235
|
+
if not collected:
|
|
236
|
+
raise ValueError(f"no Completed scan data in {path.name}")
|
|
237
|
+
|
|
238
|
+
# Mixed counting times: cps uses the first scan's value (matches MATLAB), so
|
|
239
|
+
# frames measured at other counting times are scaled incorrectly. Warn, as
|
|
240
|
+
# MATLAB's parser:importXRDML:mixedCountingTimes does.
|
|
241
|
+
if intensity == "cps":
|
|
242
|
+
unique_ct = sorted({round(c, 12) for c in counting_times_all})
|
|
243
|
+
if len(unique_ct) > 1:
|
|
244
|
+
ct_str = ", ".join(f"{c:g}" for c in unique_ct)
|
|
245
|
+
warnings.warn(
|
|
246
|
+
f"Multi-range file has inconsistent counting times across scans "
|
|
247
|
+
f"({ct_str} s). cps normalisation uses the first value "
|
|
248
|
+
f"({counting_time:g} s); intensities from other ranges will be "
|
|
249
|
+
f"incorrectly scaled.",
|
|
250
|
+
stacklevel=2,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
att: dict[str, Any] = {
|
|
254
|
+
"attenuator_factor": float(att_factor) if np.isfinite(att_factor) else None,
|
|
255
|
+
"attenuator_material": att_material,
|
|
256
|
+
"attenuator_activate_level": float(att_level) if np.isfinite(att_level) else None,
|
|
257
|
+
"n_scans_att_corrected": n_att_corrected,
|
|
258
|
+
}
|
|
259
|
+
pole = _classify_pole(collected)
|
|
260
|
+
if pole is not None:
|
|
261
|
+
tilt_axis, two_theta_deg = pole
|
|
262
|
+
return _build_pole(collected, tilt_axis, two_theta_deg, path, intensity,
|
|
263
|
+
counting_time, intensity_tag, att)
|
|
264
|
+
if _is_2d(collected, sec_name):
|
|
265
|
+
assert sec_name is not None # guaranteed by _is_2d
|
|
266
|
+
return _build_2d(collected, sec_name, path, intensity, counting_time,
|
|
267
|
+
wavelength, intensity_tag, att)
|
|
268
|
+
cloud = _classify_cloud(collected)
|
|
269
|
+
if cloud is not None:
|
|
270
|
+
cloud_axis, mesh_kind = cloud
|
|
271
|
+
return _build_2d_cloud(collected, cloud_axis, mesh_kind, path, intensity,
|
|
272
|
+
counting_time, wavelength, intensity_tag, att)
|
|
273
|
+
return _build_1d(collected, path, intensity, counting_time, intensity_tag, att)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _is_2d(scans: list[_Scan], sec_name: str | None) -> bool:
|
|
277
|
+
"""True for an RSM mesh: shared 2theta range + a varying secondary motor."""
|
|
278
|
+
if len(scans) <= 1 or sec_name is None:
|
|
279
|
+
return False
|
|
280
|
+
s0, e0 = scans[0].tt_range
|
|
281
|
+
tt_same = all(
|
|
282
|
+
abs(s.tt_range[0] - s0) < 1e-4 and abs(s.tt_range[1] - e0) < 1e-4 for s in scans
|
|
283
|
+
)
|
|
284
|
+
sec_vals = [s.sec_value for s in scans]
|
|
285
|
+
if any(np.isnan(v) for v in sec_vals):
|
|
286
|
+
return False
|
|
287
|
+
sec_varies = (max(sec_vals) - min(sec_vals)) > 1e-6
|
|
288
|
+
return tt_same and sec_varies
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _build_1d(
|
|
292
|
+
scans: list[_Scan],
|
|
293
|
+
path: Path,
|
|
294
|
+
intensity: str,
|
|
295
|
+
counting_time: float,
|
|
296
|
+
intensity_tag: str,
|
|
297
|
+
att: dict[str, Any],
|
|
298
|
+
) -> DataStruct:
|
|
299
|
+
"""Concatenate Completed scans into a single 2theta/Intensity trace (unchanged
|
|
300
|
+
behaviour; golden-frozen)."""
|
|
301
|
+
two_theta_parts: list[NDArray[np.float64]] = []
|
|
302
|
+
counts_parts: list[NDArray[np.float64]] = []
|
|
303
|
+
for s in scans:
|
|
304
|
+
start, end = s.tt_range
|
|
305
|
+
two_theta_parts.append(np.linspace(start, end, s.counts.size))
|
|
306
|
+
counts_parts.append(s.counts)
|
|
307
|
+
two_theta = np.concatenate(two_theta_parts)
|
|
308
|
+
counts = np.concatenate(counts_parts)
|
|
309
|
+
|
|
310
|
+
values, unit = _apply_intensity(counts, intensity, counting_time)
|
|
311
|
+
metadata: dict[str, Any] = {
|
|
312
|
+
"source": str(path),
|
|
313
|
+
"parser_name": "import_xrdml",
|
|
314
|
+
"x_column_name": "2-Theta",
|
|
315
|
+
"x_column_unit": "deg",
|
|
316
|
+
"num_points": int(two_theta.size),
|
|
317
|
+
"counting_time": counting_time,
|
|
318
|
+
"intensity_tag": intensity_tag,
|
|
319
|
+
"is2D": False,
|
|
320
|
+
}
|
|
321
|
+
metadata.update(att)
|
|
322
|
+
return DataStruct.create(
|
|
323
|
+
two_theta, values, labels=["Intensity"], units=[unit], metadata=metadata
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _build_2d(
|
|
328
|
+
scans: list[_Scan],
|
|
329
|
+
sec_name: str,
|
|
330
|
+
path: Path,
|
|
331
|
+
intensity: str,
|
|
332
|
+
counting_time: float,
|
|
333
|
+
wavelength: float,
|
|
334
|
+
intensity_tag: str,
|
|
335
|
+
att: dict[str, Any],
|
|
336
|
+
) -> DataStruct:
|
|
337
|
+
"""Assemble an RSM mesh and flatten to a scattered multi-column DataStruct."""
|
|
338
|
+
order = sorted(range(len(scans)), key=lambda i: scans[i].sec_value)
|
|
339
|
+
sec_sorted = np.asarray([scans[i].sec_value for i in order], dtype=float)
|
|
340
|
+
n_pix = scans[order[0]].counts.size
|
|
341
|
+
|
|
342
|
+
first = scans[order[0]]
|
|
343
|
+
if first.tt_list is not None and first.tt_list.size == n_pix:
|
|
344
|
+
two_theta_vec = first.tt_list
|
|
345
|
+
else:
|
|
346
|
+
two_theta_vec = np.linspace(first.tt_range[0], first.tt_range[1], n_pix)
|
|
347
|
+
|
|
348
|
+
intensity_map = np.zeros((len(scans), n_pix), dtype=float)
|
|
349
|
+
for row, i in enumerate(order):
|
|
350
|
+
vals = scans[i].counts
|
|
351
|
+
m = min(vals.size, n_pix)
|
|
352
|
+
intensity_map[row, :m] = vals[:m]
|
|
353
|
+
|
|
354
|
+
map_values, unit = _apply_intensity(intensity_map, intensity, counting_time)
|
|
355
|
+
|
|
356
|
+
# Scattered flatten: row-major over (frame, pixel) -> N*M rows.
|
|
357
|
+
omega_grid, tt_grid = np.meshgrid(sec_sorted, two_theta_vec, indexing="ij")
|
|
358
|
+
columns = [tt_grid.ravel(), omega_grid.ravel(), map_values.ravel()]
|
|
359
|
+
labels = ["2Theta", sec_name, "Intensity"]
|
|
360
|
+
units = ["deg", "deg", unit]
|
|
361
|
+
|
|
362
|
+
# Reciprocal space: only meaningful (coplanar) when the secondary axis is Omega.
|
|
363
|
+
if sec_name == "Omega" and np.isfinite(wavelength) and wavelength > 0:
|
|
364
|
+
qx, qz = compute_qspace(two_theta_vec[None, :], sec_sorted[:, None], wavelength)
|
|
365
|
+
columns += [qx.ravel(), qz.ravel()]
|
|
366
|
+
labels += ["Qx", "Qz"]
|
|
367
|
+
units += ["Ang^-1", "Ang^-1"]
|
|
368
|
+
|
|
369
|
+
values = np.column_stack(columns)
|
|
370
|
+
n_frames, n_pixels = intensity_map.shape
|
|
371
|
+
metadata: dict[str, Any] = {
|
|
372
|
+
"source": str(path),
|
|
373
|
+
"parser_name": "import_xrdml",
|
|
374
|
+
"x_column_name": "2-Theta",
|
|
375
|
+
"x_column_unit": "deg",
|
|
376
|
+
"num_points": int(values.shape[0]),
|
|
377
|
+
"counting_time": counting_time,
|
|
378
|
+
"intensity_tag": intensity_tag,
|
|
379
|
+
"is2D": True,
|
|
380
|
+
"mesh_kind": "mesh",
|
|
381
|
+
"map_shape": [int(n_frames), int(n_pixels)],
|
|
382
|
+
"axis1_name": sec_name,
|
|
383
|
+
"axis2_name": "2Theta",
|
|
384
|
+
"wavelength_a": float(wavelength) if np.isfinite(wavelength) else None,
|
|
385
|
+
}
|
|
386
|
+
metadata.update(att)
|
|
387
|
+
return DataStruct.create(
|
|
388
|
+
np.arange(values.shape[0], dtype=float),
|
|
389
|
+
values,
|
|
390
|
+
labels=labels,
|
|
391
|
+
units=units,
|
|
392
|
+
metadata=metadata,
|
|
393
|
+
)
|
|
394
|
+
|
quantized/jobs.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Background job registry — poll-model long-op progress (GOTO #9).
|
|
2
|
+
|
|
3
|
+
Thread-pool execution with polled status, mirroring fermiviewer's ``jobs.py``:
|
|
4
|
+
routes submit a callable (usually a closure over a pure calc function) and
|
|
5
|
+
return a job id; the frontend GET-polls ``/api/jobs/{id}`` while the job is
|
|
6
|
+
live. No WebSocket — polling is the transport.
|
|
7
|
+
|
|
8
|
+
Lives at the package root: ``calc``/``io`` are pure libraries (no threading,
|
|
9
|
+
no long-op state), and ``routes`` are thin adapters (``routes/jobs_api.py``
|
|
10
|
+
adapts this store). Cancellation is cooperative: ``JobStore.cancel`` sets an
|
|
11
|
+
abort flag; the job body observes it through its ``abort_check`` callable, and
|
|
12
|
+
``Job.report`` (the progress callback) raises :class:`JobCancelled` so any
|
|
13
|
+
job that reports progress cancels promptly without extra plumbing.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
import uuid
|
|
21
|
+
from collections.abc import Callable
|
|
22
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
__all__ = ["AbortFn", "Job", "JobCancelled", "JobStore", "ProgressFn", "jobs"]
|
|
27
|
+
|
|
28
|
+
ProgressFn = Callable[[float, str], None]
|
|
29
|
+
AbortFn = Callable[[], bool]
|
|
30
|
+
|
|
31
|
+
_TERMINAL = ("done", "error", "cancelled")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class JobCancelled(Exception):
|
|
35
|
+
"""Raised inside a job body when cancellation was requested."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Job:
|
|
40
|
+
id: str
|
|
41
|
+
status: str = "pending" # pending | running | done | error | cancelled
|
|
42
|
+
progress: float = 0.0
|
|
43
|
+
message: str = ""
|
|
44
|
+
result: Any = None
|
|
45
|
+
error: str = ""
|
|
46
|
+
finished_at: float | None = None # time.monotonic() at terminal transition
|
|
47
|
+
_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
|
48
|
+
_abort: threading.Event = field(default_factory=threading.Event, repr=False)
|
|
49
|
+
|
|
50
|
+
def report(self, fraction: float, message: str = "") -> None:
|
|
51
|
+
"""Progress callback handed to the job body.
|
|
52
|
+
|
|
53
|
+
Raises :class:`JobCancelled` when a cancel has been requested, so a
|
|
54
|
+
job that reports progress honours cancellation with no extra checks.
|
|
55
|
+
"""
|
|
56
|
+
if self._abort.is_set():
|
|
57
|
+
raise JobCancelled(f"job {self.id} cancelled")
|
|
58
|
+
with self._lock:
|
|
59
|
+
self.progress = max(0.0, min(1.0, fraction))
|
|
60
|
+
if message:
|
|
61
|
+
self.message = message
|
|
62
|
+
|
|
63
|
+
def aborted(self) -> bool:
|
|
64
|
+
"""Abort flag for job bodies that poll instead of report."""
|
|
65
|
+
return self._abort.is_set()
|
|
66
|
+
|
|
67
|
+
def snapshot(self, include_result: bool = False) -> dict[str, Any]:
|
|
68
|
+
with self._lock:
|
|
69
|
+
out: dict[str, Any] = {
|
|
70
|
+
"id": self.id,
|
|
71
|
+
"status": self.status,
|
|
72
|
+
"progress": self.progress,
|
|
73
|
+
"message": self.message,
|
|
74
|
+
}
|
|
75
|
+
if self.status == "error":
|
|
76
|
+
out["error"] = self.error
|
|
77
|
+
if include_result and self.status == "done":
|
|
78
|
+
out["result"] = self.result
|
|
79
|
+
return out
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class JobStore:
|
|
83
|
+
"""ThreadPoolExecutor-backed registry of polled background jobs."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, max_workers: int = 2) -> None:
|
|
86
|
+
self._jobs: dict[str, Job] = {}
|
|
87
|
+
self._pool = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="qz-job")
|
|
88
|
+
self._lock = threading.Lock()
|
|
89
|
+
|
|
90
|
+
def submit(self, fn: Callable[[ProgressFn, AbortFn], Any]) -> str:
|
|
91
|
+
"""Run ``fn(progress, abort_check)`` in the pool; returns the id at once.
|
|
92
|
+
|
|
93
|
+
``progress(fraction, message)`` updates the polled snapshot (and raises
|
|
94
|
+
:class:`JobCancelled` after a cancel request); ``abort_check()`` returns
|
|
95
|
+
True once cancellation was requested, for bodies that poll the flag
|
|
96
|
+
between iterations instead.
|
|
97
|
+
"""
|
|
98
|
+
job = Job(id=uuid.uuid4().hex[:12])
|
|
99
|
+
with self._lock:
|
|
100
|
+
# Bound the registry: drop the oldest finished jobs past 100.
|
|
101
|
+
if len(self._jobs) > 100:
|
|
102
|
+
# Grace window (review 2026-07-11): the poll protocol is two
|
|
103
|
+
# requests (status -> result); never evict a job that JUST
|
|
104
|
+
# finished or its unread result 404s between the two.
|
|
105
|
+
now = time.monotonic()
|
|
106
|
+
finished = [
|
|
107
|
+
k
|
|
108
|
+
for k, j in self._jobs.items()
|
|
109
|
+
if j.status in _TERMINAL
|
|
110
|
+
and j.finished_at is not None
|
|
111
|
+
and now - j.finished_at > 30.0
|
|
112
|
+
]
|
|
113
|
+
for k in finished[:50]:
|
|
114
|
+
del self._jobs[k]
|
|
115
|
+
self._jobs[job.id] = job
|
|
116
|
+
|
|
117
|
+
def run() -> None:
|
|
118
|
+
with job._lock:
|
|
119
|
+
if job._abort.is_set():
|
|
120
|
+
job.status = "cancelled"
|
|
121
|
+
job.finished_at = time.monotonic()
|
|
122
|
+
return
|
|
123
|
+
job.status = "running"
|
|
124
|
+
try:
|
|
125
|
+
result = fn(job.report, job.aborted)
|
|
126
|
+
with job._lock:
|
|
127
|
+
if job._abort.is_set():
|
|
128
|
+
job.status = "cancelled"
|
|
129
|
+
else:
|
|
130
|
+
job.result = result
|
|
131
|
+
job.progress = 1.0
|
|
132
|
+
job.status = "done"
|
|
133
|
+
job.finished_at = time.monotonic()
|
|
134
|
+
except JobCancelled:
|
|
135
|
+
with job._lock:
|
|
136
|
+
job.status = "cancelled"
|
|
137
|
+
job.finished_at = time.monotonic()
|
|
138
|
+
except Exception as e: # noqa: BLE001 — surfaced to the polling client
|
|
139
|
+
with job._lock:
|
|
140
|
+
job.error = str(e)
|
|
141
|
+
job.status = "error"
|
|
142
|
+
job.finished_at = time.monotonic()
|
|
143
|
+
|
|
144
|
+
self._pool.submit(run)
|
|
145
|
+
return job.id
|
|
146
|
+
|
|
147
|
+
def get(self, job_id: str) -> Job | None:
|
|
148
|
+
return self._jobs.get(job_id)
|
|
149
|
+
|
|
150
|
+
def cancel(self, job_id: str) -> Job | None:
|
|
151
|
+
"""Request cancellation; returns the job (or None if unknown).
|
|
152
|
+
|
|
153
|
+
Sets the abort flag checked by the job's callbacks. A still-pending
|
|
154
|
+
job is marked cancelled immediately; a finished job is left as-is.
|
|
155
|
+
"""
|
|
156
|
+
job = self._jobs.get(job_id)
|
|
157
|
+
if job is None:
|
|
158
|
+
return None
|
|
159
|
+
with job._lock:
|
|
160
|
+
if job.status in ("pending", "running"):
|
|
161
|
+
job._abort.set()
|
|
162
|
+
if job.status == "pending":
|
|
163
|
+
job.status = "cancelled"
|
|
164
|
+
return job
|
|
165
|
+
|
|
166
|
+
def list(self) -> list[dict[str, Any]]:
|
|
167
|
+
with self._lock:
|
|
168
|
+
items = list(self._jobs.values())
|
|
169
|
+
return [j.snapshot() for j in items]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
jobs = JobStore()
|
|
173
|
+
"""Process-wide default job store."""
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""quantized plugin system (gap #8).
|
|
2
|
+
|
|
3
|
+
A stable, pure contract for third-party contributions — parsers, fit models, and
|
|
4
|
+
pipeline steps — discovered from a drop-in config-dir folder or from installed
|
|
5
|
+
packages via the ``quantized.plugins`` entry point. See ``docs/plugins.md`` for
|
|
6
|
+
the full contract, worked examples, and the trust model.
|
|
7
|
+
|
|
8
|
+
Public surface::
|
|
9
|
+
|
|
10
|
+
from quantized.plugins import load_plugins, loaded_plugins, plugins_dir
|
|
11
|
+
from quantized.plugins import list_steps, run_step
|
|
12
|
+
from quantized.plugins import disabled_sources, enable_plugin, disable_plugin
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from quantized.plugins.contract import (
|
|
18
|
+
API_VERSION,
|
|
19
|
+
InvalidManifest,
|
|
20
|
+
PluginInfo,
|
|
21
|
+
PluginManifest,
|
|
22
|
+
)
|
|
23
|
+
from quantized.plugins.loader import (
|
|
24
|
+
ENTRY_POINT_GROUP,
|
|
25
|
+
disable_plugin,
|
|
26
|
+
disabled_sources,
|
|
27
|
+
enable_plugin,
|
|
28
|
+
load_plugins,
|
|
29
|
+
loaded_plugins,
|
|
30
|
+
plugins_dir,
|
|
31
|
+
unload_plugins,
|
|
32
|
+
)
|
|
33
|
+
from quantized.plugins.steps import list_steps, run_step
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"API_VERSION",
|
|
37
|
+
"ENTRY_POINT_GROUP",
|
|
38
|
+
"InvalidManifest",
|
|
39
|
+
"PluginInfo",
|
|
40
|
+
"PluginManifest",
|
|
41
|
+
"disable_plugin",
|
|
42
|
+
"disabled_sources",
|
|
43
|
+
"enable_plugin",
|
|
44
|
+
"list_steps",
|
|
45
|
+
"load_plugins",
|
|
46
|
+
"loaded_plugins",
|
|
47
|
+
"plugins_dir",
|
|
48
|
+
"run_step",
|
|
49
|
+
"unload_plugins",
|
|
50
|
+
]
|