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.
Files changed (73) hide show
  1. eagleplot/Home.py +362 -0
  2. eagleplot/__init__.py +19 -0
  3. eagleplot/annotations.py +215 -0
  4. eagleplot/assets/logo.ico +0 -0
  5. eagleplot/assets/logo.png +0 -0
  6. eagleplot/assets/nccu-horiz-logo.png +0 -0
  7. eagleplot/assets/nccu-wings.png +0 -0
  8. eagleplot/batch.py +446 -0
  9. eagleplot/cli.py +719 -0
  10. eagleplot/data_tools.py +849 -0
  11. eagleplot/example-data/Artificial/bimodal_distribution.csv +501 -0
  12. eagleplot/example-data/Artificial/correlation_matrix.npy +0 -0
  13. eagleplot/example-data/Artificial/gaussian_surface.npy +0 -0
  14. eagleplot/example-data/Artificial/grouped_categorical.csv +241 -0
  15. eagleplot/example-data/Artificial/ir_spectrum.csv +1802 -0
  16. eagleplot/example-data/Artificial/molecular_properties.csv +121 -0
  17. eagleplot/example-data/Artificial/normal_distribution.csv +401 -0
  18. eagleplot/example-data/Artificial/reaction_kinetics.csv +121 -0
  19. eagleplot/example-data/Artificial/scatter_correlation.csv +201 -0
  20. eagleplot/example-data/Artificial/sine_cosine_waves.csv +301 -0
  21. eagleplot/gallery/box_plot.png +0 -0
  22. eagleplot/gallery/contour_plot.png +0 -0
  23. eagleplot/gallery/distribution_plot.png +0 -0
  24. eagleplot/gallery/heatmap.png +0 -0
  25. eagleplot/gallery/histogram.png +0 -0
  26. eagleplot/gallery/interactive_3d_surface.png +0 -0
  27. eagleplot/gallery/interactive_heatmap.png +0 -0
  28. eagleplot/gallery/interactive_histogram.png +0 -0
  29. eagleplot/gallery/interactive_line.png +0 -0
  30. eagleplot/gallery/interactive_scatter.png +0 -0
  31. eagleplot/gallery/line_plot.png +0 -0
  32. eagleplot/gallery/manifest.json +196 -0
  33. eagleplot/gallery/regression_plot.png +0 -0
  34. eagleplot/gallery/scatter_plot.png +0 -0
  35. eagleplot/io.py +1008 -0
  36. eagleplot/math.py +1210 -0
  37. eagleplot/molecular/__init__.py +21 -0
  38. eagleplot/molecular/atom_data.py +287 -0
  39. eagleplot/molecular/parsers.py +747 -0
  40. eagleplot/nist.py +241 -0
  41. eagleplot/pages/10_Molecular_Viz.py +225 -0
  42. eagleplot/pages/11_Batch_Analysis.py +325 -0
  43. eagleplot/pages/12_Help.py +199 -0
  44. eagleplot/pages/13_Settings.py +388 -0
  45. eagleplot/pages/1_Data_Upload.py +521 -0
  46. eagleplot/pages/2_Quick_Plot.py +1693 -0
  47. eagleplot/pages/3_Analysis_Tools.py +2510 -0
  48. eagleplot/pages/4_Multi_Plot_Dashboard.py +791 -0
  49. eagleplot/pages/5_Advanced_Plotting.py +691 -0
  50. eagleplot/pages/6_Export_Results.py +442 -0
  51. eagleplot/pages/7_Gallery.py +201 -0
  52. eagleplot/pages/8_Data_Tools.py +434 -0
  53. eagleplot/pages/9_Spectroscopy.py +821 -0
  54. eagleplot/pages/__init__.py +8 -0
  55. eagleplot/peaks.py +513 -0
  56. eagleplot/plotting.py +2630 -0
  57. eagleplot/plugin_loader.py +208 -0
  58. eagleplot/report.py +448 -0
  59. eagleplot/signal.py +494 -0
  60. eagleplot/spectroscopy.py +830 -0
  61. eagleplot/utils/__init__.py +76 -0
  62. eagleplot/utils/access.py +77 -0
  63. eagleplot/utils/data_preview.py +352 -0
  64. eagleplot/utils/plot_config.py +2721 -0
  65. eagleplot/utils/session_state.py +535 -0
  66. eagleplot/utils/theming.py +312 -0
  67. eagleplot/utils/user_settings.py +294 -0
  68. eagleplot-3.0.0.dist-info/METADATA +304 -0
  69. eagleplot-3.0.0.dist-info/RECORD +73 -0
  70. eagleplot-3.0.0.dist-info/WHEEL +5 -0
  71. eagleplot-3.0.0.dist-info/entry_points.txt +2 -0
  72. eagleplot-3.0.0.dist-info/licenses/LICENSE +21 -0
  73. eagleplot-3.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,8 @@
1
+ """Multi-page Streamlit app pages.
2
+
3
+ Streamlit automatically discovers pages in this directory.
4
+ Each page file should be named with a number prefix for ordering:
5
+ - 1_Data_Upload.py
6
+ - 2_Quick_Plot.py
7
+ - etc.
8
+ """
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
+ }