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,353 @@
1
+ """Interactive + domain-specific backgrounds (GOTO_PLAN #2, #3, #7).
2
+
3
+ New features beyond MATLAB parity (no goldens — reference-value and
4
+ invariant tests in ``tests/test_calc_backgrounds.py``):
5
+
6
+ - :func:`anchor_baseline` — baseline through user-picked (x, y) anchor
7
+ points (linear / pchip / spline), extrapolation clamped to the end
8
+ anchors.
9
+ - :func:`shirley_background` — the classic iterative Shirley step
10
+ background for XPS/XAS spectra (Shirley 1972).
11
+ - :func:`xrd_low_angle_background` — hyperbolic air-scatter / beam-tail
12
+ background for powder XRD low-angle upturn (TOPAS ``One_on_X`` form).
13
+ - :func:`footprint_factor` / :func:`footprint_correction` — geometric
14
+ beam-footprint correction for XRR/NR specular scans (Gibaud & Vignaud).
15
+
16
+ Pure layer: ndarray in -> ndarray out. No fastapi / pydantic imports.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import math
22
+ from typing import Any
23
+
24
+ import numpy as np
25
+ from numpy.typing import ArrayLike, NDArray
26
+
27
+ from quantized.calc._clipfit import _iterative_clip_fit
28
+
29
+ __all__ = [
30
+ "anchor_baseline",
31
+ "footprint_correction",
32
+ "footprint_factor",
33
+ "shirley_background",
34
+ "xrd_low_angle_background",
35
+ ]
36
+
37
+ _EPS = float(np.finfo(float).eps)
38
+
39
+
40
+ def anchor_baseline(
41
+ x: ArrayLike,
42
+ y: ArrayLike,
43
+ anchors: ArrayLike,
44
+ *,
45
+ method: str = "pchip",
46
+ ) -> NDArray[np.float64]:
47
+ """Baseline through user-picked (x, y) anchor points. GOTO_PLAN #2.
48
+
49
+ Interpolates the ``anchors`` — a sequence of ``(x, y)`` pairs picked on
50
+ the plot — across the full data grid ``x``:
51
+
52
+ - ``method='linear'``: piecewise-linear through the anchors.
53
+ - ``method='pchip'``: shape-preserving cubic (Fritsch & Carlson 1980,
54
+ SIAM J. Numer. Anal. 17, 238) — no overshoot between anchors.
55
+ - ``method='spline'``: interpolating B-spline of degree
56
+ ``min(3, n_anchors - 1)`` (degrades gracefully: 2 anchors -> linear,
57
+ 3 -> quadratic, >=4 -> cubic).
58
+
59
+ Outside the anchor x-range the baseline is CLAMPED to the end anchors'
60
+ y values (constant extrapolation) — a subtraction should never invent
61
+ a diverging polynomial tail beyond where the user anchored it.
62
+
63
+ ``y`` (the signal the baseline sits under) is validated against ``x``
64
+ for length but does not influence the curve: the baseline is defined
65
+ by the anchors alone. Anchors are sorted by x internally; duplicate or
66
+ non-finite anchor x values raise ``ValueError`` (the interpolant needs
67
+ strictly monotone knots).
68
+ """
69
+ xv = np.asarray(x, dtype=float).ravel()
70
+ yv = np.asarray(y, dtype=float).ravel()
71
+ if xv.size != yv.size:
72
+ raise ValueError(f"x and y must have the same length (got {xv.size} vs {yv.size})")
73
+ a = np.asarray(anchors, dtype=float)
74
+ if a.ndim != 2 or a.shape[1] != 2:
75
+ raise ValueError("anchors must be a sequence of (x, y) pairs")
76
+ if a.shape[0] < 2:
77
+ raise ValueError(f"need at least 2 anchors (got {a.shape[0]})")
78
+ if not np.all(np.isfinite(a)):
79
+ raise ValueError("anchor coordinates must be finite")
80
+ order = np.argsort(a[:, 0], kind="stable")
81
+ ax, ay = a[order, 0], a[order, 1]
82
+ if np.any(np.diff(ax) <= 0):
83
+ raise ValueError("anchor x values must be strictly monotone (no duplicates)")
84
+
85
+ if method == "linear":
86
+ base = np.interp(xv, ax, ay, left=ay[0], right=ay[-1])
87
+ elif method == "pchip":
88
+ from scipy.interpolate import PchipInterpolator
89
+
90
+ base = PchipInterpolator(ax, ay, extrapolate=False)(xv)
91
+ elif method == "spline":
92
+ from scipy.interpolate import make_interp_spline
93
+
94
+ k = min(3, ax.size - 1)
95
+ spl = make_interp_spline(ax, ay, k=k)
96
+ base = np.asarray(spl(xv), dtype=float)
97
+ # make_interp_spline extrapolates; clamp outside the anchor range.
98
+ base[xv < ax[0]] = np.nan
99
+ base[xv > ax[-1]] = np.nan
100
+ else:
101
+ raise ValueError("method must be linear/pchip/spline")
102
+
103
+ base = np.asarray(base, dtype=float)
104
+ # Clamp extrapolation to the end anchors (pchip/spline left NaN outside).
105
+ base = np.where(xv < ax[0], ay[0], base)
106
+ base = np.where(xv > ax[-1], ay[-1], base)
107
+ return np.asarray(base, dtype=float)
108
+
109
+
110
+ def shirley_background(
111
+ x: ArrayLike,
112
+ y: ArrayLike,
113
+ *,
114
+ max_iter: int = 50,
115
+ tol: float = 1e-6,
116
+ edge_average: int = 1,
117
+ ) -> tuple[NDArray[np.float64], dict[str, Any]]:
118
+ """Iterative Shirley step background for XPS/XAS spectra. GOTO_PLAN #3.
119
+
120
+ The Shirley background at a point is proportional to the integrated
121
+ peak intensity (signal above background) remaining on ONE side of that
122
+ point — it steps up under a peak by exactly the fraction of the peak
123
+ area crossed. With endpoint levels ``I1 = y[0]`` and ``I2 = y[-1]``
124
+ (each optionally averaged over ``edge_average`` samples) the fixed
125
+ point iterated here is::
126
+
127
+ B_{n+1}(x_i) = I2 + (I1 - I2) * A_i(B_n) / A_0(B_n)
128
+ A_i(B) = integral_{x_i}^{x_end} (y - B) dx (trapezoidal)
129
+
130
+ which pins ``B(x_0) = I1`` and ``B(x_end) = I2`` at every iteration.
131
+ Iteration stops when ``max|B_{n+1} - B_n|`` drops below ``tol`` times
132
+ the spectrum's peak-to-peak range; exceeding ``max_iter`` raises
133
+ ``ValueError`` (surfaced as 422 at the route boundary, never a 500).
134
+
135
+ A flat spectrum (no peak area, ``A_0 ~ 0``) short-circuits to the
136
+ linear ramp between the endpoint levels — for constant data that is a
137
+ constant equal to the data, i.e. a near-zero background after
138
+ subtraction of the flat level.
139
+
140
+ x may be ascending or descending (XPS binding-energy scans are often
141
+ descending); the result is returned in the input order.
142
+
143
+ References:
144
+ D. A. Shirley, Phys. Rev. B 5, 4709 (1972).
145
+ A. Proctor & P. M. A. Sherwood, Anal. Chem. 54, 13 (1982)
146
+ (the iterative scheme).
147
+ """
148
+ xv = np.asarray(x, dtype=float).ravel()
149
+ yv = np.asarray(y, dtype=float).ravel()
150
+ if xv.size != yv.size:
151
+ raise ValueError(f"x and y must have the same length (got {xv.size} vs {yv.size})")
152
+ n = xv.size
153
+ if n < 3:
154
+ raise ValueError("need at least 3 points for a Shirley background")
155
+ if not np.all(np.isfinite(xv)) or not np.all(np.isfinite(yv)):
156
+ raise ValueError("x and y must be finite")
157
+ if max_iter < 1:
158
+ raise ValueError("max_iter must be >= 1")
159
+ if tol <= 0:
160
+ raise ValueError("tol must be positive")
161
+ if edge_average < 1:
162
+ raise ValueError("edge_average must be >= 1")
163
+
164
+ dx = np.diff(xv)
165
+ flipped = False
166
+ if np.all(dx < 0):
167
+ xv, yv = xv[::-1], yv[::-1]
168
+ flipped = True
169
+ elif not np.all(dx > 0):
170
+ raise ValueError("x must be strictly monotone (ascending or descending)")
171
+
172
+ m = min(edge_average, n)
173
+ i1 = float(np.mean(yv[:m])) # background level at the low-x end
174
+ i2 = float(np.mean(yv[-m:])) # background level at the high-x end
175
+ y_range = float(np.ptp(yv))
176
+ scale = max(y_range, abs(i1 - i2), _EPS)
177
+
178
+ # Start from the linear ramp between the endpoint levels.
179
+ ramp = np.asarray(np.interp(xv, [xv[0], xv[-1]], [i1, i2]), dtype=float)
180
+ b = ramp.copy()
181
+ converged = False
182
+ n_iter = 0
183
+ for it in range(1, max_iter + 1):
184
+ n_iter = it
185
+ resid = yv - b
186
+ # A_i = trapezoidal integral of (y - B) from x_i to the end.
187
+ seg = 0.5 * (resid[:-1] + resid[1:]) * np.diff(xv)
188
+ a_right = np.concatenate([np.cumsum(seg[::-1])[::-1], [0.0]])
189
+ a_tot = float(a_right[0])
190
+ if abs(a_tot) < _EPS * scale:
191
+ b = ramp
192
+ converged = True # no peak area -> the ramp IS the fixed point
193
+ break
194
+ b_new = i2 + (i1 - i2) * a_right / a_tot
195
+ delta = float(np.max(np.abs(b_new - b)))
196
+ b = np.asarray(b_new, dtype=float)
197
+ if delta < tol * scale:
198
+ converged = True
199
+ break
200
+
201
+ if not converged:
202
+ raise ValueError(
203
+ f"Shirley background did not converge after {max_iter} iterations; "
204
+ "increase max_iter or loosen tol"
205
+ )
206
+ if flipped:
207
+ b = b[::-1]
208
+ return np.asarray(b, dtype=float), {"nIter": n_iter, "converged": converged}
209
+
210
+
211
+ def xrd_low_angle_background(
212
+ x: ArrayLike,
213
+ y: ArrayLike,
214
+ *,
215
+ include_x2: bool = True,
216
+ max_iter: int = 100,
217
+ tol: float = 1e-6,
218
+ ) -> tuple[NDArray[np.float64], dict[str, Any]]:
219
+ """Low-angle air-scatter / beam-tail background for powder XRD. GOTO_PLAN #7a.
220
+
221
+ The low-2theta intensity upturn from air scatter and the direct-beam
222
+ tail is conventionally modelled with a hyperbolic term in 2theta —
223
+ the ``One_on_X`` background of TOPAS (A. A. Coelho, J. Appl. Cryst.
224
+ 51, 210 (2018)); GSAS-II and FullProf ship the same 1/x form. Fitted
225
+ model (linear in its coefficients)::
226
+
227
+ B(2theta) = b0 + b1 / (2theta) [+ b2 / (2theta)^2]
228
+
229
+ with the optional ``1/x^2`` term (``include_x2``) sharpening the
230
+ beam-tail rise. So Bragg peaks cannot inflate the fit, the
231
+ least-squares solve is wrapped in the Lieber-Mahadevan-Jansen
232
+ iterative clip (Appl. Spectrosc. 57, 1363 (2003)): after each fit the
233
+ working signal is clamped to ``min(signal, fit)`` and refit, until
234
+ the RMS change falls below ``tol`` times the data range (converged)
235
+ or ``max_iter`` is reached (returned with ``converged=False``, like
236
+ :func:`quantized.calc.baseline.baseline_modpoly`).
237
+
238
+ Requires strictly positive x (2theta in degrees) — the hyperbolic
239
+ basis diverges at 0. Returns ``(background, info)`` with
240
+ ``info = {"coeffs": [b0, b1(, b2)], "nIter", "converged"}``.
241
+ """
242
+ xv = np.asarray(x, dtype=float).ravel()
243
+ yv = np.asarray(y, dtype=float).ravel()
244
+ if xv.size != yv.size:
245
+ raise ValueError(f"x and y must have the same length (got {xv.size} vs {yv.size})")
246
+ n = xv.size
247
+ n_terms = 3 if include_x2 else 2
248
+ if n < n_terms + 1:
249
+ raise ValueError(f"need at least {n_terms + 1} points (got {n})")
250
+ if not np.all(np.isfinite(xv)) or not np.all(np.isfinite(yv)):
251
+ raise ValueError("x and y must be finite")
252
+ if np.any(xv <= 0):
253
+ raise ValueError("x must be strictly positive (2-theta in degrees)")
254
+ if max_iter < 1:
255
+ raise ValueError("max_iter must be >= 1")
256
+
257
+ cols = [np.ones(n), 1.0 / xv]
258
+ if include_x2:
259
+ cols.append(1.0 / xv**2)
260
+ basis = np.column_stack(cols)
261
+
262
+ y_range = max(float(np.ptp(yv)), _EPS)
263
+
264
+ def _fit(y_work: NDArray[np.float64]) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
265
+ c, *_ = np.linalg.lstsq(basis, y_work, rcond=None)
266
+ return np.asarray(c, dtype=float), np.asarray(basis @ c, dtype=float)
267
+
268
+ res = _iterative_clip_fit(
269
+ yv,
270
+ _fit,
271
+ max_iter=max_iter,
272
+ tol=tol,
273
+ y_range=y_range,
274
+ init=(np.zeros(n_terms), np.zeros(n)),
275
+ )
276
+ info = {
277
+ "coeffs": [float(c) for c in res.coeffs],
278
+ "nIter": res.n_iter,
279
+ "converged": res.converged,
280
+ }
281
+ return np.asarray(res.fit, dtype=float), info
282
+
283
+
284
+ def footprint_factor(
285
+ theta_deg: ArrayLike,
286
+ *,
287
+ beam_width: float,
288
+ sample_length: float,
289
+ ) -> NDArray[np.float64]:
290
+ """Illuminated fraction F(theta) of the beam for XRR/NR geometry.
291
+
292
+ A beam of (full) width ``w`` incident at grazing angle theta
293
+ illuminates a strip of length ``w / sin(theta)`` on the sample. Below
294
+ the spill-over angle ``theta_spill = arcsin(w / L)`` that strip
295
+ exceeds the sample length ``L`` and only the fraction::
296
+
297
+ F(theta) = L * sin(theta) / w (theta < theta_spill)
298
+ F(theta) = 1 (theta >= theta_spill)
299
+
300
+ of the beam actually strikes the sample (uniform / top-hat beam
301
+ profile assumed). Points with ``sin(theta) <= 0`` get ``F = 1``
302
+ (a non-grazing point cannot be footprint-corrected; leave it alone).
303
+
304
+ Reference: A. Gibaud & G. Vignaud, "Specular Reflectivity from Smooth
305
+ and Rough Surfaces", in J. Daillant & A. Gibaud (eds), *X-ray and
306
+ Neutron Reflectivity*, Lect. Notes Phys. 770, Springer (2009), sec.
307
+ 3.3 (beam footprint / spill-over).
308
+ """
309
+ if beam_width <= 0:
310
+ raise ValueError("beam_width must be positive")
311
+ if sample_length <= 0:
312
+ raise ValueError("sample_length must be positive")
313
+ th = np.asarray(theta_deg, dtype=float).ravel()
314
+ s = np.sin(np.radians(th))
315
+ frac = np.asarray(sample_length * s / beam_width, dtype=float)
316
+ out = np.where(s <= 0, 1.0, np.minimum(frac, 1.0))
317
+ return np.asarray(out, dtype=float)
318
+
319
+
320
+ def footprint_correction(
321
+ theta: ArrayLike,
322
+ y: ArrayLike,
323
+ *,
324
+ beam_width: float,
325
+ sample_length: float,
326
+ two_theta: bool = False,
327
+ ) -> tuple[NDArray[np.float64], dict[str, Any]]:
328
+ """Beam-footprint (spill-over) correction for XRR/NR scans. GOTO_PLAN #7b.
329
+
330
+ Divides the measured intensity by the illuminated fraction
331
+ :func:`footprint_factor`, i.e. multiplies by ``w / (L sin(theta))``
332
+ below the spill-over angle and by exactly 1 above it — applying the
333
+ correction twice above spill-over is therefore a no-op (idempotent
334
+ there). ``two_theta=True`` reads the axis as the detector angle
335
+ 2theta and uses ``theta = x / 2`` for the geometry.
336
+
337
+ ``beam_width`` and ``sample_length`` share any one length unit (only
338
+ the ratio ``w / L`` enters). Returns ``(corrected, info)`` with
339
+ ``info = {"spilloverDeg": ...}`` (the incidence angle, in degrees,
340
+ above which no correction is applied; 90 when ``w >= L``).
341
+
342
+ Reference: Gibaud & Vignaud (2009), see :func:`footprint_factor`.
343
+ """
344
+ xv = np.asarray(theta, dtype=float).ravel()
345
+ yv = np.asarray(y, dtype=float).ravel()
346
+ if xv.size != yv.size:
347
+ raise ValueError(f"theta and y must have the same length (got {xv.size} vs {yv.size})")
348
+ th = xv / 2.0 if two_theta else xv
349
+ factor = footprint_factor(th, beam_width=beam_width, sample_length=sample_length)
350
+ corrected = np.asarray(yv / factor, dtype=float)
351
+ ratio = min(beam_width / sample_length, 1.0)
352
+ spill = math.degrees(math.asin(ratio))
353
+ return corrected, {"spilloverDeg": spill}
@@ -0,0 +1,349 @@
1
+ """Baseline estimation. Ports of MATLAB +utilities/baseline*.m.
2
+
3
+ Pure functions: spectrum in, baseline out.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import math
9
+ from typing import Any
10
+
11
+ import numpy as np
12
+ from numpy.typing import ArrayLike, NDArray
13
+ from scipy import sparse
14
+ from scipy.interpolate import interp1d
15
+ from scipy.sparse.linalg import spsolve
16
+
17
+ from quantized.calc._clipfit import _iterative_clip_fit
18
+
19
+ __all__ = [
20
+ "baseline_als",
21
+ "baseline_modpoly",
22
+ "baseline_rolling_ball",
23
+ "estimate_background",
24
+ "fit_region_background",
25
+ ]
26
+
27
+ _EPS = float(np.finfo(float).eps)
28
+
29
+
30
+ def fit_region_background(
31
+ x: ArrayLike,
32
+ y: ArrayLike,
33
+ x_min: float,
34
+ x_max: float,
35
+ *,
36
+ y_min: float | None = None,
37
+ y_max: float | None = None,
38
+ order: int = 1,
39
+ ) -> dict[str, Any]:
40
+ """Fit a polynomial background to the data inside a box region.
41
+
42
+ Pure core of BosonPlotter's "Fit BG from Box" (``+bosonPlotter/onBGMouseUp``):
43
+ select points with ``x_min <= x <= x_max`` (and, when given, ``y_min <= y <=
44
+ y_max``), then ``polyfit`` a degree-``order`` polynomial to them (raw x, no
45
+ normalisation — matching MATLAB ``polyfit``). The GUI cursor/box drawing and
46
+ trim/prefix mapping are not part of this pure layer.
47
+
48
+ Returns a dict with ``coeffs`` (highest-degree-first, MATLAB ``polyfit``
49
+ order), ``background`` (the polynomial evaluated across the *full* x for
50
+ subtraction), ``n_points``, region ``mean``/``std`` (sample, N-1)/``min``/
51
+ ``max`` of the selected y, and ``order``. Raises ``ValueError`` when fewer
52
+ than ``order + 1`` points fall inside the box.
53
+ """
54
+ xv = np.asarray(x, dtype=float).ravel()
55
+ yv = np.asarray(y, dtype=float).ravel()
56
+ n = min(xv.size, yv.size)
57
+ xv, yv = xv[:n], yv[:n]
58
+
59
+ mask = (xv >= x_min) & (xv <= x_max) & ~np.isnan(xv) & ~np.isnan(yv)
60
+ if y_min is not None:
61
+ mask &= yv >= y_min
62
+ if y_max is not None:
63
+ mask &= yv <= y_max
64
+ xp, yp = xv[mask], yv[mask]
65
+
66
+ if xp.size < 2:
67
+ raise ValueError(f"need at least 2 points inside the box to fit (got {xp.size})")
68
+ if xp.size < order + 1:
69
+ raise ValueError(f"need at least {order + 1} points for an order-{order} fit")
70
+
71
+ coeffs = np.polyfit(xp, yp, order)
72
+ background = np.asarray(np.polyval(coeffs, xv), dtype=float)
73
+ return {
74
+ "coeffs": [float(c) for c in coeffs],
75
+ "background": background,
76
+ "n_points": int(xp.size),
77
+ "mean": float(np.mean(yp)),
78
+ "std": float(np.std(yp, ddof=1)),
79
+ "min": float(np.min(yp)),
80
+ "max": float(np.max(yp)),
81
+ "order": int(order),
82
+ }
83
+
84
+
85
+ def _matlab_round(x: float) -> int:
86
+ """Round half away from zero (MATLAB ``round``)."""
87
+ return int(math.copysign(math.floor(abs(x) + 0.5), x))
88
+
89
+
90
+ def baseline_als(
91
+ y: NDArray[np.float64],
92
+ *,
93
+ lam: float = 1e6,
94
+ p: float = 0.01,
95
+ max_iter: int = 20,
96
+ tol: float = 1e-6,
97
+ ) -> NDArray[np.float64]:
98
+ """Asymmetric least-squares (Eilers/Whittaker) baseline. Port of baselineALS.
99
+
100
+ Solves ``(W + lam·DᵀD) z = W·y`` iteratively, reweighting w = p where
101
+ y>z else (1-p), until the weights converge.
102
+ """
103
+ if lam <= 0:
104
+ raise ValueError("lam must be positive")
105
+ if not 0 < p < 1:
106
+ raise ValueError("p must be in (0, 1)")
107
+ yv = np.asarray(y, dtype=float).ravel()
108
+ n = yv.size
109
+ if n < 3:
110
+ return yv.copy()
111
+ if not np.all(np.isfinite(yv)):
112
+ # A non-finite y makes the sparse reweighting collapse to a singular
113
+ # system; the solve then returns all-NaN with a noisy MatrixRankWarning.
114
+ # MATLAB's backslash propagates NaN the same way, so return NaN directly
115
+ # (parity-preserving, and skips the warning) rather than computing it.
116
+ return np.full(n, np.nan)
117
+
118
+ # Second-difference operator D: (n-2) x n (rows: y[i] - 2y[i+1] + y[i+2]).
119
+ diff2 = sparse.diags(
120
+ diagonals=[1.0, -2.0, 1.0], offsets=[0, 1, 2], shape=(n - 2, n)
121
+ ).tocsc()
122
+ dtd = (diff2.T @ diff2).tocsc()
123
+
124
+ w = np.ones(n)
125
+ z = yv.copy()
126
+ for _ in range(max_iter):
127
+ big_w = sparse.diags(w, 0, shape=(n, n))
128
+ c = (big_w + lam * dtd).tocsc()
129
+ z = spsolve(c, w * yv)
130
+ w_new = p * (yv > z) + (1.0 - p) * (yv <= z)
131
+ if float(np.max(np.abs(w_new - w))) < tol:
132
+ w = w_new
133
+ break
134
+ w = w_new
135
+ return np.asarray(z, dtype=float)
136
+
137
+
138
+ def _snip_background(
139
+ x: NDArray[np.float64], y: NDArray[np.float64], n: int, max_window_deg: float, passes: int
140
+ ) -> NDArray[np.float64]:
141
+ """SNIP (iterative peak-clipping) background in sqrt space, then boxcar-smoothed."""
142
+ dx = float(np.median(np.diff(x)))
143
+ if not math.isfinite(dx) or dx <= 0:
144
+ # NaN dx (from a NaN in x) must short-circuit too — NaN <= 0 is False,
145
+ # so the old guard fell through to _matlab_round(NaN) -> ValueError.
146
+ return y.copy()
147
+ w_max = max(1, min(_matlab_round(max_window_deg / dx), (n - 1) // 2))
148
+ v = np.sqrt(np.maximum(y, 0.0))
149
+ for w in range(w_max, 0, -1):
150
+ v_new = v.copy()
151
+ avg = (v[: n - 2 * w] + v[2 * w :]) / 2.0
152
+ v_new[w : n - w] = np.minimum(v[w : n - w], avg)
153
+ v = v_new
154
+ bg = np.asarray(v**2, dtype=float)
155
+ kernel = np.ones(5) / 5.0
156
+ for _ in range(passes):
157
+ padded = np.concatenate([bg[1:3][::-1], bg, bg[n - 3 : n - 1][::-1]])
158
+ bg = np.asarray(np.convolve(padded, kernel, mode="valid")[:n], dtype=float)
159
+ return bg
160
+
161
+
162
+ def _poly_background(
163
+ x: NDArray[np.float64], y: NDArray[np.float64], n: int, poly_degree: int, iter_sigma: float
164
+ ) -> NDArray[np.float64]:
165
+ """Polynomial background with iterative robust (MAD) outlier rejection."""
166
+ deg = min(poly_degree, max(1, n // 3 - 1))
167
+ mask = np.ones(n, dtype=bool)
168
+ bg = y.copy()
169
+ for _ in range(4):
170
+ xm, ym = x[mask], y[mask]
171
+ if xm.size < deg + 1:
172
+ break
173
+ xc = float(np.mean(xm))
174
+ xs = max(float(np.std(xm, ddof=1)), _EPS)
175
+ coeffs = np.polyfit((xm - xc) / xs, ym, deg)
176
+ bg = np.asarray(np.polyval(coeffs, (x - xc) / xs), dtype=float)
177
+ residual = y - bg
178
+ rm = residual[mask]
179
+ sigma = 1.4826 * float(np.median(np.abs(rm - np.median(rm))))
180
+ if sigma < _EPS:
181
+ break
182
+ mask = residual < iter_sigma * sigma
183
+ if int(mask.sum()) < deg + 1:
184
+ mask = np.ones(n, dtype=bool)
185
+ break
186
+ return np.asarray(bg, dtype=float)
187
+
188
+
189
+ def _iterative_refine(
190
+ x: NDArray[np.float64],
191
+ y: NDArray[np.float64],
192
+ bg: NDArray[np.float64],
193
+ n: int,
194
+ method: str,
195
+ max_window_deg: float,
196
+ passes: int,
197
+ poly_degree: int,
198
+ iter_max_passes: int,
199
+ iter_sigma: float,
200
+ ) -> NDArray[np.float64]:
201
+ """Refine a background by masking+dilating peaks and re-estimating on the rest."""
202
+ for _ in range(iter_max_passes):
203
+ residual = y - bg
204
+ below_med = residual[residual < np.median(residual)]
205
+ ref = below_med if below_med.size > 5 else residual
206
+ sigma = 1.4826 * float(np.median(np.abs(ref - np.median(ref))))
207
+ data_range = float(np.max(y) - np.min(y))
208
+ if sigma < max(_EPS, data_range * 1e-10):
209
+ break
210
+ dilated = residual > iter_sigma * sigma
211
+ for _ in range(max(3, _matlab_round(0.005 * n))):
212
+ prev = dilated.copy()
213
+ dilated[1:] = dilated[1:] | prev[:-1]
214
+ dilated[:-1] = dilated[:-1] | prev[1:]
215
+ non_peak = ~dilated
216
+ if int(non_peak.sum()) < 10:
217
+ break
218
+ bg_prev = bg
219
+ if method == "snip":
220
+ y_clean = y.copy()
221
+ fill = interp1d(
222
+ x[non_peak], y[non_peak], kind="linear", fill_value="extrapolate"
223
+ )
224
+ y_clean[dilated] = fill(x[dilated])
225
+ bg = _snip_background(x, y_clean, n, max_window_deg, passes)
226
+ else:
227
+ bg = _poly_background(x, y, n, poly_degree, iter_sigma)
228
+ if float(np.max(np.abs(bg - bg_prev))) < 0.01 * sigma:
229
+ break
230
+ return bg
231
+
232
+
233
+ def estimate_background(
234
+ x: ArrayLike,
235
+ y: ArrayLike,
236
+ *,
237
+ method: str = "snip",
238
+ max_window_deg: float = 2.0,
239
+ smooth_passes: int = 3,
240
+ poly_degree: int = 4,
241
+ iterative: bool = False,
242
+ iter_max_passes: int = 3,
243
+ iter_sigma: float = 3.0,
244
+ ) -> NDArray[np.float64]:
245
+ """Estimate a slowly-varying background. Port of utilities.estimateBackground.
246
+
247
+ ``method='snip'`` (default) uses sqrt-space iterative peak clipping; ``'polynomial'``
248
+ fits a robust low-order polynomial. With ``iterative=True`` peaks are masked,
249
+ dilated, and the background re-estimated on the remainder. The result is always
250
+ clamped to ``min(bg, y)``.
251
+ """
252
+ xv = np.asarray(x, dtype=float).ravel()
253
+ yv = np.asarray(y, dtype=float).ravel()
254
+ n = yv.size
255
+ if n < 3:
256
+ return yv.copy()
257
+ if method == "snip":
258
+ bg = _snip_background(xv, yv, n, max_window_deg, smooth_passes)
259
+ elif method == "polynomial":
260
+ bg = _poly_background(xv, yv, n, poly_degree, iter_sigma)
261
+ else:
262
+ raise ValueError("method must be snip/polynomial")
263
+ if iterative:
264
+ bg = _iterative_refine(
265
+ xv, yv, bg, n, method, max_window_deg, smooth_passes,
266
+ poly_degree, iter_max_passes, iter_sigma,
267
+ )
268
+ return np.asarray(np.minimum(bg, yv), dtype=float)
269
+
270
+
271
+ def baseline_rolling_ball(
272
+ y: ArrayLike, *, radius: int = 100, smooth: int = -1
273
+ ) -> tuple[NDArray[np.float64], dict[str, int]]:
274
+ """Rolling-ball baseline (grayscale morphological opening). Port of baselineRollingBall.
275
+
276
+ Erodes then dilates the signal with a ball-shaped structuring element of the
277
+ given ``radius`` (in samples), boxcar-smooths, and clamps to ``min(bg, y)``.
278
+ ``smooth=-1`` auto-picks a half-width of ``round(radius/10)``. Returns
279
+ ``(baseline, {"radius", "smooth"})``.
280
+ """
281
+ if radius <= 0:
282
+ raise ValueError("radius must be a positive integer (in samples)")
283
+ yv = np.asarray(y, dtype=float).ravel()
284
+ n = yv.size
285
+ smooth_hw = max(1, _matlab_round(radius / 10)) if smooth < 0 else _matlab_round(smooth)
286
+ if n < 3:
287
+ return yv.copy(), {"radius": radius, "smooth": smooth_hw}
288
+
289
+ half_w = min(radius, n - 1)
290
+ offsets = np.arange(-half_w, half_w + 1)
291
+ rise = radius - np.sqrt(np.maximum(radius * radius - offsets.astype(float) ** 2, 0.0))
292
+
293
+ eroded = np.full(n, np.inf)
294
+ for off, rise_val in zip(offsets, rise, strict=True):
295
+ i0, i1 = max(0, -int(off)), min(n - 1, n - 1 - int(off))
296
+ if i1 >= i0:
297
+ eroded[i0 : i1 + 1] = np.minimum(
298
+ eroded[i0 : i1 + 1], yv[i0 + int(off) : i1 + int(off) + 1] + rise_val
299
+ )
300
+ dilated = np.full(n, -np.inf)
301
+ for off, rise_val in zip(offsets, rise, strict=True):
302
+ i0, i1 = max(0, -int(off)), min(n - 1, n - 1 - int(off))
303
+ if i1 >= i0:
304
+ dilated[i0 : i1 + 1] = np.maximum(
305
+ dilated[i0 : i1 + 1], eroded[i0 + int(off) : i1 + int(off) + 1] - rise_val
306
+ )
307
+
308
+ baseline = dilated
309
+ if smooth_hw > 0 and n > 2 * smooth_hw:
310
+ pad = min(smooth_hw, n - 1)
311
+ kernel = np.ones(2 * smooth_hw + 1) / (2 * smooth_hw + 1)
312
+ padded = np.concatenate(
313
+ [baseline[1 : pad + 1][::-1], baseline, baseline[n - 1 - pad : n - 1][::-1]]
314
+ )
315
+ baseline = np.convolve(padded, kernel, mode="valid")[:n]
316
+ baseline = np.asarray(np.minimum(baseline, yv), dtype=float)
317
+ return baseline, {"radius": radius, "smooth": smooth_hw}
318
+
319
+
320
+ def baseline_modpoly(
321
+ y: ArrayLike, *, order: int = 5, max_iter: int = 100, tol: float = 1e-6
322
+ ) -> tuple[NDArray[np.float64], dict[str, Any]]:
323
+ """Modified-polynomial (Lieber) baseline. Port of baselineModPoly.
324
+
325
+ Iteratively fits a polynomial of ``order`` and clips the working signal to
326
+ ``min(signal, fit)`` until the RMS change (relative to the data range) drops
327
+ below ``tol``. Returns ``(baseline, {"order", "nIter", "converged"})``.
328
+ """
329
+ if order <= 0:
330
+ raise ValueError("order must be a positive integer")
331
+ yv = np.asarray(y, dtype=float).ravel()
332
+ n = yv.size
333
+ if n < 3:
334
+ return yv.copy(), {"order": order, "nIter": 0, "converged": True}
335
+
336
+ poly_ord = min(order, n - 1)
337
+ x = np.arange(1, n + 1, dtype=float)
338
+ xn = (x - float(np.mean(x))) / max(float(np.std(x, ddof=1)), _EPS)
339
+ y_range = max(float(np.max(yv) - np.min(yv)), _EPS)
340
+
341
+ def _fit(y_work: NDArray[np.float64]) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
342
+ c = np.asarray(np.polyfit(xn, y_work, poly_ord), dtype=float)
343
+ return c, np.asarray(np.polyval(c, xn), dtype=float)
344
+
345
+ # `init` preserves the pre-refactor pre-loop fit: with max_iter < 1 the
346
+ # returned coefficients are the plain polyfit of the unclipped signal.
347
+ res = _iterative_clip_fit(yv, _fit, max_iter=max_iter, tol=tol, y_range=y_range, init=_fit(yv))
348
+ baseline = np.asarray(np.minimum(np.polyval(res.coeffs, xn), yv), dtype=float)
349
+ return baseline, {"order": poly_ord, "nIter": res.n_iter, "converged": res.converged}