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,306 @@
|
|
|
1
|
+
from typing import List, Optional, Union
|
|
2
|
+
from MPSPlots.styles import mps
|
|
3
|
+
import pandas as pd
|
|
4
|
+
from FlowCyPy.units import Quantity, RIU, particle, liter
|
|
5
|
+
from FlowCyPy.population import Population
|
|
6
|
+
from FlowCyPy.utils import PropertiesReport
|
|
7
|
+
import matplotlib.patches as mpatches
|
|
8
|
+
from typing import Optional, List
|
|
9
|
+
from pint_pandas import PintArray
|
|
10
|
+
import seaborn as sns
|
|
11
|
+
import matplotlib.pyplot as plt
|
|
12
|
+
import numpy as np
|
|
13
|
+
from matplotlib.colors import LinearSegmentedColormap
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from FlowCyPy import units
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CouplingModel(Enum):
|
|
19
|
+
MIE = 'mie'
|
|
20
|
+
RAYLEIGH = 'rayleigh'
|
|
21
|
+
UNIFORM = 'uniform'
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ScattererCollection(PropertiesReport):
|
|
25
|
+
"""
|
|
26
|
+
Defines and manages the size and refractive index distributions of scatterers (particles)
|
|
27
|
+
passing through a flow cytometer. This class generates random scatterer sizes and refractive
|
|
28
|
+
indices based on a list of provided distributions (e.g., Normal, LogNormal, Uniform, etc.).
|
|
29
|
+
|
|
30
|
+
"""
|
|
31
|
+
def __init__(self, medium_refractive_index: Quantity = 1.0 * RIU, populations: List[Population] = None, coupling_model: Optional[CouplingModel] = CouplingModel.MIE):
|
|
32
|
+
"""
|
|
33
|
+
Parameters
|
|
34
|
+
----------
|
|
35
|
+
populations : List[Population]
|
|
36
|
+
A list of Population instances that define different scatterer populations.
|
|
37
|
+
coupling_model : Optional[CouplingModel], optional
|
|
38
|
+
The type of coupling factor to use (CouplingModel.MIE, CouplingModel.RAYLEIGH, CouplingModel.UNIFORM). Default is CouplingModel.MIE.
|
|
39
|
+
medium_refractive_index : float
|
|
40
|
+
The refractive index of the medium. Default is 1.0.
|
|
41
|
+
"""
|
|
42
|
+
self.populations = populations or []
|
|
43
|
+
self.medium_refractive_index = medium_refractive_index
|
|
44
|
+
self.coupling_model = coupling_model
|
|
45
|
+
self.dataframe: pd.DataFrame = None
|
|
46
|
+
|
|
47
|
+
def get_population_ratios(self) -> list[float]:
|
|
48
|
+
total_concentration = sum([p.particle_count.value for p in self.populations])
|
|
49
|
+
|
|
50
|
+
return [(p.particle_count.value / total_concentration).magnitude for p in self.populations]
|
|
51
|
+
|
|
52
|
+
def get_population_dataframe(self, total_sampling: Optional[Quantity]) -> pd.DataFrame:
|
|
53
|
+
"""
|
|
54
|
+
Generate a DataFrame by sampling particles from populations.
|
|
55
|
+
|
|
56
|
+
Parameters
|
|
57
|
+
----------
|
|
58
|
+
total_sampling : Quantity, optional
|
|
59
|
+
Total number of samples to draw, distributed across populations based on their ratios.
|
|
60
|
+
|
|
61
|
+
Returns
|
|
62
|
+
-------
|
|
63
|
+
pd.DataFrame
|
|
64
|
+
A MultiIndex DataFrame containing the sampled data from all populations. The first
|
|
65
|
+
index level indicates the population name, and the second level indexes the sampled data.
|
|
66
|
+
"""
|
|
67
|
+
ratios = self.get_population_ratios()
|
|
68
|
+
|
|
69
|
+
sampling_list = [int(ratio * total_sampling.magnitude) for ratio in ratios]
|
|
70
|
+
|
|
71
|
+
population_names = [p.name for p in self.populations]
|
|
72
|
+
|
|
73
|
+
# Create tuples for the MultiIndex
|
|
74
|
+
multi_index_tuples = [
|
|
75
|
+
(pop_name, idx) for pop_name, n in zip(population_names, sampling_list) for idx in range(n)
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
# Create the MultiIndex
|
|
79
|
+
multi_index = pd.MultiIndex.from_tuples(multi_index_tuples, names=["Population", "Index"])
|
|
80
|
+
|
|
81
|
+
# Initialize an empty DataFrame with the MultiIndex
|
|
82
|
+
scatterer_dataframe = pd.DataFrame(index=multi_index)
|
|
83
|
+
|
|
84
|
+
self.fill_dataframe_with_sampling(scatterer_dataframe=scatterer_dataframe)
|
|
85
|
+
|
|
86
|
+
return scatterer_dataframe
|
|
87
|
+
|
|
88
|
+
def add_population(self, *population: Population) -> 'ScattererCollection':
|
|
89
|
+
"""
|
|
90
|
+
Adds a population to the ScattererCollection instance with the specified attributes.
|
|
91
|
+
|
|
92
|
+
Parameters
|
|
93
|
+
----------
|
|
94
|
+
name : str
|
|
95
|
+
The name of the population.
|
|
96
|
+
size : BaseDistribution
|
|
97
|
+
The size distribution of the population.
|
|
98
|
+
refractive_index : BaseDistribution
|
|
99
|
+
The refractive index distribution of the population.
|
|
100
|
+
|
|
101
|
+
Returns
|
|
102
|
+
-------
|
|
103
|
+
ScattererCollection
|
|
104
|
+
The ScattererCollection instance (to support chaining).
|
|
105
|
+
|
|
106
|
+
Raises
|
|
107
|
+
------
|
|
108
|
+
ValueError
|
|
109
|
+
If the concentration does not have the expected dimensionality.
|
|
110
|
+
"""
|
|
111
|
+
self.populations.extend(population)
|
|
112
|
+
return population
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def concentrations(self) -> List[Quantity]:
|
|
116
|
+
"""
|
|
117
|
+
Gets the concentration of each population in the ScattererCollection instance.
|
|
118
|
+
|
|
119
|
+
Returns
|
|
120
|
+
-------
|
|
121
|
+
List[Quantity]
|
|
122
|
+
A list of concentrations for each population.
|
|
123
|
+
"""
|
|
124
|
+
return [population.concentration for population in self.populations]
|
|
125
|
+
|
|
126
|
+
@concentrations.setter
|
|
127
|
+
def concentrations(self, values: Union[List[Quantity], Quantity]) -> None:
|
|
128
|
+
"""
|
|
129
|
+
Sets the concentration of each population in the ScattererCollection instance.
|
|
130
|
+
|
|
131
|
+
Parameters
|
|
132
|
+
----------
|
|
133
|
+
values : Union[List[Quantity], Quantity]
|
|
134
|
+
A list of concentrations to set for each population, or a single concentration value to set for all populations.
|
|
135
|
+
|
|
136
|
+
Raises
|
|
137
|
+
------
|
|
138
|
+
ValueError
|
|
139
|
+
If the length of the values list does not match the number of populations or if any concentration has an incorrect dimensionality.
|
|
140
|
+
"""
|
|
141
|
+
if isinstance(values, (list, tuple)):
|
|
142
|
+
if len(values) != len(self.populations):
|
|
143
|
+
raise ValueError("The length of the values list must match the number of populations.")
|
|
144
|
+
|
|
145
|
+
for value in values:
|
|
146
|
+
if value.dimensionality != (particle / liter).dimensionality:
|
|
147
|
+
raise ValueError(
|
|
148
|
+
f"Invalid concentration dimensionality: {value.dimensionality}. Expected dimensionality is 'particles per liter' or similar."
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
for population, value in zip(self.populations, values):
|
|
152
|
+
population.concentration = value
|
|
153
|
+
else:
|
|
154
|
+
if values.dimensionality != (particle / liter).dimensionality:
|
|
155
|
+
raise ValueError(
|
|
156
|
+
f"Invalid concentration dimensionality: {values.dimensionality}. Expected dimensionality is 'particles per liter' or similar."
|
|
157
|
+
)
|
|
158
|
+
for population in self.populations:
|
|
159
|
+
population.concentration = values
|
|
160
|
+
|
|
161
|
+
def dilute(self, factor: float) -> None:
|
|
162
|
+
"""
|
|
163
|
+
Dilutes the populations in the flow cytometry system by a given factor.
|
|
164
|
+
|
|
165
|
+
Parameters
|
|
166
|
+
----------
|
|
167
|
+
factor : float
|
|
168
|
+
The dilution factor to apply to each population. For example, a factor
|
|
169
|
+
of 0.5 reduces the population density by half.
|
|
170
|
+
|
|
171
|
+
Returns
|
|
172
|
+
-------
|
|
173
|
+
None
|
|
174
|
+
The method modifies the populations in place.
|
|
175
|
+
|
|
176
|
+
Notes
|
|
177
|
+
-----
|
|
178
|
+
|
|
179
|
+
- This method iterates over all populations in the system and applies the `dilute` method of each population.
|
|
180
|
+
- The specific implementation of how a population is diluted depends on the `dilute` method defined in the `population` object.
|
|
181
|
+
|
|
182
|
+
Examples
|
|
183
|
+
--------
|
|
184
|
+
Dilute all populations by 50%:
|
|
185
|
+
>>> system.dilute(0.5)
|
|
186
|
+
"""
|
|
187
|
+
for population in self.populations:
|
|
188
|
+
population.dilute(factor)
|
|
189
|
+
|
|
190
|
+
def fill_dataframe_with_sampling(self, scatterer_dataframe: pd.DataFrame) -> None:
|
|
191
|
+
"""
|
|
192
|
+
Fills a DataFrame with size and refractive index sampling data for each population.
|
|
193
|
+
|
|
194
|
+
Parameters
|
|
195
|
+
----------
|
|
196
|
+
scatterer_dataframe : pd.DataFrame
|
|
197
|
+
A DataFrame indexed by population names (first level) and containing the
|
|
198
|
+
following columns: `'Size'`: To be filled with particle size data. `'RefractiveIndex'`:
|
|
199
|
+
To be filled with refractive index data. The DataFrame must already have the required structure.
|
|
200
|
+
|
|
201
|
+
Returns
|
|
202
|
+
-------
|
|
203
|
+
None
|
|
204
|
+
The method modifies the `scatterer_dataframe` in place, adding size and
|
|
205
|
+
refractive index sampling data.
|
|
206
|
+
|
|
207
|
+
After filling, the DataFrame will be populated with size and refractive index data.
|
|
208
|
+
"""
|
|
209
|
+
for population in self.populations:
|
|
210
|
+
# Check if population.name is in the dataframe's index
|
|
211
|
+
if population.name not in scatterer_dataframe.index:
|
|
212
|
+
continue # Skip this iteration if population.name is not present
|
|
213
|
+
|
|
214
|
+
# Safely access the sub-dataframe and proceed
|
|
215
|
+
sub_dataframe = scatterer_dataframe.xs(population.name)
|
|
216
|
+
sampling = len(sub_dataframe) * units.particle
|
|
217
|
+
|
|
218
|
+
size, ri = population.generate_sampling(sampling)
|
|
219
|
+
|
|
220
|
+
scatterer_dataframe.loc[population.name, 'Size'] = PintArray(size, dtype=size.units)
|
|
221
|
+
scatterer_dataframe.loc[population.name, 'RefractiveIndex'] = PintArray(ri, dtype=ri.units)
|
|
222
|
+
|
|
223
|
+
def plot(self, n_points: int = 200) -> None:
|
|
224
|
+
"""
|
|
225
|
+
Visualizes the joint and marginal distributions of size and refractive index
|
|
226
|
+
for multiple populations in the scatterer collection.
|
|
227
|
+
|
|
228
|
+
This method creates a joint plot using `sns.JointGrid`, where:
|
|
229
|
+
|
|
230
|
+
- The joint area displays filled contours representing the PDF values of size and refractive index.
|
|
231
|
+
- The marginal areas display the size and refractive index PDFs as filled plots.
|
|
232
|
+
|
|
233
|
+
Each population is plotted with a distinct color using a transparent colormap for the joint area.
|
|
234
|
+
|
|
235
|
+
Parameters
|
|
236
|
+
----------
|
|
237
|
+
n_points : int, optional
|
|
238
|
+
The number of points used to compute the size and refractive index PDFs. Default is 100.
|
|
239
|
+
Increasing this value results in smoother distributions but increases computation time.
|
|
240
|
+
|
|
241
|
+
Notes
|
|
242
|
+
-----
|
|
243
|
+
|
|
244
|
+
- The joint area uses a transparent colormap, transitioning from fully transparent to fully opaque for better visualization of overlapping populations.
|
|
245
|
+
- Marginal plots show semi-transparent filled curves for clarity.
|
|
246
|
+
|
|
247
|
+
Example
|
|
248
|
+
-------
|
|
249
|
+
>>> scatterer_collection.plot(n_points=200)
|
|
250
|
+
|
|
251
|
+
"""
|
|
252
|
+
def create_transparent_colormap(base_color):
|
|
253
|
+
"""
|
|
254
|
+
Create a colormap that transitions from transparent to a specified base color.
|
|
255
|
+
"""
|
|
256
|
+
return LinearSegmentedColormap.from_list(
|
|
257
|
+
"transparent_colormap",
|
|
258
|
+
[(base_color[0], base_color[1], base_color[2], 0), # Fully transparent
|
|
259
|
+
(base_color[0], base_color[1], base_color[2], 1)], # Fully opaque
|
|
260
|
+
N=256
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
# Create a JointGrid for visualization
|
|
264
|
+
with plt.style.context(mps):
|
|
265
|
+
grid = sns.JointGrid()
|
|
266
|
+
|
|
267
|
+
# Initialize a list to store legend handles
|
|
268
|
+
legend_handles = []
|
|
269
|
+
|
|
270
|
+
for index, population in enumerate(self.populations):
|
|
271
|
+
# Get size and refractive index PDFs
|
|
272
|
+
size, size_pdf = population.size.get_pdf(n_samples=n_points)
|
|
273
|
+
size_pdf /= size_pdf.max()
|
|
274
|
+
ri, ri_pdf = population.refractive_index.get_pdf(n_samples=n_points)
|
|
275
|
+
ri_pdf /= ri_pdf.max()
|
|
276
|
+
|
|
277
|
+
# Create a grid of size and ri values
|
|
278
|
+
X, Y = np.meshgrid(size, ri)
|
|
279
|
+
Z = np.outer(ri_pdf, size_pdf) # Compute the PDF values on the grid
|
|
280
|
+
|
|
281
|
+
# Create a transparent colormap for this population
|
|
282
|
+
base_color = sns.color_palette()[index]
|
|
283
|
+
transparent_cmap = create_transparent_colormap(base_color)
|
|
284
|
+
|
|
285
|
+
# Plot the joint area as a filled contour plot
|
|
286
|
+
grid.ax_joint.contourf(
|
|
287
|
+
X, Y, Z, levels=10, cmap=transparent_cmap, extend="min"
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
legend_handles.append(mpatches.Patch(color=base_color, label=population.name))
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# Plot the marginal distributions
|
|
294
|
+
grid.ax_marg_x.fill_between(size.magnitude, size_pdf, color=f"C{index}", alpha=0.5)
|
|
295
|
+
grid.ax_marg_y.fill_betweenx(ri.magnitude, ri_pdf, color=f"C{index}", alpha=0.5)
|
|
296
|
+
|
|
297
|
+
# Set axis labels
|
|
298
|
+
grid.ax_joint.legend(handles=legend_handles, title="Populations")
|
|
299
|
+
|
|
300
|
+
grid.ax_joint.set_xlabel(f"Size [{size.units}]")
|
|
301
|
+
grid.ax_joint.set_ylabel(f"Refractive Index [{ri.units}]")
|
|
302
|
+
|
|
303
|
+
plt.tight_layout()
|
|
304
|
+
|
|
305
|
+
# Show the plot
|
|
306
|
+
plt.show()
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from pydantic.dataclasses import dataclass
|
|
2
|
+
from pydantic import field_validator
|
|
3
|
+
from typing import Union, Tuple
|
|
4
|
+
import logging
|
|
5
|
+
import numpy as np
|
|
6
|
+
from FlowCyPy.units import Quantity, volt
|
|
7
|
+
|
|
8
|
+
config_dict = dict(
|
|
9
|
+
arbitrary_types_allowed=True,
|
|
10
|
+
kw_only=True,
|
|
11
|
+
slots=True,
|
|
12
|
+
extra='forbid'
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
@dataclass(config=config_dict)
|
|
16
|
+
class SignalDigitizer:
|
|
17
|
+
"""
|
|
18
|
+
Handles signal digitization and saturation for a detector.
|
|
19
|
+
|
|
20
|
+
Attributes
|
|
21
|
+
----------
|
|
22
|
+
sampling_freq : Quantity
|
|
23
|
+
The sampling frequency of the detector in hertz.
|
|
24
|
+
bit_depth : Union[int, str]
|
|
25
|
+
The number of discretization bins or bit-depth (e.g., '12bit').
|
|
26
|
+
saturation_levels : Union[str, Tuple[Quantity, Quantity]]
|
|
27
|
+
A tuple defining the lower and upper bounds for saturation, or 'auto' to set bounds dynamically.
|
|
28
|
+
"""
|
|
29
|
+
sampling_freq: Quantity
|
|
30
|
+
bit_depth: Union[int, str] = '10bit'
|
|
31
|
+
saturation_levels: Union[str, Tuple[Quantity, Quantity], Quantity] = 'auto'
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def _bit_depth(self) -> int:
|
|
35
|
+
return self._process_bit_depth(self.bit_depth)
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def bandwidth(self) -> Quantity:
|
|
39
|
+
"""
|
|
40
|
+
Automatically calculates the bandwidth based on the sampling frequency.
|
|
41
|
+
|
|
42
|
+
Returns
|
|
43
|
+
-------
|
|
44
|
+
Quantity
|
|
45
|
+
The bandwidth of the detector, which is half the sampling frequency (Nyquist limit).
|
|
46
|
+
"""
|
|
47
|
+
return self.sampling_freq / 2
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def _process_bit_depth(bit_depth: Union[int, str]) -> int:
|
|
51
|
+
"""
|
|
52
|
+
Converts bit-depth to the number of bins.
|
|
53
|
+
|
|
54
|
+
Parameters
|
|
55
|
+
----------
|
|
56
|
+
bit_depth : Union[int, str]
|
|
57
|
+
The bit-depth as an integer or a string (e.g., '10bit').
|
|
58
|
+
|
|
59
|
+
Returns
|
|
60
|
+
-------
|
|
61
|
+
int
|
|
62
|
+
The number of bins corresponding to the bit-depth.
|
|
63
|
+
"""
|
|
64
|
+
if isinstance(bit_depth, str):
|
|
65
|
+
bit_depth = int(bit_depth.rstrip('bit'))
|
|
66
|
+
bit_depth = 2 ** bit_depth
|
|
67
|
+
|
|
68
|
+
return bit_depth
|
|
69
|
+
|
|
70
|
+
@field_validator('sampling_freq')
|
|
71
|
+
def _validate_sampling_freq(cls, value):
|
|
72
|
+
"""
|
|
73
|
+
Validates that the sampling frequency is provided in hertz.
|
|
74
|
+
|
|
75
|
+
Parameters
|
|
76
|
+
----------
|
|
77
|
+
value : Quantity
|
|
78
|
+
The sampling frequency to validate.
|
|
79
|
+
|
|
80
|
+
Returns
|
|
81
|
+
-------
|
|
82
|
+
Quantity
|
|
83
|
+
The validated sampling frequency.
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
ValueError: If the sampling frequency is not in hertz.
|
|
87
|
+
"""
|
|
88
|
+
if not value.check('Hz'):
|
|
89
|
+
raise ValueError(f"sampling_freq must be in hertz, but got {value.units}")
|
|
90
|
+
return value
|
FlowCyPy/source.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from pydantic.dataclasses import dataclass
|
|
4
|
+
from pydantic import field_validator
|
|
5
|
+
from FlowCyPy.units import meter, joule, particle, degree, volt, AU
|
|
6
|
+
from PyMieSim.units import Quantity
|
|
7
|
+
from FlowCyPy.utils import PropertiesReport
|
|
8
|
+
from FlowCyPy.physical_constant import PhysicalConstant
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
config_dict = dict(
|
|
13
|
+
arbitrary_types_allowed=True,
|
|
14
|
+
kw_only=True,
|
|
15
|
+
slots=True,
|
|
16
|
+
extra='forbid'
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BaseBeam(PropertiesReport):
|
|
21
|
+
"""
|
|
22
|
+
Mixin class providing unit validation for quantities used in optical sources.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def initialization(self):
|
|
26
|
+
"""
|
|
27
|
+
Initialize beam waists based on the numerical apertures and calculate electric field amplitude at the focus.
|
|
28
|
+
"""
|
|
29
|
+
self.frequency = PhysicalConstant.c / self.wavelength
|
|
30
|
+
self.photon_energy = (PhysicalConstant.h * self.frequency).to(joule) / particle
|
|
31
|
+
|
|
32
|
+
# Calculate amplitude at focus
|
|
33
|
+
self.amplitude = self.calculate_field_amplitude_at_focus()
|
|
34
|
+
|
|
35
|
+
def calculate_field_amplitude_at_focus(self) -> Quantity:
|
|
36
|
+
return NotImplementedError('This method should be implemneted by the derived class!')
|
|
37
|
+
|
|
38
|
+
@field_validator('wavelength', mode='plain')
|
|
39
|
+
def validate_wavelength(cls, value, field):
|
|
40
|
+
if not isinstance(value, Quantity):
|
|
41
|
+
raise ValueError(f"{value} must be a Quantity with distance units [<prefix>meter].")
|
|
42
|
+
|
|
43
|
+
if not value.check('meter'):
|
|
44
|
+
raise ValueError(f"{field} must be a Quantity with distance units [<prefix>meter], but got {value.units}.")
|
|
45
|
+
|
|
46
|
+
return value
|
|
47
|
+
|
|
48
|
+
@field_validator('polarization', mode='plain')
|
|
49
|
+
def validate_polarization(cls, value, field):
|
|
50
|
+
if not isinstance(value, Quantity):
|
|
51
|
+
raise ValueError(f"{value} must be a Quantity with angle units [<prefix>degree or <prefix>radian].")
|
|
52
|
+
|
|
53
|
+
if not value.check('degree'):
|
|
54
|
+
raise ValueError(f"{field} must be a Quantity with angle units [<prefix>degree or <prefix>radian], but got {value.units}.")
|
|
55
|
+
|
|
56
|
+
return value
|
|
57
|
+
|
|
58
|
+
@field_validator('optical_power', mode='plain')
|
|
59
|
+
def validate_optical_power(cls, value, field):
|
|
60
|
+
if not isinstance(value, Quantity):
|
|
61
|
+
raise ValueError(f"{value} must be a Quantity with power units [<prefix>watt].")
|
|
62
|
+
|
|
63
|
+
if not value.check('watt'):
|
|
64
|
+
raise ValueError(f"{field} must be a Quantity with power units [<prefix>watt], but got {value.units}.")
|
|
65
|
+
|
|
66
|
+
return value
|
|
67
|
+
|
|
68
|
+
@field_validator('numerical_aperture', 'numerical_aperture_x', 'numerical_aperture_y', mode='plain')
|
|
69
|
+
def validate_numerical_apertures(cls, value, field):
|
|
70
|
+
if not isinstance(value, Quantity):
|
|
71
|
+
raise ValueError(f"{value} must be a Quantity with arbitrary units [AU].")
|
|
72
|
+
|
|
73
|
+
if not value.check(AU):
|
|
74
|
+
raise ValueError(f"{field} must be a Quantity with arbitrary units [AU], but got {value.units}.")
|
|
75
|
+
|
|
76
|
+
if value.magnitude < 0 or value.magnitude > 1:
|
|
77
|
+
raise ValueError(f"{field} must be between 0 and 1, but got {value.magnitude}")
|
|
78
|
+
return value
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(config=config_dict)
|
|
82
|
+
class GaussianBeam(BaseBeam):
|
|
83
|
+
"""
|
|
84
|
+
Represents a monochromatic Gaussian laser beam focused by a standard lens.
|
|
85
|
+
|
|
86
|
+
Parameters
|
|
87
|
+
----------
|
|
88
|
+
optical_power : Quantity
|
|
89
|
+
The optical power of the laser (in watts).
|
|
90
|
+
wavelength : Quantity
|
|
91
|
+
The wavelength of the laser (in meters).
|
|
92
|
+
numerical_aperture : Quantity
|
|
93
|
+
The numerical aperture (NA) of the lens focusing the Gaussian beam (unitless).
|
|
94
|
+
polarization : Optional[Quantity]
|
|
95
|
+
The polarization of the laser source in degrees (default is 0 degrees).
|
|
96
|
+
RIN : Optional[float], optional
|
|
97
|
+
The Relative Intensity Noise (RIN) of the laser, specified as dB/Hz. Default is -120.0 dB/Hz, representing a pretty stable laser.
|
|
98
|
+
|
|
99
|
+
Attributes
|
|
100
|
+
----------
|
|
101
|
+
waist : Quantity
|
|
102
|
+
The beam waist at the focus, calculated as `waist = wavelength / (pi * numerical_aperture)`.
|
|
103
|
+
frequency : Quantity
|
|
104
|
+
The frequency of the laser, calculated as `frequency = c / wavelength`.
|
|
105
|
+
photon_energy : Quantity
|
|
106
|
+
The energy of a single photon, calculated as `photon_energy = h * frequency`.
|
|
107
|
+
amplitude : Quantity
|
|
108
|
+
The electric field amplitude at the focus, derived from optical power, waist, and fundamental constants.
|
|
109
|
+
|
|
110
|
+
Notes
|
|
111
|
+
-----
|
|
112
|
+
RIN Interpretation:
|
|
113
|
+
- The Relative Intensity Noise (RIN) represents fluctuations in the laser's intensity relative to its mean optical power.
|
|
114
|
+
- For a Gaussian beam, the instantaneous intensity at any given time can be modeled as: I(t) = <I> * (1 + ΔI)
|
|
115
|
+
|
|
116
|
+
where:
|
|
117
|
+
- `<I>` is the mean intensity derived from the optical power.
|
|
118
|
+
- `ΔI` is the noise component, modeled as a Gaussian random variable with:
|
|
119
|
+
- Mean = 0 (fluctuations are around the mean intensity).
|
|
120
|
+
- Variance = RIN * <I>².
|
|
121
|
+
|
|
122
|
+
"""
|
|
123
|
+
optical_power: Quantity
|
|
124
|
+
wavelength: Quantity
|
|
125
|
+
numerical_aperture: Quantity
|
|
126
|
+
polarization: Optional[Quantity] = 0 * degree
|
|
127
|
+
RIN: Optional[float] = -120.0
|
|
128
|
+
|
|
129
|
+
def __post_init__(self):
|
|
130
|
+
"""
|
|
131
|
+
Initialize additional parameters like beam waist, frequency, photon energy,
|
|
132
|
+
and electric field amplitude at the focus.
|
|
133
|
+
"""
|
|
134
|
+
self.initialization()
|
|
135
|
+
|
|
136
|
+
def calculate_field_amplitude_at_focus(self) -> Quantity:
|
|
137
|
+
r"""
|
|
138
|
+
Calculate the electric field amplitude (E0) at the focus for a Gaussian beam.
|
|
139
|
+
|
|
140
|
+
The electric field amplitude at the focus is given by:
|
|
141
|
+
|
|
142
|
+
.. math::
|
|
143
|
+
E_0 = \sqrt{\frac{2 P}{\pi \epsilon_0 c w_0^2}}
|
|
144
|
+
|
|
145
|
+
where:
|
|
146
|
+
- `P` is the optical power of the beam,
|
|
147
|
+
- `epsilon_0` is the permittivity of free space,
|
|
148
|
+
- `c` is the speed of light,
|
|
149
|
+
- `w_0` is the beam waist at the focus.
|
|
150
|
+
|
|
151
|
+
Returns
|
|
152
|
+
-------
|
|
153
|
+
Quantity
|
|
154
|
+
The electric field amplitude at the focus in volts per meter.
|
|
155
|
+
"""
|
|
156
|
+
self.waist = self.wavelength / (PhysicalConstant.pi * self.numerical_aperture)
|
|
157
|
+
|
|
158
|
+
E0 = np.sqrt(2 * self.optical_power / (PhysicalConstant.pi * PhysicalConstant.epsilon_0 * PhysicalConstant.c * self.waist**2))
|
|
159
|
+
|
|
160
|
+
return E0.to(volt / meter)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass(config=config_dict)
|
|
164
|
+
class AstigmaticGaussianBeam(BaseBeam):
|
|
165
|
+
"""
|
|
166
|
+
Represents an astigmatic Gaussian laser beam focused by a cylindrical lens system.
|
|
167
|
+
|
|
168
|
+
Parameters
|
|
169
|
+
----------
|
|
170
|
+
optical_power : Quantity
|
|
171
|
+
The optical power of the laser (in watts).
|
|
172
|
+
wavelength : Quantity
|
|
173
|
+
The wavelength of the laser (in meters).
|
|
174
|
+
numerical_aperture_x : Quantity
|
|
175
|
+
The numerical aperture of the lens along the x-axis (unitless).
|
|
176
|
+
numerical_aperture_y : Quantity
|
|
177
|
+
The numerical aperture of the lens along the y-axis (unitless).
|
|
178
|
+
polarization : Optional[Quantity]
|
|
179
|
+
The polarization of the laser source in degrees (default is 0 degrees).
|
|
180
|
+
RIN : Optional[float], optional
|
|
181
|
+
The Relative Intensity Noise (RIN) of the laser, specified as a fractional value (e.g., 0.01 for 1% RIN).
|
|
182
|
+
Default is 0.0, representing a perfectly stable laser.
|
|
183
|
+
|
|
184
|
+
Attributes
|
|
185
|
+
----------
|
|
186
|
+
waist_x : Quantity
|
|
187
|
+
The beam waist at the focus along the x-axis, calculated as `waist_x = wavelength / (pi * numerical_aperture_x)`.
|
|
188
|
+
waist_y : Quantity
|
|
189
|
+
The beam waist at the focus along the y-axis, calculated as `waist_y = wavelength / (pi * numerical_aperture_y)`.
|
|
190
|
+
amplitude : Quantity
|
|
191
|
+
The electric field amplitude at the focus, derived from optical power, waist_x, waist_y, and fundamental constants.
|
|
192
|
+
|
|
193
|
+
Notes
|
|
194
|
+
-----
|
|
195
|
+
RIN Interpretation:
|
|
196
|
+
- The Relative Intensity Noise (RIN) represents fluctuations in the laser's intensity relative to its mean optical power.
|
|
197
|
+
- For a Gaussian beam, the instantaneous intensity at any given time can be modeled as:
|
|
198
|
+
|
|
199
|
+
I(t) = <I> * (1 + ΔI)
|
|
200
|
+
|
|
201
|
+
where:
|
|
202
|
+
- `<I>` is the mean intensity derived from the optical power.
|
|
203
|
+
- `ΔI` is the noise component, modeled as a Gaussian random variable with:
|
|
204
|
+
- Mean = 0 (fluctuations are around the mean intensity).
|
|
205
|
+
- Variance = RIN * <I>².
|
|
206
|
+
|
|
207
|
+
"""
|
|
208
|
+
optical_power: Quantity
|
|
209
|
+
wavelength: Quantity
|
|
210
|
+
numerical_aperture_x: Quantity
|
|
211
|
+
numerical_aperture_y: Quantity
|
|
212
|
+
polarization: Optional[Quantity] = 0 * degree
|
|
213
|
+
RIN: Optional[float] = 0.0
|
|
214
|
+
|
|
215
|
+
def __post_init__(self):
|
|
216
|
+
"""
|
|
217
|
+
Initialize additional parameters like beam waist, frequency, photon energy,
|
|
218
|
+
and electric field amplitude at the focus.
|
|
219
|
+
"""
|
|
220
|
+
self.initialization()
|
|
221
|
+
|
|
222
|
+
def calculate_field_amplitude_at_focus(self) -> Quantity:
|
|
223
|
+
"""
|
|
224
|
+
Calculate the electric field amplitude (E0) at the focus for an astigmatic Gaussian beam.
|
|
225
|
+
|
|
226
|
+
The electric field amplitude at the focus is given by:
|
|
227
|
+
|
|
228
|
+
.. math::
|
|
229
|
+
E_0 = \\sqrt{\\frac{2 P}{\\pi \\epsilon_0 c w_{0x} w_{0y}}}
|
|
230
|
+
|
|
231
|
+
where:
|
|
232
|
+
- `P` is the optical power of the beam,
|
|
233
|
+
- `epsilon_0` is the permittivity of free space,
|
|
234
|
+
- `c` is the speed of light,
|
|
235
|
+
- `w_{0x}` is the beam waist at the focus along the x-axis,
|
|
236
|
+
- `w_{0y}` is the beam waist at the focus along the y-axis.
|
|
237
|
+
|
|
238
|
+
Returns
|
|
239
|
+
-------
|
|
240
|
+
Quantity
|
|
241
|
+
The electric field amplitude at the focus in volts per meter.
|
|
242
|
+
"""
|
|
243
|
+
# Calculate waists based on numerical apertures
|
|
244
|
+
self.waist_x = (self.wavelength / (PhysicalConstant.pi * self.numerical_aperture_x))
|
|
245
|
+
self.waist_y = (self.wavelength / (PhysicalConstant.pi * self.numerical_aperture_y))
|
|
246
|
+
|
|
247
|
+
E0 = np.sqrt(2 * self.optical_power / (PhysicalConstant.pi * PhysicalConstant.epsilon_0 * PhysicalConstant.c * self.waist_x * self.waist_y))
|
|
248
|
+
|
|
249
|
+
return E0.to(volt / meter)
|
FlowCyPy/units.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Initialize a unit registry
|
|
2
|
+
from PyMieSim.units import ureg, Quantity # noqa F401
|
|
3
|
+
from PyMieSim.units import * # noqa F401
|
|
4
|
+
|
|
5
|
+
_scaled_units_str_list = [
|
|
6
|
+
'watt', 'volt', 'meter', 'second', 'liter', 'hertz', 'ohm', 'ampere'
|
|
7
|
+
]
|
|
8
|
+
|
|
9
|
+
for _units_str in _scaled_units_str_list:
|
|
10
|
+
for scale in ['nano', 'micro', 'milli', '', 'kilo', 'mega']:
|
|
11
|
+
_unit = scale + _units_str
|
|
12
|
+
globals()[_unit] = getattr(ureg, _unit)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
joule = ureg.joule
|
|
16
|
+
coulomb = ureg.coulomb
|
|
17
|
+
power = ureg.watt.dimensionality
|
|
18
|
+
kelvin = ureg.kelvin
|
|
19
|
+
celsius = ureg.celsius
|
|
20
|
+
particle = ureg.particle
|
|
21
|
+
degree = ureg.degree
|
|
22
|
+
distance = ureg.meter.dimensionality
|
|
23
|
+
time = ureg.second.dimensionality
|
|
24
|
+
volume = ureg.liter.dimensionality
|
|
25
|
+
frequency = ureg.hertz.dimensionality
|
|
26
|
+
dB = ureg.dB
|
|
27
|
+
|
|
28
|
+
# Define a custom unit 'bit_bins'
|
|
29
|
+
ureg.define("bit_bins = [detector_resolution]")
|
|
30
|
+
bit_bins = ureg.bit_bins
|