FlowCyPy 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- FlowCyPy/__init__.py +13 -0
- FlowCyPy/_version.py +16 -0
- FlowCyPy/acquisition.py +652 -0
- FlowCyPy/classifier.py +208 -0
- FlowCyPy/coupling_mechanism/__init__.py +4 -0
- FlowCyPy/coupling_mechanism/empirical.py +47 -0
- FlowCyPy/coupling_mechanism/mie.py +207 -0
- FlowCyPy/coupling_mechanism/rayleigh.py +116 -0
- FlowCyPy/coupling_mechanism/uniform.py +40 -0
- FlowCyPy/coupling_mechanism.py +205 -0
- FlowCyPy/cytometer.py +314 -0
- FlowCyPy/detector.py +439 -0
- FlowCyPy/directories.py +36 -0
- FlowCyPy/distribution/__init__.py +16 -0
- FlowCyPy/distribution/base_class.py +79 -0
- FlowCyPy/distribution/delta.py +104 -0
- FlowCyPy/distribution/lognormal.py +124 -0
- FlowCyPy/distribution/normal.py +128 -0
- FlowCyPy/distribution/particle_size_distribution.py +132 -0
- FlowCyPy/distribution/uniform.py +117 -0
- FlowCyPy/distribution/weibull.py +115 -0
- FlowCyPy/flow_cell.py +198 -0
- FlowCyPy/helper.py +81 -0
- FlowCyPy/logger.py +136 -0
- FlowCyPy/noises.py +34 -0
- FlowCyPy/particle_count.py +127 -0
- FlowCyPy/peak_locator/__init__.py +4 -0
- FlowCyPy/peak_locator/base_class.py +163 -0
- FlowCyPy/peak_locator/basic.py +108 -0
- FlowCyPy/peak_locator/derivative.py +143 -0
- FlowCyPy/peak_locator/moving_average.py +166 -0
- FlowCyPy/physical_constant.py +19 -0
- FlowCyPy/plottings.py +269 -0
- FlowCyPy/population.py +136 -0
- FlowCyPy/populations_instances.py +65 -0
- FlowCyPy/scatterer_collection.py +306 -0
- FlowCyPy/signal_digitizer.py +90 -0
- FlowCyPy/source.py +249 -0
- FlowCyPy/units.py +30 -0
- FlowCyPy/utils.py +191 -0
- FlowCyPy-0.7.0.dist-info/LICENSE +21 -0
- FlowCyPy-0.7.0.dist-info/METADATA +252 -0
- FlowCyPy-0.7.0.dist-info/RECORD +45 -0
- FlowCyPy-0.7.0.dist-info/WHEEL +5 -0
- FlowCyPy-0.7.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pandas as pd
|
|
3
|
+
from scipy.integrate import cumulative_trapezoid
|
|
4
|
+
import matplotlib.pyplot as plt
|
|
5
|
+
import pint_pandas
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from FlowCyPy.units import Quantity
|
|
8
|
+
from scipy.signal import peak_widths
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BasePeakLocator(ABC):
|
|
12
|
+
"""
|
|
13
|
+
A base class to handle common functionality for peak detection,
|
|
14
|
+
including area calculation under peaks.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
peak_properties: pd.DataFrame = None
|
|
18
|
+
|
|
19
|
+
def init_data(self, dataframe: pd.DataFrame) -> None:
|
|
20
|
+
"""
|
|
21
|
+
Initialize signal and time data for peak detection.
|
|
22
|
+
|
|
23
|
+
Parameters
|
|
24
|
+
----------
|
|
25
|
+
dataframe : pd.DataFrame
|
|
26
|
+
A DataFrame containing the signal data with columns 'Signal' and 'Time'.
|
|
27
|
+
|
|
28
|
+
Raises
|
|
29
|
+
------
|
|
30
|
+
ValueError
|
|
31
|
+
If the DataFrame is missing required columns or is empty.
|
|
32
|
+
"""
|
|
33
|
+
if not {'Signal', 'Time'}.issubset(dataframe.columns):
|
|
34
|
+
raise ValueError("The DataFrame must contain 'Signal' and 'Time' columns.")
|
|
35
|
+
|
|
36
|
+
if dataframe.empty:
|
|
37
|
+
raise ValueError("The DataFrame is empty. Please provide valid signal data.")
|
|
38
|
+
|
|
39
|
+
self.data = pd.DataFrame(index=np.arange(len(dataframe)))
|
|
40
|
+
self.data['Signal'] = dataframe['Signal']
|
|
41
|
+
self.data['Time'] = dataframe['Time']
|
|
42
|
+
|
|
43
|
+
self.dt = self.data.Time[1] - self.data.Time[0]
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def _compute_algorithm_specific_features(self) -> None:
|
|
47
|
+
"""
|
|
48
|
+
Abstract method for computing features specific to the detection algorithm.
|
|
49
|
+
This must be implemented by subclasses.
|
|
50
|
+
"""
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
def detect_peaks(self, compute_area: bool = True) -> pd.DataFrame:
|
|
54
|
+
"""
|
|
55
|
+
Detect peaks and compute peak properties.
|
|
56
|
+
|
|
57
|
+
Parameters
|
|
58
|
+
----------
|
|
59
|
+
compute_area : bool, optional
|
|
60
|
+
If True, computes the area under each peak (default is True).
|
|
61
|
+
|
|
62
|
+
Returns
|
|
63
|
+
-------
|
|
64
|
+
pd.DataFrame
|
|
65
|
+
A DataFrame containing peak properties (times, heights, widths, etc.).
|
|
66
|
+
"""
|
|
67
|
+
peak_indices, widths_samples, width_heights, left_ips, right_ips = self._compute_algorithm_specific_features()
|
|
68
|
+
|
|
69
|
+
peak_times = self.data['Time'].values[peak_indices]
|
|
70
|
+
heights = self.data['Signal'].values[peak_indices]
|
|
71
|
+
widths = widths_samples * self.dt
|
|
72
|
+
|
|
73
|
+
# Store results in `peak_properties`
|
|
74
|
+
self.peak_properties = pd.DataFrame({
|
|
75
|
+
'PeakTimes': peak_times,
|
|
76
|
+
'Heights': heights,
|
|
77
|
+
'Widths': pint_pandas.PintArray(widths, dtype=widths.units),
|
|
78
|
+
'WidthHeights': pint_pandas.PintArray(width_heights, dtype=heights.units),
|
|
79
|
+
'LeftIPs': pint_pandas.PintArray(left_ips * self.dt, dtype=self.dt.units),
|
|
80
|
+
'RightIPs': pint_pandas.PintArray(right_ips * self.dt, dtype=self.dt.units),
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
# Compute areas if needed
|
|
84
|
+
if compute_area:
|
|
85
|
+
self._compute_peak_areas()
|
|
86
|
+
|
|
87
|
+
return self.peak_properties
|
|
88
|
+
|
|
89
|
+
def _compute_peak_areas(self) -> None:
|
|
90
|
+
"""
|
|
91
|
+
Computes the areas under the detected peaks using cumulative integration.
|
|
92
|
+
|
|
93
|
+
The cumulative integral of the signal is interpolated at the left and right
|
|
94
|
+
interpolated positions of each peak to compute the enclosed area.
|
|
95
|
+
|
|
96
|
+
Adds an 'Areas' column to `self.peak_properties`.
|
|
97
|
+
|
|
98
|
+
Raises
|
|
99
|
+
------
|
|
100
|
+
RuntimeError
|
|
101
|
+
If `peak_properties` or `data` has not been initialized.
|
|
102
|
+
"""
|
|
103
|
+
if not hasattr(self, 'peak_properties') or self.peak_properties.empty:
|
|
104
|
+
raise RuntimeError("No peaks detected. Run `detect_peaks()` first.")
|
|
105
|
+
|
|
106
|
+
if not hasattr(self, 'data') or self.data.empty:
|
|
107
|
+
raise RuntimeError("Signal data is not initialized. Call `init_data()` first.")
|
|
108
|
+
|
|
109
|
+
# Compute cumulative integral of the signal
|
|
110
|
+
cumulative = cumulative_trapezoid(
|
|
111
|
+
self.data.Signal.values.quantity.magnitude,
|
|
112
|
+
x=self.data.Time.values.quantity.magnitude,
|
|
113
|
+
initial=0 # Include 0 at the start
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Interpolate cumulative integral at left and right interpolated positions
|
|
117
|
+
left_cum_integral = np.interp(
|
|
118
|
+
x=self.peak_properties.LeftIPs,
|
|
119
|
+
xp=self.data.index,
|
|
120
|
+
fp=cumulative
|
|
121
|
+
)
|
|
122
|
+
right_cum_integral = np.interp(
|
|
123
|
+
x=self.peak_properties.RightIPs,
|
|
124
|
+
xp=self.data.index,
|
|
125
|
+
fp=cumulative
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# Compute areas under peaks
|
|
129
|
+
areas = right_cum_integral - left_cum_integral
|
|
130
|
+
|
|
131
|
+
# Add areas with units to peak properties
|
|
132
|
+
self.peak_properties['Areas'] = pint_pandas.PintArray(
|
|
133
|
+
areas, dtype=self.data.Signal.pint.units * self.data.Time.pint.units
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def _add_to_ax(self, time_unit: str | Quantity, signal_unit: str | Quantity, ax: plt.Axes = None) -> None:
|
|
137
|
+
"""
|
|
138
|
+
Plots the signal with detected peaks and FWHM lines.
|
|
139
|
+
|
|
140
|
+
Parameters
|
|
141
|
+
----------
|
|
142
|
+
time_unit : str or Quantity
|
|
143
|
+
Unit for the time axis (e.g., 'microsecond').
|
|
144
|
+
signal_unit : str or Quantity
|
|
145
|
+
Unit for the signal axis (e.g., 'volt').
|
|
146
|
+
ax : plt.Axes
|
|
147
|
+
The matplotlib Axes to plot on. Creates a new figure if not provided.
|
|
148
|
+
"""
|
|
149
|
+
# Plot vertical lines at peak times
|
|
150
|
+
for _, row in self.peak_properties.iterrows():
|
|
151
|
+
ax.axvline(row['PeakTimes'].to(time_unit), color='black', linestyle='-', lw=0.8)
|
|
152
|
+
|
|
153
|
+
self._add_custom_to_ax(ax=ax, time_unit=time_unit, signal_unit=signal_unit)
|
|
154
|
+
|
|
155
|
+
def _compute_peak_widths(self, peak_indices: list[int], values: np.ndarray) -> None:
|
|
156
|
+
# Compute peak properties
|
|
157
|
+
widths_samples, width_heights, left_ips, right_ips = peak_widths(
|
|
158
|
+
x=values,
|
|
159
|
+
peaks=peak_indices,
|
|
160
|
+
rel_height=self.rel_height
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
return widths_samples, width_heights, left_ips, right_ips
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
import matplotlib.pyplot as plt
|
|
3
|
+
import pandas as pd
|
|
4
|
+
import numpy as np
|
|
5
|
+
from scipy.signal import find_peaks
|
|
6
|
+
from FlowCyPy.peak_locator.base_class import BasePeakLocator
|
|
7
|
+
from FlowCyPy.units import Quantity, volt
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class BasicPeakLocator(BasePeakLocator):
|
|
12
|
+
"""
|
|
13
|
+
A basic peak detector class that identifies peaks in a signal using a threshold-based method.
|
|
14
|
+
|
|
15
|
+
Parameters
|
|
16
|
+
----------
|
|
17
|
+
threshold : Quantity, optional
|
|
18
|
+
The minimum height required for a peak to be considered significant. Default is `Quantity(0.1, volt)`.
|
|
19
|
+
min_peak_distance : Quantity, optional
|
|
20
|
+
The minimum distance between detected peaks. Default is `Quantity(0.1)`.
|
|
21
|
+
rel_height : float, optional
|
|
22
|
+
The relative height at which the peak width is measured. Default is `0.5`.
|
|
23
|
+
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
threshold: Quantity = Quantity(0.0, volt)
|
|
27
|
+
rel_height: float = 0.5
|
|
28
|
+
min_peak_distance: Quantity = None
|
|
29
|
+
|
|
30
|
+
def init_data(self, dataframe: pd.DataFrame) -> None:
|
|
31
|
+
"""
|
|
32
|
+
Initialize the data for peak detection.
|
|
33
|
+
|
|
34
|
+
Parameters
|
|
35
|
+
----------
|
|
36
|
+
dataframe : pd.DataFrame
|
|
37
|
+
A DataFrame containing the signal data with columns 'Signal' and 'Time'.
|
|
38
|
+
|
|
39
|
+
Raises
|
|
40
|
+
------
|
|
41
|
+
ValueError
|
|
42
|
+
If the DataFrame is missing required columns or is empty.
|
|
43
|
+
"""
|
|
44
|
+
super().init_data(dataframe)
|
|
45
|
+
|
|
46
|
+
if self.threshold is not None:
|
|
47
|
+
self.threshold = self.threshold.to(self.data['Signal'].values.units)
|
|
48
|
+
|
|
49
|
+
if self.min_peak_distance is not None:
|
|
50
|
+
self.min_peak_distance = self.min_peak_distance.to(self.data['Time'].values.units)
|
|
51
|
+
|
|
52
|
+
def _compute_algorithm_specific_features(self) -> None:
|
|
53
|
+
"""
|
|
54
|
+
Compute peaks based on the moving average algorithm.
|
|
55
|
+
"""
|
|
56
|
+
peak_indices = self._compute_peak_positions()
|
|
57
|
+
|
|
58
|
+
widths_samples, width_heights, left_ips, right_ips = self._compute_peak_widths(
|
|
59
|
+
peak_indices,
|
|
60
|
+
self.data['Signal'].values
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
return peak_indices, widths_samples, width_heights, left_ips, right_ips
|
|
64
|
+
|
|
65
|
+
def _compute_peak_positions(self) -> pd.DataFrame:
|
|
66
|
+
"""
|
|
67
|
+
Detects peaks in the signal and calculates their properties such as heights, widths, and areas.
|
|
68
|
+
|
|
69
|
+
Parameters
|
|
70
|
+
----------
|
|
71
|
+
detector : pd.DataFrame
|
|
72
|
+
DataFrame with the signal data to detect peaks in.
|
|
73
|
+
|
|
74
|
+
Returns
|
|
75
|
+
-------
|
|
76
|
+
peak_times : Quantity
|
|
77
|
+
The times at which peaks occur.
|
|
78
|
+
heights : Quantity
|
|
79
|
+
The heights of the detected peaks.
|
|
80
|
+
widths : Quantity
|
|
81
|
+
The widths of the detected peaks.
|
|
82
|
+
areas : Quantity or None
|
|
83
|
+
The areas under each peak, if `compute_area` is True.
|
|
84
|
+
"""
|
|
85
|
+
# Find peaks in the difference signal
|
|
86
|
+
peak_indices, _ = find_peaks(
|
|
87
|
+
self.data['Signal'].values,
|
|
88
|
+
height=None if self.threshold is None else self.threshold.magnitude,
|
|
89
|
+
distance=None if self.min_peak_distance is None else int(np.ceil(self.min_peak_distance / self.dt))
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
return peak_indices
|
|
93
|
+
|
|
94
|
+
def _add_custom_to_ax(self, time_unit: str | Quantity, signal_unit: str | Quantity, ax: plt.Axes = None) -> None:
|
|
95
|
+
"""
|
|
96
|
+
Add algorithm-specific elements to the plot.
|
|
97
|
+
|
|
98
|
+
Parameters
|
|
99
|
+
----------
|
|
100
|
+
time_unit : str or Quantity
|
|
101
|
+
The unit for the time axis (e.g., 'microsecond').
|
|
102
|
+
signal_unit : str or Quantity
|
|
103
|
+
The unit for the signal axis (e.g., 'volt').
|
|
104
|
+
ax : matplotlib.axes.Axes
|
|
105
|
+
The Axes object to add elements to.
|
|
106
|
+
"""
|
|
107
|
+
# Plot the signal threshold line
|
|
108
|
+
ax.axhline(y=self.threshold.to(signal_unit).magnitude, color='black', linestyle='--', label='Threshold', lw=1)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
import numpy as np
|
|
3
|
+
import matplotlib.pyplot as plt
|
|
4
|
+
from scipy.signal import find_peaks
|
|
5
|
+
from FlowCyPy.peak_locator.base_class import BasePeakLocator
|
|
6
|
+
import pandas as pd
|
|
7
|
+
import pint_pandas
|
|
8
|
+
from FlowCyPy.units import Quantity, microsecond
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class DerivativePeakLocator(BasePeakLocator):
|
|
13
|
+
"""
|
|
14
|
+
Detects peaks in a signal using a derivative-based algorithm.
|
|
15
|
+
A peak is identified when the derivative exceeds a defined threshold.
|
|
16
|
+
|
|
17
|
+
Parameters
|
|
18
|
+
----------
|
|
19
|
+
derivative_threshold : Quantity, optional
|
|
20
|
+
The minimum derivative value required to detect a peak.
|
|
21
|
+
Default is `Quantity(0.1)`.
|
|
22
|
+
min_peak_distance : Quantity, optional
|
|
23
|
+
The minimum distance between detected peaks.
|
|
24
|
+
Default is `Quantity(0.1)`.
|
|
25
|
+
rel_height : float, optional
|
|
26
|
+
The relative height at which the peak width is measured. Default is `0.5` (half-height).
|
|
27
|
+
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
derivative_threshold: Quantity = Quantity(0.1, 'volt/microsecond')
|
|
31
|
+
min_peak_distance: Quantity = Quantity(0.1, microsecond)
|
|
32
|
+
rel_height: float = 0.5
|
|
33
|
+
|
|
34
|
+
def init_data(self, dataframe: pd.DataFrame) -> None:
|
|
35
|
+
"""
|
|
36
|
+
Initialize the data for peak detection.
|
|
37
|
+
|
|
38
|
+
Parameters
|
|
39
|
+
----------
|
|
40
|
+
dataframe : pd.DataFrame
|
|
41
|
+
A DataFrame containing the signal data with columns 'Signal' and 'Time'.
|
|
42
|
+
|
|
43
|
+
Raises
|
|
44
|
+
------
|
|
45
|
+
ValueError
|
|
46
|
+
If the DataFrame is missing required columns or is empty.
|
|
47
|
+
"""
|
|
48
|
+
super().init_data(dataframe)
|
|
49
|
+
|
|
50
|
+
if self.derivative_threshold is not None:
|
|
51
|
+
self.derivative_threshold = self.derivative_threshold.to(
|
|
52
|
+
self.data['Signal'].values.units / self.data['Time'].values.units
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
if self.min_peak_distance is not None:
|
|
56
|
+
self.min_peak_distance = self.min_peak_distance.to(self.data['Time'].values.units)
|
|
57
|
+
|
|
58
|
+
def _compute_algorithm_specific_features(self) -> None:
|
|
59
|
+
"""
|
|
60
|
+
Compute peaks based on the moving average algorithm.
|
|
61
|
+
"""
|
|
62
|
+
peak_indices = self._compute_peak_positions()
|
|
63
|
+
|
|
64
|
+
widths_samples, width_heights, left_ips, right_ips = self._compute_peak_widths(
|
|
65
|
+
peak_indices,
|
|
66
|
+
self.data['Signal'].values
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return peak_indices, widths_samples, width_heights, left_ips, right_ips
|
|
70
|
+
|
|
71
|
+
def _compute_peak_positions(self) -> None:
|
|
72
|
+
"""
|
|
73
|
+
Compute peaks based on the derivative of the signal and refine their positions
|
|
74
|
+
to align with the actual maxima in the original signal.
|
|
75
|
+
"""
|
|
76
|
+
# Compute the derivative of the signal
|
|
77
|
+
derivative = np.gradient(
|
|
78
|
+
self.data['Signal'].values.quantity.magnitude,
|
|
79
|
+
self.data['Time'].values.quantity.magnitude
|
|
80
|
+
)
|
|
81
|
+
derivative = pint_pandas.PintArray(
|
|
82
|
+
derivative, dtype=self.data['Signal'].values.units / self.data['Time'].values.units
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Add the derivative to the DataFrame
|
|
86
|
+
self.data['Derivative'] = derivative
|
|
87
|
+
|
|
88
|
+
# Detect peaks in the derivative signal
|
|
89
|
+
derivative_peak_indices, _ = find_peaks(
|
|
90
|
+
self.data['Derivative'].values.quantity.magnitude,
|
|
91
|
+
height=self.derivative_threshold.magnitude,
|
|
92
|
+
prominence=0.1, # Adjust this if needed
|
|
93
|
+
plateau_size=True,
|
|
94
|
+
distance=None if self.min_peak_distance is None else int(np.ceil(self.min_peak_distance / self.dt))
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Refine detected peaks to align with maxima in the original signal
|
|
98
|
+
refined_peak_indices = []
|
|
99
|
+
refinement_window = 5 # Number of samples around each derivative peak to search for the max
|
|
100
|
+
|
|
101
|
+
for idx in derivative_peak_indices:
|
|
102
|
+
# Define search window boundaries
|
|
103
|
+
window_start = max(0, idx - refinement_window)
|
|
104
|
+
window_end = min(len(self.data) - 1, idx + refinement_window)
|
|
105
|
+
|
|
106
|
+
# Find the maximum in the original signal within the window
|
|
107
|
+
true_max_idx = window_start + np.argmax(self.data['Signal'].iloc[window_start:window_end])
|
|
108
|
+
refined_peak_indices.append(true_max_idx)
|
|
109
|
+
|
|
110
|
+
refined_peak_indices = np.unique(refined_peak_indices) # Remove duplicates
|
|
111
|
+
|
|
112
|
+
return refined_peak_indices
|
|
113
|
+
|
|
114
|
+
def _add_custom_to_ax(self, time_unit: str | Quantity, signal_unit: str | Quantity, ax: plt.Axes) -> None:
|
|
115
|
+
"""
|
|
116
|
+
Add algorithm-specific elements to the plot.
|
|
117
|
+
|
|
118
|
+
Parameters
|
|
119
|
+
----------
|
|
120
|
+
time_unit : str or Quantity
|
|
121
|
+
The unit for the time axis (e.g., 'microsecond').
|
|
122
|
+
signal_unit : str or Quantity
|
|
123
|
+
The unit for the signal axis (e.g., 'volt').
|
|
124
|
+
ax : matplotlib.axes.Axes
|
|
125
|
+
The Axes object to add elements to.
|
|
126
|
+
"""
|
|
127
|
+
# Plot the derivative
|
|
128
|
+
ax.plot(
|
|
129
|
+
self.data.Time,
|
|
130
|
+
self.data.Derivative,
|
|
131
|
+
linestyle='--',
|
|
132
|
+
color='C2',
|
|
133
|
+
label='Derivative'
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# Plot the derivative threshold line
|
|
137
|
+
ax.axhline(
|
|
138
|
+
y=self.derivative_threshold.to(self.data['Derivative'].values.units).magnitude,
|
|
139
|
+
color='black',
|
|
140
|
+
linestyle='--',
|
|
141
|
+
label='Derivative Threshold',
|
|
142
|
+
lw=1
|
|
143
|
+
)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
import numpy as np
|
|
3
|
+
import matplotlib.pyplot as plt
|
|
4
|
+
from scipy.signal import find_peaks
|
|
5
|
+
from FlowCyPy.peak_locator.base_class import BasePeakLocator
|
|
6
|
+
import pandas as pd
|
|
7
|
+
import pint_pandas
|
|
8
|
+
from FlowCyPy.units import Quantity
|
|
9
|
+
from pint_pandas import PintArray
|
|
10
|
+
from typing import Tuple, Callable
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class MovingAverage(BasePeakLocator):
|
|
15
|
+
"""
|
|
16
|
+
Detects peaks in a signal using a moving average algorithm.
|
|
17
|
+
A peak is identified when the signal exceeds the moving average by a defined threshold.
|
|
18
|
+
|
|
19
|
+
Parameters
|
|
20
|
+
----------
|
|
21
|
+
threshold : Quantity, optional
|
|
22
|
+
The minimum difference between the signal and its moving average required to detect a peak. Default is `Quantity(0.2)`.
|
|
23
|
+
window_size : Quantity, optional
|
|
24
|
+
The window size for calculating the moving average. Default is `Quantity(500)`.
|
|
25
|
+
min_peak_distance : Quantity, optional
|
|
26
|
+
The minimum distance between detected peaks. Default is `Quantity(0.1)`.
|
|
27
|
+
rel_height : float, optional
|
|
28
|
+
The relative height at which the peak width is measured. Default is `0.5` (half-height).
|
|
29
|
+
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
threshold: Quantity
|
|
33
|
+
window_size: Quantity
|
|
34
|
+
min_peak_distance: Quantity = None
|
|
35
|
+
rel_height: float = 0.1
|
|
36
|
+
|
|
37
|
+
def init_data(self, dataframe: pd.DataFrame) -> None:
|
|
38
|
+
"""
|
|
39
|
+
Initialize the data for peak detection.
|
|
40
|
+
|
|
41
|
+
Parameters
|
|
42
|
+
----------
|
|
43
|
+
dataframe : pd.DataFrame
|
|
44
|
+
A DataFrame containing the signal data with columns 'Signal' and 'Time'.
|
|
45
|
+
|
|
46
|
+
Raises
|
|
47
|
+
------
|
|
48
|
+
ValueError
|
|
49
|
+
If the DataFrame is missing required columns or is empty.
|
|
50
|
+
"""
|
|
51
|
+
super().init_data(dataframe)
|
|
52
|
+
|
|
53
|
+
if self.threshold is not None:
|
|
54
|
+
self.threshold = self.threshold.to(self.data['Signal'].values.units)
|
|
55
|
+
|
|
56
|
+
self.window_size = self.window_size.to(self.data['Time'].values.units)
|
|
57
|
+
|
|
58
|
+
if self.min_peak_distance is not None:
|
|
59
|
+
self.min_peak_distance = self.min_peak_distance.to(self.data['Time'].values.units)
|
|
60
|
+
|
|
61
|
+
def _compute_algorithm_specific_features(self) -> None:
|
|
62
|
+
"""
|
|
63
|
+
Compute peaks based on the moving average algorithm.
|
|
64
|
+
"""
|
|
65
|
+
peak_indices = self._compute_peak_positions()
|
|
66
|
+
|
|
67
|
+
widths_samples, width_heights, left_ips, right_ips = self._compute_peak_widths(peak_indices, self.data['Difference'].values)
|
|
68
|
+
|
|
69
|
+
return peak_indices, widths_samples, width_heights, left_ips, right_ips
|
|
70
|
+
|
|
71
|
+
def _compute_peak_positions(self) -> None:
|
|
72
|
+
# Calculate moving average
|
|
73
|
+
window_size_samples = int(np.ceil(self.window_size / self.dt))
|
|
74
|
+
moving_avg = self.data['Signal'].rolling(window=window_size_samples, center=True, min_periods=1).mean()
|
|
75
|
+
|
|
76
|
+
# Reattach Pint units to the moving average
|
|
77
|
+
moving_avg = pint_pandas.PintArray(moving_avg, dtype=self.data['Signal'].values.units)
|
|
78
|
+
|
|
79
|
+
# Add the moving average to the DataFrame
|
|
80
|
+
self.data['MovingAverage'] = moving_avg
|
|
81
|
+
|
|
82
|
+
# Compute the difference signal
|
|
83
|
+
self.data['Difference'] = self.data['Signal'] - self.data['MovingAverage']
|
|
84
|
+
|
|
85
|
+
# Detect peaks
|
|
86
|
+
peak_indices, _ = find_peaks(
|
|
87
|
+
self.data['Difference'].values.quantity.magnitude,
|
|
88
|
+
height=None if self.threshold is None else self.threshold.magnitude,
|
|
89
|
+
distance=None if self.min_peak_distance is None else int(np.ceil(self.min_peak_distance / self.dt))
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
return peak_indices
|
|
93
|
+
|
|
94
|
+
def _add_custom_to_ax(self, time_unit: str | Quantity, signal_unit: str | Quantity, ax: plt.Axes) -> None:
|
|
95
|
+
"""
|
|
96
|
+
Add algorithm-specific elements to the plot.
|
|
97
|
+
|
|
98
|
+
Parameters
|
|
99
|
+
----------
|
|
100
|
+
time_unit : str or Quantity
|
|
101
|
+
The unit for the time axis (e.g., 'microsecond').
|
|
102
|
+
signal_unit : str or Quantity
|
|
103
|
+
The unit for the signal axis (e.g., 'volt').
|
|
104
|
+
ax : matplotlib.axes.Axes
|
|
105
|
+
The Axes object to add elements to.
|
|
106
|
+
"""
|
|
107
|
+
ax.plot(
|
|
108
|
+
self.data.Time,
|
|
109
|
+
self.data.Difference,
|
|
110
|
+
linestyle='--',
|
|
111
|
+
color='C1',
|
|
112
|
+
label='MA-difference'
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Plot the signal threshold line
|
|
116
|
+
ax.axhline(y=self.threshold.to(signal_unit).magnitude, color='black', linestyle='--', label='Threshold', lw=1)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def format_input(function: Callable) -> Callable:
|
|
120
|
+
def wrapper(self, dataframe: pd.DataFrame, y: str, x: str = None):
|
|
121
|
+
x = dataframe[x] if x is not None else dataframe.index
|
|
122
|
+
|
|
123
|
+
new_dataframe = pd.DataFrame(
|
|
124
|
+
data=dict(
|
|
125
|
+
x=x, y=dataframe[y]
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
return function(self=self, dataframe=new_dataframe)
|
|
130
|
+
|
|
131
|
+
return wrapper
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@format_input
|
|
135
|
+
def process_data(self, dataframe: pd.DataFrame) -> Tuple[pd.DataFrame, pd.DataFrame]:
|
|
136
|
+
dt = dataframe.x[1] - dataframe.x[0]
|
|
137
|
+
|
|
138
|
+
window_size_samples = int(np.ceil(self.window_size / dt))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
y_units = dataframe['y'].pint.units
|
|
142
|
+
x_units = dataframe['x'].pint.units
|
|
143
|
+
|
|
144
|
+
moving_avg = dataframe['y'].rolling(window=window_size_samples, center=True, min_periods=1).mean()
|
|
145
|
+
|
|
146
|
+
dataframe['MovingAverage'] = PintArray(moving_avg, y_units)
|
|
147
|
+
|
|
148
|
+
dataframe['Difference'] = dataframe['y'] - dataframe['MovingAverage']
|
|
149
|
+
|
|
150
|
+
peak_indices, meta = find_peaks(
|
|
151
|
+
x=dataframe['Difference'],
|
|
152
|
+
height=self.threshold.to(dataframe['Difference'].pint.units).magnitude,
|
|
153
|
+
distance=window_size_samples,
|
|
154
|
+
rel_height=0.1,
|
|
155
|
+
width=1
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
peak_dataframe = pd.DataFrame(
|
|
159
|
+
dict(
|
|
160
|
+
PeakPosition=dataframe['x'][peak_indices],
|
|
161
|
+
PeakHeight=PintArray(meta['peak_heights'], y_units),
|
|
162
|
+
PeakWidth=PintArray(meta['widths'], x_units),
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
return dataframe, peak_dataframe
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from FlowCyPy.units import meter, joule, second, farad, kelvin, coulomb
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
config_dict = dict(
|
|
6
|
+
arbitrary_types_allowed=True,
|
|
7
|
+
kw_only=True,
|
|
8
|
+
slots=True,
|
|
9
|
+
extra='forbid'
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PhysicalConstant:
|
|
14
|
+
h = 6.62607015e-34 * joule * second # Planck constant
|
|
15
|
+
c = 3e8 * meter / second # Speed of light
|
|
16
|
+
epsilon_0 = 8.8541878128e-12 * farad / meter # Permtivitty of vacuum
|
|
17
|
+
pi = np.pi # Pi, what else?
|
|
18
|
+
kb = 1.380649e-23 * joule / kelvin # Botlzmann constant
|
|
19
|
+
e = 1.602176634e-19 * coulomb # Electron charge
|