eagleplot 3.0.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.
- eagleplot/Home.py +362 -0
- eagleplot/__init__.py +19 -0
- eagleplot/annotations.py +215 -0
- eagleplot/assets/logo.ico +0 -0
- eagleplot/assets/logo.png +0 -0
- eagleplot/assets/nccu-horiz-logo.png +0 -0
- eagleplot/assets/nccu-wings.png +0 -0
- eagleplot/batch.py +446 -0
- eagleplot/cli.py +719 -0
- eagleplot/data_tools.py +849 -0
- eagleplot/example-data/Artificial/bimodal_distribution.csv +501 -0
- eagleplot/example-data/Artificial/correlation_matrix.npy +0 -0
- eagleplot/example-data/Artificial/gaussian_surface.npy +0 -0
- eagleplot/example-data/Artificial/grouped_categorical.csv +241 -0
- eagleplot/example-data/Artificial/ir_spectrum.csv +1802 -0
- eagleplot/example-data/Artificial/molecular_properties.csv +121 -0
- eagleplot/example-data/Artificial/normal_distribution.csv +401 -0
- eagleplot/example-data/Artificial/reaction_kinetics.csv +121 -0
- eagleplot/example-data/Artificial/scatter_correlation.csv +201 -0
- eagleplot/example-data/Artificial/sine_cosine_waves.csv +301 -0
- eagleplot/gallery/box_plot.png +0 -0
- eagleplot/gallery/contour_plot.png +0 -0
- eagleplot/gallery/distribution_plot.png +0 -0
- eagleplot/gallery/heatmap.png +0 -0
- eagleplot/gallery/histogram.png +0 -0
- eagleplot/gallery/interactive_3d_surface.png +0 -0
- eagleplot/gallery/interactive_heatmap.png +0 -0
- eagleplot/gallery/interactive_histogram.png +0 -0
- eagleplot/gallery/interactive_line.png +0 -0
- eagleplot/gallery/interactive_scatter.png +0 -0
- eagleplot/gallery/line_plot.png +0 -0
- eagleplot/gallery/manifest.json +196 -0
- eagleplot/gallery/regression_plot.png +0 -0
- eagleplot/gallery/scatter_plot.png +0 -0
- eagleplot/io.py +1008 -0
- eagleplot/math.py +1210 -0
- eagleplot/molecular/__init__.py +21 -0
- eagleplot/molecular/atom_data.py +287 -0
- eagleplot/molecular/parsers.py +747 -0
- eagleplot/nist.py +241 -0
- eagleplot/pages/10_Molecular_Viz.py +225 -0
- eagleplot/pages/11_Batch_Analysis.py +325 -0
- eagleplot/pages/12_Help.py +199 -0
- eagleplot/pages/13_Settings.py +388 -0
- eagleplot/pages/1_Data_Upload.py +521 -0
- eagleplot/pages/2_Quick_Plot.py +1693 -0
- eagleplot/pages/3_Analysis_Tools.py +2510 -0
- eagleplot/pages/4_Multi_Plot_Dashboard.py +791 -0
- eagleplot/pages/5_Advanced_Plotting.py +691 -0
- eagleplot/pages/6_Export_Results.py +442 -0
- eagleplot/pages/7_Gallery.py +201 -0
- eagleplot/pages/8_Data_Tools.py +434 -0
- eagleplot/pages/9_Spectroscopy.py +821 -0
- eagleplot/pages/__init__.py +8 -0
- eagleplot/peaks.py +513 -0
- eagleplot/plotting.py +2630 -0
- eagleplot/plugin_loader.py +208 -0
- eagleplot/report.py +448 -0
- eagleplot/signal.py +494 -0
- eagleplot/spectroscopy.py +830 -0
- eagleplot/utils/__init__.py +76 -0
- eagleplot/utils/access.py +77 -0
- eagleplot/utils/data_preview.py +352 -0
- eagleplot/utils/plot_config.py +2721 -0
- eagleplot/utils/session_state.py +535 -0
- eagleplot/utils/theming.py +312 -0
- eagleplot/utils/user_settings.py +294 -0
- eagleplot-3.0.0.dist-info/METADATA +304 -0
- eagleplot-3.0.0.dist-info/RECORD +73 -0
- eagleplot-3.0.0.dist-info/WHEEL +5 -0
- eagleplot-3.0.0.dist-info/entry_points.txt +2 -0
- eagleplot-3.0.0.dist-info/licenses/LICENSE +21 -0
- eagleplot-3.0.0.dist-info/top_level.txt +1 -0
eagleplot/peaks.py
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
"""Peak Analysis Module.
|
|
2
|
+
|
|
3
|
+
Auto-detection, integration, FWHM computation, and model fitting for
|
|
4
|
+
spectral / chromatographic peaks.
|
|
5
|
+
|
|
6
|
+
All functions accept NumPy arrays and return plain dicts.
|
|
7
|
+
|
|
8
|
+
Functions
|
|
9
|
+
---------
|
|
10
|
+
find_peaks -- detect peaks with scipy.signal.find_peaks
|
|
11
|
+
integrate_peaks -- trapezoidal area under each peak
|
|
12
|
+
compute_fwhm -- FWHM in x-axis units for each peak
|
|
13
|
+
fit_peak -- fit one peak + background model
|
|
14
|
+
fit_multipeak -- fit N simultaneous peaks + background
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
from scipy.optimize import curve_fit
|
|
23
|
+
from scipy.signal import find_peaks as _sp_find_peaks
|
|
24
|
+
from scipy.signal import peak_prominences, peak_widths
|
|
25
|
+
from scipy.special import voigt_profile
|
|
26
|
+
|
|
27
|
+
# ─── Internal profile functions ───────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _gaussian(x: np.ndarray, center: float, amplitude: float, sigma: float) -> np.ndarray:
|
|
31
|
+
return amplitude * np.exp(-0.5 * ((x - center) / sigma) ** 2)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _lorentzian(x: np.ndarray, center: float, amplitude: float, gamma: float) -> np.ndarray:
|
|
35
|
+
return amplitude * gamma**2 / ((x - center) ** 2 + gamma**2)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _pseudo_voigt(
|
|
39
|
+
x: np.ndarray, center: float, amplitude: float, sigma: float, eta: float
|
|
40
|
+
) -> np.ndarray:
|
|
41
|
+
"""Linear combination: η·Lorentzian + (1−η)·Gaussian."""
|
|
42
|
+
return eta * _lorentzian(x, center, amplitude, sigma) + (1 - eta) * _gaussian(
|
|
43
|
+
x, center, amplitude, sigma
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _voigt_peak(
|
|
48
|
+
x: np.ndarray, center: float, amplitude: float, sigma: float, gamma: float
|
|
49
|
+
) -> np.ndarray:
|
|
50
|
+
"""Voigt profile normalised so that the peak value equals *amplitude*."""
|
|
51
|
+
norm = voigt_profile(0.0, sigma, gamma)
|
|
52
|
+
if norm == 0:
|
|
53
|
+
return np.zeros_like(x)
|
|
54
|
+
return amplitude * voigt_profile(x - center, sigma, gamma) / norm
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _build_model(model: str):
|
|
58
|
+
"""Return (profile_fn, n_params_per_peak) for the requested model name."""
|
|
59
|
+
model = model.lower()
|
|
60
|
+
if model == "gaussian":
|
|
61
|
+
return _gaussian, 3
|
|
62
|
+
if model == "lorentzian":
|
|
63
|
+
return _lorentzian, 3
|
|
64
|
+
if model in ("pseudo_voigt", "pseudo-voigt"):
|
|
65
|
+
return _pseudo_voigt, 4
|
|
66
|
+
if model == "voigt":
|
|
67
|
+
return _voigt_peak, 4
|
|
68
|
+
raise ValueError(f"Unknown model '{model}'. Choose: gaussian, lorentzian, pseudo_voigt, voigt.")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ─── Public API ───────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def find_peaks(
|
|
75
|
+
y: np.ndarray,
|
|
76
|
+
x: Optional[np.ndarray] = None,
|
|
77
|
+
height: Optional[float] = None,
|
|
78
|
+
prominence: Optional[float] = None,
|
|
79
|
+
width: Optional[float] = None,
|
|
80
|
+
distance: Optional[int] = None,
|
|
81
|
+
) -> Dict[str, Any]:
|
|
82
|
+
"""Detect peaks in a 1-D signal.
|
|
83
|
+
|
|
84
|
+
Thin wrapper around :func:`scipy.signal.find_peaks` that also computes
|
|
85
|
+
prominences, widths, and converts sample-domain quantities to x-axis units
|
|
86
|
+
when *x* is provided.
|
|
87
|
+
|
|
88
|
+
Parameters
|
|
89
|
+
----------
|
|
90
|
+
y : np.ndarray
|
|
91
|
+
1-D signal values.
|
|
92
|
+
x : np.ndarray, optional
|
|
93
|
+
Corresponding x-axis values. When supplied, peak positions and widths
|
|
94
|
+
are returned in x-axis units as well as sample indices.
|
|
95
|
+
height : float, optional
|
|
96
|
+
Minimum peak height.
|
|
97
|
+
prominence : float, optional
|
|
98
|
+
Minimum peak prominence.
|
|
99
|
+
width : float, optional
|
|
100
|
+
Minimum peak width (in samples).
|
|
101
|
+
distance : int, optional
|
|
102
|
+
Minimum number of samples between peaks.
|
|
103
|
+
|
|
104
|
+
Returns
|
|
105
|
+
-------
|
|
106
|
+
dict
|
|
107
|
+
Keys:
|
|
108
|
+
|
|
109
|
+
- ``indices`` : sample indices of detected peaks
|
|
110
|
+
- ``positions`` : x-values at peak positions (or indices if no *x*)
|
|
111
|
+
- ``heights`` : y-values at peak positions
|
|
112
|
+
- ``prominences`` : peak prominences
|
|
113
|
+
- ``widths_samples`` : FWHM widths in samples
|
|
114
|
+
- ``widths`` : FWHM widths in x-axis units (= widths_samples if no *x*)
|
|
115
|
+
- ``left_ips`` : left interpolated half-maximum sample positions
|
|
116
|
+
- ``right_ips`` : right interpolated half-maximum sample positions
|
|
117
|
+
- ``n_peaks`` : number of detected peaks
|
|
118
|
+
"""
|
|
119
|
+
y = np.asarray(y, dtype=float)
|
|
120
|
+
kwargs: Dict[str, Any] = {}
|
|
121
|
+
if height is not None:
|
|
122
|
+
kwargs["height"] = height
|
|
123
|
+
if prominence is not None:
|
|
124
|
+
kwargs["prominence"] = prominence
|
|
125
|
+
if width is not None:
|
|
126
|
+
kwargs["width"] = width
|
|
127
|
+
if distance is not None:
|
|
128
|
+
kwargs["distance"] = distance
|
|
129
|
+
|
|
130
|
+
indices, _ = _sp_find_peaks(y, **kwargs)
|
|
131
|
+
if len(indices) == 0:
|
|
132
|
+
empty = np.array([], dtype=float)
|
|
133
|
+
return {
|
|
134
|
+
"indices": np.array([], dtype=int),
|
|
135
|
+
"positions": empty,
|
|
136
|
+
"heights": empty,
|
|
137
|
+
"prominences": empty,
|
|
138
|
+
"widths_samples": empty,
|
|
139
|
+
"widths": empty,
|
|
140
|
+
"left_ips": empty,
|
|
141
|
+
"right_ips": empty,
|
|
142
|
+
"n_peaks": 0,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
prominences_arr, _, _ = peak_prominences(y, indices)
|
|
146
|
+
widths_samples, _, left_ips, right_ips = peak_widths(y, indices, rel_height=0.5)
|
|
147
|
+
|
|
148
|
+
if x is not None:
|
|
149
|
+
x = np.asarray(x, dtype=float)
|
|
150
|
+
positions = x[indices]
|
|
151
|
+
# Convert sample-domain widths to x-axis units via linear interpolation
|
|
152
|
+
left_x = np.interp(left_ips, np.arange(len(x)), x)
|
|
153
|
+
right_x = np.interp(right_ips, np.arange(len(x)), x)
|
|
154
|
+
widths_x = right_x - left_x
|
|
155
|
+
else:
|
|
156
|
+
positions = indices.astype(float)
|
|
157
|
+
widths_x = widths_samples
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
"indices": indices,
|
|
161
|
+
"positions": positions,
|
|
162
|
+
"heights": y[indices],
|
|
163
|
+
"prominences": prominences_arr,
|
|
164
|
+
"widths_samples": widths_samples,
|
|
165
|
+
"widths": widths_x,
|
|
166
|
+
"left_ips": left_ips,
|
|
167
|
+
"right_ips": right_ips,
|
|
168
|
+
"n_peaks": len(indices),
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def integrate_peaks(
|
|
173
|
+
y: np.ndarray,
|
|
174
|
+
x: np.ndarray,
|
|
175
|
+
peaks: Dict[str, Any],
|
|
176
|
+
bounds: str = "auto",
|
|
177
|
+
) -> Dict[str, Any]:
|
|
178
|
+
"""Compute the area under each detected peak.
|
|
179
|
+
|
|
180
|
+
Uses trapezoidal integration on the background-subtracted signal. The
|
|
181
|
+
background under each peak is approximated as a straight line between the
|
|
182
|
+
left and right base points (as determined by scipy's ``peak_prominences``).
|
|
183
|
+
|
|
184
|
+
Parameters
|
|
185
|
+
----------
|
|
186
|
+
y : np.ndarray
|
|
187
|
+
Signal values.
|
|
188
|
+
x : np.ndarray
|
|
189
|
+
Corresponding x-axis values.
|
|
190
|
+
peaks : dict
|
|
191
|
+
Output of :func:`find_peaks`.
|
|
192
|
+
bounds : str
|
|
193
|
+
Currently only ``'auto'`` is supported (prominence-based bases).
|
|
194
|
+
|
|
195
|
+
Returns
|
|
196
|
+
-------
|
|
197
|
+
dict
|
|
198
|
+
Keys: ``areas``, ``left_bases`` (x values), ``right_bases`` (x values).
|
|
199
|
+
"""
|
|
200
|
+
y = np.asarray(y, dtype=float)
|
|
201
|
+
x = np.asarray(x, dtype=float)
|
|
202
|
+
indices = peaks["indices"]
|
|
203
|
+
|
|
204
|
+
if len(indices) == 0:
|
|
205
|
+
empty = np.array([], dtype=float)
|
|
206
|
+
return {"areas": empty, "left_bases": empty, "right_bases": empty}
|
|
207
|
+
|
|
208
|
+
_, left_b, right_b = peak_prominences(y, indices)
|
|
209
|
+
left_b = left_b.astype(int)
|
|
210
|
+
right_b = right_b.astype(int)
|
|
211
|
+
|
|
212
|
+
areas = []
|
|
213
|
+
left_xs = []
|
|
214
|
+
right_xs = []
|
|
215
|
+
for l_idx, r_idx in zip(left_b, right_b):
|
|
216
|
+
x_seg = x[l_idx : r_idx + 1]
|
|
217
|
+
y_seg = y[l_idx : r_idx + 1]
|
|
218
|
+
baseline = np.linspace(y[l_idx], y[r_idx], len(x_seg))
|
|
219
|
+
area = np.trapezoid(np.maximum(y_seg - baseline, 0.0), x_seg)
|
|
220
|
+
areas.append(area)
|
|
221
|
+
left_xs.append(x[l_idx])
|
|
222
|
+
right_xs.append(x[r_idx])
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
"areas": np.array(areas),
|
|
226
|
+
"left_bases": np.array(left_xs),
|
|
227
|
+
"right_bases": np.array(right_xs),
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def compute_fwhm(
|
|
232
|
+
y: np.ndarray,
|
|
233
|
+
x: np.ndarray,
|
|
234
|
+
peaks: Dict[str, Any],
|
|
235
|
+
) -> Dict[str, Any]:
|
|
236
|
+
"""Return FWHM values in x-axis units for each peak.
|
|
237
|
+
|
|
238
|
+
Parameters
|
|
239
|
+
----------
|
|
240
|
+
y : np.ndarray
|
|
241
|
+
Signal values.
|
|
242
|
+
x : np.ndarray
|
|
243
|
+
Corresponding x-axis values.
|
|
244
|
+
peaks : dict
|
|
245
|
+
Output of :func:`find_peaks`.
|
|
246
|
+
|
|
247
|
+
Returns
|
|
248
|
+
-------
|
|
249
|
+
dict
|
|
250
|
+
Keys: ``fwhm`` (x-axis units), ``fwhm_samples``, ``centers`` (x values).
|
|
251
|
+
"""
|
|
252
|
+
y = np.asarray(y, dtype=float)
|
|
253
|
+
x = np.asarray(x, dtype=float)
|
|
254
|
+
indices = peaks["indices"]
|
|
255
|
+
|
|
256
|
+
if len(indices) == 0:
|
|
257
|
+
empty = np.array([], dtype=float)
|
|
258
|
+
return {"fwhm": empty, "fwhm_samples": empty, "centers": empty}
|
|
259
|
+
|
|
260
|
+
widths_samples, _, left_ips, right_ips = peak_widths(y, indices, rel_height=0.5)
|
|
261
|
+
left_x = np.interp(left_ips, np.arange(len(x)), x)
|
|
262
|
+
right_x = np.interp(right_ips, np.arange(len(x)), x)
|
|
263
|
+
fwhm_x = right_x - left_x
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
"fwhm": fwhm_x,
|
|
267
|
+
"fwhm_samples": widths_samples,
|
|
268
|
+
"centers": x[indices],
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def fit_peak(
|
|
273
|
+
y: np.ndarray,
|
|
274
|
+
x: np.ndarray,
|
|
275
|
+
center_guess: float,
|
|
276
|
+
model: str = "gaussian",
|
|
277
|
+
background: str = "linear",
|
|
278
|
+
) -> Dict[str, Any]:
|
|
279
|
+
"""Fit a single-peak model (+ optional background) to a 1-D signal.
|
|
280
|
+
|
|
281
|
+
Parameters
|
|
282
|
+
----------
|
|
283
|
+
y : np.ndarray
|
|
284
|
+
Signal values.
|
|
285
|
+
x : np.ndarray
|
|
286
|
+
Corresponding x-axis values.
|
|
287
|
+
center_guess : float
|
|
288
|
+
Initial guess for the peak center (in x-axis units).
|
|
289
|
+
model : str
|
|
290
|
+
Peak profile: ``'gaussian'``, ``'lorentzian'``, ``'pseudo_voigt'``,
|
|
291
|
+
or ``'voigt'``.
|
|
292
|
+
background : str
|
|
293
|
+
Background model: ``'none'``, ``'constant'``, or ``'linear'``.
|
|
294
|
+
|
|
295
|
+
Returns
|
|
296
|
+
-------
|
|
297
|
+
dict
|
|
298
|
+
Keys: ``params``, ``param_names``, ``std_errors``, ``fitted_y``,
|
|
299
|
+
``residuals``, ``r_squared``, ``model``, ``background``.
|
|
300
|
+
"""
|
|
301
|
+
y = np.asarray(y, dtype=float)
|
|
302
|
+
x = np.asarray(x, dtype=float)
|
|
303
|
+
profile_fn, n_pp = _build_model(model)
|
|
304
|
+
|
|
305
|
+
amp_guess = float(y.max() - y.min())
|
|
306
|
+
width_guess = float((x.max() - x.min()) / 10.0)
|
|
307
|
+
|
|
308
|
+
if model in ("gaussian", "lorentzian"):
|
|
309
|
+
p0_peak = [center_guess, amp_guess, width_guess]
|
|
310
|
+
peak_names = ["center", "amplitude", "width"]
|
|
311
|
+
else:
|
|
312
|
+
p0_peak = [center_guess, amp_guess, width_guess, 0.5]
|
|
313
|
+
peak_names = ["center", "amplitude", "width", "eta_or_gamma"]
|
|
314
|
+
|
|
315
|
+
if background == "none":
|
|
316
|
+
p0 = p0_peak
|
|
317
|
+
bg_names: List[str] = []
|
|
318
|
+
|
|
319
|
+
def full_model(xv: np.ndarray, *p: float) -> np.ndarray:
|
|
320
|
+
return profile_fn(xv, *p[:n_pp])
|
|
321
|
+
|
|
322
|
+
elif background == "constant":
|
|
323
|
+
p0 = p0_peak + [float(y.min())]
|
|
324
|
+
bg_names = ["bg_offset"]
|
|
325
|
+
|
|
326
|
+
def full_model(xv: np.ndarray, *p: float) -> np.ndarray: # type: ignore[misc]
|
|
327
|
+
return profile_fn(xv, *p[:n_pp]) + p[n_pp]
|
|
328
|
+
|
|
329
|
+
else: # linear
|
|
330
|
+
slope_guess = (float(y[-1]) - float(y[0])) / (float(x[-1]) - float(x[0]) + 1e-12)
|
|
331
|
+
p0 = p0_peak + [float(y.min()), slope_guess]
|
|
332
|
+
bg_names = ["bg_offset", "bg_slope"]
|
|
333
|
+
|
|
334
|
+
def full_model(xv: np.ndarray, *p: float) -> np.ndarray: # type: ignore[misc]
|
|
335
|
+
return profile_fn(xv, *p[:n_pp]) + p[n_pp] + p[n_pp + 1] * xv
|
|
336
|
+
|
|
337
|
+
popt, pcov = curve_fit(full_model, x, y, p0=p0, maxfev=20000)
|
|
338
|
+
perr = np.sqrt(np.diag(pcov))
|
|
339
|
+
fitted_y = full_model(x, *popt)
|
|
340
|
+
residuals = y - fitted_y
|
|
341
|
+
ss_res = float(np.sum(residuals**2))
|
|
342
|
+
ss_tot = float(np.sum((y - y.mean()) ** 2))
|
|
343
|
+
r2 = 1.0 - ss_res / ss_tot if ss_tot > 0 else 0.0
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
"params": popt,
|
|
347
|
+
"param_names": peak_names + bg_names,
|
|
348
|
+
"std_errors": perr,
|
|
349
|
+
"fitted_y": fitted_y,
|
|
350
|
+
"residuals": residuals,
|
|
351
|
+
"r_squared": r2,
|
|
352
|
+
"model": model,
|
|
353
|
+
"background": background,
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def fit_multipeak(
|
|
358
|
+
y: np.ndarray,
|
|
359
|
+
x: np.ndarray,
|
|
360
|
+
n_peaks: int,
|
|
361
|
+
model: str = "gaussian",
|
|
362
|
+
background: str = "linear",
|
|
363
|
+
initial_guesses: Optional[List[Tuple[float, float, float]]] = None,
|
|
364
|
+
) -> Dict[str, Any]:
|
|
365
|
+
"""Fit N simultaneous peak profiles (+ optional shared background) to data.
|
|
366
|
+
|
|
367
|
+
Parameters
|
|
368
|
+
----------
|
|
369
|
+
y : np.ndarray
|
|
370
|
+
Signal values.
|
|
371
|
+
x : np.ndarray
|
|
372
|
+
Corresponding x-axis values.
|
|
373
|
+
n_peaks : int
|
|
374
|
+
Number of peaks to fit.
|
|
375
|
+
model : str
|
|
376
|
+
Peak profile: ``'gaussian'``, ``'lorentzian'``, ``'pseudo_voigt'``,
|
|
377
|
+
or ``'voigt'``.
|
|
378
|
+
background : str
|
|
379
|
+
Background model applied to the summed peaks: ``'none'``,
|
|
380
|
+
``'constant'``, or ``'linear'``.
|
|
381
|
+
initial_guesses : list of (center, amplitude, width), optional
|
|
382
|
+
One tuple per peak. If ``None``, guesses are estimated automatically
|
|
383
|
+
using :func:`find_peaks`.
|
|
384
|
+
|
|
385
|
+
Returns
|
|
386
|
+
-------
|
|
387
|
+
dict
|
|
388
|
+
Keys:
|
|
389
|
+
|
|
390
|
+
- ``params`` : all optimised parameters (peaks then background)
|
|
391
|
+
- ``param_names`` : corresponding parameter names
|
|
392
|
+
- ``std_errors`` : standard errors from covariance diagonal
|
|
393
|
+
- ``fitted_y`` : summed model evaluated at *x*
|
|
394
|
+
- ``individual_y`` : list of per-peak curves (without background)
|
|
395
|
+
- ``residuals`` : y − fitted_y
|
|
396
|
+
- ``r_squared``
|
|
397
|
+
- ``model``
|
|
398
|
+
- ``background``
|
|
399
|
+
- ``n_peaks``
|
|
400
|
+
- ``peak_summaries`` : list of dicts with center, amplitude, width, fwhm per peak
|
|
401
|
+
"""
|
|
402
|
+
y = np.asarray(y, dtype=float)
|
|
403
|
+
x = np.asarray(x, dtype=float)
|
|
404
|
+
profile_fn, n_pp = _build_model(model)
|
|
405
|
+
|
|
406
|
+
# ── Auto-estimate guesses when not provided ───────────────────────────────
|
|
407
|
+
if initial_guesses is None:
|
|
408
|
+
detected = find_peaks(y, x=x, prominence=(y.max() - y.min()) * 0.05)
|
|
409
|
+
if detected["n_peaks"] >= n_peaks:
|
|
410
|
+
# Sort by prominence, take top n_peaks
|
|
411
|
+
order = np.argsort(detected["prominences"])[::-1][:n_peaks]
|
|
412
|
+
guesses = [
|
|
413
|
+
(
|
|
414
|
+
float(detected["positions"][i]),
|
|
415
|
+
float(detected["heights"][i]),
|
|
416
|
+
float(detected["widths"][i]) / 2.0 or (x.max() - x.min()) / (4 * n_peaks),
|
|
417
|
+
)
|
|
418
|
+
for i in order
|
|
419
|
+
]
|
|
420
|
+
else:
|
|
421
|
+
# Distribute evenly
|
|
422
|
+
x_range = float(x.max() - x.min())
|
|
423
|
+
amp_guess = float(y.max() - y.min())
|
|
424
|
+
guesses = [
|
|
425
|
+
(
|
|
426
|
+
float(x.min()) + (i + 1) * x_range / (n_peaks + 1),
|
|
427
|
+
amp_guess,
|
|
428
|
+
x_range / (4 * n_peaks),
|
|
429
|
+
)
|
|
430
|
+
for i in range(n_peaks)
|
|
431
|
+
]
|
|
432
|
+
else:
|
|
433
|
+
guesses = list(initial_guesses)
|
|
434
|
+
|
|
435
|
+
# ── Build parameter vector p0 ─────────────────────────────────────────────
|
|
436
|
+
p0_peaks: List[float] = []
|
|
437
|
+
peak_param_names: List[str] = []
|
|
438
|
+
base_names = (
|
|
439
|
+
["center", "amplitude", "sigma"]
|
|
440
|
+
if model in ("gaussian", "lorentzian")
|
|
441
|
+
else ["center", "amplitude", "sigma", "eta"]
|
|
442
|
+
)
|
|
443
|
+
for i, (ctr, amp, wid) in enumerate(guesses):
|
|
444
|
+
if n_pp == 3:
|
|
445
|
+
p0_peaks.extend([ctr, amp, abs(wid)])
|
|
446
|
+
else:
|
|
447
|
+
p0_peaks.extend([ctr, amp, abs(wid), 0.5])
|
|
448
|
+
peak_param_names += [f"peak{i + 1}_{nm}" for nm in base_names]
|
|
449
|
+
|
|
450
|
+
if background == "none":
|
|
451
|
+
p0 = p0_peaks
|
|
452
|
+
bg_names: List[str] = []
|
|
453
|
+
|
|
454
|
+
def full_model(xv: np.ndarray, *p: float) -> np.ndarray:
|
|
455
|
+
return sum(profile_fn(xv, *p[n_pp * k : n_pp * (k + 1)]) for k in range(n_peaks))
|
|
456
|
+
|
|
457
|
+
elif background == "constant":
|
|
458
|
+
p0 = p0_peaks + [float(y.min())]
|
|
459
|
+
bg_names = ["bg_offset"]
|
|
460
|
+
|
|
461
|
+
def full_model(xv: np.ndarray, *p: float) -> np.ndarray: # type: ignore[misc]
|
|
462
|
+
peaks_sum = sum(profile_fn(xv, *p[n_pp * k : n_pp * (k + 1)]) for k in range(n_peaks))
|
|
463
|
+
return peaks_sum + p[n_peaks * n_pp]
|
|
464
|
+
|
|
465
|
+
else: # linear
|
|
466
|
+
slope_guess = (float(y[-1]) - float(y[0])) / (float(x[-1]) - float(x[0]) + 1e-12)
|
|
467
|
+
p0 = p0_peaks + [float(y.min()), slope_guess]
|
|
468
|
+
bg_names = ["bg_offset", "bg_slope"]
|
|
469
|
+
|
|
470
|
+
def full_model(xv: np.ndarray, *p: float) -> np.ndarray: # type: ignore[misc]
|
|
471
|
+
peaks_sum = sum(profile_fn(xv, *p[n_pp * k : n_pp * (k + 1)]) for k in range(n_peaks))
|
|
472
|
+
off = n_peaks * n_pp
|
|
473
|
+
return peaks_sum + p[off] + p[off + 1] * xv
|
|
474
|
+
|
|
475
|
+
popt, pcov = curve_fit(full_model, x, y, p0=p0, maxfev=30000)
|
|
476
|
+
perr = np.sqrt(np.diag(pcov))
|
|
477
|
+
fitted_y = full_model(x, *popt)
|
|
478
|
+
residuals = y - fitted_y
|
|
479
|
+
ss_res = float(np.sum(residuals**2))
|
|
480
|
+
ss_tot = float(np.sum((y - y.mean()) ** 2))
|
|
481
|
+
r2 = 1.0 - ss_res / ss_tot if ss_tot > 0 else 0.0
|
|
482
|
+
|
|
483
|
+
# ── Per-peak curves and summaries ─────────────────────────────────────────
|
|
484
|
+
individual_y = [profile_fn(x, *popt[n_pp * k : n_pp * (k + 1)]) for k in range(n_peaks)]
|
|
485
|
+
|
|
486
|
+
peak_summaries = []
|
|
487
|
+
for k in range(n_peaks):
|
|
488
|
+
p_k = popt[n_pp * k : n_pp * (k + 1)]
|
|
489
|
+
center_k, amp_k, width_k = float(p_k[0]), float(p_k[1]), float(p_k[2])
|
|
490
|
+
# FWHM formula for Gaussian/Lorentzian; approximate for others
|
|
491
|
+
if model == "gaussian":
|
|
492
|
+
fwhm = 2.0 * np.sqrt(2.0 * np.log(2.0)) * abs(width_k)
|
|
493
|
+
elif model == "lorentzian":
|
|
494
|
+
fwhm = 2.0 * abs(width_k)
|
|
495
|
+
else:
|
|
496
|
+
fwhm = 2.0 * np.sqrt(2.0 * np.log(2.0)) * abs(width_k)
|
|
497
|
+
peak_summaries.append(
|
|
498
|
+
{"center": center_k, "amplitude": amp_k, "width": abs(width_k), "fwhm": fwhm}
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
return {
|
|
502
|
+
"params": popt,
|
|
503
|
+
"param_names": peak_param_names + bg_names,
|
|
504
|
+
"std_errors": perr,
|
|
505
|
+
"fitted_y": fitted_y,
|
|
506
|
+
"individual_y": individual_y,
|
|
507
|
+
"residuals": residuals,
|
|
508
|
+
"r_squared": r2,
|
|
509
|
+
"model": model,
|
|
510
|
+
"background": background,
|
|
511
|
+
"n_peaks": n_peaks,
|
|
512
|
+
"peak_summaries": peak_summaries,
|
|
513
|
+
}
|