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.
Files changed (45) hide show
  1. FlowCyPy/__init__.py +13 -0
  2. FlowCyPy/_version.py +16 -0
  3. FlowCyPy/acquisition.py +652 -0
  4. FlowCyPy/classifier.py +208 -0
  5. FlowCyPy/coupling_mechanism/__init__.py +4 -0
  6. FlowCyPy/coupling_mechanism/empirical.py +47 -0
  7. FlowCyPy/coupling_mechanism/mie.py +207 -0
  8. FlowCyPy/coupling_mechanism/rayleigh.py +116 -0
  9. FlowCyPy/coupling_mechanism/uniform.py +40 -0
  10. FlowCyPy/coupling_mechanism.py +205 -0
  11. FlowCyPy/cytometer.py +314 -0
  12. FlowCyPy/detector.py +439 -0
  13. FlowCyPy/directories.py +36 -0
  14. FlowCyPy/distribution/__init__.py +16 -0
  15. FlowCyPy/distribution/base_class.py +79 -0
  16. FlowCyPy/distribution/delta.py +104 -0
  17. FlowCyPy/distribution/lognormal.py +124 -0
  18. FlowCyPy/distribution/normal.py +128 -0
  19. FlowCyPy/distribution/particle_size_distribution.py +132 -0
  20. FlowCyPy/distribution/uniform.py +117 -0
  21. FlowCyPy/distribution/weibull.py +115 -0
  22. FlowCyPy/flow_cell.py +198 -0
  23. FlowCyPy/helper.py +81 -0
  24. FlowCyPy/logger.py +136 -0
  25. FlowCyPy/noises.py +34 -0
  26. FlowCyPy/particle_count.py +127 -0
  27. FlowCyPy/peak_locator/__init__.py +4 -0
  28. FlowCyPy/peak_locator/base_class.py +163 -0
  29. FlowCyPy/peak_locator/basic.py +108 -0
  30. FlowCyPy/peak_locator/derivative.py +143 -0
  31. FlowCyPy/peak_locator/moving_average.py +166 -0
  32. FlowCyPy/physical_constant.py +19 -0
  33. FlowCyPy/plottings.py +269 -0
  34. FlowCyPy/population.py +136 -0
  35. FlowCyPy/populations_instances.py +65 -0
  36. FlowCyPy/scatterer_collection.py +306 -0
  37. FlowCyPy/signal_digitizer.py +90 -0
  38. FlowCyPy/source.py +249 -0
  39. FlowCyPy/units.py +30 -0
  40. FlowCyPy/utils.py +191 -0
  41. FlowCyPy-0.7.0.dist-info/LICENSE +21 -0
  42. FlowCyPy-0.7.0.dist-info/METADATA +252 -0
  43. FlowCyPy-0.7.0.dist-info/RECORD +45 -0
  44. FlowCyPy-0.7.0.dist-info/WHEEL +5 -0
  45. FlowCyPy-0.7.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,205 @@
