quantized-lab 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. quantized/__init__.py +11 -0
  2. quantized/__main__.py +14 -0
  3. quantized/api.py +220 -0
  4. quantized/app.py +206 -0
  5. quantized/calc/__init__.py +8 -0
  6. quantized/calc/_clipfit.py +76 -0
  7. quantized/calc/_natural_neighbor.py +219 -0
  8. quantized/calc/aggregate.py +159 -0
  9. quantized/calc/backgrounds.py +353 -0
  10. quantized/calc/baseline.py +349 -0
  11. quantized/calc/batch_fit.py +148 -0
  12. quantized/calc/constants.py +27 -0
  13. quantized/calc/corrections.py +192 -0
  14. quantized/calc/crystallography.py +400 -0
  15. quantized/calc/diffusion.py +120 -0
  16. quantized/calc/electrical.py +248 -0
  17. quantized/calc/electrochemistry.py +176 -0
  18. quantized/calc/element_data.json +1 -0
  19. quantized/calc/element_data.py +59 -0
  20. quantized/calc/errors.py +246 -0
  21. quantized/calc/figure.py +417 -0
  22. quantized/calc/figure_break.py +152 -0
  23. quantized/calc/figure_categorical.py +156 -0
  24. quantized/calc/figure_corner.py +229 -0
  25. quantized/calc/figure_facets.py +127 -0
  26. quantized/calc/figure_field.py +137 -0
  27. quantized/calc/figure_hitmap.py +116 -0
  28. quantized/calc/figure_labels.py +62 -0
  29. quantized/calc/figure_map.py +287 -0
  30. quantized/calc/figure_overrides.py +159 -0
  31. quantized/calc/figure_page.py +266 -0
  32. quantized/calc/figure_scale.py +125 -0
  33. quantized/calc/figure_statplots.py +167 -0
  34. quantized/calc/figure_styles.py +131 -0
  35. quantized/calc/figure_ternary.py +239 -0
  36. quantized/calc/figure_ticks.py +217 -0
  37. quantized/calc/fit_autoguess.py +156 -0
  38. quantized/calc/fit_bootstrap.py +163 -0
  39. quantized/calc/fit_bumps.py +258 -0
  40. quantized/calc/fit_constraints.py +114 -0
  41. quantized/calc/fit_equation.py +264 -0
  42. quantized/calc/fit_findxy.py +80 -0
  43. quantized/calc/fit_models.py +195 -0
  44. quantized/calc/fit_models_special.py +189 -0
  45. quantized/calc/fit_odr.py +99 -0
  46. quantized/calc/fit_scan.py +243 -0
  47. quantized/calc/fit_stats.py +215 -0
  48. quantized/calc/fitting.py +199 -0
  49. quantized/calc/formula.py +90 -0
  50. quantized/calc/global_curve_fit.py +305 -0
  51. quantized/calc/global_fit.py +181 -0
  52. quantized/calc/interp2d.py +260 -0
  53. quantized/calc/linecut.py +269 -0
  54. quantized/calc/magnetic.py +414 -0
  55. quantized/calc/magnetometry.py +464 -0
  56. quantized/calc/map.py +228 -0
  57. quantized/calc/mcmc.py +177 -0
  58. quantized/calc/optics.py +228 -0
  59. quantized/calc/pawley.py +251 -0
  60. quantized/calc/peak_batch.py +128 -0
  61. quantized/calc/peak_fit.py +259 -0
  62. quantized/calc/peak_integrate.py +104 -0
  63. quantized/calc/peak_multifit.py +260 -0
  64. quantized/calc/peak_track.py +134 -0
  65. quantized/calc/peaks.py +298 -0
  66. quantized/calc/peakshapes.py +85 -0
  67. quantized/calc/plotting.py +147 -0
  68. quantized/calc/processing.py +232 -0
  69. quantized/calc/qspace.py +48 -0
  70. quantized/calc/reductions.py +155 -0
  71. quantized/calc/reductions_fft.py +383 -0
  72. quantized/calc/refl_sld_presets.json +1 -0
  73. quantized/calc/reflectivity.py +80 -0
  74. quantized/calc/registry.py +303 -0
  75. quantized/calc/relaxation.py +119 -0
  76. quantized/calc/report.py +253 -0
  77. quantized/calc/report_emit.py +227 -0
  78. quantized/calc/resample.py +142 -0
  79. quantized/calc/rsm.py +91 -0
  80. quantized/calc/rsm_analyze.py +245 -0
  81. quantized/calc/semiconductor.py +488 -0
  82. quantized/calc/sld.py +131 -0
  83. quantized/calc/sld_formula.py +138 -0
  84. quantized/calc/spectral.py +357 -0
  85. quantized/calc/statplots.py +214 -0
  86. quantized/calc/stats.py +399 -0
  87. quantized/calc/stats_anova2.py +202 -0
  88. quantized/calc/stats_anova_ext.py +338 -0
  89. quantized/calc/stats_dist.py +196 -0
  90. quantized/calc/stats_glm.py +245 -0
  91. quantized/calc/stats_multivar.py +289 -0
  92. quantized/calc/stats_roc.py +157 -0
  93. quantized/calc/stats_survival.py +261 -0
  94. quantized/calc/stats_tests.py +380 -0
  95. quantized/calc/substrates.py +181 -0
  96. quantized/calc/superconductor.py +359 -0
  97. quantized/calc/surface_fit.py +290 -0
  98. quantized/calc/surface_models.py +156 -0
  99. quantized/calc/thermal.py +119 -0
  100. quantized/calc/thin_film.py +425 -0
  101. quantized/calc/unit_convert.py +259 -0
  102. quantized/calc/units.py +80 -0
  103. quantized/calc/vacuum.py +290 -0
  104. quantized/calc/xray.py +169 -0
  105. quantized/cli.py +214 -0
  106. quantized/datastruct.py +153 -0
  107. quantized/io/__init__.py +11 -0
  108. quantized/io/_hdf5_layout.py +308 -0
  109. quantized/io/_jcamp_asdf.py +135 -0
  110. quantized/io/_xrdml_scan.py +291 -0
  111. quantized/io/base.py +82 -0
  112. quantized/io/bruker_brml.py +177 -0
  113. quantized/io/bruker_raw.py +158 -0
  114. quantized/io/cif.py +266 -0
  115. quantized/io/consolidated.py +122 -0
  116. quantized/io/delimited.py +222 -0
  117. quantized/io/excel.py +135 -0
  118. quantized/io/hdf5.py +192 -0
  119. quantized/io/import_filters.py +178 -0
  120. quantized/io/import_preview.py +262 -0
  121. quantized/io/jcamp.py +179 -0
  122. quantized/io/lakeshore.py +163 -0
  123. quantized/io/ncnr.py +278 -0
  124. quantized/io/netcdf.py +195 -0
  125. quantized/io/opus.py +231 -0
  126. quantized/io/origin.py +346 -0
  127. quantized/io/origin_com.py +194 -0
  128. quantized/io/origin_project/__init__.py +221 -0
  129. quantized/io/origin_project/annotation_marks.py +288 -0
  130. quantized/io/origin_project/container.py +262 -0
  131. quantized/io/origin_project/curve_style_color.py +359 -0
  132. quantized/io/origin_project/figure_geometry.py +108 -0
  133. quantized/io/origin_project/figure_layers.py +333 -0
  134. quantized/io/origin_project/figure_text.py +258 -0
  135. quantized/io/origin_project/figures.py +210 -0
  136. quantized/io/origin_project/figures_opju.py +440 -0
  137. quantized/io/origin_project/notes.py +302 -0
  138. quantized/io/origin_project/opj.py +459 -0
  139. quantized/io/origin_project/opj_curves.py +297 -0
  140. quantized/io/origin_project/opj_shapes.py +148 -0
  141. quantized/io/origin_project/opju.py +146 -0
  142. quantized/io/origin_project/opju_axis_real_form.py +418 -0
  143. quantized/io/origin_project/opju_axis_specimen_form.py +167 -0
  144. quantized/io/origin_project/opju_codec.py +370 -0
  145. quantized/io/origin_project/opju_curves.py +497 -0
  146. quantized/io/origin_project/opju_curves_allcols.py +258 -0
  147. quantized/io/origin_project/opju_figure_curves.py +302 -0
  148. quantized/io/origin_project/opju_figure_text.py +245 -0
  149. quantized/io/origin_project/opju_reports.py +129 -0
  150. quantized/io/origin_project/origin_richtext.py +145 -0
  151. quantized/io/origin_project/preview.py +132 -0
  152. quantized/io/origin_project/templates.py +314 -0
  153. quantized/io/origin_project/tree.py +379 -0
  154. quantized/io/origin_project/tree_opju.py +228 -0
  155. quantized/io/origin_project/windows.py +238 -0
  156. quantized/io/origin_project/windows_opju.py +393 -0
  157. quantized/io/origin_project/writer.py +156 -0
  158. quantized/io/origin_project/writer_blocks.py +282 -0
  159. quantized/io/qd.py +380 -0
  160. quantized/io/refl1d.py +132 -0
  161. quantized/io/registry.py +210 -0
  162. quantized/io/report_export.py +347 -0
  163. quantized/io/rigaku.py +100 -0
  164. quantized/io/sims.py +398 -0
  165. quantized/io/spc.py +311 -0
  166. quantized/io/xrd_csv.py +308 -0
  167. quantized/io/xrdml.py +394 -0
  168. quantized/jobs.py +173 -0
  169. quantized/plugins/__init__.py +50 -0
  170. quantized/plugins/contract.py +111 -0
  171. quantized/plugins/loader.py +394 -0
  172. quantized/plugins/steps.py +90 -0
  173. quantized/routes/__init__.py +7 -0
  174. quantized/routes/_bookcache.py +62 -0
  175. quantized/routes/_export_common.py +27 -0
  176. quantized/routes/_payload.py +58 -0
  177. quantized/routes/_uploadcache.py +59 -0
  178. quantized/routes/aggregate.py +46 -0
  179. quantized/routes/baseline.py +210 -0
  180. quantized/routes/books.py +117 -0
  181. quantized/routes/calc.py +56 -0
  182. quantized/routes/corrections.py +78 -0
  183. quantized/routes/crystallography.py +80 -0
  184. quantized/routes/diffusion.py +58 -0
  185. quantized/routes/electrical.py +101 -0
  186. quantized/routes/electrochemistry.py +83 -0
  187. quantized/routes/export.py +280 -0
  188. quantized/routes/export_facets.py +83 -0
  189. quantized/routes/export_figures.py +471 -0
  190. quantized/routes/export_page.py +125 -0
  191. quantized/routes/fitting.py +379 -0
  192. quantized/routes/fitting_bumps.py +97 -0
  193. quantized/routes/import_template.py +97 -0
  194. quantized/routes/import_wizard.py +150 -0
  195. quantized/routes/jobs_api.py +59 -0
  196. quantized/routes/magnetic.py +135 -0
  197. quantized/routes/magnetometry.py +133 -0
  198. quantized/routes/optics.py +98 -0
  199. quantized/routes/parsers.py +281 -0
  200. quantized/routes/peaks.py +184 -0
  201. quantized/routes/plot.py +103 -0
  202. quantized/routes/reductions.py +121 -0
  203. quantized/routes/reference.py +58 -0
  204. quantized/routes/reflectivity.py +91 -0
  205. quantized/routes/report_export.py +119 -0
  206. quantized/routes/rsm.py +136 -0
  207. quantized/routes/samples.py +32 -0
  208. quantized/routes/semiconductor.py +207 -0
  209. quantized/routes/sld.py +42 -0
  210. quantized/routes/spectral.py +54 -0
  211. quantized/routes/statplots.py +99 -0
  212. quantized/routes/stats.py +418 -0
  213. quantized/routes/stats_design.py +321 -0
  214. quantized/routes/substrates.py +46 -0
  215. quantized/routes/superconductor.py +139 -0
  216. quantized/routes/thermal.py +57 -0
  217. quantized/routes/thin_film.py +153 -0
  218. quantized/routes/vacuum.py +113 -0
  219. quantized/routes/xray.py +32 -0
  220. quantized/samples/demo_vsm.csv +42 -0
  221. quantized/server_launch.py +251 -0
  222. quantized/web/assets/JetBrainsMono-Bold-CUogYd9I.woff2 +0 -0
  223. quantized/web/assets/JetBrainsMono-Regular-CA-Os4ii.woff2 +0 -0
  224. quantized/web/assets/index-BHmmCL-x.js +27 -0
  225. quantized/web/assets/index-BiZzN7J6.css +1 -0
  226. quantized/web/index.html +13 -0
  227. quantized/web/loading.html +69 -0
  228. quantized_lab-0.8.0.dist-info/METADATA +122 -0
  229. quantized_lab-0.8.0.dist-info/RECORD +233 -0
  230. quantized_lab-0.8.0.dist-info/WHEEL +4 -0
  231. quantized_lab-0.8.0.dist-info/entry_points.txt +4 -0
  232. quantized_lab-0.8.0.dist-info/licenses/LICENSE +201 -0
  233. quantized_lab-0.8.0.dist-info/licenses/NOTICE +11 -0
