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.
- {funcnodes_span-0.1.2 → funcnodes_span-0.1.4}/PKG-INFO +3 -1
- {funcnodes_span-0.1.2 → funcnodes_span-0.1.4}/funcnodes_span/__init__.py +3 -2
- funcnodes_span-0.1.4/funcnodes_span/peak_analysis.py +556 -0
- {funcnodes_span-0.1.2 → funcnodes_span-0.1.4}/pyproject.toml +7 -2
- {funcnodes_span-0.1.2 → funcnodes_span-0.1.4}/README.md +0 -0
- {funcnodes_span-0.1.2 → funcnodes_span-0.1.4}/funcnodes_span/normalization.py +0 -0
- {funcnodes_span-0.1.2 → funcnodes_span-0.1.4}/funcnodes_span/smoothing.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: funcnodes-span
|
|
3
|
-
Version: 0.1.
|
|
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.
|
|
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.
|
|
23
|
+
pytest = "^8.3.2"
|
|
19
24
|
|
|
20
25
|
[build-system]
|
|
21
26
|
requires = ["poetry-core"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|