1
+ import numpy as np
2
+ from FlowCyPy import ScattererCollection, Detector
3
+ from FlowCyPy.source import BaseBeam
4
+ from PyMieSim.experiment.scatterer import Sphere as PMS_SPHERE
5
+ from PyMieSim.experiment.source import PlaneWave
6
+ from PyMieSim.experiment.detector import Photodiode as PMS_PHOTODIODE
7
+ from PyMieSim.experiment import Setup
8
+ from PyMieSim.units import degree, watt, AU, hertz
9
+ from FlowCyPy.noises import NoiseSetting
10
+
11
+
12
+ def apply_rin_noise(source: BaseBeam, total_size: int, bandwidth: float) -> np.ndarray:
13
+ r"""
14
+ Applies Relative Intensity Noise (RIN) to the source amplitude if enabled, accounting for detection bandwidth.
15
+
16
+ Parameters
17
+ ----------
18
+ source : BaseBeam
19
+ The light source containing amplitude and RIN information.
20
+ total_size : int
21
+ The number of particles being simulated.
22
+ bandwidth : float
23
+ The detection bandwidth in Hz.
24
+
25
+ Returns
26
+ -------
27
+ np.ndarray
28
+ Array of amplitudes with RIN noise applied.
29
+
30
+ Equations
31
+ ---------
32
+ 1. Relative Intensity Noise (RIN):
33
+ RIN quantifies the fluctuations in the laser's intensity relative to its mean intensity.
34
+ RIN is typically specified as a power spectral density (PSD) in units of dB/Hz:
35
+ \[
36
+ \text{RIN (dB/Hz)} = 10 \cdot \log_{10}\left(\frac{\text{Noise Power (per Hz)}}{\text{Mean Power}}\right)
37
+ \]
38
+
39
+ 2. Conversion from dB/Hz to Linear Scale:
40
+ To compute noise power, RIN must be converted from dB to a linear scale:
41
+ \[
42
+ \text{RIN (linear)} = 10^{\text{RIN (dB/Hz)} / 10}
43
+ \]
44
+
45
+ 3. Total Noise Power:
46
+ The total noise power depends on the bandwidth (\(B\)) of the detection system:
47
+ \[
48
+ P_{\text{noise}} = \text{RIN (linear)} \cdot B
49
+ \]
50
+
51
+ 4. Standard Deviation of Amplitude Fluctuations:
52
+ The noise standard deviation for amplitude is derived from the total noise power:
53
+ \[
54
+ \sigma_{\text{amplitude}} = \sqrt{P_{\text{noise}}} \cdot \text{Amplitude}
55
+ \]
56
+ Substituting \(P_{\text{noise}}\), we get:
57
+ \[
58
+ \sigma_{\text{amplitude}} = \sqrt{\text{RIN (linear)} \cdot B} \cdot \text{Amplitude}
59
+ \]
60
+
61
+ Implementation
62
+ --------------
63
+ - The RIN value from the source is converted to linear scale using:
64
+ \[
65
+ \text{RIN (linear)} = 10^{\text{source.RIN} / 10}
66
+ \]
67
+ - The noise standard deviation is scaled by the detection bandwidth (\(B\)) in Hz:
68
+ \[
69
+ \sigma_{\text{amplitude}} = \sqrt{\text{RIN (linear)} \cdot B} \cdot \text{source.amplitude}
70
+ \]
71
+ - Gaussian noise with mean \(0\) and standard deviation \(\sigma_{\text{amplitude}}\) is applied to the source amplitude.
72
+
73
+ Notes
74
+ -----
75
+ - The bandwidth parameter (\(B\)) must be in Hz and reflects the frequency range of the detection system.
76
+ - The function assumes that RIN is specified in dB/Hz. If RIN is already in linear scale, the conversion step can be skipped.
77
+ """
78
+ amplitude_with_rin = np.ones(total_size) * source.amplitude
79
+
80
+ if NoiseSetting.include_RIN_noise and NoiseSetting.include_noises:
81
+ # Convert RIN from dB/Hz to linear scale if necessary
82
+ rin_linear = 10**(source.RIN / 10)
83
+
84
+ # Compute noise standard deviation, scaled by bandwidth
85
+ std_dev_amplitude = np.sqrt(rin_linear * bandwidth.to(hertz).magnitude) * source.amplitude
86
+
87
+ # Apply Gaussian noise to the amplitude
88
+ amplitude_with_rin += np.random.normal(
89
+ loc=0,
90
+ scale=std_dev_amplitude.to(source.amplitude.units).magnitude,
91
+ size=total_size
92
+ ) * source.amplitude.units
93
+
94
+ return amplitude_with_rin
95
+
96
+
97
+ def initialize_scatterer(scatterer: ScattererCollection, source: PlaneWave) -> PMS_SPHERE:
98
+ """
99
+ Initializes the scatterer object for the PyMieSim experiment.
100
+
101
+ Parameters
102
+ ----------
103
+ scatterer : ScattererCollection
104
+ The scatterer object containing particle data.
105
+ source : PlaneWave
106
+ The light source for the simulation.
107
+
108
+ Returns
109
+ -------
110
+ PMS_SPHERE
111
+ Initialized scatterer for the experiment.
112
+ """
113
+ size_list = scatterer.dataframe['Size'].values
114
+ ri_list = scatterer.dataframe['RefractiveIndex'].values
115
+
116
+ if len(size_list) == 0:
117
+ raise ValueError("ScattererCollection size list is empty.")
118
+
119
+ size_list = size_list.quantity.magnitude * size_list.units
120
+ ri_list = ri_list.quantity.magnitude * ri_list.units
121
+
122
+ return PMS_SPHERE(
123
+ diameter=size_list,
124
+ property=ri_list,
125
+ medium_property=np.ones(len(size_list)) * scatterer.medium_refractive_index,
126
+ source=source
127
+ )
128
+
129
+
130
+ def initialize_detector(detector: Detector, total_size: int) -> PMS_PHOTODIODE:
131
+ """
132
+ Initializes the detector object for the PyMieSim experiment.
133
+
134
+ Parameters
135
+ ----------
136
+ detector : Detector
137
+ The detector object containing configuration data.
138
+ total_size : int
139
+ The number of particles being simulated.
140
+
141
+ Returns
142
+ -------
143
+ PMS_PHOTODIODE
144
+ Initialized detector for the experiment.
145
+ """
146
+ ONES = np.ones(total_size)
147
+
148
+ return PMS_PHOTODIODE(
149
+ NA=ONES * detector.numerical_aperture,
150
+ cache_NA=ONES * 0 * AU,
151
+ gamma_offset=ONES * detector.gamma_angle,
152
+ phi_offset=ONES * detector.phi_angle,
153
+ polarization_filter=ONES * np.nan * degree,
154
+ sampling=ONES * detector.sampling
155
+ )
156
+
157
+
158
+ def compute_detected_signal(source: BaseBeam, detector: Detector, scatterer: ScattererCollection, tolerance: float = 1e-5) -> np.ndarray:
159
+ """
160
+ Computes the detected signal by analyzing the scattering properties of particles.
161
+
162
+ Parameters
163
+ ----------
164
+ source : BaseBeam
165
+ The light source object containing wavelength, power, and other optical properties.
166
+ detector : Detector
167
+ The detector object containing properties such as numerical aperture and angles.
168
+ scatterer : ScattererCollection
169
+ The scatterer object containing particle size and refractive index data.
170
+ tolerance : float, optional
171
+ The tolerance for deciding if two values of size and refractive index are "close enough" to be cached.
172
+
173
+ Returns
174
+ -------
175
+ np.ndarray
176
+ Array of coupling values for each particle, based on the detected signal.
177
+ """
178
+ size_list = scatterer.dataframe['Size'].values
179
+
180
+ if len(size_list) == 0:
181
+ return np.array([]) * watt
182
+
183
+ total_size = len(size_list)
184
+ amplitude_with_rin = apply_rin_noise(source, total_size, detector.bandwidth)
185
+
186
+ pms_source = PlaneWave(
187
+ wavelength=np.ones(total_size) * source.wavelength,
188
+ polarization=np.ones(total_size) * 0 * degree,
189
+ amplitude=amplitude_with_rin
190
+ )
191
+
192
+ pms_scatterer = initialize_scatterer(scatterer, pms_source)
193
+ pms_detector = initialize_detector(detector, total_size)
194
+
195
+ # Configure the detector
196
+ pms_detector.mode_number = ['NC00'] * total_size
197
+ pms_detector.rotation = np.ones(total_size) * 0 * degree
198
+ pms_detector.__post_init__()
199
+
200
+ # Set up the experiment
201
+ experiment = Setup(source=pms_source, scatterer=pms_scatterer, detector=pms_detector)
202
+
203
+ # Compute coupling values
204
+ coupling_value = experiment.get_sequential('coupling').squeeze()
205
+ return np.atleast_1d(coupling_value) * watt
FlowCyPy/cytometer.py ADDED
@@ -0,0 +1,314 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ import logging
5
+ import numpy as np
6
+ from typing import List, Callable, Optional
7
+ from MPSPlots.styles import mps
8
+ from FlowCyPy.flow_cell import FlowCell
9
+ from FlowCyPy.detector import Detector
10
+ import pandas as pd
11
+ import pint_pandas
12
+ from FlowCyPy import units
13
+ from FlowCyPy.units import Quantity, milliwatt
14
+ from pint_pandas import PintArray
15
+ from FlowCyPy.acquisition import Acquisition
16
+ from FlowCyPy.signal_digitizer import SignalDigitizer
17
+
18
+
19
+ # Set up logging configuration
20
+ logging.basicConfig(
21
+ level=logging.INFO,
22
+ format='%(levelname)s - %(message)s'
23
+ )
24
+
25
+
26
+ class FlowCytometer:
27
+ """
28
+ A simulation class for modeling flow cytometer signals, including Forward Scatter (FSC) and Side Scatter (SSC) channels.
29
+
30
+ The FlowCytometer class integrates optical and flow dynamics to simulate signal generation in a flow cytometer.
31
+ It handles particle distributions, flow cell properties, laser source configurations, and detector behavior to
32
+ replicate realistic cytometry conditions. This includes the generation of synthetic signal pulses for each
33
+ particle event and noise modeling for accurate signal representation.
34
+
35
+ Parameters
36
+ ----------
37
+ flow_cell : FlowCell
38
+ The flow cell object representing the fluidic and optical environment through which particles travel.
39
+ detectors : List[Detector]
40
+ A list of `Detector` objects representing the detectors used to measure optical signals (e.g., FSC and SSC). Exactly two detectors must be provided.
41
+ coupling_mechanism : str, optional
42
+ The scattering mechanism used to couple the signal from the particles to the detectors.
43
+ Supported mechanisms include: 'mie' (default): Mie scattering, 'rayleigh': Rayleigh scattering, 'uniform': Uniform signal coupling, 'empirical': Empirical data-driven coupling
44
+ background_power : Quantity, optional
45
+ The background optical power added to the detector signal. Defaults to 0 milliwatts.
46
+
47
+ Attributes
48
+ ----------
49
+ flow_cell : FlowCell
50
+ The flow cell instance representing the system environment.
51
+ scatterer_collection : ScattererCollection
52
+ A collection of particles or scatterers passing through the flow cytometer.
53
+ source : GaussianBeam
54
+ The laser beam source providing illumination to the flow cytometer.
55
+ detectors : List[Detector]
56
+ The detectors used to collect and process signals from the scatterers.
57
+ coupling_mechanism : str
58
+ The selected mechanism for signal coupling.
59
+ background_power : Quantity
60
+ The optical background power added to the detector signals.
61
+
62
+ Raises
63
+ ------
64
+ AssertionError
65
+ If the number of detectors provided is not exactly two, or if both detectors share the same name.
66
+
67
+ """
68
+ def __init__(
69
+ self,
70
+ scatterer_collection: object,
71
+ flow_cell: FlowCell,
72
+ signal_digitizer: SignalDigitizer,
73
+ detectors: List[Detector],
74
+ coupling_mechanism: Optional[str] = 'mie',
75
+ background_power: Optional[Quantity] = 0 * milliwatt):
76
+
77
+ self.scatterer_collection = scatterer_collection
78
+ self.flow_cell = flow_cell
79
+ self.source = flow_cell.source
80
+ self.detectors = detectors
81
+ self.signal_digitizer = signal_digitizer
82
+ self.coupling_mechanism = coupling_mechanism
83
+ self.background_power = background_power
84
+
85
+ assert len(self.detectors) == 2, 'For now, FlowCytometer can only take two detectors for the analysis.'
86
+ assert self.detectors[0].name != self.detectors[1].name, 'Both detectors cannot have the same name'
87
+
88
+ for detector in detectors:
89
+ detector.cytometer = self
90
+
91
+ def run_coupling_analysis(self, scatterer_dataframe: pd.DataFrame) -> None:
92
+ """
93
+ Computes and assigns the optical coupling power for each particle-detection event.
94
+
95
+ This method evaluates the coupling between the scatterers in the flow cell and the detectors
96
+ using the specified detection mechanism. The computed coupling power is stored in the
97
+ `scatterer_collection` dataframe under detector-specific columns.
98
+
99
+ Updates
100
+ -------
101
+ scatterer_collection.dataframe : pandas.DataFrame
102
+ Adds columns for each detector, labeled as "detector: <detector_name>", containing the computed
103
+ coupling power for all particle events.
104
+
105
+ Raises
106
+ ------
107
+ ValueError
108
+ If an invalid coupling mechanism is specified during initialization.
109
+ """
110
+ detection_mechanism = self._get_detection_mechanism()
111
+
112
+ for detector in self.detectors:
113
+ self.coupling_power = detection_mechanism(
114
+ source=self.source,
115
+ detector=detector,
116
+ scatterer_dataframe=scatterer_dataframe,
117
+ medium_refractive_index=self.scatterer_collection.medium_refractive_index
118
+ )
119
+
120
+ scatterer_dataframe[detector.name] = pint_pandas.PintArray(self.coupling_power, dtype=self.coupling_power.units)
121
+
122
+ def _generate_pulse_parameters(self, scatterer_dataframe: pd.DataFrame) -> None:
123
+ """
124
+ Generates and assigns random Gaussian pulse parameters for each particle event.
125
+
126
+ The generated parameters include:
127
+ - Centers: The time at which each pulse occurs.
128
+ - Widths: The standard deviation (spread) of each pulse in seconds.
129
+
130
+ Effects
131
+ -------
132
+ scatterer_collection.dataframe : pandas.DataFrame
133
+ Adds a 'Widths' column with computed pulse widths for each particle.
134
+ Uses the flow speed and beam waist to calculate pulse widths.
135
+ """
136
+ columns = pd.MultiIndex.from_product(
137
+ [[p.name for p in self.detectors], ['Centers', 'Heights']]
138
+ )
139
+
140
+ self.pulse_dataframe = pd.DataFrame(columns=columns)
141
+
142
+ self.pulse_dataframe['Centers'] = scatterer_dataframe['Time']
143
+
144
+ widths = self.source.waist / self.flow_cell.flow_speed * np.ones(len(scatterer_dataframe))
145
+
146
+ scatterer_dataframe['Widths'] = pint_pandas.PintArray(widths, dtype=widths.units)
147
+
148
+ def initialize_signal(self, run_time: Quantity) -> None:
149
+ """
150
+ Initializes the raw signal for each detector based on the source and flow cell configuration.
151
+
152
+ This method prepares the detectors for signal capture by associating each detector with the
153
+ light source and generating a time-dependent raw signal placeholder.
154
+
155
+ Effects
156
+ -------
157
+ Each detector's `raw_signal` attribute is initialized with time-dependent values
158
+ based on the flow cell's runtime.
159
+
160
+ """
161
+ dataframes = []
162
+
163
+ # Initialize the detectors
164
+ for detector in self.detectors:
165
+ dataframe = detector.get_initialized_signal(run_time=run_time, signal_digitizer=self.signal_digitizer)
166
+
167
+ dataframes.append(dataframe)
168
+
169
+ self.dataframe = pd.concat(dataframes, keys=[d.name for d in self.detectors])
170
+
171
+ self.dataframe.index.names = ["Detector", "Index"]
172
+
173
+ def get_acquisition(self, run_time: Quantity) -> None:
174
+ """
175
+ Simulates the generation of optical signal pulses for each particle event.
176
+
177
+ This method calculates Gaussian signal pulses based on particle positions, coupling power, and
178
+ widths. It adds the generated pulses, background power, and noise components (thermal and dark current)
179
+ to each detector's raw signal.
180
+
181
+ Notes
182
+ -----
183
+ - Adds Gaussian pulses to each detector's `raw_signal`.
184
+ - Includes noise and background power in the simulated signals.
185
+ - Updates detector dataframes with captured signal information.
186
+
187
+ Raises
188
+ ------
189
+ ValueError
190
+ If the scatterer collection lacks required data columns ('Widths', 'Time').
191
+ """
192
+ if not run_time.check('second'):
193
+ raise ValueError(f"flow_speed must be in meter per second, but got {run_time.units}")
194
+
195
+ self.initialize_signal(run_time=run_time)
196
+
197
+ scatterer_dataframe = self.flow_cell.generate_event_dataframe(self.scatterer_collection.populations, run_time=run_time)
198
+
199
+ self.scatterer_collection.fill_dataframe_with_sampling(scatterer_dataframe)
200
+
201
+ self.run_coupling_analysis(scatterer_dataframe)
202
+
203
+ self._generate_pulse_parameters(scatterer_dataframe)
204
+
205
+ self.scatterer_collection.dataframe = scatterer_dataframe
206
+
207
+ _widths = scatterer_dataframe['Widths'].pint.to('second').pint.quantity.magnitude
208
+ _centers = scatterer_dataframe['Time'].pint.to('second').pint.quantity.magnitude
209
+
210
+ for detector in self.detectors:
211
+ _coupling_power = scatterer_dataframe[detector.name].values
212
+
213
+ detector_signal = self.dataframe.xs(detector.name)['Signal']
214
+
215
+ # Generate noise components
216
+ detector._add_thermal_noise_to_raw_signal(signal=detector_signal)
217
+
218
+ detector._add_dark_current_noise_to_raw_signal(signal=detector_signal)
219
+
220
+ # Broadcast the time array to the shape of (number of signals, len(detector.time))
221
+ time = self.dataframe.xs(detector.name)['Time'].pint.magnitude
222
+
223
+ time_grid = np.expand_dims(time, axis=0) * units.second
224
+
225
+ centers = np.expand_dims(_centers, axis=1) * units.second
226
+ widths = np.expand_dims(_widths, axis=1) * units.second
227
+
228
+ # Compute the Gaussian for each height, center, and width using broadcasting
229
+ power_gaussians = _coupling_power[:, np.newaxis] * np.exp(- (time_grid - centers) ** 2 / (2 * widths ** 2))
230
+
231
+ total_power = np.sum(power_gaussians, axis=0) + self.background_power
232
+
233
+ # Sum all the Gaussians and add them to the detector.raw_signal
234
+ detector._add_optical_power_to_raw_signal(
235
+ signal=detector_signal,
236
+ optical_power=total_power,
237
+ wavelength=self.flow_cell.source.wavelength
238
+ )
239
+
240
+ digitized_signal = detector.capture_signal(signal=detector_signal)
241
+
242
+ self.dataframe.loc[detector.name, 'Signal'] = PintArray(detector_signal, detector_signal.pint.units)
243
+
244
+ self.dataframe.loc[detector.name, 'DigitizedSignal'] = PintArray(digitized_signal, units.bit_bins)
245
+
246
+ experiment = Acquisition(
247
+ cytometer=self,
248
+ run_time=run_time,
249
+ scatterer_dataframe=scatterer_dataframe,
250
+ detector_dataframe=self.dataframe
251
+ )
252
+
253
+ return experiment
254
+
255
+ def _get_detection_mechanism(self) -> Callable:
256
+ """
257
+ Retrieves the detection mechanism function for signal coupling based on the selected method.
258
+
259
+ Supported Coupling Mechanisms
260
+ -----------------------------
261
+ - 'mie': Mie scattering.
262
+ - 'rayleigh': Rayleigh scattering.
263
+ - 'uniform': Uniform scattering.
264
+ - 'empirical': Empirical (data-driven) scattering.
265
+
266
+ Returns
267
+ -------
268
+ Callable
269
+ A function that computes the detected signal for scatterer sizes and particle distributions.
270
+
271
+ Raises
272
+ ------
273
+ ValueError
274
+ If an unsupported coupling mechanism is specified.
275
+ """
276
+ from FlowCyPy import coupling_mechanism
277
+
278
+ # Determine which coupling mechanism to use and compute the corresponding factors
279
+ match self.coupling_mechanism.lower():
280
+ case 'rayleigh':
281
+ return coupling_mechanism.rayleigh.compute_detected_signal
282
+ case 'uniform':
283
+ return coupling_mechanism.uniform.compute_detected_signal
284
+ case 'mie':
285
+ return coupling_mechanism.mie.compute_detected_signal
286
+ case 'empirical':
287
+ return coupling_mechanism.empirical.compute_detected_signal
288
+ case _:
289
+ raise ValueError("Invalid coupling mechanism. Choose 'rayleigh' or 'uniform'.")
290
+
291
+ def add_detector(self, **kwargs) -> Detector:
292
+ """
293
+ Dynamically adds a new detector to the system configuration.
294
+
295
+ Parameters
296
+ ----------
297
+ **kwargs : dict
298
+ Keyword arguments passed to the `Detector` constructor.
299
+
300
+ Returns
301
+ -------
302
+ Detector
303
+ The newly added detector instance.
304
+
305
+ Effects
306
+ -------
307
+ - Appends the created detector to the `detectors` list.
308
+ """
309
+ detector = Detector(**kwargs)
310
+
311
+ self.detectors.append(detector)
312
+
313
+ return detector
314
+