FlowCyPy 1.0.4__cp313-cp313-macosx_26_0_arm64.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 (65) hide show
  1. FlowCyPy/.dylibs/libfftw3.3.dylib +0 -0
  2. FlowCyPy/.dylibs/libgcc_s.1.1.dylib +0 -0
  3. FlowCyPy/.dylibs/libgomp.1.dylib +0 -0
  4. FlowCyPy/.dylibs/libstdc++.6.dylib +0 -0
  5. FlowCyPy/__init__.py +60 -0
  6. FlowCyPy/_utils.cpython-313-darwin.so +0 -0
  7. FlowCyPy/_version.py +24 -0
  8. FlowCyPy/digital_processing/__init__.py +3 -0
  9. FlowCyPy/digital_processing/classifier.cpython-313-darwin.so +0 -0
  10. FlowCyPy/digital_processing/discriminator.cpython-313-darwin.so +0 -0
  11. FlowCyPy/digital_processing/libclassifier_lib.a +0 -0
  12. FlowCyPy/digital_processing/libdiscriminator_lib.a +0 -0
  13. FlowCyPy/digital_processing/libpeak_locator_lib.a +0 -0
  14. FlowCyPy/digital_processing/peak_locator.cpython-313-darwin.so +0 -0
  15. FlowCyPy/digital_processing/pipeline.py +32 -0
  16. FlowCyPy/directories.py +30 -0
  17. FlowCyPy/flow_cytometer.py +492 -0
  18. FlowCyPy/fluidics/__init__.py +72 -0
  19. FlowCyPy/fluidics/distributions.cpython-313-darwin.so +0 -0
  20. FlowCyPy/fluidics/event_collection.py +1082 -0
  21. FlowCyPy/fluidics/flow_cell.cpython-313-darwin.so +0 -0
  22. FlowCyPy/fluidics/libdistributions_lib.a +0 -0
  23. FlowCyPy/fluidics/libflow_cell_lib.a +0 -0
  24. FlowCyPy/fluidics/libpopulation_events_lib.a +0 -0
  25. FlowCyPy/fluidics/libpopulations_lib.a +0 -0
  26. FlowCyPy/fluidics/libscatterer_collection_lib.a +0 -0
  27. FlowCyPy/fluidics/population_events.cpython-313-darwin.so +0 -0
  28. FlowCyPy/fluidics/populations.cpython-313-darwin.so +0 -0
  29. FlowCyPy/fluidics/scatterer_collection.cpython-313-darwin.so +0 -0
  30. FlowCyPy/fluidics/system.py +307 -0
  31. FlowCyPy/interface_pint.cpython-313-darwin.so +0 -0
  32. FlowCyPy/libpint.a +0 -0
  33. FlowCyPy/libutils_lib.a +0 -0
  34. FlowCyPy/opto_electronics/__init__.py +50 -0
  35. FlowCyPy/opto_electronics/amplifier.cpython-313-darwin.so +0 -0
  36. FlowCyPy/opto_electronics/circuits.cpython-313-darwin.so +0 -0
  37. FlowCyPy/opto_electronics/coupling_model.cpython-313-darwin.so +0 -0
  38. FlowCyPy/opto_electronics/detector.cpython-313-darwin.so +0 -0
  39. FlowCyPy/opto_electronics/digitizer.cpython-313-darwin.so +0 -0
  40. FlowCyPy/opto_electronics/libamplifier_lib.a +0 -0
  41. FlowCyPy/opto_electronics/libcircuits_lib.a +0 -0
  42. FlowCyPy/opto_electronics/libcoupling_model_lib.a +0 -0
  43. FlowCyPy/opto_electronics/libdetector_lib.a +0 -0
  44. FlowCyPy/opto_electronics/libdigitizer_lib.a +0 -0
  45. FlowCyPy/opto_electronics/libsource_lib.a +0 -0
  46. FlowCyPy/opto_electronics/source.cpython-313-darwin.so +0 -0
  47. FlowCyPy/opto_electronics/system.py +285 -0
  48. FlowCyPy/presets/__init__.py +3 -0
  49. FlowCyPy/presets/detector.py +86 -0
  50. FlowCyPy/presets/flow_cytometer.py +426 -0
  51. FlowCyPy/presets/population.py +110 -0
  52. FlowCyPy/run_record.py +479 -0
  53. FlowCyPy/sub_frames/__init__.py +0 -0
  54. FlowCyPy/sub_frames/acquisition.py +363 -0
  55. FlowCyPy/sub_frames/classifier.py +60 -0
  56. FlowCyPy/sub_frames/events.py +147 -0
  57. FlowCyPy/sub_frames/peak_metrics.py +47 -0
  58. FlowCyPy/sub_frames/peaks.py +901 -0
  59. FlowCyPy/sub_frames/triggered.py +243 -0
  60. FlowCyPy/units.py +17 -0
  61. FlowCyPy/workflow.py +286 -0
  62. flowcypy-1.0.4.dist-info/METADATA +493 -0
  63. flowcypy-1.0.4.dist-info/RECORD +65 -0
  64. flowcypy-1.0.4.dist-info/WHEEL +6 -0
  65. flowcypy-1.0.4.dist-info/licenses/LICENSE +21 -0
