funcnodes-span 0.2.1__tar.gz → 0.2.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.2.1
3
+ Version: 0.2.3
4
4
  Summary:
5
5
  Home-page: https://linkdlab.de/
6
6
  Author: Kourosh Rezaei
@@ -5,7 +5,7 @@ from .smoothing import SMOOTH_NODE_SHELF as SMOOTH
5
5
  from .peak_analysis import PEAKS_NODE_SHELF as PEAK
6
6
  from .baseline import BASELINE_NODE_SHELF as BASELINE
7
7
 
8
- __version__ = "0.2.1"
8
+ __version__ = "0.2.3"
9
9
 
10
10
  NODE_SHELF = fn.Shelf(
11
11
  name="Spectral Analysis",
@@ -0,0 +1,161 @@
1
+ from typing import Literal, Tuple
2
+ from funcnodes import NodeDecorator, Shelf
3
+ import numpy as np
4
+ from scipy.interpolate import interp1d
5
+ from exposedfunctionality import controlled_wrapper
6
+ import funcnodes as fn
7
+
8
+
9
+ class NormMode(fn.DataEnum):
10
+ ZERO_ONE = "zero_one"
11
+ MINUS_ONE_ONE = "minus_one_one"
12
+ SUM_ABS = "sum_abs"
13
+ SUM = "sum"
14
+ EUCLIDEAN = "euclidean"
15
+ MEAN_STD = "mean_std"
16
+ MAX = "max"
17
+
18
+
19
+ @NodeDecorator(id="span.basics.norm", name="Normalization node")
20
+ def _norm(array: np.ndarray, mode: NormMode = NormMode.ZERO_ONE) -> np.ndarray:
21
+ # """
22
+ # Apply different normalizations to the array.
23
+
24
+ # Args:
25
+ # array (np.ndarray): The input array to be normalized.
26
+ # mode (NormMode): The normalization mode to apply. Defaults to NormMode.ZERO_ONE.
27
+
28
+ # Returns:
29
+ # np.ndarray: The normalized array.
30
+
31
+ # Raises:
32
+ # ValueError: If an unsupported normalization mode is provided.
33
+ # """
34
+ mode = NormMode.v(mode)
35
+ normalization_methods = {
36
+ NormMode.ZERO_ONE.value: lambda x: (x - np.amin(x)) / (np.amax(x) - np.amin(x)),
37
+ NormMode.MINUS_ONE_ONE.value: lambda x: 2
38
+ * ((x - np.amin(x)) / (np.amax(x) - np.amin(x)))
39
+ - 1,
40
+ NormMode.SUM_ABS.value: lambda x: x / np.abs(x).sum(),
41
+ NormMode.SUM.value: lambda x: x / x.sum(),
42
+ NormMode.EUCLIDEAN.value: lambda x: x / np.sqrt((x**2).sum()),
43
+ NormMode.MEAN_STD.value: lambda x: (x - x.mean()) / x.std(),
44
+ NormMode.MAX.value: lambda x: x / x.max(),
45
+ }
46
+ if mode not in normalization_methods.keys():
47
+ raise ValueError(f"Unsupported normalization mode: {mode}")
48
+ return normalization_methods[mode](array)
49
+
50
+
51
+ def density_normalization(
52
+ x: np.ndarray,
53
+ y: np.ndarray,
54
+ num_points: int = None,
55
+ distance_estimation: Literal["median", "mean", "min", "max"] = "median",
56
+ ) -> Tuple[np.ndarray, np.ndarray]:
57
+ """
58
+ Redistributes the x and y coordinates to have evenly spaced x-values while retaining original points.
59
+
60
+ Parameters:
61
+ x (np.ndarray): Uneven, unsorted x coordinates.
62
+ y (np.ndarray): Corresponding y values.
63
+ num_points (int, optional): Total number of points in the final grid.
64
+ Must be greater than or equal to the number of unique original x values.
65
+ If None, it is estimated based on the specified distance estimation method.
66
+ distance_estimation (Literal["median", "mean", "min", "max"], optional):
67
+ Method to estimate the spacing between points when num_points is not provided.
68
+ Options include "median", "mean", "min", "max". Defaults to "median".
69
+
70
+ Returns:
71
+ Tuple[np.ndarray, np.ndarray]:
72
+ - x_new: Evenly spaced x coordinates including original x points.
73
+ - y_new: Corresponding interpolated y values."""
74
+ # Convert inputs to numpy arrays
75
+ x = np.array(x)
76
+ y = np.array(y)
77
+
78
+ # if x is already evenly spaced, return the input
79
+ if len(np.unique(np.diff(x))) == 1:
80
+ return x, y
81
+
82
+ # Sort the data based on x values
83
+ sorted_indices = np.argsort(x)
84
+ x_sorted = x[sorted_indices]
85
+ y_sorted = y[sorted_indices]
86
+
87
+ # Handle duplicate x-values by averaging their y-values
88
+ unique_x, indices, counts = np.unique(
89
+ x_sorted, return_index=True, return_counts=True
90
+ )
91
+ y_unique = np.array([y_sorted[i : i + c].mean() for i, c in zip(indices, counts)])
92
+
93
+ # Determine number of points for the evenly spaced grid
94
+
95
+ if num_points is None:
96
+ distance_func = {
97
+ "mean": np.mean,
98
+ "median": np.median,
99
+ "min": np.min,
100
+ "max": np.max,
101
+ }.get(distance_estimation, np.median) # Default to median if key not found
102
+
103
+ unique_diffs = np.diff(unique_x)
104
+
105
+ # Handle the case where there is only one unique_x point
106
+ if len(unique_diffs) == 0:
107
+ num_points = 1
108
+ else:
109
+ estimated_diff = distance_func(unique_diffs)
110
+ total_range = unique_x.max() - unique_x.min()
111
+
112
+ # Prevent division by zero
113
+ if estimated_diff == 0:
114
+ num_points = len(unique_x)
115
+ else:
116
+ num_points = int(total_range / estimated_diff)
117
+ else:
118
+ num_points = int(num_points)
119
+ # Create evenly spaced x-values
120
+ x_new = np.linspace(unique_x.min(), unique_x.max(), num_points)
121
+
122
+ # Create an interpolation function
123
+ interp_func = interp1d(unique_x, y_unique, kind="linear", fill_value="extrapolate")
124
+
125
+ # Compute the interpolated y-values
126
+ y_new = interp_func(x_new)
127
+
128
+ return x_new, y_new
129
+
130
+
131
+ @NodeDecorator(
132
+ id="span.norm.density",
133
+ name="Density normalization node",
134
+ description="Redistributes the x and y coordinates to have evenly spaced x-values while retaining original points.",
135
+ outputs=[
136
+ {
137
+ "name": "x_new",
138
+ "description": "Evenly spaced x coordinates including original x points.",
139
+ },
140
+ {
141
+ "name": "y_new",
142
+ "description": "Corresponding interpolated y values.",
143
+ },
144
+ ],
145
+ )
146
+ @controlled_wrapper(density_normalization, wrapper_attribute="__fnwrapped__")
147
+ def density_normalization_node(
148
+ x: np.ndarray,
149
+ y: np.ndarray,
150
+ num_points: int = None,
151
+ distance_estimation: Literal["median", "mean", "min", "max"] = "median",
152
+ ) -> Tuple[np.ndarray, np.ndarray]:
153
+ return density_normalization(x, y, num_points, distance_estimation)
154
+
155
+
156
+ NORM_NODE_SHELF = Shelf(
157
+ nodes=[_norm, density_normalization_node],
158
+ subshelves=[],
159
+ name="Normalization",
160
+ description="Normalization of the spectra",
161
+ )
@@ -2,7 +2,7 @@ from funcnodes import NodeDecorator, Shelf
2
2
  import funcnodes as fn
3
3
  import numpy as np
4
4
  from exposedfunctionality import controlled_wrapper
5
- from typing import Optional, List, Tuple, Dict
5
+ from typing import Optional, List, Tuple, Dict, Union
6
6
  from scipy.signal import find_peaks
7
7
  from scipy.stats import norm
8
8
  from scipy import signal, interpolate
@@ -13,6 +13,7 @@ import plotly.graph_objs as go
13
13
  from plotly.subplots import make_subplots
14
14
  import re
15
15
  from dataclasses import dataclass
16
+ from .normalization import density_normalization
16
17
 
17
18
 
18
19
  @dataclass
@@ -21,6 +22,8 @@ class FittinInfo:
21
22
  best_values: dict
22
23
  data: np.ndarray
23
24
  userkws: dict
25
+ best_fit: np.ndarray
26
+ rsquared: float
24
27
 
25
28
 
26
29
  @dataclass
@@ -197,8 +200,8 @@ def compute_peak_properties(
197
200
  @NodeDecorator(id="span.basics.peaks", name="Peak finder")
198
201
  @controlled_wrapper(find_peaks, wrapper_attribute="__fnwrapped__")
199
202
  def peak_finder(
200
- x_array: np.ndarray,
201
- y_array: np.ndarray,
203
+ x: np.ndarray,
204
+ y: np.ndarray,
202
205
  noise_level: Optional[int] = None,
203
206
  height: Optional[float] = None,
204
207
  threshold: Optional[float] = None,
@@ -210,23 +213,46 @@ def peak_finder(
210
213
  plateau_size: Optional[int] = None,
211
214
  ) -> List[PeakProperties]:
212
215
  peak_lst = []
213
- x_array = np.array(x_array, dtype=float)
214
- y_array = np.array(y_array, dtype=float)
216
+ x = np.array(x, dtype=float)
217
+ y = np.array(y, dtype=float)
215
218
  noise_level = int(noise_level) if noise_level is not None else None
216
219
  height = float(height) if height is not None else None
217
220
  threshold = float(threshold) if threshold is not None else None
218
221
  distance = float(distance) if distance is not None else None
219
222
  prominence = float(prominence) if prominence is not None else None
220
223
  width = float(width) if width is not None else None
221
- wlen = int(wlen) if wlen is not None else None
224
+ wlen = float(wlen) if wlen is not None else None
222
225
  rel_height = float(rel_height)
223
- plateau_size = int(plateau_size) if plateau_size is not None else None
226
+ plateau_size = float(plateau_size) if plateau_size is not None else None
224
227
 
225
- height = 0.05 * np.max(y_array) if height is None else height
228
+ height = 0.05 * np.max(y) if height is None else height
226
229
  noise_level = 5000 if noise_level is None else noise_level
227
230
 
231
+ if x is not None:
232
+ x, y = density_normalization(
233
+ x,
234
+ y,
235
+ )
236
+
237
+ xdiff = x[1] - x[0]
238
+ # if x is given width is based on the x scale and has to be converted to index
239
+ if width is not None:
240
+ width = width / xdiff
241
+
242
+ # same for distance
243
+ if distance is not None:
244
+ distance = distance / xdiff
245
+
246
+ # same for wlen
247
+ if wlen is not None:
248
+ wlen = wlen / xdiff
249
+
250
+ # same for plateau_size
251
+ if plateau_size is not None:
252
+ plateau_size = plateau_size / xdiff
253
+
228
254
  # Make a copy of the input array
229
- y_array_copy = np.copy(y_array)
255
+ y_array_copy = np.copy(y)
230
256
 
231
257
  # Find the peaks in the copy of the input array
232
258
  peaks, _ = find_peaks(
@@ -235,8 +261,8 @@ def peak_finder(
235
261
  prominence=prominence,
236
262
  height=height,
237
263
  distance=distance,
238
- width=width,
239
- wlen=wlen,
264
+ width=max(1, width) if width is not None else None,
265
+ wlen=int(wlen) if wlen is not None else None,
240
266
  rel_height=rel_height,
241
267
  plateau_size=plateau_size,
242
268
  )
@@ -263,7 +289,7 @@ def peak_finder(
263
289
  # Find the right minimum of the peak
264
290
  right_min = mins[np.argmax(mins > peak)]
265
291
  if right_min < peak:
266
- right_min = len(y_array) - 1
292
+ right_min = len(y) - 1
267
293
 
268
294
  try:
269
295
  # Find the left minimum of the peak
@@ -289,7 +315,7 @@ def peak_finder(
289
315
  for peak in peaks:
290
316
  right_min = mins[np.argmax(mins > peak)]
291
317
  if right_min < peak:
292
- right_min = len(y_array) - 1
318
+ right_min = len(y) - 1
293
319
  try:
294
320
  left_min = np.array(mins)[np.where(np.array(mins) < peak)][-1]
295
321
  except IndexError:
@@ -307,7 +333,7 @@ def peak_finder(
307
333
 
308
334
  for peak_nr, peak in enumerate(peak_lst):
309
335
  peak_properties = compute_peak_properties(
310
- x_array=x_array, y_array=y_array, peak_indices=peak, peak_nr=peak_nr
336
+ x_array=x, y_array=y, peak_indices=peak, peak_nr=peak_nr
311
337
  )
312
338
  peak_properties_list.append(peak_properties)
313
339
 
@@ -382,8 +408,8 @@ class BaselineModel(fn.DataEnum):
382
408
 
383
409
  @NodeDecorator(id="span.basics.fit", name="Fit 1D", separate_process=True)
384
410
  def fit_1D(
385
- x_array: np.ndarray,
386
- y_array: np.ndarray,
411
+ x: np.ndarray,
412
+ y: np.ndarray,
387
413
  basic_peaks: List[PeakProperties],
388
414
  main_model: FittingModel = FittingModel.Gaussian,
389
415
  baseline_model: BaselineModel = BaselineModel.Exponential,
@@ -408,15 +434,14 @@ def fit_1D(
408
434
 
409
435
  # """
410
436
 
411
- x_array = np.array(x_array)
412
- y_array = np.array(y_array)
437
+ x = np.array(x)
438
+ y = np.array(y)
413
439
 
414
440
  main_model = FittingModel.v(main_model)
415
441
 
416
442
  baseline_model = BaselineModel.v(baseline_model)
417
443
  peaks = copy.deepcopy(basic_peaks)
418
- y = y_array
419
- x = x_array
444
+
420
445
  # if is_sturated:
421
446
 
422
447
  lowest_index = min(dictionary.i_index for dictionary in peaks)
@@ -486,18 +511,20 @@ def fit_1D(
486
511
  best_values=out.best_values,
487
512
  data=out.data,
488
513
  userkws=out.userkws,
514
+ best_fit=out.best_fit,
515
+ rsquared=out.rsquared,
489
516
  )
490
517
 
491
518
  peak_properties_list = []
492
519
 
493
520
  for key in com.keys():
494
521
  if key != "baseline":
495
- y_array = com[key]
496
- peak_lst = [(0, np.argmax(y_array), len(y_array) - 1)]
522
+ y = com[key]
523
+ peak_lst = [(0, np.argmax(y), len(y) - 1)]
497
524
  for peak_nr, peak in enumerate(peak_lst):
498
525
  peak_properties = compute_peak_properties(
499
- x_array=x_array,
500
- y_array=y_array,
526
+ x_array=x,
527
+ y_array=y,
501
528
  peak_indices=peak,
502
529
  peak_nr=peak_nr,
503
530
  is_fitted=True,
@@ -516,7 +543,7 @@ def fit_1D(
516
543
  def force_peak_finder(
517
544
  x: np.array,
518
545
  y: np.array,
519
- basic_peaks: List[PeakProperties],
546
+ basic_peaks: Union[List[PeakProperties], PeakProperties],
520
547
  ) -> List[PeakProperties]:
521
548
  # """
522
549
  # Identify and return the two peaks around the main peak in the given peaks dictionary.
@@ -531,10 +558,14 @@ def force_peak_finder(
531
558
  # Returns:
532
559
  # - dict: A dictionary containing information about the two identified peaks.
533
560
  # """
534
- if len(basic_peaks) != 1:
535
- raise ValueError("This method accepts one and only one main peak as an input.")
561
+ if isinstance(basic_peaks, (list, np.ndarray, tuple)):
562
+ if len(basic_peaks) != 1:
563
+ raise ValueError(
564
+ "This method accepts one and only one main peak as an input."
565
+ )
566
+ basic_peaks = basic_peaks[0]
536
567
 
537
- peaks = copy.deepcopy(basic_peaks[0])
568
+ peaks = copy.deepcopy(basic_peaks)
538
569
  main_peak_i_index = peaks.i_index
539
570
  main_peak_r_index = peaks.index
540
571
  main_peak_f_index = peaks.f_index
@@ -566,7 +597,7 @@ def force_peak_finder(
566
597
  ): # seond peak is in the leftside of the max peak #TODO: fix this
567
598
  common_point = max([num for num in max_pp if num < main_peak_r_index])
568
599
 
569
- print("Left convoluted")
600
+ # print("Left convoluted")
570
601
  peak1 = {
571
602
  "I.Index": main_peak_i_index,
572
603
  "R.Index": max(
@@ -581,7 +612,7 @@ def force_peak_finder(
581
612
  }
582
613
  else:
583
614
  common_point = next((x for x in max_pp if x > main_peak_r_index), None)
584
- print("Right convoluted")
615
+ # print("Right convoluted")
585
616
  peak1 = {
586
617
  "I.Index": main_peak_i_index,
587
618
  "R.Index": main_peak_r_index,
@@ -615,13 +646,11 @@ def force_peak_finder(
615
646
  default_render_options={"data": {"src": "figure"}},
616
647
  outputs=[{"name": "figure"}],
617
648
  )
618
- def plot_peaks(
619
- x_array: np.array, y_array: np.array, peaks_dict: List[PeakProperties]
620
- ) -> go.Figure:
649
+ def plot_peaks(x: np.array, y: np.array, peaks_dict: List[PeakProperties]) -> go.Figure:
621
650
  fig = go.Figure()
622
651
 
623
652
  # Set up line plot
624
- plot_trace = {"x": x_array, "y": y_array, "mode": "lines", "name": "data"}
653
+ plot_trace = {"x": x, "y": y, "mode": "lines", "name": "data"}
625
654
 
626
655
  fig.add_trace(go.Scatter(**plot_trace))
627
656
 
@@ -633,16 +662,16 @@ def plot_peaks(
633
662
  initial_idx = peak.i_index
634
663
  ending_idx = peak.f_index
635
664
  peak_height = peak.y_at_index
636
- plot_y_min = min(y_array[initial_idx], y_array[ending_idx])
665
+ plot_y_min = min(y[initial_idx], y[ending_idx])
637
666
 
638
667
  # Create a scatter trace that simulates a rectangle
639
668
  fig.add_trace(
640
669
  go.Scatter(
641
670
  x=[
642
- x_array[initial_idx],
643
- x_array[ending_idx],
644
- x_array[ending_idx],
645
- x_array[initial_idx],
671
+ x[initial_idx],
672
+ x[ending_idx],
673
+ x[ending_idx],
674
+ x[initial_idx],
646
675
  ],
647
676
  y=[plot_y_min, plot_y_min, peak_height, peak_height],
648
677
  fill="toself",
@@ -689,10 +718,10 @@ def plot_fitted_peaks(peaks: List[PeakProperties]) -> go.Figure:
689
718
  if not peak._is_fitted:
690
719
  raise ValueError("No fitting information is available.")
691
720
 
692
- x = peak.fitting_info["userkws"]["x"]
721
+ x = peak.fitting_info.userkws["x"]
693
722
  # Extract data from peaks
694
- y = peak.fitting_info["data"]
695
- best_fit = peak.fitting_info["best_fit"]
723
+ y = peak.fitting_info.data
724
+ best_fit = peak.fitting_info.best_fit
696
725
 
697
726
  # Create a subplot with 1 row, 1 column, and a secondary y-axis
698
727
  fig = make_subplots(specs=[[{"secondary_y": True}]])
@@ -741,8 +770,8 @@ def plot_fitted_peaks(peaks: List[PeakProperties]) -> go.Figure:
741
770
  fig.update_yaxes(title_text="Baseline corrected", secondary_y=True)
742
771
  fig.update_layout(
743
772
  title={
744
- "text": f"{peak.fitting_info['model_name']} model with fitting "
745
- f"score = {np.round(peak.fitting_info['rsquared'], 4)}",
773
+ "text": f"{peak.fitting_info.model_name} model with fitting "
774
+ f"score = {np.round(peak.fitting_info.rsquared, 4)}",
746
775
  "x": 0.5, # Center the title
747
776
  "xanchor": "center",
748
777
  },
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "funcnodes-span"
3
- version = "0.2.1"
3
+ version = "0.2.3"
4
4
  description = ""
5
5
  authors = ["Kourosh Rezaei <kouroshrezaei90@gmail.com>"]
6
6
  readme = "README.md"
@@ -1,54 +0,0 @@
1
- from funcnodes import NodeDecorator, Shelf
2
- import numpy as np
3
-
4
- import funcnodes as fn
5
-
6
-
7
- class NormMode(fn.DataEnum):
8
- ZERO_ONE = "zero_one"
9
- MINUS_ONE_ONE = "minus_one_one"
10
- SUM_ABS = "sum_abs"
11
- SUM = "sum"
12
- EUCLIDEAN = "euclidean"
13
- MEAN_STD = "mean_std"
14
- MAX = "max"
15
-
16
-
17
- @NodeDecorator(id="span.basics.norm", name="Normalization node")
18
- def _norm(array: np.ndarray, mode: NormMode = NormMode.ZERO_ONE) -> np.ndarray:
19
- # """
20
- # Apply different normalizations to the array.
21
-
22
- # Args:
23
- # array (np.ndarray): The input array to be normalized.
24
- # mode (NormMode): The normalization mode to apply. Defaults to NormMode.ZERO_ONE.
25
-
26
- # Returns:
27
- # np.ndarray: The normalized array.
28
-
29
- # Raises:
30
- # ValueError: If an unsupported normalization mode is provided.
31
- # """
32
- mode = NormMode.v(mode)
33
- normalization_methods = {
34
- NormMode.ZERO_ONE.value: lambda x: (x - np.amin(x)) / (np.amax(x) - np.amin(x)),
35
- NormMode.MINUS_ONE_ONE.value: lambda x: 2
36
- * ((x - np.amin(x)) / (np.amax(x) - np.amin(x)))
37
- - 1,
38
- NormMode.SUM_ABS.value: lambda x: x / np.abs(x).sum(),
39
- NormMode.SUM.value: lambda x: x / x.sum(),
40
- NormMode.EUCLIDEAN.value: lambda x: x / np.sqrt((x**2).sum()),
41
- NormMode.MEAN_STD.value: lambda x: (x - x.mean()) / x.std(),
42
- NormMode.MAX.value: lambda x: x / x.max(),
43
- }
44
- if mode not in normalization_methods.keys():
45
- raise ValueError(f"Unsupported normalization mode: {mode}")
46
- return normalization_methods[mode](array)
47
-
48
-
49
- NORM_NODE_SHELF = Shelf(
50
- nodes=[_norm],
51
- subshelves=[],
52
- name="Normalization",
53
- description="Normalization of the spectra",
54
- )
File without changes