funcnodes-span 0.1.2__tar.gz → 0.1.4__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: funcnodes-span
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary:
5
5
  Author: Kourosh Rezaei
6
6
  Author-email: kouroshrezaei90@gmail.com
@@ -11,6 +11,8 @@ Classifier: Programming Language :: Python :: 3.12
11
11
  Requires-Dist: funcnodes (>=0.2.21,<0.3.0)
12
12
  Requires-Dist: funcnodes_numpy (>=0.1.57,<0.2.0)
13
13
  Requires-Dist: funcnodes_pandas (>=0.1.9,<0.2.0)
14
+ Requires-Dist: funcnodes_plotly (>=0.1.7,<0.2.0)
15
+ Requires-Dist: lmfit (>=1.3.2,<2.0.0)
14
16
  Requires-Dist: pooch (>=1.8.2,<2.0.0)
15
17
  Requires-Dist: scipy (>=1.14.0,<2.0.0)
16
18
  Description-Content-Type: text/markdown
@@ -2,9 +2,9 @@ import funcnodes as fn
2
2
 
3
3
  from .normalization import NORM_NODE_SHELF as NORM
4
4
  from .smoothing import SMOOTH_NODE_SHELF as SMOOTH
5
+ from .peak_analysis import PEAK_NODE_SHELF as PEAK
5
6
 
6
-
7
- __version__ = "0.1.2"
7
+ __version__ = "0.1.4"
8
8
 
