scispectrum 0.3.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.
- scispectrum/__init__.py +4 -0
- scispectrum/background/__init__.py +4 -0
- scispectrum/background/als.py +81 -0
- scispectrum/background/base.py +31 -0
- scispectrum/background/minimum_in_envelope.py +125 -0
- scispectrum/background/polynomial.py +165 -0
- scispectrum/background/snip.py +65 -0
- scispectrum/background/snip_utils.py +10 -0
- scispectrum/calibration/__init__.py +7 -0
- scispectrum/calibration/axis.py +60 -0
- scispectrum/calibration/detector_calibration.py +158 -0
- scispectrum/calibration/models/__init__.py +3 -0
- scispectrum/calibration/models/base.py +31 -0
- scispectrum/calibration/models/energy_poly.py +25 -0
- scispectrum/calibration/models/hpge_fwhm_model.py +10 -0
- scispectrum/calibration/resolution.py +21 -0
- scispectrum/core/__init__.py +2 -0
- scispectrum/core/domain.py +198 -0
- scispectrum/core/spectrum.py +282 -0
- scispectrum/domain_analysis/__init__.py +0 -0
- scispectrum/domain_analysis/background.py +63 -0
- scispectrum/domain_analysis/find_peaks.py +208 -0
- scispectrum/domain_analysis/moment.py +74 -0
- scispectrum/domain_analysis/morphology.py +107 -0
- scispectrum/domain_analysis/single_peak.py +140 -0
- scispectrum/domain_fitting/__init__.py +3 -0
- scispectrum/domain_fitting/abstract_fitting_class.py +20 -0
- scispectrum/domain_fitting/multi_gaussian_fitter.py +410 -0
- scispectrum/identification/__init__.py +3 -0
- scispectrum/identification/base.py +18 -0
- scispectrum/identification/convolution.py +84 -0
- scispectrum/identification/kernels/__init__.py +1 -0
- scispectrum/identification/kernels/base.py +42 -0
- scispectrum/identification/kernels/mexican_hat.py +24 -0
- scispectrum/identification/kernels/utils.py +1 -0
- scispectrum/identification/snr.py +192 -0
- scispectrum/io/__init__.py +4 -0
- scispectrum/io/time_channel.py +175 -0
- scispectrum/utils/__init__.py +0 -0
- scispectrum/utils/gaussian.py +12 -0
- scispectrum/utils/smoothing.py +88 -0
- scispectrum-0.3.0.dist-info/METADATA +220 -0
- scispectrum-0.3.0.dist-info/RECORD +46 -0
- scispectrum-0.3.0.dist-info/WHEEL +5 -0
- scispectrum-0.3.0.dist-info/licenses/LICENSE +21 -0
- scispectrum-0.3.0.dist-info/top_level.txt +1 -0
scispectrum/__init__.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from scipy import sparse
|
|
3
|
+
from scipy.sparse.linalg import spsolve
|
|
4
|
+
from scispectrum.background.base import BackgroundEstimator
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ALSBackground(BackgroundEstimator):
|
|
8
|
+
"""
|
|
9
|
+
Asymmetric Least Squares (ALS) background estimator.
|
|
10
|
+
|
|
11
|
+
Estimates a smooth background by iteratively fitting a weighted
|
|
12
|
+
smoothing spline, penalizing points above the current estimate
|
|
13
|
+
more lightly than points below it. This asymmetry drives the
|
|
14
|
+
baseline to sit beneath the signal peaks.
|
|
15
|
+
|
|
16
|
+
Parameters
|
|
17
|
+
----------
|
|
18
|
+
lam : float
|
|
19
|
+
Smoothness penalty. Larger values produce a smoother background.
|
|
20
|
+
Typical range: 1e3 to 1e7. Default is 1e5.
|
|
21
|
+
p : float
|
|
22
|
+
Asymmetry parameter. Controls the weight given to points above
|
|
23
|
+
the current estimate. Should be small (e.g. 0.001 to 0.1) so
|
|
24
|
+
the baseline stays below peaks. Default is 0.01.
|
|
25
|
+
max_iter : int
|
|
26
|
+
Number of reweighting iterations. More iterations refine the
|
|
27
|
+
baseline but increase compute time. Default is 20.
|
|
28
|
+
|
|
29
|
+
References
|
|
30
|
+
----------
|
|
31
|
+
Eilers, P.H.C. and Boelens, H.F.M. (2005).
|
|
32
|
+
"Baseline correction with asymmetric least squares smoothing."
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, lam=1e5, p=0.01, max_iter=20):
|
|
36
|
+
self.lam = lam
|
|
37
|
+
self.p = p
|
|
38
|
+
self.max_iter = max_iter
|
|
39
|
+
|
|
40
|
+
def estimate(self, axis: np.ndarray, counts: np.ndarray) -> np.ndarray:
|
|
41
|
+
"""
|
|
42
|
+
Estimate the background of a spectrum using ALS.
|
|
43
|
+
|
|
44
|
+
Parameters
|
|
45
|
+
----------
|
|
46
|
+
axis : np.ndarray
|
|
47
|
+
Axis values (not used by ALS, required by the interface).
|
|
48
|
+
counts : np.ndarray
|
|
49
|
+
Spectrum counts.
|
|
50
|
+
|
|
51
|
+
Returns
|
|
52
|
+
-------
|
|
53
|
+
np.ndarray
|
|
54
|
+
Estimated background, same shape as counts.
|
|
55
|
+
"""
|
|
56
|
+
y = counts
|
|
57
|
+
n = len(y)
|
|
58
|
+
|
|
59
|
+
# Second-order difference matrix (n-2 x n) — penalizes curvature
|
|
60
|
+
# in the background estimate. D.T @ D appears in the smoothness term.
|
|
61
|
+
D = sparse.diags([1, -2, 1], [0, 1, 2], shape=(n - 2, n), dtype=float)
|
|
62
|
+
DTD = D.T @ D
|
|
63
|
+
|
|
64
|
+
# Initialize weights uniformly — all points treated equally
|
|
65
|
+
w = np.ones(n)
|
|
66
|
+
|
|
67
|
+
for _ in range(self.max_iter):
|
|
68
|
+
# Diagonal weight matrix for the current iteration
|
|
69
|
+
W = sparse.diags(w, 0)
|
|
70
|
+
|
|
71
|
+
# Solve the weighted penalized least squares system:
|
|
72
|
+
# (W + lam * D'D) z = W y
|
|
73
|
+
Z = W + self.lam * DTD
|
|
74
|
+
z = spsolve(Z.tocsc(), w * y)
|
|
75
|
+
|
|
76
|
+
# Asymmetric reweighting: points above the baseline get weight p,
|
|
77
|
+
# points below get weight 1-p. This pulls the baseline downward
|
|
78
|
+
# toward the true background, away from peaks.
|
|
79
|
+
w = np.where(y > z, self.p, 1 - self.p)
|
|
80
|
+
|
|
81
|
+
return z
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BackgroundEstimator(ABC):
|
|
7
|
+
"""
|
|
8
|
+
Abstract base class for background estimation algorithms.
|
|
9
|
+
|
|
10
|
+
All auxiliary inputs (resolution calibration, convolution objects, etc.)
|
|
11
|
+
must be passed at construction time, not to estimate().
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def estimate(self, axis: np.ndarray, counts: np.ndarray) -> np.ndarray:
|
|
16
|
+
"""
|
|
17
|
+
Estimate background for a 1D spectrum.
|
|
18
|
+
|
|
19
|
+
Parameters
|
|
20
|
+
----------
|
|
21
|
+
axis : np.ndarray
|
|
22
|
+
Axis values (e.g. energy in keV).
|
|
23
|
+
counts : np.ndarray
|
|
24
|
+
Spectrum counts.
|
|
25
|
+
|
|
26
|
+
Returns
|
|
27
|
+
-------
|
|
28
|
+
np.ndarray
|
|
29
|
+
Estimated background, same shape as counts.
|
|
30
|
+
"""
|
|
31
|
+
pass
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from scipy.ndimage import gaussian_filter1d
|
|
3
|
+
from scispectrum.background.base import BackgroundEstimator
|
|
4
|
+
from scispectrum.calibration import ResolutionCalibration
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class MinimaEnvelopeBackground(BackgroundEstimator):
|
|
8
|
+
"""
|
|
9
|
+
Non-parametric SNR-gated minimum envelope background estimator.
|
|
10
|
+
|
|
11
|
+
In each iteration, low-SNR channels are replaced by a resolution-weighted
|
|
12
|
+
local Gaussian average; high-SNR channels (peaks) are filled from their
|
|
13
|
+
neighbours. Converges to a smooth background beneath all peaks.
|
|
14
|
+
Works well for gamma spectroscopy.
|
|
15
|
+
|
|
16
|
+
Parameters
|
|
17
|
+
----------
|
|
18
|
+
resolution_calib : ResolutionCalibration
|
|
19
|
+
Maps axis values to FWHM in the same units.
|
|
20
|
+
conv : Convolution
|
|
21
|
+
Convolution object used to compute the local SNR map.
|
|
22
|
+
iterations : int
|
|
23
|
+
Number of estimation iterations. Default is 20.
|
|
24
|
+
window_scale : float
|
|
25
|
+
Half-window size as a multiple of sigma. Default is 5.0.
|
|
26
|
+
snr_threshold : float
|
|
27
|
+
SNR above which a channel is treated as a peak. Default is 4.0.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
resolution_calib: ResolutionCalibration,
|
|
33
|
+
conv,
|
|
34
|
+
iterations: int = 20,
|
|
35
|
+
window_scale: float = 5.0,
|
|
36
|
+
snr_threshold: float = 4.0,
|
|
37
|
+
):
|
|
38
|
+
self.resolution_calib = resolution_calib
|
|
39
|
+
self.conv = conv
|
|
40
|
+
self.iterations = iterations
|
|
41
|
+
self.window_scale = window_scale
|
|
42
|
+
self.snr_threshold = snr_threshold
|
|
43
|
+
|
|
44
|
+
def _fill_with_local_min(self, initial_bg: np.ndarray, axis: np.ndarray) -> np.ndarray:
|
|
45
|
+
dx = axis[1] - axis[0]
|
|
46
|
+
n = len(initial_bg)
|
|
47
|
+
|
|
48
|
+
fwhm = np.array([self.resolution_calib(xi) for xi in axis])
|
|
49
|
+
radius_pts = np.maximum((self.window_scale * fwhm / dx).astype(int), 1)
|
|
50
|
+
|
|
51
|
+
left_fill = initial_bg.copy()
|
|
52
|
+
for i in range(n):
|
|
53
|
+
if left_fill[i] > 0:
|
|
54
|
+
continue
|
|
55
|
+
window = left_fill[max(0, i - radius_pts[i]):i]
|
|
56
|
+
valid = window[window > 0]
|
|
57
|
+
if len(valid):
|
|
58
|
+
left_fill[i] = np.min(valid)
|
|
59
|
+
|
|
60
|
+
right_fill = initial_bg.copy()
|
|
61
|
+
for i in range(n - 1, -1, -1):
|
|
62
|
+
if right_fill[i] > 0:
|
|
63
|
+
continue
|
|
64
|
+
window = right_fill[i + 1:min(n, i + radius_pts[i] + 1)]
|
|
65
|
+
valid = window[window > 0]
|
|
66
|
+
if len(valid):
|
|
67
|
+
right_fill[i] = np.min(valid)
|
|
68
|
+
|
|
69
|
+
filled = np.minimum(
|
|
70
|
+
np.where(left_fill > 0, left_fill, np.inf),
|
|
71
|
+
np.where(right_fill > 0, right_fill, np.inf),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
valid_vals = initial_bg[initial_bg > 0]
|
|
75
|
+
fallback = np.min(valid_vals) if len(valid_vals) else 0.0
|
|
76
|
+
filled[~np.isfinite(filled)] = fallback
|
|
77
|
+
|
|
78
|
+
return filled
|
|
79
|
+
|
|
80
|
+
def estimate(self, axis: np.ndarray, counts: np.ndarray) -> np.ndarray:
|
|
81
|
+
"""
|
|
82
|
+
Estimate background using the minimum envelope method.
|
|
83
|
+
|
|
84
|
+
Parameters
|
|
85
|
+
----------
|
|
86
|
+
axis : np.ndarray
|
|
87
|
+
Axis values.
|
|
88
|
+
counts : np.ndarray
|
|
89
|
+
Spectrum counts.
|
|
90
|
+
|
|
91
|
+
Returns
|
|
92
|
+
-------
|
|
93
|
+
np.ndarray
|
|
94
|
+
Estimated background, same shape as counts.
|
|
95
|
+
"""
|
|
96
|
+
dx = axis[1] - axis[0]
|
|
97
|
+
fwhm = np.array([self.resolution_calib(xi) for xi in axis])
|
|
98
|
+
sigma_pts = np.maximum((fwhm / 2.355) / dx, 1.0)
|
|
99
|
+
|
|
100
|
+
new_bg = counts.copy()
|
|
101
|
+
|
|
102
|
+
for _ in range(self.iterations):
|
|
103
|
+
prev_bg = new_bg.copy()
|
|
104
|
+
new_bg = np.zeros_like(counts)
|
|
105
|
+
|
|
106
|
+
_, _, n_sigma = self.conv.apply(axis, prev_bg)
|
|
107
|
+
|
|
108
|
+
for i in range(len(axis)):
|
|
109
|
+
if n_sigma[i] >= self.snr_threshold:
|
|
110
|
+
continue
|
|
111
|
+
sigma = sigma_pts[i]
|
|
112
|
+
radius = int(self.window_scale * sigma)
|
|
113
|
+
i_start = max(0, i - radius)
|
|
114
|
+
i_end = min(len(counts), i + radius + 1)
|
|
115
|
+
idx = np.arange(i_start, i_end)
|
|
116
|
+
w = np.exp(-0.5 * ((idx - i) / sigma) ** 2)
|
|
117
|
+
w /= w.sum()
|
|
118
|
+
new_bg[i] = np.sum(w * prev_bg[i_start:i_end])
|
|
119
|
+
|
|
120
|
+
new_bg = self._fill_with_local_min(new_bg, axis)
|
|
121
|
+
|
|
122
|
+
sigma_global = np.mean(sigma_pts)
|
|
123
|
+
new_bg = gaussian_filter1d(new_bg, sigma=sigma_global)
|
|
124
|
+
|
|
125
|
+
return new_bg
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from typing import Callable
|
|
3
|
+
from scispectrum.background.base import BackgroundEstimator
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class IterativePolyFit(BackgroundEstimator):
|
|
7
|
+
"""
|
|
8
|
+
Iterative polynomial baseline correction (Gan et al. 2006).
|
|
9
|
+
|
|
10
|
+
Fits a polynomial to the spectrum, clips peaks above the fit,
|
|
11
|
+
and repeats until convergence. The final polynomial is the background.
|
|
12
|
+
Note: the background can be negative.
|
|
13
|
+
|
|
14
|
+
Parameters
|
|
15
|
+
----------
|
|
16
|
+
degree : int
|
|
17
|
+
Polynomial degree. Default is 5.
|
|
18
|
+
max_iter : int
|
|
19
|
+
Maximum number of iterations. Default is 100.
|
|
20
|
+
tolerance : float
|
|
21
|
+
Convergence threshold (relative change in fit norm). Default is 0.001.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, degree=5, max_iter=100, tolerance=0.001):
|
|
25
|
+
self.degree = degree
|
|
26
|
+
self.max_iter = max_iter
|
|
27
|
+
self.tolerance = tolerance
|
|
28
|
+
|
|
29
|
+
def estimate(self, axis: np.ndarray, counts: np.ndarray) -> np.ndarray:
|
|
30
|
+
y_work = counts.copy()
|
|
31
|
+
last_fit = np.zeros_like(counts)
|
|
32
|
+
|
|
33
|
+
for i in range(self.max_iter):
|
|
34
|
+
coeffs = np.polyfit(axis, y_work, self.degree)
|
|
35
|
+
current_fit = np.polyval(coeffs, axis)
|
|
36
|
+
|
|
37
|
+
if i > 0:
|
|
38
|
+
numerator = np.linalg.norm(current_fit - last_fit)
|
|
39
|
+
denominator = np.linalg.norm(last_fit)
|
|
40
|
+
if denominator > 0 and (numerator / denominator) < self.tolerance:
|
|
41
|
+
break
|
|
42
|
+
|
|
43
|
+
last_fit = current_fit
|
|
44
|
+
y_work = np.minimum(y_work, current_fit)
|
|
45
|
+
|
|
46
|
+
return current_fit
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class IterativePolyFitWithMinimum(BackgroundEstimator):
|
|
50
|
+
"""
|
|
51
|
+
Iterative polynomial baseline (Gan et al. 2006) with SNR-gated minimum envelope.
|
|
52
|
+
|
|
53
|
+
In low-SNR regions, the working signal is floored by the local minimum within
|
|
54
|
+
a resolution-scaled window. High-SNR regions (peaks) are filled from their
|
|
55
|
+
neighbours to avoid collapsing the baseline.
|
|
56
|
+
|
|
57
|
+
Parameters
|
|
58
|
+
----------
|
|
59
|
+
resolution : callable
|
|
60
|
+
Maps axis values to FWHM in the same units.
|
|
61
|
+
conv : Convolution
|
|
62
|
+
Convolution object used to compute the local SNR map.
|
|
63
|
+
degree : int
|
|
64
|
+
Polynomial degree. Default is 5.
|
|
65
|
+
max_iter : int
|
|
66
|
+
Maximum iterations. Default is 100.
|
|
67
|
+
tolerance : float
|
|
68
|
+
Convergence threshold. Default is 1e-3.
|
|
69
|
+
window_scale : float
|
|
70
|
+
Half-window size as a multiple of sigma. Default is 5.0.
|
|
71
|
+
snr_threshold : float
|
|
72
|
+
SNR above which a channel is treated as a peak. Default is 4.0.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
resolution: Callable[[float], float],
|
|
78
|
+
conv,
|
|
79
|
+
degree=5,
|
|
80
|
+
max_iter=100,
|
|
81
|
+
tolerance=1e-3,
|
|
82
|
+
window_scale=5.0,
|
|
83
|
+
snr_threshold=4.0,
|
|
84
|
+
):
|
|
85
|
+
self.resolution = resolution
|
|
86
|
+
self.conv = conv
|
|
87
|
+
self.degree = degree
|
|
88
|
+
self.max_iter = max_iter
|
|
89
|
+
self.tolerance = tolerance
|
|
90
|
+
self.window_scale = window_scale
|
|
91
|
+
self.snr_threshold = snr_threshold
|
|
92
|
+
|
|
93
|
+
def _fill_with_side_min(self, bg: np.ndarray) -> np.ndarray:
|
|
94
|
+
n = len(bg)
|
|
95
|
+
|
|
96
|
+
left_vals = np.full(n, np.nan)
|
|
97
|
+
last_val = np.nan
|
|
98
|
+
for i in range(n):
|
|
99
|
+
if bg[i] > 0:
|
|
100
|
+
last_val = bg[i]
|
|
101
|
+
left_vals[i] = last_val
|
|
102
|
+
|
|
103
|
+
right_vals = np.full(n, np.nan)
|
|
104
|
+
last_val = np.nan
|
|
105
|
+
for i in range(n - 1, -1, -1):
|
|
106
|
+
if bg[i] > 0:
|
|
107
|
+
last_val = bg[i]
|
|
108
|
+
right_vals[i] = last_val
|
|
109
|
+
|
|
110
|
+
filled = bg.copy()
|
|
111
|
+
for i in range(n):
|
|
112
|
+
if bg[i] > 0:
|
|
113
|
+
continue
|
|
114
|
+
l, r = left_vals[i], right_vals[i]
|
|
115
|
+
if np.isfinite(l) and np.isfinite(r):
|
|
116
|
+
filled[i] = min(l, r)
|
|
117
|
+
elif np.isfinite(l):
|
|
118
|
+
filled[i] = l
|
|
119
|
+
elif np.isfinite(r):
|
|
120
|
+
filled[i] = r
|
|
121
|
+
else:
|
|
122
|
+
filled[i] = 0.0
|
|
123
|
+
|
|
124
|
+
return filled
|
|
125
|
+
|
|
126
|
+
def _estimate_minimum_vector(self, axis: np.ndarray, counts: np.ndarray) -> np.ndarray:
|
|
127
|
+
dx = axis[1] - axis[0]
|
|
128
|
+
_, _, n_sigma = self.conv.apply(axis, counts)
|
|
129
|
+
|
|
130
|
+
fwhm = np.array([self.resolution(xi) for xi in axis])
|
|
131
|
+
sigma_pts = np.maximum((fwhm / 2.355) / dx, 1.0)
|
|
132
|
+
|
|
133
|
+
bg_min = np.zeros_like(counts)
|
|
134
|
+
for i in range(len(axis)):
|
|
135
|
+
if np.abs(n_sigma[i]) < self.snr_threshold:
|
|
136
|
+
sigma = sigma_pts[i]
|
|
137
|
+
radius = int(self.window_scale * sigma)
|
|
138
|
+
i_start = max(0, i - radius)
|
|
139
|
+
i_end = min(len(counts), i + radius + 1)
|
|
140
|
+
bg_min[i] = np.min(counts[i_start:i_end])
|
|
141
|
+
|
|
142
|
+
return self._fill_with_side_min(bg_min)
|
|
143
|
+
|
|
144
|
+
def estimate(self, axis: np.ndarray, counts: np.ndarray) -> np.ndarray:
|
|
145
|
+
counts = counts.astype(float)
|
|
146
|
+
y_min = self._estimate_minimum_vector(axis, counts)
|
|
147
|
+
|
|
148
|
+
y_work = counts.copy()
|
|
149
|
+
last_fit = np.zeros_like(counts)
|
|
150
|
+
|
|
151
|
+
for i in range(self.max_iter):
|
|
152
|
+
coeffs = np.polyfit(axis, y_work, self.degree)
|
|
153
|
+
current_fit = np.polyval(coeffs, axis)
|
|
154
|
+
|
|
155
|
+
if i > 0:
|
|
156
|
+
num = np.linalg.norm(current_fit - last_fit)
|
|
157
|
+
den = np.linalg.norm(last_fit)
|
|
158
|
+
if den > 0 and (num / den) < self.tolerance:
|
|
159
|
+
break
|
|
160
|
+
|
|
161
|
+
last_fit = current_fit
|
|
162
|
+
y_work = np.minimum(y_work, current_fit)
|
|
163
|
+
y_work = np.maximum(y_work, y_min)
|
|
164
|
+
|
|
165
|
+
return current_fit
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from typing import Callable
|
|
3
|
+
from scispectrum.utils.smoothing import adaptive_gaussian_smoothing
|
|
4
|
+
from scispectrum.background.base import BackgroundEstimator
|
|
5
|
+
from .snip_utils import ll_transform, inv_ll_transform
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SNIPBackground(BackgroundEstimator):
|
|
9
|
+
"""
|
|
10
|
+
Classical SNIP background estimation (Ryan, 1988).
|
|
11
|
+
|
|
12
|
+
Parameters
|
|
13
|
+
----------
|
|
14
|
+
iterations : int
|
|
15
|
+
Number of SNIP clipping iterations.
|
|
16
|
+
resolution : callable
|
|
17
|
+
Maps axis values to FWHM in the same units.
|
|
18
|
+
Required when smooth=True (the default).
|
|
19
|
+
smooth : bool
|
|
20
|
+
If True, apply resolution-adaptive Gaussian smoothing before SNIP.
|
|
21
|
+
Default is True.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, iterations: int, resolution: Callable[[np.ndarray], np.ndarray] = None, smooth: bool = True):
|
|
25
|
+
self.iterations = int(iterations)
|
|
26
|
+
self.resolution = resolution
|
|
27
|
+
self.smooth = smooth
|
|
28
|
+
|
|
29
|
+
def estimate(self, axis: np.ndarray, counts: np.ndarray) -> np.ndarray:
|
|
30
|
+
"""
|
|
31
|
+
Estimate background using SNIP.
|
|
32
|
+
|
|
33
|
+
Parameters
|
|
34
|
+
----------
|
|
35
|
+
axis : np.ndarray
|
|
36
|
+
Axis values.
|
|
37
|
+
counts : np.ndarray
|
|
38
|
+
Spectrum counts.
|
|
39
|
+
|
|
40
|
+
Returns
|
|
41
|
+
-------
|
|
42
|
+
np.ndarray
|
|
43
|
+
Estimated background, same shape as counts.
|
|
44
|
+
"""
|
|
45
|
+
return self._sinp(axis, counts)
|
|
46
|
+
|
|
47
|
+
def _sinp(self, x: np.ndarray, y: np.ndarray) -> np.ndarray:
|
|
48
|
+
if self.smooth:
|
|
49
|
+
if self.resolution is None:
|
|
50
|
+
raise ValueError("resolution must be provided at construction when smooth=True.")
|
|
51
|
+
y = np.asarray(y, dtype=float)
|
|
52
|
+
z = ll_transform(y)
|
|
53
|
+
z = adaptive_gaussian_smoothing(x, z, resolution=self.resolution)
|
|
54
|
+
else:
|
|
55
|
+
y = np.asarray(y, dtype=float)
|
|
56
|
+
z = ll_transform(y)
|
|
57
|
+
|
|
58
|
+
n = len(z)
|
|
59
|
+
for k in range(1, self.iterations + 1):
|
|
60
|
+
z_new = z.copy()
|
|
61
|
+
for i in range(k, n - k):
|
|
62
|
+
z_new[i] = min(z[i], 0.5 * (z[i - k] + z[i + k]))
|
|
63
|
+
z = z_new
|
|
64
|
+
|
|
65
|
+
return inv_ll_transform(z)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from typing import Callable
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
class AxisCalibration:
|
|
5
|
+
"""
|
|
6
|
+
Generic calibration for a spectrum axis.
|
|
7
|
+
|
|
8
|
+
Parameters
|
|
9
|
+
----------
|
|
10
|
+
func : Callable[[np.ndarray], np.ndarray]
|
|
11
|
+
Function mapping raw channels -> physical values.
|
|
12
|
+
name : str, optional
|
|
13
|
+
Name of the axis (e.g., "energy", "time", "wavelength").
|
|
14
|
+
"""
|
|
15
|
+
def __init__(self, func: Callable[[np.ndarray], np.ndarray], name: str = "axis"):
|
|
16
|
+
if not callable(func):
|
|
17
|
+
raise TypeError("mapping must be callable")
|
|
18
|
+
self.func = func
|
|
19
|
+
self.name = name
|
|
20
|
+
|
|
21
|
+
def __call__(self, channels)-> np.ndarray:
|
|
22
|
+
"""Return the axis values at a given channels."""
|
|
23
|
+
return self.func(channels)
|
|
24
|
+
|
|
25
|
+
def apply(self, channels) -> np.ndarray:
|
|
26
|
+
return self.func(channels)
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def from_array(cls, axis: np.ndarray, name: str = "axis") -> "AxisCalibration":
|
|
30
|
+
"""
|
|
31
|
+
Create an AxisCalibration from a pre-computed physical axis array.
|
|
32
|
+
|
|
33
|
+
The calibration is built using linear interpolation over the array,
|
|
34
|
+
so channels are assumed to be integer indices 0, 1, 2, ...
|
|
35
|
+
|
|
36
|
+
Parameters
|
|
37
|
+
----------
|
|
38
|
+
axis : np.ndarray
|
|
39
|
+
Pre-computed physical axis values (e.g. energy in keV).
|
|
40
|
+
Must have the same length as the spectrum counts array.
|
|
41
|
+
name : str, optional
|
|
42
|
+
Name of the axis. Default is "axis".
|
|
43
|
+
|
|
44
|
+
Returns
|
|
45
|
+
-------
|
|
46
|
+
AxisCalibration
|
|
47
|
+
|
|
48
|
+
Examples
|
|
49
|
+
--------
|
|
50
|
+
>>> axis = np.array([1460.0, 1461.5, 1463.0])
|
|
51
|
+
>>> calib = AxisCalibration.from_array(axis, name="energy_keV")
|
|
52
|
+
>>> calib.apply(np.array([0, 1, 2]))
|
|
53
|
+
array([1460. , 1461.5, 1463. ])
|
|
54
|
+
"""
|
|
55
|
+
if not isinstance(axis, np.ndarray) or axis.ndim != 1:
|
|
56
|
+
raise TypeError("axis must be a 1D numpy array")
|
|
57
|
+
|
|
58
|
+
channels = np.arange(len(axis))
|
|
59
|
+
func = lambda ch: np.interp(ch, channels, axis)
|
|
60
|
+
return cls(func=func, name=name)
|