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.
Files changed (46) hide show
  1. scispectrum/__init__.py +4 -0
  2. scispectrum/background/__init__.py +4 -0
  3. scispectrum/background/als.py +81 -0
  4. scispectrum/background/base.py +31 -0
  5. scispectrum/background/minimum_in_envelope.py +125 -0
  6. scispectrum/background/polynomial.py +165 -0
  7. scispectrum/background/snip.py +65 -0
  8. scispectrum/background/snip_utils.py +10 -0
  9. scispectrum/calibration/__init__.py +7 -0
  10. scispectrum/calibration/axis.py +60 -0
  11. scispectrum/calibration/detector_calibration.py +158 -0
  12. scispectrum/calibration/models/__init__.py +3 -0
  13. scispectrum/calibration/models/base.py +31 -0
  14. scispectrum/calibration/models/energy_poly.py +25 -0
  15. scispectrum/calibration/models/hpge_fwhm_model.py +10 -0
  16. scispectrum/calibration/resolution.py +21 -0
  17. scispectrum/core/__init__.py +2 -0
  18. scispectrum/core/domain.py +198 -0
  19. scispectrum/core/spectrum.py +282 -0
  20. scispectrum/domain_analysis/__init__.py +0 -0
  21. scispectrum/domain_analysis/background.py +63 -0
  22. scispectrum/domain_analysis/find_peaks.py +208 -0
  23. scispectrum/domain_analysis/moment.py +74 -0
  24. scispectrum/domain_analysis/morphology.py +107 -0
  25. scispectrum/domain_analysis/single_peak.py +140 -0
  26. scispectrum/domain_fitting/__init__.py +3 -0
  27. scispectrum/domain_fitting/abstract_fitting_class.py +20 -0
  28. scispectrum/domain_fitting/multi_gaussian_fitter.py +410 -0
  29. scispectrum/identification/__init__.py +3 -0
  30. scispectrum/identification/base.py +18 -0
  31. scispectrum/identification/convolution.py +84 -0
  32. scispectrum/identification/kernels/__init__.py +1 -0
  33. scispectrum/identification/kernels/base.py +42 -0
  34. scispectrum/identification/kernels/mexican_hat.py +24 -0
  35. scispectrum/identification/kernels/utils.py +1 -0
  36. scispectrum/identification/snr.py +192 -0
  37. scispectrum/io/__init__.py +4 -0
  38. scispectrum/io/time_channel.py +175 -0
  39. scispectrum/utils/__init__.py +0 -0
  40. scispectrum/utils/gaussian.py +12 -0
  41. scispectrum/utils/smoothing.py +88 -0
  42. scispectrum-0.3.0.dist-info/METADATA +220 -0
  43. scispectrum-0.3.0.dist-info/RECORD +46 -0
  44. scispectrum-0.3.0.dist-info/WHEEL +5 -0
  45. scispectrum-0.3.0.dist-info/licenses/LICENSE +21 -0
  46. scispectrum-0.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,4 @@
1
+ from .core import Spectrum, Domain
2
+ from .io import TimeChannelParser
3
+ from .calibration import ResolutionCalibration, AxisCalibration
4
+
@@ -0,0 +1,4 @@
1
+ from .als import ALSBackground
2
+ from .snip import SNIPBackground
3
+ from .polynomial import IterativePolyFit, IterativePolyFitWithMinimum
4
+ from .minimum_in_envelope import MinimaEnvelopeBackground
@@ -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,10 @@
1
+ import numpy as np
2
+
3
+
4
+ def ll_transform(y):
5
+ return np.log(np.log(y + 1.0) + 1.0)
6
+
7
+
8
+ def inv_ll_transform(y):
9
+ return np.exp(np.exp(y) - 1.0) - 1.0
10
+
@@ -0,0 +1,7 @@
1
+ """
2
+ module for energy and fwhm calibration
3
+ This module is quite lacking
4
+ """
5
+ from .resolution import ResolutionCalibration
6
+ from .axis import AxisCalibration
7
+ from .models import BaseCalibrationModel
@@ -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)