Binary file
Binary file
Binary file
Binary file
FlowCyPy/__init__.py ADDED
@@ -0,0 +1,60 @@
1
+ try:
2
+ from ._version import version as __version__ # noqa: F401
3
+
4
+ except ImportError:
5
+ __version__ = "0.0.0"
6
+
7
+ debug_mode = False
8
+
9
+
10
+ _LAZY_EXPORTS = {
11
+ "FlowCytometer": (".flow_cytometer", "FlowCytometer"),
12
+ "Workflow": (".workflow", "Workflow"),
13
+ "Fluidics": (".fluidics", "Fluidics"),
14
+ "distributions": (".fluidics", "distributions"),
15
+ "populations": (".fluidics", "populations"),
16
+ "OptoElectronics": (".opto_electronics", "OptoElectronics"),
17
+ "circuits": (".opto_electronics", "circuits"),
18
+ "source": (".opto_electronics", "source"),
19
+ "DigitalProcessing": (".digital_processing", "DigitalProcessing"),
20
+ "classifier": (".digital_processing", "classifier"),
21
+ "discriminator": (".digital_processing", "discriminator"),
22
+ "peak_locator": (".digital_processing", "peak_locator"),
23
+ "ureg": (".units", "ureg"),
24
+ }
25
+
26
+
27
+ def __getattr__(name: str):
28
+ """Resolve public objects only when they are first accessed.
29
+
30
+ Keeping the compatibility exports lazy makes ``import FlowCyPy`` cheap
31
+ while retaining the established top-level API.
32
+ """
33
+ try:
34
+ module_name, attribute_name = _LAZY_EXPORTS[name]
35
+ except KeyError as error:
36
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from error
37
+
38
+ from importlib import import_module
39
+
40
+ value = getattr(import_module(module_name, __name__), attribute_name)
41
+ globals()[name] = value
42
+ return value
43
+
44
+ __all__ = [
45
+ "__version__",
46
+ "FlowCytometer",
47
+ "Workflow",
48
+ "Fluidics",
49
+ "distributions",
50
+ "populations",
51
+ "OptoElectronics",
52
+ "circuits",
53
+ "source",
54
+ "DigitalProcessing",
55
+ "classifier",
56
+ "discriminator",
57
+ "peak_locator",
58
+ "ureg",
59
+ "debug_mode",
60
+ ]
Binary file
FlowCyPy/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '1.0.4'
22
+ __version_tuple__ = version_tuple = (1, 0, 4)
23
+
24
+ __commit_id__ = commit_id = 'g347e1bfc3'
@@ -0,0 +1,3 @@
1
+ from .pipeline import DigitalProcessing, discriminator, peak_locator
2
+
3
+ __all__ = ["DigitalProcessing", "discriminator", "peak_locator"]
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ import FlowCyPy.units as _
4
+ from . import discriminator
5
+ from . import peak_locator
6
+
7
+
8
+ class DigitalProcessing:
9
+ """
10
+ Container for analog and digital signal processing stages in a simulated flow cytometry pipeline.
11
+
12
+ This class orchestrates the application of analog electronic filtering, digitization, triggering,
13
+ and peak detection algorithms. It integrates key building blocks of the signal acquisition chain
14
+ in a modular and extensible way.
15
+
16
+ Parameters
17
+ ----------
18
+ discriminator : discriminator.BaseDiscriminator
19
+ Component responsible for extracting signal segments based on threshold-crossing
20
+ or window-based logic applied to the analog signal.
21
+ peak_algorithm : peak_locator.BasePeakLocator
22
+ Peak detection algorithm that locates and characterizes peaks in the digitized signal
23
+ (e.g., based on amplitude, area, or width).
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ discriminator: discriminator.BaseDiscriminator = None,
29
+ peak_algorithm: peak_locator.BasePeakLocator = None,
30
+ ):
31
+ self.discriminator = discriminator
32
+ self.peak_algorithm = peak_algorithm
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ from pathlib import Path
5
+
6
+ import FlowCyPy
7
+
8
+ __all__ = ["root_path", "project_path", "doc_path", "doc_css_path", "logo_path"]
9
+
10
+ root_path = Path(FlowCyPy.__path__[0])
11
+
12
+ project_path = root_path.parents[0]
13
+
14
+ example_directory = root_path.joinpath("examples")
15
+
16
+ doc_path = project_path.joinpath("docs")
17
+
18
+ doc_css_path = doc_path.joinpath("source/_static/default.css")
19
+
20
+ logo_path = doc_path.joinpath("images/logo.png")
21
+
22
+ examples_path = root_path.joinpath("examples")
23
+
24
+ if __name__ == "__main__":
25
+ for path_name in __all__:
26
+ path = locals()[path_name]
27
+ print(path)
28
+ assert path.exists(), f"Path {path_name} do not exists"
29
+
30
+ # -
@@ -0,0 +1,492 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ import logging
4
+ import math
5
+ from typing import Optional
6
+ import numpy as np
7
+ from TypedUnit import Power, Time, ureg, validate_units
8
+
9
+ from FlowCyPy.fluidics import Fluidics
10
+ from FlowCyPy.fluidics import populations
11
+ from FlowCyPy.fluidics.event_collection import EventCollection
12
+ from FlowCyPy.opto_electronics import OptoElectronics
13
+ from FlowCyPy.run_record import RunRecord
14
+ from FlowCyPy.digital_processing import DigitalProcessing
15
+ from FlowCyPy.sub_frames.acquisition import AcquisitionDataFrame
16
+ from FlowCyPy.sub_frames.peaks import PeakDataFrame
17
+ from FlowCyPy.sub_frames.triggered import TriggerDataFrame
18
+
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class FlowCytometer:
24
+ """
25
+ High level orchestrator for the flow cytometry simulation pipeline.
26
+
27
+ This class keeps only the persistent fluidic definition and the constant
28
+ optical background level. All other configuration objects are passed
29
+ explicitly to the methods that use them.
30
+
31
+ This design keeps the workflow fully visible to the caller and avoids any
32
+ ambiguity about which opto electronic or signal processing configuration is
33
+ active for a given computation.
34
+
35
+ Parameters
36
+ ----------
37
+ fluidics : Fluidics
38
+ Fluidic subsystem, including flow cell and population definitions.
39
+ background_power : pint.Quantity, optional
40
+ Constant optical background added to every detector channel.
41
+ """
42
+
43
+ @validate_units
44
+ def __init__(
45
+ self,
46
+ fluidics: Fluidics,
47
+ background_power: Optional[Power] = 0 * ureg.milliwatt,
48
+ ):
49
+ self.fluidics = fluidics
50
+ self.background_power = background_power
51
+
52
+ def _add_explicit_model_signals(
53
+ self,
54
+ event_collection: EventCollection,
55
+ signal_dict: dict,
56
+ opto_electronics: OptoElectronics,
57
+ ) -> None:
58
+ """
59
+ Add explicit model detector signals to the optical power dictionary.
60
+
61
+ Parameters
62
+ ----------
63
+ event_collection : EventCollection
64
+ Population event blocks.
65
+ signal_dict : dict
66
+ Optical power signal dictionary updated in place.
67
+ opto_electronics : OptoElectronics
68
+ Opto electronic configuration.
69
+ """
70
+ for events in event_collection:
71
+ if not isinstance(events.sampling_method, populations.ExplicitModel):
72
+ continue
73
+
74
+ if events.empty:
75
+ continue
76
+
77
+ time_array_seconds = signal_dict["Time"].to("second").magnitude
78
+
79
+ if time_array_seconds.size == 0:
80
+ continue
81
+
82
+ if time_array_seconds.size == 1:
83
+ acquisition_window_seconds = time_array_seconds[0]
84
+ else:
85
+ acquisition_window_seconds = (
86
+ time_array_seconds[-1] - time_array_seconds[0]
87
+ + (time_array_seconds[1] - time_array_seconds[0])
88
+ )
89
+
90
+ event_times = events.get_quantity("Time").to("second")
91
+ event_velocities = events.get_quantity("Velocity").to("meter / second")
92
+ pulse_widths = opto_electronics.source.get_particle_width(
93
+ event_velocities
94
+ ).to("second")
95
+
96
+ event_time_values = event_times.magnitude
97
+ velocity_values = event_velocities.magnitude
98
+ pulse_width_values = pulse_widths.magnitude
99
+ support_margin_seconds = 4.0 * pulse_width_values
100
+
101
+ prepend_mask = event_time_values >= (
102
+ acquisition_window_seconds - support_margin_seconds
103
+ )
104
+ append_mask = event_time_values < support_margin_seconds
105
+
106
+ wrapped_time_values = np.concatenate(
107
+ [
108
+ event_time_values[prepend_mask] - acquisition_window_seconds,
109
+ event_time_values,
110
+ event_time_values[append_mask] + acquisition_window_seconds,
111
+ ]
112
+ )
113
+ wrapped_velocity_values = np.concatenate(
114
+ [
115
+ velocity_values[prepend_mask],
116
+ velocity_values,
117
+ velocity_values[append_mask],
118
+ ]
119
+ )
120
+
121
+ wrapped_times = wrapped_time_values * ureg.second
122
+ wrapped_velocities = wrapped_velocity_values * ureg.meter / ureg.second
123
+
124
+ for detector in opto_electronics.detectors:
125
+ event_amplitudes = events.get_quantity(detector.name).to("watt")
126
+ amplitude_values = event_amplitudes.magnitude
127
+ wrapped_amplitude_values = np.concatenate(
128
+ [
129
+ amplitude_values[prepend_mask],
130
+ amplitude_values,
131
+ amplitude_values[append_mask],
132
+ ]
133
+ )
134
+
135
+ generated_pulse_signal = opto_electronics.source.generate_pulses(
136
+ velocities=wrapped_velocities,
137
+ pulse_centers=wrapped_times,
138
+ pulse_amplitudes=wrapped_amplitude_values * ureg.watt,
139
+ time_array=signal_dict["Time"],
140
+ base_level=0 * ureg.watt,
141
+ )
142
+
143
+ signal_dict[detector.name] += generated_pulse_signal
144
+
145
+ def _add_gamma_model_signals(
146
+ self,
147
+ event_collection: EventCollection,
148
+ signal_dict: dict,
149
+ opto_electronics: OptoElectronics,
150
+ ) -> None:
151
+ """
152
+ Add gamma model detector signals to the optical power dictionary.
153
+
154
+ Parameters
155
+ ----------
156
+ event_collection : EventCollection
157
+ Population event blocks.
158
+ signal_dict : dict
159
+ Optical power signal dictionary updated in place.
160
+ opto_electronics : OptoElectronics
161
+ Opto electronic configuration.
162
+ """
163
+ for events in event_collection:
164
+ if not isinstance(events.sampling_method, populations.GammaModel):
165
+ continue
166
+
167
+ if events.empty:
168
+ continue
169
+
170
+ expected_number_of_particles = events.metadata["ExpectedNumberOfParticles"]
171
+
172
+ if expected_number_of_particles == 0:
173
+ continue
174
+
175
+ if len(opto_electronics.detectors) == 0:
176
+ raise ValueError("opto_electronics must contain at least one detector.")
177
+
178
+ reference_detector = opto_electronics.detectors[0]
179
+
180
+ reference_mean_amplitude = events.get_quantity(
181
+ reference_detector.name
182
+ ).mean()
183
+
184
+ reference_power_trace = opto_electronics.source.get_gamma_trace(
185
+ time_array=signal_dict["Time"],
186
+ shape=expected_number_of_particles,
187
+ scale=reference_mean_amplitude,
188
+ mean_velocity=events.metadata["VelocityMean"],
189
+ )
190
+
191
+ if reference_mean_amplitude.magnitude == 0:
192
+ raise ValueError(
193
+ "Gamma Model reference mean amplitude is zero, cannot normalize."
194
+ )
195
+
196
+ particle_trace = reference_power_trace / reference_mean_amplitude
197
+
198
+ events.metadata["ParticleTrace"] = particle_trace
199
+ events.metadata["TimeTrace"] = signal_dict["Time"]
200
+
201
+ for detector in opto_electronics.detectors:
202
+ mean_amplitude = events.get_quantity(detector.name).mean()
203
+ detector_power_trace = particle_trace * mean_amplitude
204
+ signal_dict[detector.name] += detector_power_trace
205
+
206
+ def compute_analog(
207
+ self,
208
+ run_time: Time,
209
+ event_collection: EventCollection,
210
+ opto_electronics: OptoElectronics,
211
+ ) -> dict:
212
+ """
213
+ Compute the full analog detector waveforms from an event collection.
214
+
215
+ Parameters
216
+ ----------
217
+ run_time : Time
218
+ Acquisition duration.
219
+ event_collection : EventCollection
220
+ Population event blocks with optical coupling already evaluated.
221
+ opto_electronics : OptoElectronics
222
+ Opto electronic configuration.
223
+
224
+ Returns
225
+ -------
226
+ dict
227
+ Analog voltage signal dictionary.
228
+ """
229
+ self._validate_run_time(run_time)
230
+ power_signal_dict = opto_electronics.initialize_optical_signal_dict(
231
+ run_time=run_time,
232
+ background_power=self.background_power,
233
+ )
234
+
235
+ self._add_explicit_model_signals(
236
+ event_collection=event_collection,
237
+ signal_dict=power_signal_dict,
238
+ opto_electronics=opto_electronics,
239
+ )
240
+
241
+ self._add_gamma_model_signals(
242
+ event_collection=event_collection,
243
+ signal_dict=power_signal_dict,
244
+ opto_electronics=opto_electronics,
245
+ )
246
+
247
+ return opto_electronics.convert_optical_power_to_voltage(power_signal_dict)
248
+
249
+ def process_analog(
250
+ self,
251
+ run_time: Time,
252
+ event_collection: EventCollection,
253
+ analog_dict: dict,
254
+ digital_processing: DigitalProcessing,
255
+ opto_electronics: OptoElectronics,
256
+ ) -> RunRecord:
257
+ """
258
+ Process an existing analog acquisition through the downstream signal
259
+ processing chain.
260
+
261
+ Parameters
262
+ ----------
263
+ run_time : Time
264
+ Acquisition duration.
265
+ event_collection : EventCollection
266
+ Event collection associated with the analog signals.
267
+ analog_dict : dict
268
+ Analog voltage signal dictionary.
269
+ digital_processing : DigitalProcessing
270
+ Signal processing configuration.
271
+ opto_electronics : OptoElectronics
272
+ Opto electronic configuration associated with the analog signal.
273
+
274
+ Returns
275
+ -------
276
+ RunRecord
277
+ Run record containing the analog acquisition and all downstream
278
+ results that could be computed.
279
+ """
280
+ run_record = RunRecord(
281
+ run_time=run_time,
282
+ event_collection=event_collection,
283
+ opto_electronics=opto_electronics,
284
+ digital_processing=digital_processing,
285
+ )
286
+
287
+ processed_analog_dict = opto_electronics.apply_analog_processing(
288
+ analog_dict=analog_dict,
289
+ )
290
+
291
+ run_record.signal.analog = AcquisitionDataFrame._construct_from_signal_dict(
292
+ signal_dict=processed_analog_dict,
293
+ )
294
+
295
+ triggered_analog_dict = (
296
+ digital_processing.discriminator.run_with_dict(processed_analog_dict)
297
+ if digital_processing.discriminator is not None
298
+ else None
299
+ )
300
+
301
+ if triggered_analog_dict is None:
302
+ return run_record
303
+
304
+ if len(triggered_analog_dict["segment_id"]) == 0:
305
+ logger.debug(
306
+ "No triggers detected. Returning analog signal without digital processing."
307
+ )
308
+ return run_record
309
+
310
+ triggered_digital_dict = opto_electronics.digitizer.digitize_data_dict(
311
+ triggered_analog_dict
312
+ )
313
+
314
+ run_record.signal.digital = TriggerDataFrame._construct_from_flat_dict(
315
+ triggered_digital_dict,
316
+ )
317
+
318
+ if digital_processing.peak_algorithm is not None:
319
+ peaks_dict = digital_processing.peak_algorithm.run(triggered_digital_dict)
320
+
321
+ run_record.peaks = PeakDataFrame._construct_from_dict(peaks_dict)
322
+
323
+ return run_record
324
+
325
+ def process_run(
326
+ self,
327
+ run_record: RunRecord,
328
+ digital_processing: DigitalProcessing,
329
+ ) -> RunRecord:
330
+ """
331
+ Process an existing acquired run through the downstream signal
332
+ processing chain.
333
+
334
+ This method is intended for notebook workflows where the caller already
335
+ has a ``RunRecord`` returned by ``acquire()`` and wants to apply
336
+ downstream processing without passing the acquisition configuration again.
337
+
338
+ Parameters
339
+ ----------
340
+ run_record : RunRecord
341
+ Existing run record containing analog acquisition data and the
342
+ opto electronic configuration used to produce it.
343
+ digital_processing : DigitalProcessing
344
+ Signal processing configuration.
345
+
346
+ Returns
347
+ -------
348
+ RunRecord
349
+ Processed run record.
350
+
351
+ Raises
352
+ ------
353
+ ValueError
354
+ If the input run record does not contain an ``opto_electronics``
355
+ configuration.
356
+ ValueError
357
+ If the input run record does not contain analog signal data.
358
+ """
359
+ if run_record.opto_electronics is None:
360
+ raise ValueError(
361
+ "The provided run_record does not contain opto_electronics. "
362
+ "Make sure it was created with acquire() or manually populated."
363
+ )
364
+
365
+ if run_record.signal is None or run_record.signal.analog is None:
366
+ raise ValueError(
367
+ "The provided run_record does not contain analog signal data."
368
+ )
369
+
370
+ return self.process_analog(
371
+ run_time=run_record.run_time,
372
+ event_collection=run_record.event_collection,
373
+ analog_dict=run_record.signal.analog.raw_data,
374
+ digital_processing=digital_processing,
375
+ opto_electronics=run_record.opto_electronics,
376
+ )
377
+
378
+ def acquire(
379
+ self,
380
+ run_time: Time,
381
+ opto_electronics: OptoElectronics,
382
+ random_state: int | np.random.Generator | None = None,
383
+ ) -> RunRecord:
384
+ """
385
+ Run the acquisition pipeline up to the analog stage.
386
+
387
+ Parameters
388
+ ----------
389
+ run_time : Time
390
+ Acquisition duration.
391
+ opto_electronics : OptoElectronics
392
+ Opto electronic configuration.
393
+
394
+ Returns
395
+ -------
396
+ RunRecord
397
+ Run record containing the event collection, analog voltage
398
+ waveforms, and the opto electronic configuration used to produce
399
+ them.
400
+ """
401
+ self._validate_run_time(run_time)
402
+ self._validate_opto_electronics(opto_electronics)
403
+ event_collection = self.fluidics.generate_event_collection(
404
+ run_time=run_time,
405
+ sampling_rate=opto_electronics.digitizer.sampling_rate,
406
+ random_state=random_state,
407
+ )
408
+
409
+ opto_electronics.add_coupling_to_dataframe(
410
+ event_collection=event_collection,
411
+ compute_cross_section=False,
412
+ )
413
+
414
+ processed_analog_dict = self.compute_analog(
415
+ run_time=run_time,
416
+ event_collection=event_collection,
417
+ opto_electronics=opto_electronics,
418
+ )
419
+
420
+ run_record = RunRecord(
421
+ run_time=run_time,
422
+ event_collection=event_collection,
423
+ opto_electronics=opto_electronics,
424
+ )
425
+
426
+ run_record.signal.analog = AcquisitionDataFrame._construct_from_signal_dict(
427
+ signal_dict=processed_analog_dict,
428
+ )
429
+
430
+ return run_record
431
+
432
+ @staticmethod
433
+ def _validate_run_time(run_time: Time) -> None:
434
+ """Reject empty, negative, non-finite, or unitless run durations."""
435
+ try:
436
+ seconds = float(run_time.to("second").magnitude)
437
+ except (AttributeError, TypeError, ValueError) as error:
438
+ raise TypeError("run_time must be a time quantity.") from error
439
+
440
+ if not math.isfinite(seconds) or seconds <= 0:
441
+ raise ValueError("run_time must be finite and strictly positive.")
442
+
443
+ @staticmethod
444
+ def _validate_opto_electronics(opto_electronics: OptoElectronics) -> None:
445
+ """Validate detector channels before starting an acquisition."""
446
+ detectors = getattr(opto_electronics, "detectors", None)
447
+ if not detectors:
448
+ raise ValueError("opto_electronics must contain at least one detector.")
449
+
450
+ names = [getattr(detector, "name", None) for detector in detectors]
451
+ if any(not isinstance(name, str) or not name.strip() for name in names):
452
+ raise ValueError("Each detector must have a non-empty name.")
453
+ if len(names) != len(set(names)):
454
+ raise ValueError("Detector names must be unique.")
455
+
456
+ def run(
457
+ self,
458
+ run_time: Time,
459
+ opto_electronics: OptoElectronics,
460
+ digital_processing: Optional[DigitalProcessing] = None,
461
+ random_state: int | np.random.Generator | None = None,
462
+ ) -> RunRecord:
463
+ """
464
+ Run the full simulation pipeline from event generation to peak extraction.
465
+
466
+ Parameters
467
+ ----------
468
+ run_time : Time
469
+ Acquisition duration.
470
+ opto_electronics : OptoElectronics
471
+ Opto electronic configuration.
472
+ digital_processing : DigitalProcessing, optional
473
+ Signal processing configuration. If None, no digital processing is applied.
474
+
475
+ Returns
476
+ -------
477
+ RunRecord
478
+ Full run record containing all available stages.
479
+ """
480
+ if digital_processing is None:
481
+ digital_processing = DigitalProcessing()
482
+
483
+ acquired_run_record = self.acquire(
484
+ run_time=run_time,
485
+ opto_electronics=opto_electronics,
486
+ random_state=random_state,
487
+ )
488
+
489
+ return self.process_run(
490
+ run_record=acquired_run_record,
491
+ digital_processing=digital_processing,
492
+ )