quantized-lab 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. quantized/__init__.py +11 -0
  2. quantized/__main__.py +14 -0
  3. quantized/api.py +220 -0
  4. quantized/app.py +206 -0
  5. quantized/calc/__init__.py +8 -0
  6. quantized/calc/_clipfit.py +76 -0
  7. quantized/calc/_natural_neighbor.py +219 -0
  8. quantized/calc/aggregate.py +159 -0
  9. quantized/calc/backgrounds.py +353 -0
  10. quantized/calc/baseline.py +349 -0
  11. quantized/calc/batch_fit.py +148 -0
  12. quantized/calc/constants.py +27 -0
  13. quantized/calc/corrections.py +192 -0
  14. quantized/calc/crystallography.py +400 -0
  15. quantized/calc/diffusion.py +120 -0
  16. quantized/calc/electrical.py +248 -0
  17. quantized/calc/electrochemistry.py +176 -0
  18. quantized/calc/element_data.json +1 -0
  19. quantized/calc/element_data.py +59 -0
  20. quantized/calc/errors.py +246 -0
  21. quantized/calc/figure.py +417 -0
  22. quantized/calc/figure_break.py +152 -0
  23. quantized/calc/figure_categorical.py +156 -0
  24. quantized/calc/figure_corner.py +229 -0
  25. quantized/calc/figure_facets.py +127 -0
  26. quantized/calc/figure_field.py +137 -0
  27. quantized/calc/figure_hitmap.py +116 -0
  28. quantized/calc/figure_labels.py +62 -0
  29. quantized/calc/figure_map.py +287 -0
  30. quantized/calc/figure_overrides.py +159 -0
  31. quantized/calc/figure_page.py +266 -0
  32. quantized/calc/figure_scale.py +125 -0
  33. quantized/calc/figure_statplots.py +167 -0
  34. quantized/calc/figure_styles.py +131 -0
  35. quantized/calc/figure_ternary.py +239 -0
  36. quantized/calc/figure_ticks.py +217 -0
  37. quantized/calc/fit_autoguess.py +156 -0
  38. quantized/calc/fit_bootstrap.py +163 -0
  39. quantized/calc/fit_bumps.py +258 -0
  40. quantized/calc/fit_constraints.py +114 -0
  41. quantized/calc/fit_equation.py +264 -0
  42. quantized/calc/fit_findxy.py +80 -0
  43. quantized/calc/fit_models.py +195 -0
  44. quantized/calc/fit_models_special.py +189 -0
  45. quantized/calc/fit_odr.py +99 -0
  46. quantized/calc/fit_scan.py +243 -0
  47. quantized/calc/fit_stats.py +215 -0
  48. quantized/calc/fitting.py +199 -0
  49. quantized/calc/formula.py +90 -0
  50. quantized/calc/global_curve_fit.py +305 -0
  51. quantized/calc/global_fit.py +181 -0
  52. quantized/calc/interp2d.py +260 -0
  53. quantized/calc/linecut.py +269 -0
  54. quantized/calc/magnetic.py +414 -0
  55. quantized/calc/magnetometry.py +464 -0
  56. quantized/calc/map.py +228 -0
  57. quantized/calc/mcmc.py +177 -0
  58. quantized/calc/optics.py +228 -0
  59. quantized/calc/pawley.py +251 -0
  60. quantized/calc/peak_batch.py +128 -0
  61. quantized/calc/peak_fit.py +259 -0
  62. quantized/calc/peak_integrate.py +104 -0
  63. quantized/calc/peak_multifit.py +260 -0
  64. quantized/calc/peak_track.py +134 -0
  65. quantized/calc/peaks.py +298 -0
  66. quantized/calc/peakshapes.py +85 -0
  67. quantized/calc/plotting.py +147 -0
  68. quantized/calc/processing.py +232 -0
  69. quantized/calc/qspace.py +48 -0
  70. quantized/calc/reductions.py +155 -0
  71. quantized/calc/reductions_fft.py +383 -0
  72. quantized/calc/refl_sld_presets.json +1 -0
  73. quantized/calc/reflectivity.py +80 -0
  74. quantized/calc/registry.py +303 -0
  75. quantized/calc/relaxation.py +119 -0
  76. quantized/calc/report.py +253 -0
  77. quantized/calc/report_emit.py +227 -0
  78. quantized/calc/resample.py +142 -0
  79. quantized/calc/rsm.py +91 -0
  80. quantized/calc/rsm_analyze.py +245 -0
  81. quantized/calc/semiconductor.py +488 -0
  82. quantized/calc/sld.py +131 -0
  83. quantized/calc/sld_formula.py +138 -0
  84. quantized/calc/spectral.py +357 -0
  85. quantized/calc/statplots.py +214 -0
  86. quantized/calc/stats.py +399 -0
  87. quantized/calc/stats_anova2.py +202 -0
  88. quantized/calc/stats_anova_ext.py +338 -0
  89. quantized/calc/stats_dist.py +196 -0
  90. quantized/calc/stats_glm.py +245 -0
  91. quantized/calc/stats_multivar.py +289 -0
  92. quantized/calc/stats_roc.py +157 -0
  93. quantized/calc/stats_survival.py +261 -0
  94. quantized/calc/stats_tests.py +380 -0
  95. quantized/calc/substrates.py +181 -0
  96. quantized/calc/superconductor.py +359 -0
  97. quantized/calc/surface_fit.py +290 -0
  98. quantized/calc/surface_models.py +156 -0
  99. quantized/calc/thermal.py +119 -0
  100. quantized/calc/thin_film.py +425 -0
  101. quantized/calc/unit_convert.py +259 -0
  102. quantized/calc/units.py +80 -0
  103. quantized/calc/vacuum.py +290 -0
  104. quantized/calc/xray.py +169 -0
  105. quantized/cli.py +214 -0
  106. quantized/datastruct.py +153 -0
  107. quantized/io/__init__.py +11 -0
  108. quantized/io/_hdf5_layout.py +308 -0
  109. quantized/io/_jcamp_asdf.py +135 -0
  110. quantized/io/_xrdml_scan.py +291 -0
  111. quantized/io/base.py +82 -0
  112. quantized/io/bruker_brml.py +177 -0
  113. quantized/io/bruker_raw.py +158 -0
  114. quantized/io/cif.py +266 -0
  115. quantized/io/consolidated.py +122 -0
  116. quantized/io/delimited.py +222 -0
  117. quantized/io/excel.py +135 -0
  118. quantized/io/hdf5.py +192 -0
  119. quantized/io/import_filters.py +178 -0
  120. quantized/io/import_preview.py +262 -0
  121. quantized/io/jcamp.py +179 -0
  122. quantized/io/lakeshore.py +163 -0
  123. quantized/io/ncnr.py +278 -0
  124. quantized/io/netcdf.py +195 -0
  125. quantized/io/opus.py +231 -0
  126. quantized/io/origin.py +346 -0
  127. quantized/io/origin_com.py +194 -0
  128. quantized/io/origin_project/__init__.py +221 -0
  129. quantized/io/origin_project/annotation_marks.py +288 -0
  130. quantized/io/origin_project/container.py +262 -0
  131. quantized/io/origin_project/curve_style_color.py +359 -0
  132. quantized/io/origin_project/figure_geometry.py +108 -0
  133. quantized/io/origin_project/figure_layers.py +333 -0
  134. quantized/io/origin_project/figure_text.py +258 -0
  135. quantized/io/origin_project/figures.py +210 -0
  136. quantized/io/origin_project/figures_opju.py +440 -0
  137. quantized/io/origin_project/notes.py +302 -0
  138. quantized/io/origin_project/opj.py +459 -0
  139. quantized/io/origin_project/opj_curves.py +297 -0
  140. quantized/io/origin_project/opj_shapes.py +148 -0
  141. quantized/io/origin_project/opju.py +146 -0
  142. quantized/io/origin_project/opju_axis_real_form.py +418 -0
  143. quantized/io/origin_project/opju_axis_specimen_form.py +167 -0
  144. quantized/io/origin_project/opju_codec.py +370 -0
  145. quantized/io/origin_project/opju_curves.py +497 -0
  146. quantized/io/origin_project/opju_curves_allcols.py +258 -0
  147. quantized/io/origin_project/opju_figure_curves.py +302 -0
  148. quantized/io/origin_project/opju_figure_text.py +245 -0
  149. quantized/io/origin_project/opju_reports.py +129 -0
  150. quantized/io/origin_project/origin_richtext.py +145 -0
  151. quantized/io/origin_project/preview.py +132 -0
  152. quantized/io/origin_project/templates.py +314 -0
  153. quantized/io/origin_project/tree.py +379 -0
  154. quantized/io/origin_project/tree_opju.py +228 -0
  155. quantized/io/origin_project/windows.py +238 -0
  156. quantized/io/origin_project/windows_opju.py +393 -0
  157. quantized/io/origin_project/writer.py +156 -0
  158. quantized/io/origin_project/writer_blocks.py +282 -0
  159. quantized/io/qd.py +380 -0
  160. quantized/io/refl1d.py +132 -0
  161. quantized/io/registry.py +210 -0
  162. quantized/io/report_export.py +347 -0
  163. quantized/io/rigaku.py +100 -0
  164. quantized/io/sims.py +398 -0
  165. quantized/io/spc.py +311 -0
  166. quantized/io/xrd_csv.py +308 -0
  167. quantized/io/xrdml.py +394 -0
  168. quantized/jobs.py +173 -0
  169. quantized/plugins/__init__.py +50 -0
  170. quantized/plugins/contract.py +111 -0
  171. quantized/plugins/loader.py +394 -0
  172. quantized/plugins/steps.py +90 -0
  173. quantized/routes/__init__.py +7 -0
  174. quantized/routes/_bookcache.py +62 -0
  175. quantized/routes/_export_common.py +27 -0
  176. quantized/routes/_payload.py +58 -0
  177. quantized/routes/_uploadcache.py +59 -0
  178. quantized/routes/aggregate.py +46 -0
  179. quantized/routes/baseline.py +210 -0
  180. quantized/routes/books.py +117 -0
  181. quantized/routes/calc.py +56 -0
  182. quantized/routes/corrections.py +78 -0
  183. quantized/routes/crystallography.py +80 -0
  184. quantized/routes/diffusion.py +58 -0
  185. quantized/routes/electrical.py +101 -0
  186. quantized/routes/electrochemistry.py +83 -0
  187. quantized/routes/export.py +280 -0
  188. quantized/routes/export_facets.py +83 -0
  189. quantized/routes/export_figures.py +471 -0
  190. quantized/routes/export_page.py +125 -0
  191. quantized/routes/fitting.py +379 -0
  192. quantized/routes/fitting_bumps.py +97 -0
  193. quantized/routes/import_template.py +97 -0
  194. quantized/routes/import_wizard.py +150 -0
  195. quantized/routes/jobs_api.py +59 -0
  196. quantized/routes/magnetic.py +135 -0
  197. quantized/routes/magnetometry.py +133 -0
  198. quantized/routes/optics.py +98 -0
  199. quantized/routes/parsers.py +281 -0
  200. quantized/routes/peaks.py +184 -0
  201. quantized/routes/plot.py +103 -0
  202. quantized/routes/reductions.py +121 -0
  203. quantized/routes/reference.py +58 -0
  204. quantized/routes/reflectivity.py +91 -0
  205. quantized/routes/report_export.py +119 -0
  206. quantized/routes/rsm.py +136 -0
  207. quantized/routes/samples.py +32 -0
  208. quantized/routes/semiconductor.py +207 -0
  209. quantized/routes/sld.py +42 -0
  210. quantized/routes/spectral.py +54 -0
  211. quantized/routes/statplots.py +99 -0
  212. quantized/routes/stats.py +418 -0
  213. quantized/routes/stats_design.py +321 -0
  214. quantized/routes/substrates.py +46 -0
  215. quantized/routes/superconductor.py +139 -0
  216. quantized/routes/thermal.py +57 -0
  217. quantized/routes/thin_film.py +153 -0
  218. quantized/routes/vacuum.py +113 -0
  219. quantized/routes/xray.py +32 -0
  220. quantized/samples/demo_vsm.csv +42 -0
  221. quantized/server_launch.py +251 -0
  222. quantized/web/assets/JetBrainsMono-Bold-CUogYd9I.woff2 +0 -0
  223. quantized/web/assets/JetBrainsMono-Regular-CA-Os4ii.woff2 +0 -0
  224. quantized/web/assets/index-BHmmCL-x.js +27 -0
  225. quantized/web/assets/index-BiZzN7J6.css +1 -0
  226. quantized/web/index.html +13 -0
  227. quantized/web/loading.html +69 -0
  228. quantized_lab-0.8.0.dist-info/METADATA +122 -0
  229. quantized_lab-0.8.0.dist-info/RECORD +233 -0
  230. quantized_lab-0.8.0.dist-info/WHEEL +4 -0
  231. quantized_lab-0.8.0.dist-info/entry_points.txt +4 -0
  232. quantized_lab-0.8.0.dist-info/licenses/LICENSE +201 -0
  233. quantized_lab-0.8.0.dist-info/licenses/NOTICE +11 -0
quantized/io/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
+ ]