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