@@ -0,0 +1,260 @@
1
+ """Scattered 2-D interpolation + regridding. Ports of MATLAB +utilities.
2
+
3
+ Pure calc layer. ``interpolate2d`` interpolates scattered (x, y, z) onto query
4
+ points; ``regrid2d`` builds a regular grid and calls it.
5
+
6
+ Parity notes:
7
+ * ``linear`` / ``nearest`` delegate to ``scipy.interpolate.griddata`` (Qhull
8
+ Delaunay) and match MATLAB ``scatteredInterpolant`` inside the convex hull.
9
+ * ``idw`` and ``thinplate`` are hand-rolled (inverse-distance weighting and
10
+ thin-plate-spline linear systems) and match MATLAB exactly.
11
+ * ``natural`` (the MATLAB default) and ``cubic`` (which MATLAB aliases to
12
+ ``natural``) use Sibson natural-neighbour interpolation, hand-rolled in
13
+ ``_natural_neighbor`` from the Delaunay triangulation. Sibson coordinates
14
+ are geometrically unique, so this matches MATLAB's 'natural' to ~1e-9 at
15
+ interior points and reproduces affine fields exactly (linear precision).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Any
21
+
22
+ import numpy as np
23
+ from numpy.typing import ArrayLike, NDArray
24
+ from scipy.interpolate import griddata
25
+ from scipy.spatial import Delaunay
26
+ from scipy.spatial._qhull import QhullError
27
+
28
+ from quantized.calc._natural_neighbor import sibson_interpolate
29
+
30
+ __all__ = ["interpolate2d", "regrid2d"]
31
+
32
+ _SCATTERED = ("linear", "natural", "nearest", "cubic")
33
+
34
+
35
+ def _unique_rows(
36
+ xv: NDArray[np.float64], yv: NDArray[np.float64], zv: NDArray[np.float64]
37
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]:
38
+ """Drop duplicate (x, y) rows keeping first occurrence (MATLAB unique 'stable')."""
39
+ seen: set[tuple[float, float]] = set()
40
+ keep: list[int] = []
41
+ for i in range(xv.size):
42
+ key = (float(xv[i]), float(yv[i]))
43
+ if key not in seen:
44
+ seen.add(key)
45
+ keep.append(i)
46
+ if len(keep) < xv.size:
47
+ return xv[keep], yv[keep], zv[keep]
48
+ return xv, yv, zv
49
+
50
+
51
+ def _hull_mask(
52
+ xv: NDArray[np.float64],
53
+ yv: NDArray[np.float64],
54
+ qpts: NDArray[np.float64],
55
+ zqv: NDArray[np.float64],
56
+ ) -> NDArray[np.float64]:
57
+ """Set query points outside the data convex hull to NaN (extrapolation='none')."""
58
+ if xv.size < 3:
59
+ return zqv
60
+ try:
61
+ tri = Delaunay(np.column_stack([xv, yv]))
62
+ except QhullError:
63
+ return zqv
64
+ out = zqv.copy()
65
+ out[tri.find_simplex(qpts) < 0] = np.nan
66
+ return out
67
+
68
+
69
+ def _interp_scattered(
70
+ xv: NDArray[np.float64],
71
+ yv: NDArray[np.float64],
72
+ zv: NDArray[np.float64],
73
+ xqv: NDArray[np.float64],
74
+ yqv: NDArray[np.float64],
75
+ method: str,
76
+ extrapolation: str,
77
+ ) -> NDArray[np.float64]:
78
+ pts = np.column_stack([xv, yv])
79
+ qpts = np.column_stack([xqv, yqv])
80
+ if method in ("natural", "cubic"):
81
+ # Sibson natural-neighbour (MATLAB aliases 'cubic' to 'natural'); already
82
+ # NaN outside the convex hull.
83
+ zqv = sibson_interpolate(xv, yv, zv, xqv, yqv)
84
+ elif method == "nearest":
85
+ zqv = np.asarray(griddata(pts, zv, qpts, method="nearest"), dtype=float)
86
+ if extrapolation == "none":
87
+ zqv = _hull_mask(xv, yv, qpts, zqv)
88
+ return zqv
89
+ else: # linear
90
+ try:
91
+ zqv = np.asarray(griddata(pts, zv, qpts, method="linear"), dtype=float)
92
+ except QhullError:
93
+ # Degenerate (collinear / coincident) cloud — Qhull can't triangulate,
94
+ # so linear interpolation is undefined. Degrade to NaN (as for points
95
+ # outside the convex hull) rather than let QhullError escape as a 500;
96
+ # mirrors the QhullError guards already used by _hull_mask and natural.
97
+ zqv = np.full(qpts.shape[0], np.nan)
98
+ if extrapolation == "nearest":
99
+ nan_mask = np.isnan(zqv)
100
+ if nan_mask.any():
101
+ zqv[nan_mask] = griddata(pts, zv, qpts[nan_mask], method="nearest")
102
+ return zqv
103
+
104
+
105
+ def _interp_idw(
106
+ xv: NDArray[np.float64],
107
+ yv: NDArray[np.float64],
108
+ zv: NDArray[np.float64],
109
+ xqv: NDArray[np.float64],
110
+ yqv: NDArray[np.float64],
111
+ power: float,
112
+ ) -> NDArray[np.float64]:
113
+ zqv = np.empty(xqv.size)
114
+ for k in range(xqv.size):
115
+ d = np.sqrt((xqv[k] - xv) ** 2 + (yqv[k] - yv) ** 2)
116
+ exact = np.flatnonzero(d == 0)
117
+ if exact.size:
118
+ zqv[k] = zv[exact[0]]
119
+ continue
120
+ w = 1.0 / (d**power)
121
+ zqv[k] = float(np.sum(w * zv) / np.sum(w))
122
+ return zqv
123
+
124
+
125
+ def _interp_thinplate(
126
+ xv: NDArray[np.float64],
127
+ yv: NDArray[np.float64],
128
+ zv: NDArray[np.float64],
129
+ xqv: NDArray[np.float64],
130
+ yqv: NDArray[np.float64],
131
+ lam: float,
132
+ extrapolation: str,
133
+ ) -> NDArray[np.float64]:
134
+ n = xv.size
135
+ r2 = (xv[:, None] - xv[None, :]) ** 2 + (yv[:, None] - yv[None, :]) ** 2
136
+ r = np.sqrt(r2)
137
+ phi = np.zeros((n, n))
138
+ mask = r > 0
139
+ phi[mask] = r2[mask] * np.log(r[mask])
140
+ pmat = np.column_stack([np.ones(n), xv, yv])
141
+ amat = np.block([[phi + lam * np.eye(n), pmat], [pmat.T, np.zeros((3, 3))]])
142
+ try:
143
+ coeff = np.linalg.solve(amat, np.concatenate([zv, np.zeros(3)]))
144
+ except np.linalg.LinAlgError as exc:
145
+ raise ValueError(
146
+ "thin-plate spline: data matrix is singular — points are collinear or "
147
+ "coincident; use a coarser grid or a different interpolation method"
148
+ ) from exc
149
+ w, a = coeff[:n], coeff[n : n + 3]
150
+
151
+ zqv = np.empty(xqv.size)
152
+ for qi in range(xqv.size):
153
+ r2q = (xqv[qi] - xv) ** 2 + (yqv[qi] - yv) ** 2
154
+ rq = np.sqrt(r2q)
155
+ phiq = np.zeros(n)
156
+ mk = rq > 0
157
+ phiq[mk] = r2q[mk] * np.log(rq[mk])
158
+ zqv[qi] = float(w @ phiq + a[0] + a[1] * xqv[qi] + a[2] * yqv[qi])
159
+ if extrapolation == "none":
160
+ zqv = _hull_mask(xv, yv, np.column_stack([xqv, yqv]), zqv)
161
+ return zqv
162
+
163
+
164
+ def interpolate2d(
165
+ x: ArrayLike,
166
+ y: ArrayLike,
167
+ z: ArrayLike,
168
+ xq: ArrayLike,
169
+ yq: ArrayLike,
170
+ *,
171
+ method: str = "natural",
172
+ idw_power: float = 2.0,
173
+ extrapolation: str = "none",
174
+ smoothing: float = 0.0,
175
+ ) -> dict[str, Any]:
176
+ """Interpolate scattered (x, y, z) onto query points. Port of utilities.interpolate2D.
177
+
178
+ Returns ``{"zq", "method", "stats": {"nPoints", "rmse"}}`` (rmse is NaN — the
179
+ expensive leave-one-out estimate is not computed). See module docstring for
180
+ per-method parity. ``extrapolation='none'`` yields NaN outside the convex hull.
181
+ """
182
+ xv = np.asarray(x, dtype=float).ravel()
183
+ yv = np.asarray(y, dtype=float).ravel()
184
+ zv = np.asarray(z, dtype=float).ravel()
185
+ if not (xv.size == yv.size == zv.size):
186
+ raise ValueError("x, y, and z must have the same number of elements")
187
+ if xv.size < 3:
188
+ raise ValueError("at least 3 data points are required for 2-D interpolation")
189
+ xv, yv, zv = _unique_rows(xv, yv, zv)
190
+
191
+ query_shape = np.asarray(xq, dtype=float).shape
192
+ xqv = np.asarray(xq, dtype=float).ravel()
193
+ yqv = np.asarray(yq, dtype=float).ravel()
194
+
195
+ if method in _SCATTERED:
196
+ zqv = _interp_scattered(xv, yv, zv, xqv, yqv, method, extrapolation)
197
+ elif method == "thinplate":
198
+ zqv = _interp_thinplate(xv, yv, zv, xqv, yqv, smoothing, extrapolation)
199
+ elif method == "idw":
200
+ zqv = _interp_idw(xv, yv, zv, xqv, yqv, idw_power)
201
+ if extrapolation == "none":
202
+ zqv = _hull_mask(xv, yv, np.column_stack([xqv, yqv]), zqv)
203
+ else:
204
+ raise ValueError(f"unknown method {method!r}")
205
+
206
+ return {
207
+ "zq": zqv.reshape(query_shape),
208
+ "method": method,
209
+ "stats": {"nPoints": int(xv.size), "rmse": float("nan")},
210
+ }
211
+
212
+
213
+ def regrid2d(
214
+ x: ArrayLike,
215
+ y: ArrayLike,
216
+ z: ArrayLike,
217
+ *,
218
+ nx: int = 100,
219
+ ny: int = 100,
220
+ method: str = "natural",
221
+ xlim: tuple[float, float] | None = None,
222
+ ylim: tuple[float, float] | None = None,
223
+ extrapolation: str = "none",
224
+ smoothing: float = 0.0,
225
+ idw_power: float = 2.0,
226
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]:
227
+ """Resample scattered data onto a regular grid. Port of utilities.regrid2D.
228
+
229
+ Returns ``(Xq, Yq, Zq)`` meshgrids (shape ``ny x nx``). Limits default to the
230
+ data extent. See ``interpolate2d`` for per-method parity caveats.
231
+ """
232
+ xv = np.asarray(x, dtype=float).ravel()
233
+ yv = np.asarray(y, dtype=float).ravel()
234
+ zv = np.asarray(z, dtype=float).ravel()
235
+ # Scattered interpolation (scatteredInterpolant/griddata) needs finite points;
236
+ # drop any non-finite (x, y, z) triple so real data with NaN gaps still grids.
237
+ # No-op on clean data, so golden parity holds.
238
+ finite = np.isfinite(xv) & np.isfinite(yv) & np.isfinite(zv)
239
+ if not bool(finite.all()):
240
+ xv, yv, zv = xv[finite], yv[finite], zv[finite]
241
+ xl = (float(xv.min()), float(xv.max())) if xlim is None else (float(xlim[0]), float(xlim[1]))
242
+ yl = (float(yv.min()), float(yv.max())) if ylim is None else (float(ylim[0]), float(ylim[1]))
243
+ if xl[0] >= xl[1]:
244
+ raise ValueError(
245
+ "cannot build a 2-D grid: the x axis has no range "
246
+ f"(min == max == {xl[0]:g}); it is constant or single-valued"
247
+ )
248
+ if yl[0] >= yl[1]:
249
+ raise ValueError(
250
+ "cannot build a 2-D grid: the y axis has no range "
251
+ f"(min == max == {yl[0]:g}); it is constant or single-valued"
252
+ )
253
+ x_grid = np.linspace(xl[0], xl[1], nx)
254
+ y_grid = np.linspace(yl[0], yl[1], ny)
255
+ xq, yq = np.meshgrid(x_grid, y_grid)
256
+ result = interpolate2d(
257
+ xv, yv, zv, xq, yq,
258
+ method=method, extrapolation=extrapolation, smoothing=smoothing, idw_power=idw_power,
259
+ )
260
+ return xq, yq, np.asarray(result["zq"], dtype=float)
@@ -0,0 +1,269 @@
1
+ """Line cuts + projections from 2-D RSM DataStructs (ORIGIN_GAP_PLAN #18/#46).
2
+
3
+ Port + extension of MATLAB ``+bosonPlotter/extract2DLineCut.m``. The MATLAB
4
+ reference offered single nearest-row/column cuts (Shift/Ctrl+click) in angular
5
+ or Q space. Extended here:
6
+
7
+ - **width-averaged cuts** (``width > 0`` averages every row/column whose axis
8
+ value falls within ±width/2 — a swath, not one detector line);
9
+ - **arbitrary segment cuts** (:func:`cut_segment` — any angle through the
10
+ cloud, distance-parametrized, with optional perpendicular averaging);
11
+ - **integrated projections** (:func:`projection` — the full-map sum onto
12
+ either axis; the "pixels" direction reproduces MATLAB's integrated 1-D
13
+ fallback ``sum(intensityMap, 1)``).
14
+
15
+ Cuts work on the (frames × pixels) index grid via ``metadata.map_shape``, so
16
+ they are exact detector-line extractions for all three mesh kinds (mesh /
17
+ snapshot / coupled). Segment cuts interpolate the scattered cloud directly
18
+ and need no grid. Every function returns a 1-D ``DataStruct`` ready for the
19
+ library (plot, fit, export like any scan).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import Any
25
+
26
+ import numpy as np
27
+ from numpy.typing import NDArray
28
+
29
+ from quantized.calc.interp2d import interpolate2d
30
+ from quantized.datastruct import DataStruct
31
+
32
+ __all__ = ["cut_segment", "line_cut", "projection"]
33
+
34
+ _SPACES = ("angular", "q")
35
+
36
+
37
+ def _full_grids(ds: DataStruct) -> dict[str, Any]:
38
+ """Reshape the scattered 2-D DataStruct to full (N, M) per-point grids."""
39
+ if not ds.metadata.get("is2D"):
40
+ raise ValueError("dataset is not a 2-D map (metadata.is2D not set)")
41
+ shape = ds.metadata.get("map_shape")
42
+ if not shape or len(shape) != 2:
43
+ raise ValueError(
44
+ "dataset has no regular (frames x pixels) grid (map_shape missing) — "
45
+ "use cut_segment, which works on the scattered cloud"
46
+ )
47
+ n, m = int(shape[0]), int(shape[1])
48
+ axis1_name = str(ds.metadata.get("axis1_name", "Omega"))
49
+ out: dict[str, Any] = {
50
+ "tt": ds.column("2Theta").reshape(n, m),
51
+ "sec": ds.column(axis1_name).reshape(n, m),
52
+ "i": ds.column("Intensity").reshape(n, m),
53
+ "qx": None,
54
+ "qz": None,
55
+ "sec_name": axis1_name,
56
+ "unit": ds.units[list(ds.labels).index("Intensity")],
57
+ }
58
+ if "Qx" in ds.labels and "Qz" in ds.labels:
59
+ out["qx"] = ds.column("Qx").reshape(n, m)
60
+ out["qz"] = ds.column("Qz").reshape(n, m)
61
+ return out
62
+
63
+
64
+ def _require_q(g: dict[str, Any]) -> None:
65
+ if g["qx"] is None:
66
+ raise ValueError("dataset has no Qx/Qz columns — Q-space cut unavailable")
67
+
68
+
69
+ def _cut_result(
70
+ x: NDArray[np.float64],
71
+ y: NDArray[np.float64],
72
+ *,
73
+ label: str,
74
+ x_name: str,
75
+ x_unit: str,
76
+ unit: str,
77
+ ds: DataStruct,
78
+ extra: dict[str, Any] | None = None,
79
+ ) -> DataStruct:
80
+ """Assemble the 1-D cut DataStruct (ascending x for the plot stage)."""
81
+ order = np.argsort(x, kind="stable")
82
+ metadata: dict[str, Any] = {
83
+ "source": ds.metadata.get("source", ""),
84
+ "parser_name": "line_cut",
85
+ "x_column_name": x_name,
86
+ "x_column_unit": x_unit,
87
+ "cut_label": label,
88
+ "is2D": False,
89
+ **(extra or {}),
90
+ }
91
+ return DataStruct.create(
92
+ np.asarray(x, dtype=float)[order],
93
+ np.asarray(y, dtype=float)[order],
94
+ labels=["Intensity"],
95
+ units=[unit],
96
+ metadata=metadata,
97
+ )
98
+
99
+
100
+ def line_cut(
101
+ ds: DataStruct,
102
+ *,
103
+ direction: str,
104
+ value: float,
105
+ space: str = "angular",
106
+ width: float = 0.0,
107
+ ) -> DataStruct:
108
+ """Horizontal / vertical cut through the map at a fixed axis value.
109
+
110
+ ``direction='h'``: intensity vs the horizontal axis (2Theta, or Qx in
111
+ Q-space) at the frame(s) nearest ``value`` on the vertical axis.
112
+ ``direction='v'``: intensity vs the vertical axis (secondary motor, or Qz)
113
+ at the pixel column(s) nearest ``value`` on the horizontal axis.
114
+
115
+ ``width=0`` reproduces MATLAB's single nearest-line cut; ``width>0``
116
+ averages every line whose axis value lies within ±width/2 of ``value``
117
+ (falls back to the nearest single line when none do).
118
+ """
119
+ if direction not in ("h", "v"):
120
+ raise ValueError(f'direction must be "h" or "v", got "{direction}"')
121
+ if space not in _SPACES:
122
+ raise ValueError(f"space must be one of {_SPACES}, got {space!r}")
123
+ if width < 0:
124
+ raise ValueError("width must be >= 0")
125
+ g = _full_grids(ds)
126
+ if space == "q":
127
+ _require_q(g)
128
+
129
+ if direction == "h":
130
+ sel = np.mean(g["sec"] if space == "angular" else g["qz"], axis=1)
131
+ x_grid = g["tt"] if space == "angular" else g["qx"]
132
+ pick = np.abs(sel - value) <= width / 2.0
133
+ if not pick.any():
134
+ pick = np.zeros(sel.size, dtype=bool)
135
+ pick[int(np.argmin(np.abs(sel - value)))] = True
136
+ x = np.mean(x_grid[pick, :], axis=0)
137
+ y = np.mean(g["i"][pick, :], axis=0)
138
+ fixed_name = g["sec_name"] if space == "angular" else "Qz"
139
+ x_name = "2Theta" if space == "angular" else "Qx"
140
+ else:
141
+ sel = np.mean(g["tt"] if space == "angular" else g["qx"], axis=0)
142
+ x_grid = g["sec"] if space == "angular" else g["qz"]
143
+ pick = np.abs(sel - value) <= width / 2.0
144
+ if not pick.any():
145
+ pick = np.zeros(sel.size, dtype=bool)
146
+ pick[int(np.argmin(np.abs(sel - value)))] = True
147
+ x = np.mean(x_grid[:, pick], axis=1)
148
+ y = np.mean(g["i"][:, pick], axis=1)
149
+ fixed_name = "2Theta" if space == "angular" else "Qx"
150
+ x_name = g["sec_name"] if space == "angular" else "Qz"
151
+
152
+ unit_ax = "deg" if space == "angular" else "Ang^-1"
153
+ centre = float(np.mean(sel[pick]))
154
+ tag = "H-cut" if direction == "h" else "V-cut"
155
+ label = f"{tag} {fixed_name}≈{centre:.6g} {unit_ax}"
156
+ if width > 0:
157
+ label += f" ±{width / 2:.4g}"
158
+ return _cut_result(
159
+ x, y, label=label, x_name=x_name, x_unit=unit_ax, unit=g["unit"], ds=ds,
160
+ extra={"cut_direction": direction, "cut_value": centre,
161
+ "cut_width": width, "cut_space": space, "n_lines": int(pick.sum())},
162
+ )
163
+
164
+
165
+ def cut_segment(
166
+ ds: DataStruct,
167
+ *,
168
+ p0: tuple[float, float],
169
+ p1: tuple[float, float],
170
+ n: int = 200,
171
+ width: float = 0.0,
172
+ space: str = "angular",
173
+ ) -> DataStruct:
174
+ """Arbitrary straight cut from ``p0`` to ``p1`` through the scattered cloud.
175
+
176
+ Coordinates are ``(2Theta, <axis1>)`` for ``space='angular'`` or
177
+ ``(Qx, Qz)`` for ``space='q'``. Samples ``n`` points along the segment by
178
+ linear scattered interpolation; ``width>0`` additionally averages 7
179
+ parallel lines spread over ±width/2 perpendicular to the cut (NaN outside
180
+ the data hull is ignored). x = distance from ``p0``.
181
+ """
182
+ if space not in _SPACES:
183
+ raise ValueError(f"space must be one of {_SPACES}, got {space!r}")
184
+ if not ds.metadata.get("is2D"):
185
+ raise ValueError("dataset is not a 2-D map (metadata.is2D not set)")
186
+ if n < 2:
187
+ raise ValueError("n must be >= 2")
188
+ if width < 0:
189
+ raise ValueError("width must be >= 0")
190
+ axis1_name = str(ds.metadata.get("axis1_name", "Omega"))
191
+ if space == "q":
192
+ if "Qx" not in ds.labels:
193
+ raise ValueError("dataset has no Qx/Qz columns — Q-space cut unavailable")
194
+ xs, ys = ds.column("Qx"), ds.column("Qz")
195
+ unit_ax = "Ang^-1"
196
+ else:
197
+ xs, ys = ds.column("2Theta"), ds.column(axis1_name)
198
+ unit_ax = "deg"
199
+ iv = ds.column("Intensity")
200
+
201
+ d = np.asarray([p1[0] - p0[0], p1[1] - p0[1]], dtype=float)
202
+ length = float(np.hypot(*d))
203
+ if length <= 0:
204
+ raise ValueError("p0 and p1 must be distinct points")
205
+ d /= length
206
+ perp = np.asarray([-d[1], d[0]])
207
+ t = np.linspace(0.0, length, n)
208
+ base_x = p0[0] + t * d[0]
209
+ base_y = p0[1] + t * d[1]
210
+
211
+ offsets = np.linspace(-width / 2.0, width / 2.0, 7) if width > 0 else np.asarray([0.0])
212
+ xq = (base_x[None, :] + offsets[:, None] * perp[0]).ravel()
213
+ yq = (base_y[None, :] + offsets[:, None] * perp[1]).ravel()
214
+ zq = np.asarray(
215
+ interpolate2d(xs, ys, iv, xq, yq, method="linear")["zq"], dtype=float
216
+ ).reshape(offsets.size, n)
217
+ with np.errstate(invalid="ignore"):
218
+ y = np.nanmean(zq, axis=0)
219
+
220
+ unit = ds.units[list(ds.labels).index("Intensity")]
221
+ label = (
222
+ f"Cut ({p0[0]:.5g}, {p0[1]:.5g})→({p1[0]:.5g}, {p1[1]:.5g}) "
223
+ f"[{'Q' if space == 'q' else 'angular'}]"
224
+ )
225
+ if width > 0:
226
+ label += f" ±{width / 2:.4g}"
227
+ return _cut_result(
228
+ t, y, label=label, x_name="Distance", x_unit=unit_ax, unit=unit, ds=ds,
229
+ extra={"cut_p0": list(p0), "cut_p1": list(p1), "cut_width": width,
230
+ "cut_space": space, "cut_samples": int(n)},
231
+ )
232
+
233
+
234
+ def projection(
235
+ ds: DataStruct,
236
+ *,
237
+ axis: str = "pixels",
238
+ space: str = "angular",
239
+ ) -> DataStruct:
240
+ """Integrate the whole map onto one axis.
241
+
242
+ ``axis='pixels'``: sum over frames → intensity vs 2Theta (or Qx) — exactly
243
+ MATLAB importXRDML's integrated 1-D fallback for a mesh.
244
+ ``axis='frames'``: sum over pixels → intensity vs the secondary motor
245
+ (or Qz) — a rocking-curve-like profile.
246
+ """
247
+ if axis not in ("pixels", "frames"):
248
+ raise ValueError(f'axis must be "pixels" or "frames", got "{axis}"')
249
+ if space not in _SPACES:
250
+ raise ValueError(f"space must be one of {_SPACES}, got {space!r}")
251
+ g = _full_grids(ds)
252
+ if space == "q":
253
+ _require_q(g)
254
+
255
+ if axis == "pixels":
256
+ x = np.mean(g["tt"] if space == "angular" else g["qx"], axis=0)
257
+ y = np.sum(g["i"], axis=0)
258
+ x_name = "2Theta" if space == "angular" else "Qx"
259
+ else:
260
+ x = np.mean(g["sec"] if space == "angular" else g["qz"], axis=1)
261
+ y = np.sum(g["i"], axis=1)
262
+ x_name = g["sec_name"] if space == "angular" else "Qz"
263
+
264
+ unit_ax = "deg" if space == "angular" else "Ang^-1"
265
+ label = f"Projection Σ{'frames' if axis == 'pixels' else 'pixels'} → I vs {x_name}"
266
+ return _cut_result(
267
+ x, y, label=label, x_name=x_name, x_unit=unit_ax, unit=g["unit"], ds=ds,
268
+ extra={"cut_axis": axis, "cut_space": space},
269
+ )