9
9
  NODE_SHELF = fn.Shelf(
10
10
  name="Spectral Analysis",
@@ -13,5 +13,6 @@ NODE_SHELF = fn.Shelf(
13
13
  subshelves=[
14
14
  NORM,
15
15
  SMOOTH,
16
+ PEAK
16
17
  ],
17
18
  )
@@ -0,0 +1,556 @@
1
+ from funcnodes import NodeDecorator, Shelf
2
+ import numpy as np
3
+ from enum import Enum
4
+ from exposedfunctionality import controlled_wrapper
5
+ from typing import Optional, TypedDict, List
6
+ from scipy.signal import find_peaks
7
+ from scipy.stats import norm
8
+ from scipy import interpolate
9
+ import copy
10
+ import lmfit
11
+ import plotly.graph_objs as go
12
+ from plotly.subplots import make_subplots
13
+ import re
14
+
15
+
16
+ class PeakProperties(TypedDict):
17
+ id: str
18
+ i_index: int
19
+ index: int
20
+ f_index: int
21
+ x_at_i_index: int
22
+ x_at_index: int
23
+ x_at_f_index: int
24
+ y_at_index: int
25
+ y_at_f_index: int
26
+ y_at_i_index: int
27
+ area: float
28
+ symmetricity: float
29
+ tailing: float
30
+ FWHM: float
31
+ plate_nr: float
32
+ width: float
33
+ _is_fitted: bool = False
34
+ _is_force_fitted: bool = False
35
+ fitting_data: Optional[dict] = None
36
+ fitting_info: Optional[dict] = None
37
+
38
+
39
+ def compute_peak_properties(
40
+ x_array: np.ndarray,
41
+ y_array: np.ndarray,
42
+ peak_indices: List[int],
43
+ peak_nr: int,
44
+ is_fitted: bool = False,
45
+ is_force_fitted: bool = False,
46
+ fitting_data: Optional[dict] = None,
47
+ fitting_info: Optional[dict] = None,
48
+ ) -> PeakProperties:
49
+ # """
50
+ # Compute various properties of a given peak.
51
+
52
+ # Parameters:
53
+ # - x_array: np.ndarray - The array of x-values (e.g., time or wavelength).
54
+ # - y_array: np.ndarray - The array of y-values (e.g., intensity).
55
+ # - peak_indices: List[int] - A list containing the start index, peak index, and end index of the peak.
56
+ # - peak_nr: int - The identifier number of the peak.
57
+ # - is_fitted: bool = False - A flag indicating whether the peak is fitted or not.
58
+ # - is_force_fitted: bool = False - A flag indicating whether the peak is forced fitted or not.
59
+ # - fitting_data: Optional[dict] = None - A dictionary containing the fitting data if the peak is fitted.
60
+ # - fitting_info: Optional[dict] = None - A dictionary containing the fitting information if the peak is fitted.
61
+
62
+ # Returns:
63
+ # - peak_properties: PeakProperties - A dictionary containing various properties of the peak.
64
+ # """
65
+
66
+ i_index, index, f_index = peak_indices
67
+
68
+ # Extract the relevant portion of the arrays
69
+ selected_signal = y_array[i_index:f_index]
70
+ selected_time = x_array[i_index:f_index]
71
+
72
+ # Create an interpolated time array with higher resolution
73
+ selected_time_interpol = np.linspace(
74
+ selected_time[0],
75
+ selected_time[-1],
76
+ num=len(selected_time) * 10,
77
+ endpoint=True,
78
+ )
79
+
80
+ # Interpolate the selected signal to match the interpolated time array
81
+ f_interpol = interpolate.interp1d(selected_time, selected_signal, kind="linear")
82
+ selected_signal_interpol = f_interpol(selected_time_interpol)
83
+
84
+ # Determine the amplitude and position of the peak in the interpolated signal
85
+ amplitude = np.max(selected_signal_interpol)
86
+ peak_position = np.where(selected_signal_interpol == amplitude)[0][0]
87
+
88
+ # Split the interpolated signal into left and right spectra relative to the peak
89
+ left_spectrum = selected_signal_interpol[:peak_position]
90
+ right_spectrum = selected_signal_interpol[peak_position:]
91
+
92
+ # Helper function to compute indices for a given percentage of amplitude
93
+ def compute_indices(spectrum, percentage):
94
+ try:
95
+ left_index = (np.abs(spectrum - percentage * amplitude)).argmin()
96
+ except ValueError:
97
+ left_index = 0
98
+ return left_index
99
+
100
+ # Compute FWHM (Full Width at Half Maximum)
101
+ FWHM_left_index = compute_indices(left_spectrum, 0.5)
102
+ FWHM_right_index = compute_indices(right_spectrum, 0.5) + peak_position
103
+ FWHM_a = abs(
104
+ selected_time_interpol[FWHM_left_index] - selected_time_interpol[peak_position]
105
+ )
106
+ FWHM_b = abs(
107
+ selected_time_interpol[FWHM_right_index] - selected_time_interpol[peak_position]
108
+ )
109
+ FWHM = np.around(FWHM_b / FWHM_a, 2) if FWHM_a != 0 else np.nan
110
+
111
+ # Compute Symmetricity
112
+ symmetricity_left_index = compute_indices(left_spectrum, 0.1)
113
+ symmetricity_right_index = compute_indices(right_spectrum, 0.1) + peak_position
114
+ symmetricity_a = abs(
115
+ selected_time_interpol[symmetricity_left_index]
116
+ - selected_time_interpol[peak_position]
117
+ )
118
+ symmetricity_b = abs(
119
+ selected_time_interpol[symmetricity_right_index]
120
+ - selected_time_interpol[peak_position]
121
+ )
122
+ symmetricity = (
123
+ np.around(symmetricity_b / symmetricity_a, 2) if symmetricity_a != 0 else np.nan
124
+ )
125
+
126
+ # Compute Tailing
127
+ tailing_left_index = compute_indices(left_spectrum, 0.05)
128
+ tailing_right_index = compute_indices(right_spectrum, 0.05) + peak_position
129
+ tailing_a = abs(
130
+ selected_time_interpol[tailing_left_index]
131
+ - selected_time_interpol[peak_position]
132
+ )
133
+ tailing_b = abs(
134
+ selected_time_interpol[tailing_right_index]
135
+ - selected_time_interpol[peak_position]
136
+ )
137
+ tailing = (
138
+ np.around(((tailing_a + tailing_b) / 2 * tailing_a), 2)
139
+ if tailing_a != 0
140
+ else np.nan
141
+ )
142
+
143
+ # Compute Area under the peak
144
+ area = abs(np.trapz(selected_signal_interpol, selected_time_interpol))
145
+
146
+ # Compute Plate Number
147
+ try:
148
+ plate_nr = 2 * np.pi * ((x_array[i_index] * y_array[index]) / area) ** 2
149
+ except ZeroDivisionError:
150
+ plate_nr = np.nan
151
+
152
+ # Compute Width of the peak
153
+ width = x_array[f_index] - x_array[i_index]
154
+
155
+ # Populate the PeakProperties dictionary
156
+ peak_properties: PeakProperties = {
157
+ "id": str(peak_nr + 1) + "_fitted" if is_fitted else str(peak_nr + 1),
158
+ "i_index": i_index,
159
+ "index": index,
160
+ "f_index": f_index,
161
+ "x_at_i_index": x_array[i_index],
162
+ "x_at_index": x_array[index],
163
+ "x_at_f_index": x_array[f_index],
164
+ "y_at_i_index": y_array[i_index],
165
+ "y_at_index": y_array[index],
166
+ "y_at_f_index": y_array[f_index],
167
+ "area": area,
168
+ "symmetricity": symmetricity,
169
+ "tailing": tailing,
170
+ "FWHM": FWHM,
171
+ "plate_nr": plate_nr,
172
+ "width": width,
173
+ "_is_fitted": is_fitted,
174
+ "_is_force_fitted": is_force_fitted,
175
+ "fitting_data": fitting_data,
176
+ "fitting_info": fitting_info,
177
+ }
178
+
179
+ return peak_properties
180
+
181
+
182
+ @NodeDecorator(id="span.basics.peaks", name="Peak finder node")
183
+ @controlled_wrapper(find_peaks, wrapper_attribute="__fnwrapped__")
184
+ def peak_finder(
185
+ x_array: np.ndarray,
186
+ y_array: np.ndarray,
187
+ noise_level: Optional[int] = None,
188
+ height: Optional[float] = None,
189
+ threshold: Optional[float] = None,
190
+ distance: Optional[float] = None,
191
+ prominence: Optional[float] = None,
192
+ width: Optional[float] = None,
193
+ wlen: Optional[int] = None,
194
+ rel_height: Optional[float] = None,
195
+ plateau_size: Optional[int] = None,
196
+ ) -> dict:
197
+ peak_lst = []
198
+ height = 0.05 * np.max(y_array) if height is None else height
199
+ noise_level = 5000 if noise_level is None else noise_level
200
+
201
+ # Make a copy of the input array
202
+ y_array_copy = np.copy(y_array)
203
+
204
+ # Find the peaks in the copy of the input array
205
+ peaks, _ = find_peaks(
206
+ y_array_copy,
207
+ threshold=threshold,
208
+ prominence=prominence,
209
+ height=height,
210
+ distance=distance,
211
+ width=width,
212
+ wlen=wlen,
213
+ rel_height=rel_height,
214
+ plateau_size=plateau_size,
215
+ )
216
+
217
+ # Calculate the standard deviation of peak prominences
218
+
219
+ np.random.seed(seed=1)
220
+ # Fit a normal distribution to the input array
221
+ mu, std = norm.fit(y_array_copy)
222
+ if peaks is not None:
223
+ try:
224
+ # Add noise to the input array
225
+ noise = np.random.normal(
226
+ mu / noise_level, std / noise_level, np.shape(y_array_copy)
227
+ )
228
+ y_array_copy = y_array_copy + noise
229
+
230
+ # Find the minimums in the copy of the input array
231
+ mins, _ = find_peaks(-1 * y_array_copy)
232
+
233
+ # Iterate over the peaks
234
+ for peak in peaks:
235
+ # Calculate the prominences of the peak
236
+ # Find the right minimum of the peak
237
+ right_min = mins[np.argmax(mins > peak)]
238
+ if right_min < peak:
239
+ right_min = len(y_array) - 1
240
+
241
+ try:
242
+ # Find the left minimum of the peak
243
+ left_min = np.array(mins)[np.where(np.array(mins) < peak)][-1]
244
+ except IndexError:
245
+ left_min = 0
246
+
247
+ if height is None:
248
+ # If no height is specified, append the peak bounds to the peak list
249
+ peak_lst.append([left_min, peak, right_min])
250
+
251
+ else:
252
+ # If a height is specified, append the peak bounds to the peak list
253
+ # if the peak's value is greater than the height
254
+ if y_array_copy[peak] > height:
255
+ peak_lst.append([left_min, peak, right_min])
256
+
257
+ except ValueError:
258
+ # If an error occurs when adding noise to the input array, add stronger noise and try again
259
+ noise = np.random.normal(mu / 100, std / 100, np.shape(y_array_copy))
260
+ y_array_copy = y_array_copy + noise
261
+ mins, _ = find_peaks(-1 * y_array_copy)
262
+ for peak in peaks:
263
+ right_min = mins[np.argmax(mins > peak)]
264
+ if right_min < peak:
265
+ right_min = len(y_array) - 1
266
+ try:
267
+ left_min = np.array(mins)[np.where(np.array(mins) < peak)][-1]
268
+ except IndexError:
269
+ left_min = 0
270
+ if height is None:
271
+ # If no height is specified, append the peak bounds to the peak list
272
+ peak_lst.append([left_min, peak, right_min])
273
+ else:
274
+ # If a height is specified, append the peak bounds to the peak list
275
+ # if the peak's value is greater than the height
276
+ if y_array_copy[peak] > height:
277
+ peak_lst.append([left_min, peak, right_min])
278
+
279
+ peak_properties_list = []
280
+
281
+ for peak_nr, peak in enumerate(peak_lst):
282
+ peak_properties = compute_peak_properties(
283
+ x_array=x_array, y_array=y_array, peak_indices=peak, peak_nr=peak_nr
284
+ )
285
+ peak_properties_list.append(peak_properties)
286
+
287
+ return peak_properties_list
288
+
289
+
290
+ # ['Constant', 'Complex Constant', 'Linear', 'Quadratic', 'Polynomial',
291
+ # 'Spline', 'Gaussian', 'Gaussian-2D', 'Lorentzian', 'Split-Lorentzian', 'Voigt',
292
+ # 'PseudoVoigt', 'Moffat', 'Pearson4', 'Pearson7', 'StudentsT', 'Breit-Wigner', 'Log-Normal',
293
+ # 'Damped Oscillator', 'Damped Harmonic Oscillator', 'Exponential Gaussian', 'Skewed Gaussian',
294
+ # 'Skewed Voigt', 'Thermal Distribution', 'Doniach', 'Power Law', 'Exponential', 'Step',
295
+ # 'Rectangle', 'Expression']
296
+
297
+
298
+ class FittingModel(Enum):
299
+ ComplexConstant = "Complex Constant"
300
+ Linear = "Linear"
301
+ Quadratic = "Quadratic"
302
+ Polynomial = "Polynomial"
303
+ Spline = "Spline"
304
+ Gaussian = "Gaussian"
305
+ Gaussian2D = "Gaussian-2D"
306
+ Lorentzian = "Lorentzian"
307
+ SplitLorentzian = "Split-Lorentzian"
308
+ Voigt = "Voigt"
309
+ PseudoVoigt = "PseudoVoigt"
310
+ Moffat = "Moffat"
311
+ Pearson4 = "Pearson4"
312
+ Pearson7 = "Pearson7"
313
+ StudentsT = "StudentsT"
314
+ BreitWigner = "Breit-Wigner"
315
+ LogNormal = "Log-Normal"
316
+ DampedOscillator = "Damped Oscillator"
317
+ DampedHarmonicOscillator = "Damped Harmonic Oscillator"
318
+ ExponentialGaussian = "Exponential Gaussian"
319
+ SkewedGaussian = "Skewed Gaussian"
320
+ SkewedVoigt = "Skewed Voigt"
321
+ ThermalDistribution = "Thermal Distribution"
322
+ Doniach = "Doniach"
323
+ PowerLaw = "Power Law"
324
+ Exponential = "Exponential"
325
+ Step = "Step"
326
+ Rectangle = "Rectangle"
327
+ Expression = "Expression"
328
+ Constant = "Constant"
329
+
330
+ @classmethod
331
+ def default(cls):
332
+ return cls.Gaussian.value
333
+
334
+
335
+ @NodeDecorator(id="span.basics.fit", name="Fit 1D")
336
+ def fit_1D(
337
+ x_array: np.ndarray,
338
+ y_array: np.ndarray,
339
+ basic_peaks: List[PeakProperties],
340
+ model: FittingModel = FittingModel.default(),
341
+ ) -> List[PeakProperties]:
342
+ # """
343
+ # Fit a 1D model to the given data.
344
+
345
+ # Parameters:
346
+ # peaks: dict
347
+ # Dictionary containing the data and information about the peaks.
348
+ # fitting_model: Optional[str]
349
+ # The model to use for fitting. Defaults to "Gaussian".
350
+ # preview: bool
351
+ # Whether to preview the fit with a plot. Defaults to True.
352
+ # color: Optional[Tuple[int, int, int] | str]
353
+ # Color for the plot.
354
+
355
+ # Returns:
356
+ # Tuple[dict, Optional[Figure]]:
357
+ # A tuple containing a dictionary of evaluated components of the fit and additional information about the fit, and an optional figure for the plot.
358
+
359
+ # """
360
+ if isinstance(model, FittingModel):
361
+ model = model.value
362
+ peaks = copy.deepcopy(basic_peaks)
363
+ y = y_array
364
+ x = x_array
365
+ # if is_sturated:
366
+ # if len(peaks['peaks']) == 2:
367
+ # peaks['peaks'] = [{
368
+ # "Peak #": 'peak1_',
369
+ # "Index": peaks['peaks'][0]['Ending index'] + int( (peaks['peaks'][1]['Initial index'] - peaks['peaks'][0]['Ending index'])/ 2),
370
+ # "Initial index": peaks['peaks'][0]['Ending index'],
371
+ # "Ending index": peaks['peaks'][1]['Initial index'],
372
+ # "Retention": np.NaN,
373
+ # "Area": np.NaN,
374
+ # "Height": y[peaks['peaks'][0]['Ending index'] + int( (peaks['peaks'][1]['Initial index'] - peaks['peaks'][0]['Ending index'])/ 2)],
375
+ # "Symmetricity": np.NaN,
376
+ # "Tailing": np.NaN,
377
+ # "FWHM": np.NaN,
378
+ # "Plate #": np.NaN,
379
+ # "Width": x[peaks['peaks'][1]['Initial index']] - x[peaks['peaks'][0]['Ending index']],
380
+ # "is_fitted": False,
381
+ # }]
382
+ # not_saturated_x = np.concatenate((x[:peaks['peaks'][0]['Ending index']-1], x[peaks['peaks'][1]['Initial index']+1:]))
383
+ # not_saturated_y = np.concatenate((y[:peaks['peaks'][0]['Ending index']-1], y[peaks['peaks'][1]['Initial index']+1:]))
384
+ # else:
385
+ # raise ValueError('invalid number of peak selection. Either the entire or two sides of the saturated peak should be selected')
386
+
387
+ lowest_index = min(dictionary["i_index"] for dictionary in peaks)
388
+ highest_index = max(dictionary["f_index"] for dictionary in peaks)
389
+
390
+ # list of modelnames:
391
+ # lmfit.models.__dict__['lmfit_models'].keys()
392
+ # ['Constant', 'Complex Constant', 'Linear', 'Quadratic', 'Polynomial', 'Spline', 'Gaussian', 'Gaussian-2D', 'Lorentzian', 'Split-Lorentzian', 'Voigt', 'PseudoVoigt', 'Moffat', 'Pearson4', 'Pearson7', 'StudentsT', 'Breit-Wigner', 'Log-Normal', 'Damped Oscillator', 'Damped Harmonic Oscillator', 'Exponential Gaussian', 'Skewed Gaussian', 'Skewed Voigt', 'Thermal Distribution', 'Doniach', 'Power Law', 'Exponential', 'Step', 'Rectangle', 'Expression']
393
+ # peak like models are: GaussianModel, LorentzianModel, VoigtModel and their modified versions
394
+
395
+ fitting_model = lmfit.models.__dict__["lmfit_models"][model]
396
+ # bkg1 = lmfit.models.__dict__["lmfit_models"]["Spline"](prefix="baseline", xknots=np.concatenate((x[:lowest_index], x[highest_index:])))
397
+ bkg2 = lmfit.models.__dict__["lmfit_models"]["Exponential"](prefix="baseline")
398
+
399
+ f = bkg2
400
+
401
+ pars = f.guess(y, x=x)
402
+ for index, peak in enumerate(peaks):
403
+ model = fitting_model(prefix=f"peak{index+1}_")
404
+ pars.update(model.make_params())
405
+ pars[f"peak{index+1}_center"].set(
406
+ value=x[peak["index"]],
407
+ min=x[peak["i_index"]],
408
+ max=x[peak["f_index"]],
409
+ )
410
+ pars[f"peak{index+1}_sigma"].set(
411
+ value=(x[peak["f_index"]] - x[peak["i_index"]]) / 2
412
+ )
413
+ pars[f"peak{index+1}_amplitude"].set(value=y[peak["index"]], min=0)
414
+
415
+ if model == "Exponential Gaussian" or model == "Skewed Gaussian":
416
+ pars[f"peak{index+1}_gamma"].set(value=1)
417
+
418
+ f += model
419
+
420
+ out = f.fit(y, pars, x=x)
421
+
422
+ f = bkg2
423
+ pars = f.guess(y, x=x)
424
+ for index, peak in enumerate(peaks):
425
+ model = fitting_model(prefix=f"peak{index+1}_")
426
+ pars.update(model.make_params())
427
+ pars[f"peak{index+1}_center"].set(
428
+ value=out.__dict__["best_values"][f"peak{index+1}_center"],
429
+ min=x[peak["i_index"]],
430
+ max=x[peak["f_index"]],
431
+ )
432
+ pars[f"peak{index+1}_sigma"].set(
433
+ value=(x[peak["f_index"]] - x[peak["i_index"]]) / 2
434
+ )
435
+ pars[f"peak{index+1}_amplitude"].set(
436
+ value=out.__dict__["best_values"][f"peak{index+1}_amplitude"], min=0
437
+ )
438
+
439
+ if model == "Exponential Gaussian" or model == "Skewed Gaussian":
440
+ pars[f"peak{index+1}_gamma"].set(
441
+ value=out.__dict__["best_values"][f"peak{index+1}_gamma"]
442
+ )
443
+
444
+ f += model
445
+
446
+ out = f.fit(y, pars, x=x)
447
+ com = out.eval_components(x=x)
448
+ info_dict = out.__dict__
449
+ info_dict["model_name"] = model
450
+
451
+ peak_properties_list = []
452
+
453
+ for key in com.keys():
454
+ if key != "baseline":
455
+ y_array = com[key]
456
+ peak_lst = [(0, np.argmax(y_array), len(y_array) - 1)]
457
+ for peak_nr, peak in enumerate(peak_lst):
458
+ peak_properties = compute_peak_properties(
459
+ x_array=x_array,
460
+ y_array=y_array,
461
+ peak_indices=peak,
462
+ peak_nr=peak_nr,
463
+ is_fitted=True,
464
+ fitting_data=com,
465
+ fitting_info=info_dict,
466
+ )
467
+ peak_properties_list.append(peak_properties)
468
+
469
+ return peak_properties_list
470
+
471
+
472
+ # Define a mapping from "C0", "C1", etc., to CSS color names
473
+ color_map = {
474
+ "C0": "blue",
475
+ "C1": "orange",
476
+ "C2": "green",
477
+ "C3": "red",
478
+ "C4": "purple",
479
+ "C5": "brown",
480
+ "C6": "pink",
481
+ "C7": "gray",
482
+ "C8": "olive",
483
+ "C9": "cyan",
484
+ }
485
+
486
+
487
+ @NodeDecorator(id="span.basics.fit.plot", name="Plot fit 1D")
488
+ def plot_fitted_peaks(peaks: List[PeakProperties]) -> go.Figure:
489
+ peak = peaks[0]
490
+ x = peak["fitting_info"]["userkws"]["x"]
491
+ # Extract data from peaks
492
+ y = peak["fitting_info"]["data"]
493
+ best_fit = peak["fitting_info"]["best_fit"]
494
+
495
+ # Create a subplot with 1 row, 1 column, and a secondary y-axis
496
+ fig = make_subplots(specs=[[{"secondary_y": True}]])
497
+
498
+ # Add the original data trace
499
+ fig.add_trace(
500
+ go.Scatter(
501
+ x=x, y=y, mode="lines", name="original", line=dict(color=color_map["C0"])
502
+ ),
503
+ secondary_y=False,
504
+ )
505
+
506
+ # Add the best fit trace
507
+ fig.add_trace(
508
+ go.Scatter(
509
+ x=x,
510
+ y=best_fit,
511
+ mode="lines",
512
+ name="best_fit",
513
+ line=dict(dash="dash", color=color_map["C1"]),
514
+ ),
515
+ secondary_y=False,
516
+ )
517
+
518
+ # Add the baseline and individual peak traces
519
+ for key in peak["fitting_data"].keys():
520
+ if key == "baseline":
521
+ color = color_map["C2"]
522
+ else:
523
+ peak_number = int(re.search(r"\d+", key).group())
524
+ color = color_map.get(
525
+ f"C{peak_number + 2}", "black"
526
+ ) # Default to black if not found
527
+
528
+ trace = go.Scatter(
529
+ x=x,
530
+ y=peak["fitting_data"][key],
531
+ mode="lines",
532
+ name=key,
533
+ line=dict(color=color),
534
+ )
535
+ fig.add_trace(trace, secondary_y=(key != "baseline"))
536
+
537
+ # Update axes labels and legend
538
+ fig.update_yaxes(title_text="Original", secondary_y=False)
539
+ fig.update_yaxes(title_text="Baseline corrected", secondary_y=True)
540
+ fig.update_layout(
541
+ title={
542
+ "text": f"{peak['fitting_info']['model_name']} model with fitting score = {np.round(peak['fitting_info']['rsquared'], 4)}",
543
+ "x": 0.5, # Center the title
544
+ "xanchor": "center",
545
+ },
546
+ )
547
+
548
+ return fig
549
+
550
+
551
+ PEAKS_NODE_SHELF = Shelf(
552
+ nodes=[peak_finder, fit_1D, plot_fitted_peaks],
553
+ subshelves=[],
554
+ name="Peak analysis",
555
+ description="Tools for the peak analysis of the spectra",
556
+ )
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "funcnodes-span"
3
- version = "0.1.2"
3
+ version = "0.1.4"
4
4
  description = ""
5
5
  authors = ["Kourosh Rezaei <kouroshrezaei90@gmail.com>"]
6
6
  readme = "README.md"
@@ -8,14 +8,19 @@ readme = "README.md"
8
8
  [tool.poetry.dependencies]
9
9
  python = "^3.11"
10
10
  scipy = "^1.14.0"
11
+
12
+ lmfit = "^1.3.2"
11
13
  funcnodes = "^0.2.21"
12
14
  funcnodes_numpy = "^0.1.57"
13
15
  funcnodes_pandas = "^0.1.9"
16
+
17
+ funcnodes_plotly = "^0.1.7"
18
+
14
19
  pooch = "^1.8.2"
15
20
 
16
21
 
17
22
  [tool.poetry.group.dev.dependencies]
18
- pytest = "^8.2.2"
23
+ pytest = "^8.3.2"
19
24
 
20
25
  [build-system]
21
26
  requires = ["poetry-core"]
File without changes