FlowCyPy 0.5.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 +15 -0
- FlowCyPy/_version.py +16 -0
- FlowCyPy/classifier.py +196 -0
- FlowCyPy/coupling_mechanism/__init__.py +4 -0
- FlowCyPy/coupling_mechanism/empirical.py +47 -0
- FlowCyPy/coupling_mechanism/mie.py +205 -0
- FlowCyPy/coupling_mechanism/rayleigh.py +115 -0
- FlowCyPy/coupling_mechanism/uniform.py +39 -0
- FlowCyPy/cytometer.py +198 -0
- FlowCyPy/detector.py +616 -0
- FlowCyPy/directories.py +36 -0
- FlowCyPy/distribution/__init__.py +16 -0
- FlowCyPy/distribution/base_class.py +59 -0
- FlowCyPy/distribution/delta.py +86 -0
- FlowCyPy/distribution/lognormal.py +94 -0
- FlowCyPy/distribution/normal.py +95 -0
- FlowCyPy/distribution/particle_size_distribution.py +110 -0
- FlowCyPy/distribution/uniform.py +96 -0
- FlowCyPy/distribution/weibull.py +80 -0
- FlowCyPy/event_correlator.py +244 -0
- FlowCyPy/flow_cell.py +122 -0
- FlowCyPy/helper.py +85 -0
- FlowCyPy/logger.py +322 -0
- FlowCyPy/noises.py +29 -0
- FlowCyPy/particle_count.py +102 -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 +114 -0
- FlowCyPy/physical_constant.py +19 -0
- FlowCyPy/plottings.py +270 -0
- FlowCyPy/population.py +239 -0
- FlowCyPy/populations_instances.py +49 -0
- FlowCyPy/report.py +236 -0
- FlowCyPy/scatterer.py +373 -0
- FlowCyPy/source.py +249 -0
- FlowCyPy/units.py +26 -0
- FlowCyPy/utils.py +191 -0
- FlowCyPy-0.5.0.dist-info/LICENSE +21 -0
- FlowCyPy-0.5.0.dist-info/METADATA +252 -0
- FlowCyPy-0.5.0.dist-info/RECORD +44 -0
- FlowCyPy-0.5.0.dist-info/WHEEL +5 -0
- FlowCyPy-0.5.0.dist-info/top_level.txt +1 -0
FlowCyPy/detector.py
ADDED
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pandas as pd
|
|
3
|
+
from typing import Optional, Union
|
|
4
|
+
import matplotlib.pyplot as plt
|
|
5
|
+
from FlowCyPy.units import AU, volt, watt, degree, second, ampere, coulomb
|
|
6
|
+
from FlowCyPy.utils import PropertiesReport
|
|
7
|
+
from pydantic.dataclasses import dataclass
|
|
8
|
+
from pydantic import field_validator
|
|
9
|
+
from functools import cached_property
|
|
10
|
+
import pint_pandas
|
|
11
|
+
from FlowCyPy.physical_constant import PhysicalConstant
|
|
12
|
+
from PyMieSim.units import Quantity
|
|
13
|
+
from FlowCyPy.noises import NoiseSetting
|
|
14
|
+
from FlowCyPy.helper import plot_helper
|
|
15
|
+
from FlowCyPy.peak_locator import BasePeakLocator
|
|
16
|
+
import logging
|
|
17
|
+
from copy import copy
|
|
18
|
+
|
|
19
|
+
config_dict = dict(
|
|
20
|
+
arbitrary_types_allowed=True,
|
|
21
|
+
kw_only=True,
|
|
22
|
+
slots=True,
|
|
23
|
+
extra='forbid'
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(config=config_dict, unsafe_hash=True)
|
|
28
|
+
class Detector(PropertiesReport):
|
|
29
|
+
"""
|
|
30
|
+
A class representing a signal detector used in flow cytometry.
|
|
31
|
+
|
|
32
|
+
This class models a photodetector, simulating signal acquisition, noise addition, and signal processing
|
|
33
|
+
for analysis. It can optionally simulate different noise sources: shot noise, thermal noise, and dark current noise.
|
|
34
|
+
|
|
35
|
+
Parameters
|
|
36
|
+
----------
|
|
37
|
+
name : str
|
|
38
|
+
The name or identifier of the detector.
|
|
39
|
+
sampling_freq : Quantity
|
|
40
|
+
The sampling frequency of the detector in hertz.
|
|
41
|
+
phi_angle : Quantity
|
|
42
|
+
The detection angle in degrees.
|
|
43
|
+
numerical_aperture : Quantity
|
|
44
|
+
The numerical aperture of the detector, a unitless value.
|
|
45
|
+
responsitivity : Quantity
|
|
46
|
+
Detector's responsivity, default is 1 volt per watt.
|
|
47
|
+
noise_level : Quantity
|
|
48
|
+
The base noise level added to the signal, default is 0 volts.
|
|
49
|
+
baseline_shift : Quantity
|
|
50
|
+
The baseline shift applied to the signal, default is 0 volts.
|
|
51
|
+
saturation_level : Quantity
|
|
52
|
+
The maximum signal level in volts before saturation, default is infinity.
|
|
53
|
+
dark_current : Quantity
|
|
54
|
+
The dark current of the detector, default is 0 amperes.
|
|
55
|
+
resistance : Quantity
|
|
56
|
+
Resistance of the detector, used for thermal noise simulation.
|
|
57
|
+
temperature : Quantity
|
|
58
|
+
Temperature of the detector in Kelvin, used for thermal noise simulation.
|
|
59
|
+
n_bins : Union[int, str]
|
|
60
|
+
The number of discretization bins or bit-depth (e.g., '12bit').
|
|
61
|
+
"""
|
|
62
|
+
sampling_freq: Quantity
|
|
63
|
+
phi_angle: Quantity
|
|
64
|
+
numerical_aperture: Quantity
|
|
65
|
+
|
|
66
|
+
gamma_angle: Optional[Quantity] = Quantity(0, degree)
|
|
67
|
+
sampling: Optional[Quantity] = 100 * AU
|
|
68
|
+
responsitivity: Optional[Quantity] = Quantity(1, ampere / watt)
|
|
69
|
+
noise_level: Optional[Quantity] = Quantity(0.0, volt)
|
|
70
|
+
baseline_shift: Optional[Quantity] = Quantity(0.0, volt)
|
|
71
|
+
saturation_level: Optional[Quantity] = Quantity(np.inf, volt)
|
|
72
|
+
dark_current: Optional[Quantity] = Quantity(0.0, ampere) # Dark current
|
|
73
|
+
resistance: Optional[Quantity] = Quantity(50.0, 'ohm') # Resistance for thermal noise
|
|
74
|
+
temperature: Optional[Quantity] = Quantity(0.0, 'kelvin') # Temperature for thermal noise
|
|
75
|
+
n_bins: Optional[Union[int, str]] = None
|
|
76
|
+
name: Optional[str] = None
|
|
77
|
+
|
|
78
|
+
@cached_property
|
|
79
|
+
def bandwidth(self) -> Quantity:
|
|
80
|
+
return self.sampling_freq / 2
|
|
81
|
+
"""
|
|
82
|
+
Automatically calculates the bandwidth based on the sampling frequency.
|
|
83
|
+
|
|
84
|
+
Returns
|
|
85
|
+
-------
|
|
86
|
+
Quantity
|
|
87
|
+
The bandwidth of the detector, which is half the sampling frequency (Nyquist limit).
|
|
88
|
+
"""
|
|
89
|
+
return self.sampling_freq / 2
|
|
90
|
+
|
|
91
|
+
@field_validator('sampling_freq')
|
|
92
|
+
def _validate_sampling_freq(cls, value):
|
|
93
|
+
"""
|
|
94
|
+
Validates that the sampling frequency is provided in hertz.
|
|
95
|
+
|
|
96
|
+
Parameters
|
|
97
|
+
----------
|
|
98
|
+
value : Quantity
|
|
99
|
+
The sampling frequency to validate.
|
|
100
|
+
|
|
101
|
+
Returns
|
|
102
|
+
-------
|
|
103
|
+
Quantity
|
|
104
|
+
The validated sampling frequency.
|
|
105
|
+
|
|
106
|
+
Raises:
|
|
107
|
+
ValueError: If the sampling frequency is not in hertz.
|
|
108
|
+
"""
|
|
109
|
+
if not value.check('Hz'):
|
|
110
|
+
raise ValueError(f"sampling_freq must be in hertz, but got {value.units}")
|
|
111
|
+
return value
|
|
112
|
+
|
|
113
|
+
@field_validator('phi_angle', 'gamma_angle')
|
|
114
|
+
def _validate_angles(cls, value):
|
|
115
|
+
"""
|
|
116
|
+
Validates that the provided angles are in degrees.
|
|
117
|
+
|
|
118
|
+
Parameters
|
|
119
|
+
----------
|
|
120
|
+
value : Quantity
|
|
121
|
+
The angle value to validate.
|
|
122
|
+
|
|
123
|
+
Returns
|
|
124
|
+
-------
|
|
125
|
+
Quantity
|
|
126
|
+
The validated angle.
|
|
127
|
+
|
|
128
|
+
Raises:
|
|
129
|
+
ValueError: If the angle is not in degrees.
|
|
130
|
+
"""
|
|
131
|
+
if not value.check('degree'):
|
|
132
|
+
raise ValueError(f"Angle must be in degrees, but got {value.units}")
|
|
133
|
+
return value
|
|
134
|
+
|
|
135
|
+
@field_validator('responsitivity')
|
|
136
|
+
def _validate_responsitivity(cls, value):
|
|
137
|
+
"""
|
|
138
|
+
Validates that the detector's responsivity is provided in volts per watt.
|
|
139
|
+
|
|
140
|
+
Parameters
|
|
141
|
+
----------
|
|
142
|
+
value : Quantity
|
|
143
|
+
The responsivity value to validate.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Quantity: The validated responsivity.
|
|
147
|
+
|
|
148
|
+
Raises:
|
|
149
|
+
ValueError: If the responsivity is not in volts per watt.
|
|
150
|
+
"""
|
|
151
|
+
if not value.check('A / W'):
|
|
152
|
+
raise ValueError(f"Responsitivity must be in ampere per watt, but got {value.units}")
|
|
153
|
+
return value
|
|
154
|
+
|
|
155
|
+
@field_validator('noise_level', 'baseline_shift', 'saturation_level')
|
|
156
|
+
def _validate_voltage_attributes(cls, value):
|
|
157
|
+
"""
|
|
158
|
+
Validates that noise level, baseline shift, and saturation level are all in volts.
|
|
159
|
+
|
|
160
|
+
Parameters
|
|
161
|
+
----------
|
|
162
|
+
value : Quantity
|
|
163
|
+
The voltage attribute to validate (noise level, baseline shift, or saturation).
|
|
164
|
+
|
|
165
|
+
Returns
|
|
166
|
+
-------
|
|
167
|
+
Quantity
|
|
168
|
+
The validated voltage attribute.
|
|
169
|
+
|
|
170
|
+
Raises:
|
|
171
|
+
ValueError: If the attribute is not in volts.
|
|
172
|
+
"""
|
|
173
|
+
if not value.check('volt'):
|
|
174
|
+
raise ValueError(f"Voltage attributes must be in volts, but got {value.units}")
|
|
175
|
+
return value
|
|
176
|
+
|
|
177
|
+
def __post_init__(self) -> None:
|
|
178
|
+
"""
|
|
179
|
+
Finalizes the initialization of the detector object and processes the number of bins.
|
|
180
|
+
"""
|
|
181
|
+
if self.name is None:
|
|
182
|
+
self.name = str(id(self))
|
|
183
|
+
|
|
184
|
+
self._process_n_bins()
|
|
185
|
+
|
|
186
|
+
def _convert_attr_to_SI(self) -> None:
|
|
187
|
+
# Convert all Quantity attributes to base SI units (without any prefixes)
|
|
188
|
+
for attr_name, attr_value in vars(self).items():
|
|
189
|
+
if isinstance(attr_value, Quantity):
|
|
190
|
+
setattr(self, attr_name, attr_value.to_base_units())
|
|
191
|
+
|
|
192
|
+
def _process_n_bins(self) -> None:
|
|
193
|
+
r"""
|
|
194
|
+
Processes the `n_bins` attribute to ensure it is an integer representing the number of bins.
|
|
195
|
+
|
|
196
|
+
If `n_bins` is provided as a bit-depth string (e.g., '12bit'), it converts it to an integer number of bins.
|
|
197
|
+
If no valid `n_bins` is provided, a default of 100 bins is used.
|
|
198
|
+
"""
|
|
199
|
+
if isinstance(self.n_bins, str):
|
|
200
|
+
bit_depth = int(self.n_bins.rstrip('bit'))
|
|
201
|
+
self.n_bins = 2 ** bit_depth
|
|
202
|
+
|
|
203
|
+
def _add_thermal_noise_to_raw_signal(self) -> np.ndarray:
|
|
204
|
+
r"""
|
|
205
|
+
Generates thermal noise (Johnson-Nyquist noise) based on temperature, resistance, and bandwidth.
|
|
206
|
+
|
|
207
|
+
Thermal noise is caused by the thermal agitation of charge carriers. It is given by:
|
|
208
|
+
\[
|
|
209
|
+
\sigma_{\text{thermal}} = \sqrt{4 k_B T B R}
|
|
210
|
+
\]
|
|
211
|
+
Where:
|
|
212
|
+
- \( k_B \) is the Boltzmann constant (\(1.38 \times 10^{-23}\) J/K),
|
|
213
|
+
- \( T \) is the temperature in Kelvin,
|
|
214
|
+
- \( B \) is the bandwidth,
|
|
215
|
+
- \( R \) is the resistance.
|
|
216
|
+
|
|
217
|
+
Returns
|
|
218
|
+
-------
|
|
219
|
+
np.ndarray
|
|
220
|
+
An array of thermal noise values.
|
|
221
|
+
"""
|
|
222
|
+
if self.resistance.magnitude == 0 or self.temperature.magnitude == 0 or not NoiseSetting.include_thermal_noise or not NoiseSetting.include_noises:
|
|
223
|
+
return
|
|
224
|
+
|
|
225
|
+
noise_std = np.sqrt(
|
|
226
|
+
4 * PhysicalConstant.kb * self.temperature * self.resistance * self.bandwidth
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
thermal_noise = np.random.normal(0, noise_std.to(volt).magnitude, size=len(self.dataframe)) * volt
|
|
230
|
+
|
|
231
|
+
self.dataframe['RawSignal'] += thermal_noise
|
|
232
|
+
|
|
233
|
+
return thermal_noise
|
|
234
|
+
|
|
235
|
+
def _add_dark_current_noise_to_raw_signal(self) -> np.ndarray:
|
|
236
|
+
r"""
|
|
237
|
+
Generates dark current noise (shot noise from dark current).
|
|
238
|
+
|
|
239
|
+
Dark current noise is a type of shot noise caused by the random generation of electrons in a detector,
|
|
240
|
+
even in the absence of light. It is given by:
|
|
241
|
+
\[
|
|
242
|
+
\sigma_{\text{dark current}} = \sqrt{2 e I_{\text{dark}} B}
|
|
243
|
+
\]
|
|
244
|
+
Where:
|
|
245
|
+
- \( e \) is the elementary charge,
|
|
246
|
+
- \( I_{\text{dark}} \) is the dark current,
|
|
247
|
+
- \( B \) is the bandwidth.
|
|
248
|
+
|
|
249
|
+
Returns
|
|
250
|
+
-------
|
|
251
|
+
np.ndarray
|
|
252
|
+
An array of dark current noise values.
|
|
253
|
+
"""
|
|
254
|
+
if self.dark_current.magnitude == 0 or not NoiseSetting.include_dark_current_noise or not NoiseSetting.include_noises:
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
dark_current_std = np.sqrt(
|
|
258
|
+
2 * 1.602176634e-19 * coulomb * self.dark_current * self.bandwidth
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
dark_current_noise = np.random.normal(0, dark_current_std.to(ampere).magnitude, size=len(self.dataframe)) * ampere
|
|
262
|
+
|
|
263
|
+
dark_voltage_noise = dark_current_noise * self.resistance
|
|
264
|
+
|
|
265
|
+
self.dataframe['RawSignal'] += dark_voltage_noise
|
|
266
|
+
|
|
267
|
+
return dark_voltage_noise
|
|
268
|
+
|
|
269
|
+
def _add_optical_power_to_raw_signal(self, optical_power: Quantity) -> None:
|
|
270
|
+
r"""
|
|
271
|
+
Simulates photon shot noise based on the given optical power and detector bandwidth, and returns
|
|
272
|
+
the corresponding voltage noise due to photon shot noise.
|
|
273
|
+
|
|
274
|
+
Photon shot noise arises from the random and discrete arrival of photons at the detector. The noise
|
|
275
|
+
follows Poisson statistics, and the standard deviation of the shot noise depends on the photon flux
|
|
276
|
+
and the detector bandwidth. The result is a voltage noise that models these fluctuations.
|
|
277
|
+
|
|
278
|
+
Parameters
|
|
279
|
+
----------
|
|
280
|
+
optical_power : Quantity
|
|
281
|
+
The optical power incident on the detector, in watts (W).
|
|
282
|
+
|
|
283
|
+
Returns
|
|
284
|
+
-------
|
|
285
|
+
np.ndarray
|
|
286
|
+
An array representing the voltage noise due to photon shot noise, in volts (V).
|
|
287
|
+
|
|
288
|
+
Physics:
|
|
289
|
+
- The number of photons arriving at the detector \( N_{\text{ph}} \) is given by:
|
|
290
|
+
\[
|
|
291
|
+
N_{\text{ph}} = \frac{P_{\text{opt}}}{E_{\text{photon}}}
|
|
292
|
+
\]
|
|
293
|
+
where:
|
|
294
|
+
- \( P_{\text{opt}} \) is the optical power (W),
|
|
295
|
+
- \( E_{\text{photon}} = \frac{h \cdot c}{\lambda} \) is the energy of a photon (J),
|
|
296
|
+
- \( h \) is Planck's constant \(6.626 \times 10^{-34}\, \text{J} \cdot \text{s}\),
|
|
297
|
+
- \( c \) is the speed of light \(3 \times 10^8 \, \text{m/s}\),
|
|
298
|
+
- \( \lambda \) is the wavelength of the incident light.
|
|
299
|
+
|
|
300
|
+
- The standard deviation of the current noise due to photon shot noise is:
|
|
301
|
+
\[
|
|
302
|
+
\sigma_{I_{\text{shot}}} = \sqrt{2 \cdot e \cdot I_{\text{photon}} \cdot B}
|
|
303
|
+
\]
|
|
304
|
+
where:
|
|
305
|
+
- \( I_{\text{photon}} \) is the photocurrent generated by the incident optical power (A),
|
|
306
|
+
- \( e \) is the elementary charge \(1.602 \times 10^{-19} \, \text{C}\),
|
|
307
|
+
- \( B \) is the bandwidth of the detector (Hz).
|
|
308
|
+
|
|
309
|
+
- The voltage shot noise \( \sigma_{V_{\text{shot}}} \) is then given by Ohm's law:
|
|
310
|
+
\[
|
|
311
|
+
\sigma_{V_{\text{shot}}} = \sigma_{I_{\text{shot}}} \cdot R_{\text{load}}
|
|
312
|
+
\]
|
|
313
|
+
where:
|
|
314
|
+
- \( R_{\text{load}} \) is the load resistance of the detector (ohms).
|
|
315
|
+
"""
|
|
316
|
+
# Step 1: Compute the photocurrent for all time points at once using vectorization
|
|
317
|
+
I_photon = self.responsitivity * optical_power
|
|
318
|
+
|
|
319
|
+
# Step 2: Compute the shot noise current for each time point using vectorization
|
|
320
|
+
i_shot = 2 * PhysicalConstant.e * I_photon * self.bandwidth
|
|
321
|
+
|
|
322
|
+
I_shot = np.sqrt(i_shot)
|
|
323
|
+
|
|
324
|
+
# Step 3: Convert shot noise current to shot noise voltage using vectorization
|
|
325
|
+
V_shot = I_shot * self.resistance
|
|
326
|
+
|
|
327
|
+
# Step 4: Generate Gaussian noise for each time point with standard deviation V_shot
|
|
328
|
+
noise_signal = np.random.normal(0, V_shot.to(volt).magnitude, len(self.dataframe['RawSignal'])) * volt
|
|
329
|
+
|
|
330
|
+
self.dataframe['RawSignal'] += optical_power * self.responsitivity * self.resistance
|
|
331
|
+
|
|
332
|
+
if NoiseSetting.include_shot_noise and NoiseSetting.include_noises:
|
|
333
|
+
self.dataframe['RawSignal'] += noise_signal
|
|
334
|
+
|
|
335
|
+
return noise_signal
|
|
336
|
+
|
|
337
|
+
def init_raw_signal(self, run_time: Quantity) -> None:
|
|
338
|
+
r"""
|
|
339
|
+
Initializes the raw signal and time arrays for the detector and generates optional noise.
|
|
340
|
+
|
|
341
|
+
Parameters
|
|
342
|
+
----------
|
|
343
|
+
run_time : Quantity
|
|
344
|
+
The total duration of the signal to simulate (in seconds).
|
|
345
|
+
|
|
346
|
+
The photocurrent is calculated as:
|
|
347
|
+
\[
|
|
348
|
+
I_{\text{ph}} = P_{\text{opt}} \times R_{\text{ph}}
|
|
349
|
+
\]
|
|
350
|
+
Where:
|
|
351
|
+
- \( P_{\text{opt}} \) is the optical power,
|
|
352
|
+
- \( R_{\text{ph}} \) is the responsivity in amperes per watt.
|
|
353
|
+
"""
|
|
354
|
+
self.run_time = run_time
|
|
355
|
+
time_points = int(self.sampling_freq * run_time)
|
|
356
|
+
time = np.linspace(0, run_time, time_points)
|
|
357
|
+
|
|
358
|
+
self.dataframe = pd.DataFrame(
|
|
359
|
+
data=dict(
|
|
360
|
+
Time=pint_pandas.PintArray(time, dtype=second),
|
|
361
|
+
RawSignal=pint_pandas.PintArray(np.zeros_like(time), dtype=volt),
|
|
362
|
+
Signal=pint_pandas.PintArray(np.zeros_like(time), dtype=volt)
|
|
363
|
+
)
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
def capture_signal(self) -> None:
|
|
367
|
+
"""
|
|
368
|
+
Processes and captures the final signal by applying noise, baseline shifts, and saturation.
|
|
369
|
+
"""
|
|
370
|
+
self.dataframe['Signal'] = self.dataframe['RawSignal']
|
|
371
|
+
|
|
372
|
+
self._apply_baseline_and_noise()
|
|
373
|
+
self._apply_saturation()
|
|
374
|
+
self._discretize_signal()
|
|
375
|
+
|
|
376
|
+
self.is_saturated = True if np.any(self.dataframe.Signal == self.saturation_level.to(volt).magnitude) else False
|
|
377
|
+
|
|
378
|
+
def _apply_baseline_and_noise(self) -> None:
|
|
379
|
+
"""
|
|
380
|
+
Adds baseline shift and base noise to the raw signal.
|
|
381
|
+
"""
|
|
382
|
+
w0 = np.pi / 2 / second
|
|
383
|
+
baseline = self.baseline_shift * np.sin(w0 * self.dataframe.Time)
|
|
384
|
+
|
|
385
|
+
# Scale noise level by the square root of the bandwidth
|
|
386
|
+
noise_scaling = np.sqrt(self.bandwidth.to('Hz').magnitude)
|
|
387
|
+
noise = self.noise_level * noise_scaling * np.random.normal(size=len(self.dataframe))
|
|
388
|
+
|
|
389
|
+
self.dataframe.Signal += baseline + noise
|
|
390
|
+
|
|
391
|
+
def _apply_saturation(self) -> None:
|
|
392
|
+
"""
|
|
393
|
+
Applies a saturation limit to the signal.
|
|
394
|
+
|
|
395
|
+
Signal values that exceed the saturation level are clipped to the maximum allowed value.
|
|
396
|
+
"""
|
|
397
|
+
clipped = np.clip(self.dataframe.Signal, 0 * volt, self.saturation_level)
|
|
398
|
+
self.dataframe.Signal = pint_pandas.PintArray(clipped, clipped.units)
|
|
399
|
+
|
|
400
|
+
def _discretize_signal(self) -> None:
|
|
401
|
+
"""
|
|
402
|
+
Discretizes the processed signal into a specified number of bins.
|
|
403
|
+
|
|
404
|
+
The signal is mapped to discrete levels, depending on the number of bins (derived from `n_bins`).
|
|
405
|
+
"""
|
|
406
|
+
|
|
407
|
+
if self.n_bins is not None:
|
|
408
|
+
max_level = self.saturation_level if not np.isinf(self.saturation_level) else self.dataframe.Signal.max()
|
|
409
|
+
|
|
410
|
+
bins = np.linspace(0 * max_level, max_level, self.n_bins)
|
|
411
|
+
|
|
412
|
+
digitized = np.digitize(
|
|
413
|
+
x=self.dataframe.Signal.pint.to(volt).pint.magnitude,
|
|
414
|
+
bins=bins.to(volt).magnitude
|
|
415
|
+
) - 1
|
|
416
|
+
self.dataframe.Signal = pint_pandas.PintArray(bins[digitized], volt)
|
|
417
|
+
|
|
418
|
+
@plot_helper
|
|
419
|
+
def plot(
|
|
420
|
+
self,
|
|
421
|
+
color: str = None,
|
|
422
|
+
ax: Optional[plt.Axes] = None,
|
|
423
|
+
time_unit: Optional[Union[str, Quantity]] = None,
|
|
424
|
+
signal_unit: Optional[Union[str, Quantity]] = None,
|
|
425
|
+
add_captured: bool = True,
|
|
426
|
+
add_peak_locator: bool = False,
|
|
427
|
+
add_raw: bool = False
|
|
428
|
+
) -> tuple[Quantity, Quantity]:
|
|
429
|
+
"""
|
|
430
|
+
Visualizes the signal and optional components (peaks, raw signal) over time.
|
|
431
|
+
|
|
432
|
+
This method generates a customizable plot of the processed signal as a function of time.
|
|
433
|
+
Additional components like raw signals and detected peaks can also be overlaid.
|
|
434
|
+
|
|
435
|
+
Parameters
|
|
436
|
+
----------
|
|
437
|
+
color : str, optional
|
|
438
|
+
Color for the processed signal line. Default is 'C0' (default Matplotlib color cycle).
|
|
439
|
+
ax : matplotlib.axes.Axes, optional
|
|
440
|
+
An existing Matplotlib Axes object to plot on. If None, a new Axes will be created.
|
|
441
|
+
time_unit : str or Quantity, optional
|
|
442
|
+
Desired unit for the time axis. If None, defaults to the most compact unit of the `Time` column.
|
|
443
|
+
signal_unit : str or Quantity, optional
|
|
444
|
+
Desired unit for the signal axis. If None, defaults to the most compact unit of the `Signal` column.
|
|
445
|
+
add_captured : bool, optional
|
|
446
|
+
If True, overlays the captured signal data on the plot. Default is True.
|
|
447
|
+
add_peak_locator : bool, optional
|
|
448
|
+
If True, adds the detected peaks (if available) to the plot. Default is False.
|
|
449
|
+
add_raw : bool, optional
|
|
450
|
+
If True, overlays the raw signal data on the plot. Default is False.
|
|
451
|
+
|
|
452
|
+
Returns
|
|
453
|
+
-------
|
|
454
|
+
tuple[Quantity, Quantity]
|
|
455
|
+
A tuple containing the units used for the time and signal axes, respectively.
|
|
456
|
+
|
|
457
|
+
Notes
|
|
458
|
+
-----
|
|
459
|
+
- The `Time` and `Signal` data are automatically converted to the specified units for consistency.
|
|
460
|
+
- If no `ax` is provided, a new figure and axis will be generated.
|
|
461
|
+
- Warnings are logged if peak locator data is unavailable when `add_peak_locator` is True.
|
|
462
|
+
"""
|
|
463
|
+
# Set default units if not provided
|
|
464
|
+
signal_unit = signal_unit or self.dataframe.Signal.max().to_compact().units
|
|
465
|
+
time_unit = time_unit or self.dataframe.Time.max().to_compact().units
|
|
466
|
+
|
|
467
|
+
# Plot captured signal
|
|
468
|
+
if add_captured:
|
|
469
|
+
self._add_captured_signal_to_ax(ax=ax, time_unit=time_unit, signal_unit=signal_unit, color=color)
|
|
470
|
+
|
|
471
|
+
# Overlay peak locator positions, if requested
|
|
472
|
+
if add_peak_locator:
|
|
473
|
+
self._add_peak_locator_to_ax(ax=ax, time_unit=time_unit, signal_unit=signal_unit)
|
|
474
|
+
|
|
475
|
+
# Overlay raw signal, if requested
|
|
476
|
+
if add_raw:
|
|
477
|
+
self._add_raw_signal_to_ax(ax=ax, time_unit=time_unit, signal_unit=signal_unit)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
# Customize labels
|
|
481
|
+
ax.set_xlabel(f"Time [{time_unit:P}]")
|
|
482
|
+
ax.set_ylabel(f"{self.name} [{signal_unit:P}]")
|
|
483
|
+
ax.legend()
|
|
484
|
+
|
|
485
|
+
return time_unit, signal_unit
|
|
486
|
+
|
|
487
|
+
def _add_peak_locator_to_ax(self, ax: plt.Axes, time_unit: Quantity, signal_unit: Quantity) -> None:
|
|
488
|
+
"""
|
|
489
|
+
Adds peak positions detected by the algorithm to the plot.
|
|
490
|
+
|
|
491
|
+
Parameters
|
|
492
|
+
----------
|
|
493
|
+
ax : matplotlib.axes.Axes
|
|
494
|
+
The axis object to plot on.
|
|
495
|
+
time_unit : Quantity
|
|
496
|
+
Unit for the time axis.
|
|
497
|
+
signal_unit : Quantity
|
|
498
|
+
Unit for the signal axis.
|
|
499
|
+
"""
|
|
500
|
+
if not hasattr(self, 'algorithm'):
|
|
501
|
+
logging.warning("The detector does not have a peak locator algorithm. Peaks cannot be plotted.")
|
|
502
|
+
return
|
|
503
|
+
|
|
504
|
+
self.algorithm._add_to_ax(ax=ax, signal_unit=signal_unit, time_unit=time_unit)
|
|
505
|
+
|
|
506
|
+
def _add_captured_signal_to_ax(self, ax: plt.Axes, time_unit: Quantity, signal_unit: Quantity, color: str = None) -> None:
|
|
507
|
+
"""
|
|
508
|
+
Adds the processed (captured) signal to the plot.
|
|
509
|
+
|
|
510
|
+
Parameters
|
|
511
|
+
----------
|
|
512
|
+
ax : matplotlib.axes.Axes
|
|
513
|
+
The axis object to plot on.
|
|
514
|
+
time_unit : Quantity
|
|
515
|
+
Unit for the time axis.
|
|
516
|
+
signal_unit : Quantity
|
|
517
|
+
Unit for the signal axis.
|
|
518
|
+
color : str
|
|
519
|
+
Color for the signal line.
|
|
520
|
+
"""
|
|
521
|
+
if not hasattr(self.dataframe, 'Signal'):
|
|
522
|
+
logging.warning("The detector does not have a captured signal. Please run .capture_signal() method first.")
|
|
523
|
+
return
|
|
524
|
+
|
|
525
|
+
x = self.dataframe['Time'].pint.to(time_unit)
|
|
526
|
+
y = self.dataframe['Signal'].pint.to(signal_unit)
|
|
527
|
+
ax.plot(x, y, color=color, label='Processed Signal')
|
|
528
|
+
|
|
529
|
+
def _add_raw_signal_to_ax(self, ax: plt.Axes, time_unit: Quantity, signal_unit: Quantity, color: str = None) -> None:
|
|
530
|
+
"""
|
|
531
|
+
Adds the raw signal to the plot.
|
|
532
|
+
|
|
533
|
+
Parameters
|
|
534
|
+
----------
|
|
535
|
+
ax : matplotlib.axes.Axes
|
|
536
|
+
The axis object to plot on.
|
|
537
|
+
time_unit : Quantity
|
|
538
|
+
Unit for the time axis.
|
|
539
|
+
signal_unit : Quantity
|
|
540
|
+
Unit for the signal axis.
|
|
541
|
+
color : str
|
|
542
|
+
Color for the raw signal line.
|
|
543
|
+
"""
|
|
544
|
+
if not hasattr(self.dataframe, 'RawSignal'):
|
|
545
|
+
logging.warning("The detector does not have a captured signal. Please run .init_raw_signal(run_time) method first.")
|
|
546
|
+
return
|
|
547
|
+
|
|
548
|
+
x = self.dataframe['Time'].pint.to(time_unit)
|
|
549
|
+
y = self.dataframe['RawSignal'].pint.to(signal_unit)
|
|
550
|
+
ax.plot(x, y, color=color, linestyle='--', label=f'{self.name}: Raw Signal')
|
|
551
|
+
|
|
552
|
+
def set_peak_locator(self, algorithm: BasePeakLocator, compute_peak_area: bool = True) -> None:
|
|
553
|
+
"""
|
|
554
|
+
Assigns a peak detection algorithm to the detector, analyzes the signal,
|
|
555
|
+
and extracts peak features such as height, width, and area.
|
|
556
|
+
|
|
557
|
+
Parameters
|
|
558
|
+
----------
|
|
559
|
+
algorithm : BasePeakLocator
|
|
560
|
+
An instance of a peak detection algorithm derived from BasePeakLocator.
|
|
561
|
+
compute_peak_area : bool, optional
|
|
562
|
+
Whether to compute the area under the detected peaks (default is True).
|
|
563
|
+
|
|
564
|
+
Raises
|
|
565
|
+
------
|
|
566
|
+
TypeError
|
|
567
|
+
If the provided algorithm is not an instance of BasePeakLocator.
|
|
568
|
+
ValueError
|
|
569
|
+
If the algorithm has already been initialized with peak data.
|
|
570
|
+
RuntimeError
|
|
571
|
+
If the detector's signal data (dataframe) is not available.
|
|
572
|
+
|
|
573
|
+
Notes
|
|
574
|
+
-----
|
|
575
|
+
- The `algorithm` parameter should be a fresh instance of a peak detection algorithm.
|
|
576
|
+
- The method will analyze the detector's signal immediately upon setting the algorithm.
|
|
577
|
+
- Peak detection results are stored in the algorithm's `peak_properties` attribute.
|
|
578
|
+
"""
|
|
579
|
+
|
|
580
|
+
# Ensure the algorithm is an instance of BasePeakLocator
|
|
581
|
+
if not isinstance(algorithm, BasePeakLocator):
|
|
582
|
+
raise TypeError("The algorithm must be an instance of BasePeakLocator.")
|
|
583
|
+
|
|
584
|
+
# Ensure the detector has signal data available for analysis
|
|
585
|
+
if not hasattr(self, 'dataframe') or self.dataframe is None:
|
|
586
|
+
raise RuntimeError("The detector does not have signal data available for peak detection.")
|
|
587
|
+
|
|
588
|
+
# Set the algorithm and perform peak detection
|
|
589
|
+
self.algorithm = copy(algorithm)
|
|
590
|
+
self.algorithm.init_data(self.dataframe)
|
|
591
|
+
self.algorithm.detect_peaks(compute_area=compute_peak_area)
|
|
592
|
+
|
|
593
|
+
# Log the result of peak detection
|
|
594
|
+
peak_count = len(self.algorithm.peak_properties) if hasattr(self.algorithm, 'peak_properties') else 0
|
|
595
|
+
logging.info(f"Detector {self.name}: Detected {peak_count} peaks.")
|
|
596
|
+
|
|
597
|
+
def print_properties(self) -> None:
|
|
598
|
+
"""
|
|
599
|
+
Prints specific properties of the Detector instance, such as coupling factor and medium refractive index.
|
|
600
|
+
This method calls the parent class method to handle the actual property printing logic.
|
|
601
|
+
|
|
602
|
+
"""
|
|
603
|
+
_dict = {
|
|
604
|
+
'Sampling frequency': self.sampling_freq,
|
|
605
|
+
'Phi angle': self.phi_angle,
|
|
606
|
+
'Gamma angle': self.gamma_angle,
|
|
607
|
+
'Numerical aperture': self.numerical_aperture,
|
|
608
|
+
'Responsitivity': self.responsitivity,
|
|
609
|
+
'Saturation Level': self.saturation_level,
|
|
610
|
+
'Dark Current': self.dark_current,
|
|
611
|
+
'Resistance': self.resistance,
|
|
612
|
+
'Temperature': self.temperature,
|
|
613
|
+
'N Bins': self.n_bins
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
super(Detector, self).print_properties(**_dict)
|
FlowCyPy/directories.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import FlowCyPy
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
'root_path',
|
|
10
|
+
'project_path',
|
|
11
|
+
'doc_path',
|
|
12
|
+
'doc_css_path',
|
|
13
|
+
'logo_path'
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
root_path = Path(FlowCyPy.__path__[0])
|
|
17
|
+
|
|
18
|
+
project_path = root_path.parents[0]
|
|
19
|
+
|
|
20
|
+
example_directory = root_path.joinpath('examples')
|
|
21
|
+
|
|
22
|
+
doc_path = project_path.joinpath('docs')
|
|
23
|
+
|
|
24
|
+
doc_css_path = doc_path.joinpath('source/_static/default.css')
|
|
25
|
+
|
|
26
|
+
logo_path = doc_path.joinpath('images/logo.png')
|
|
27
|
+
|
|
28
|
+
examples_path = root_path.joinpath('examples')
|
|
29
|
+
|
|
30
|
+
if __name__ == '__main__':
|
|
31
|
+
for path_name in __all__:
|
|
32
|
+
path = locals()[path_name]
|
|
33
|
+
print(path)
|
|
34
|
+
assert path.exists(), f"Path {path_name} do not exists"
|
|
35
|
+
|
|
36
|
+
# -
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from .normal import Normal
|
|
2
|
+
from .lognormal import LogNormal
|
|
3
|
+
from .uniform import Uniform
|
|
4
|
+
from .delta import Delta
|
|
5
|
+
from .weibull import Weibull
|
|
6
|
+
from .base_class import Base
|
|
7
|
+
from .particle_size_distribution import RosinRammler
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
Normal,
|
|
11
|
+
LogNormal,
|
|
12
|
+
Weibull,
|
|
13
|
+
Delta,
|
|
14
|
+
Uniform,
|
|
15
|
+
RosinRammler
|
|
16
|
+
]
|