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