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/population.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
|
|
2
|
+
import numpy as np
|
|
3
|
+
from typing import Union
|
|
4
|
+
from FlowCyPy import distribution
|
|
5
|
+
import pandas as pd
|
|
6
|
+
from dataclasses import field
|
|
7
|
+
import pint_pandas
|
|
8
|
+
from pydantic.dataclasses import dataclass
|
|
9
|
+
from pydantic import field_validator
|
|
10
|
+
from FlowCyPy.units import particle
|
|
11
|
+
from FlowCyPy.flow_cell import FlowCell
|
|
12
|
+
from FlowCyPy.utils import PropertiesReport
|
|
13
|
+
import logging
|
|
14
|
+
from PyMieSim.units import Quantity, RIU, meter
|
|
15
|
+
import warnings
|
|
16
|
+
from FlowCyPy.particle_count import ParticleCount
|
|
17
|
+
|
|
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)
|
|
28
|
+
class Population(PropertiesReport):
|
|
29
|
+
"""
|
|
30
|
+
A class representing a population of scatterers in a flow cytometry setup.
|
|
31
|
+
|
|
32
|
+
Parameters
|
|
33
|
+
----------
|
|
34
|
+
name : str
|
|
35
|
+
Name of the population distribution.
|
|
36
|
+
refractive_index : Union[distribution.Base, Quantity]
|
|
37
|
+
Refractive index or refractive index distributions.
|
|
38
|
+
size : Union[distribution.Base, Quantity]
|
|
39
|
+
Particle size or size distributions.
|
|
40
|
+
particle_count : ParticleCount
|
|
41
|
+
Scatterer density in particles per cubic meter, default is 1 particle/m³.
|
|
42
|
+
|
|
43
|
+
"""
|
|
44
|
+
name: str
|
|
45
|
+
refractive_index: Union[distribution.Base, Quantity]
|
|
46
|
+
size: Union[distribution.Base, Quantity]
|
|
47
|
+
particle_count: ParticleCount = field(init=False)
|
|
48
|
+
|
|
49
|
+
def __post_init__(self):
|
|
50
|
+
"""
|
|
51
|
+
Automatically converts all Quantity attributes to their base SI units (i.e., without any prefixes).
|
|
52
|
+
This strips units like millimeter to meter, kilogram to gram, etc.
|
|
53
|
+
"""
|
|
54
|
+
# Convert all Quantity attributes to base SI units (without any prefixes)
|
|
55
|
+
for attr_name, attr_value in vars(self).items():
|
|
56
|
+
if isinstance(attr_value, Quantity):
|
|
57
|
+
# Convert the quantity to its base unit (strip prefix)
|
|
58
|
+
setattr(self, attr_name, attr_value.to_base_units())
|
|
59
|
+
|
|
60
|
+
@field_validator('concentration')
|
|
61
|
+
def _validate_concentration(cls, value):
|
|
62
|
+
"""
|
|
63
|
+
Validates that the concentration is expressed in units of inverse volume.
|
|
64
|
+
|
|
65
|
+
Parameters
|
|
66
|
+
----------
|
|
67
|
+
value : Quantity
|
|
68
|
+
The concentration to validate.
|
|
69
|
+
|
|
70
|
+
Returns
|
|
71
|
+
-------
|
|
72
|
+
Quantity
|
|
73
|
+
The validated concentration.
|
|
74
|
+
|
|
75
|
+
Raises
|
|
76
|
+
------
|
|
77
|
+
ValueError: If the concentration is not expressed in units of inverse volume.
|
|
78
|
+
"""
|
|
79
|
+
if not value.check('particles / [length]**3'):
|
|
80
|
+
raise ValueError(f"concentration must be in units of particles per volume (e.g., particles/m^3), but got {value.units}")
|
|
81
|
+
return value
|
|
82
|
+
|
|
83
|
+
@field_validator('refractive_index')
|
|
84
|
+
def _validate_refractive_index(cls, value):
|
|
85
|
+
"""
|
|
86
|
+
Validates that the refractive index is either a Quantity or a valid distribution.Base instance.
|
|
87
|
+
|
|
88
|
+
Parameters
|
|
89
|
+
----------
|
|
90
|
+
value : Union[distribution.Base, Quantity]
|
|
91
|
+
The refractive index to validate.
|
|
92
|
+
|
|
93
|
+
Returns
|
|
94
|
+
-------
|
|
95
|
+
Union[distribution.Base, Quantity]
|
|
96
|
+
The validated refractive index.
|
|
97
|
+
|
|
98
|
+
Raises
|
|
99
|
+
------
|
|
100
|
+
TypeError
|
|
101
|
+
If the refractive index is not of type Quantity or distribution.Base.
|
|
102
|
+
"""
|
|
103
|
+
if isinstance(value, Quantity):
|
|
104
|
+
assert value.check(RIU), "The refractive index value provided does not have refractive index units [RIU]"
|
|
105
|
+
return distribution.Delta(position=value)
|
|
106
|
+
|
|
107
|
+
if isinstance(value, distribution.Base):
|
|
108
|
+
return value
|
|
109
|
+
|
|
110
|
+
raise TypeError(f"refractive_index must be of type Quantity<RIU or refractive_index_units> or distribution.Base, but got {type(value)}")
|
|
111
|
+
|
|
112
|
+
@field_validator('size')
|
|
113
|
+
def _validate_size(cls, value):
|
|
114
|
+
"""
|
|
115
|
+
Validates that the size is either a Quantity or a valid distribution.Base instance.
|
|
116
|
+
|
|
117
|
+
Parameters
|
|
118
|
+
----------
|
|
119
|
+
value : Union[distribution.Base, Quantity]
|
|
120
|
+
The size to validate.
|
|
121
|
+
|
|
122
|
+
Returns
|
|
123
|
+
-------
|
|
124
|
+
Union[distribution.Base, Quantity]
|
|
125
|
+
The validated size.
|
|
126
|
+
|
|
127
|
+
Raises
|
|
128
|
+
------
|
|
129
|
+
TypeError
|
|
130
|
+
If the size is not of type Quantity or distribution.Base.
|
|
131
|
+
"""
|
|
132
|
+
if isinstance(value, Quantity):
|
|
133
|
+
assert value.check(meter), "The size value provided does not have length units [meter]"
|
|
134
|
+
return distribution.Delta(position=value)
|
|
135
|
+
|
|
136
|
+
if isinstance(value, distribution.Base):
|
|
137
|
+
return value
|
|
138
|
+
|
|
139
|
+
raise TypeError(f"suze must be of type Quantity or distribution.Base, but got {type(value)}")
|
|
140
|
+
|
|
141
|
+
def initialize(self, flow_cell: FlowCell) -> None:
|
|
142
|
+
self.dataframe = pd.DataFrame()
|
|
143
|
+
|
|
144
|
+
if isinstance(self.size, Quantity):
|
|
145
|
+
self.size = distribution.Delta(size_value=self.size)
|
|
146
|
+
|
|
147
|
+
self.flow_cell = flow_cell
|
|
148
|
+
|
|
149
|
+
self.n_events = self.particle_count.calculate_number_of_events(
|
|
150
|
+
flow_area=self.flow_cell.flow_area,
|
|
151
|
+
flow_speed=self.flow_cell.flow_speed,
|
|
152
|
+
run_time=self.flow_cell.run_time
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
self._generate_longitudinal_positions()
|
|
156
|
+
|
|
157
|
+
logging.info(f"Population [{self.name}] initialized with an estimated {self.n_events}.")
|
|
158
|
+
|
|
159
|
+
size = self.size.generate(self.n_events)
|
|
160
|
+
self.dataframe['Size'] = pint_pandas.PintArray(size, dtype=size.units)
|
|
161
|
+
|
|
162
|
+
ri = self.refractive_index.generate(self.n_events)
|
|
163
|
+
self.dataframe['RefractiveIndex'] = pint_pandas.PintArray(ri, dtype=ri.units)
|
|
164
|
+
|
|
165
|
+
def _generate_longitudinal_positions(self) -> None:
|
|
166
|
+
r"""
|
|
167
|
+
Generate particle arrival times over the entire experiment duration based on a Poisson process.
|
|
168
|
+
|
|
169
|
+
In flow cytometry, the particle arrival times can be modeled as a Poisson process, where the time
|
|
170
|
+
intervals between successive particle arrivals follow an exponential distribution. The average rate
|
|
171
|
+
of particle arrivals (the particle flux) is given by:
|
|
172
|
+
|
|
173
|
+
.. math::
|
|
174
|
+
\text{Particle Flux} = \rho \cdot v \cdot A
|
|
175
|
+
|
|
176
|
+
where:
|
|
177
|
+
- :math:`\rho` is the scatterer density (particles per cubic meter),
|
|
178
|
+
- :math:`v` is the flow speed (meters per second),
|
|
179
|
+
- :math:`A` is the cross-sectional area of the flow tube (square meters).
|
|
180
|
+
|
|
181
|
+
The number of particles arriving in a given time interval follows a Poisson distribution, and the
|
|
182
|
+
time between successive arrivals follows an exponential distribution. The mean inter-arrival time
|
|
183
|
+
is the inverse of the particle flux:
|
|
184
|
+
|
|
185
|
+
.. math::
|
|
186
|
+
\Delta t \sim \text{Exponential}(1/\lambda)
|
|
187
|
+
|
|
188
|
+
where:
|
|
189
|
+
- :math:`\Delta t` is the time between successive particle arrivals,
|
|
190
|
+
- :math:`\lambda` is the particle flux (particles per second).
|
|
191
|
+
|
|
192
|
+
Steps:
|
|
193
|
+
1. Compute the particle flux, which is the average number of particles passing through the detection
|
|
194
|
+
region per second.
|
|
195
|
+
2. Calculate the expected number of particles over the entire experiment duration.
|
|
196
|
+
3. Generate random inter-arrival times using the exponential distribution.
|
|
197
|
+
4. Compute the cumulative arrival times by summing the inter-arrival times.
|
|
198
|
+
5. Ensure that all arrival times fall within the total experiment duration.
|
|
199
|
+
|
|
200
|
+
Returns
|
|
201
|
+
-------
|
|
202
|
+
np.ndarray
|
|
203
|
+
An array of particle arrival times (in seconds) for the entire experiment duration, based on the Poisson process.
|
|
204
|
+
"""
|
|
205
|
+
# Step 1: Compute the average particle flux (particles per second)
|
|
206
|
+
particle_flux = self.particle_count.compute_particle_flux(
|
|
207
|
+
flow_speed=self.flow_cell.flow_speed,
|
|
208
|
+
flow_area=self.flow_cell.flow_area,
|
|
209
|
+
run_time=self.flow_cell.run_time
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# Step 2: Calculate the expected number of particles over the entire experiment
|
|
213
|
+
expected_particles = self.n_events
|
|
214
|
+
|
|
215
|
+
# Step 3: Generate inter-arrival times (exponentially distributed)
|
|
216
|
+
inter_arrival_times = np.random.exponential(
|
|
217
|
+
scale=1 / particle_flux.magnitude,
|
|
218
|
+
size=int(expected_particles.magnitude)
|
|
219
|
+
) / (particle_flux.units / particle)
|
|
220
|
+
|
|
221
|
+
# Step 4: Compute cumulative arrival times
|
|
222
|
+
arrival_times = np.cumsum(inter_arrival_times)
|
|
223
|
+
|
|
224
|
+
# Step 5: Limit the arrival times to the total experiment duration
|
|
225
|
+
arrival_times = arrival_times[arrival_times <= self.flow_cell.run_time]
|
|
226
|
+
|
|
227
|
+
time = arrival_times[arrival_times <= self.flow_cell.run_time]
|
|
228
|
+
self.dataframe['Time'] = pint_pandas.PintArray(time, dtype=time.units)
|
|
229
|
+
|
|
230
|
+
position = arrival_times * self.flow_cell.flow_speed
|
|
231
|
+
|
|
232
|
+
self.dataframe['Position'] = pint_pandas.PintArray(position, dtype=position.units)
|
|
233
|
+
|
|
234
|
+
self.n_events = len(arrival_times) * particle
|
|
235
|
+
|
|
236
|
+
if self.n_events == 0:
|
|
237
|
+
warnings.warn("Population has been initialized with 0 events.")
|
|
238
|
+
|
|
239
|
+
from FlowCyPy.populations_instances import * # noqa F403
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from FlowCyPy.units import Quantity, nanometer, RIU, micrometer
|
|
2
|
+
from FlowCyPy.population import Population
|
|
3
|
+
from FlowCyPy import distribution
|
|
4
|
+
|
|
5
|
+
_populations = (
|
|
6
|
+
('Exosome', 70 * nanometer, 2.0, 1.39 * RIU, 0.02 * RIU),
|
|
7
|
+
('MicroVesicle', 400 * nanometer, 1.5, 1.39 * RIU, 0.02 * RIU),
|
|
8
|
+
('ApoptoticBodies', 2 * micrometer, 1.2, 1.40 * RIU, 0.03 * RIU),
|
|
9
|
+
('HDL', 10 * nanometer, 3.5, 1.33 * RIU, 0.01 * RIU),
|
|
10
|
+
('LDL', 20 * nanometer, 3.0, 1.35 * RIU, 0.02 * RIU),
|
|
11
|
+
('VLDL', 50 * nanometer, 2.0, 1.445 * RIU, 0.0005 * RIU),
|
|
12
|
+
('Platelet', 2000 * nanometer, 2.5, 1.38 * RIU, 0.01 * RIU),
|
|
13
|
+
('CellularDebris', 3 * micrometer, 1.0, 1.40 * RIU, 0.03 * RIU),
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
for (name, size, size_spread, ri, ri_spread) in _populations:
|
|
18
|
+
size_distribution = distribution.RosinRammler(
|
|
19
|
+
characteristic_size=size,
|
|
20
|
+
spread=size_spread
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
ri_distribution = distribution.Normal(
|
|
24
|
+
mean=ri,
|
|
25
|
+
std_dev=ri_spread
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
population = Population(
|
|
29
|
+
name=name,
|
|
30
|
+
size=size_distribution,
|
|
31
|
+
refractive_index=ri_distribution,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
locals()[name] = population
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_microbeads(size: Quantity, refractive_index: Quantity, name: str) -> Population:
|
|
38
|
+
|
|
39
|
+
size_distribution = distribution.Delta(position=size)
|
|
40
|
+
|
|
41
|
+
ri_distribution = distribution.Delta(position=refractive_index)
|
|
42
|
+
|
|
43
|
+
microbeads = Population(
|
|
44
|
+
name=name,
|
|
45
|
+
size=size_distribution,
|
|
46
|
+
refractive_index=ri_distribution
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
return microbeads
|
FlowCyPy/report.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from reportlab.lib.pagesizes import A4
|
|
3
|
+
from reportlab.lib import colors
|
|
4
|
+
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Frame, PageTemplate, Spacer, Paragraph, Image
|
|
5
|
+
from reportlab.lib.units import inch
|
|
6
|
+
from reportlab.lib.styles import getSampleStyleSheet
|
|
7
|
+
import matplotlib.pyplot as plt
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import shutil
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def df2table(df):
|
|
13
|
+
paragraphs = [[Paragraph(col) for col in df.columns]] + df.values.tolist()
|
|
14
|
+
style = [
|
|
15
|
+
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
|
|
16
|
+
('LINEBELOW', (0, 0), (-1, 0), 1, colors.black),
|
|
17
|
+
('INNERGRID', (0, 0), (-1, -1), 0.25, colors.black),
|
|
18
|
+
('BOX', (0, 0), (-1, -1), 1, colors.black),
|
|
19
|
+
('ROWBACKGROUNDS', (0, 0), (-1, -1), [colors.lightgrey, colors.white])
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
return Table(paragraphs, hAlign='LEFT', style=style)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Report:
|
|
27
|
+
flow_cell: object
|
|
28
|
+
scatterer: object
|
|
29
|
+
analyzer: object
|
|
30
|
+
output_filename: str = "flow_report.pdf"
|
|
31
|
+
title_color: object = field(default=colors.darkblue)
|
|
32
|
+
header_color: object = field(default=colors.darkgreen)
|
|
33
|
+
footer_color: object = field(default=colors.gray)
|
|
34
|
+
str_format: str = field(default='.2~P')
|
|
35
|
+
|
|
36
|
+
# Page size constants
|
|
37
|
+
width: float = field(default=A4[0], init=False)
|
|
38
|
+
height: float = field(default=A4[1], init=False)
|
|
39
|
+
|
|
40
|
+
temporary_folder = Path('./temporary_')
|
|
41
|
+
|
|
42
|
+
def add_background(self, canvas, doc):
|
|
43
|
+
"""Adds a background color to every page."""
|
|
44
|
+
canvas.saveState()
|
|
45
|
+
canvas.setFillColorRGB(0.95, 0.95, 0.95) # Light grey background
|
|
46
|
+
canvas.rect(0, 0, A4[0], A4[1], fill=True, stroke=False) # Fill the entire page
|
|
47
|
+
canvas.restoreState()
|
|
48
|
+
self.add_watermark(canvas, doc)
|
|
49
|
+
|
|
50
|
+
def add_footer(self, canvas, doc):
|
|
51
|
+
"""Adds a footer with page number to every page."""
|
|
52
|
+
canvas.saveState()
|
|
53
|
+
canvas.setFont("Helvetica-Oblique", 8)
|
|
54
|
+
canvas.setFillColor(self.footer_color)
|
|
55
|
+
canvas.drawString(1 * inch, 0.75 * inch, "Flow Cytometry Report")
|
|
56
|
+
canvas.drawRightString(A4[0] - inch, 0.75 * inch, f"Page {doc.page}")
|
|
57
|
+
canvas.restoreState()
|
|
58
|
+
|
|
59
|
+
def get_header(self):
|
|
60
|
+
"""Returns a Paragraph for the report header."""
|
|
61
|
+
style = getSampleStyleSheet()['Title']
|
|
62
|
+
style.textColor = self.title_color
|
|
63
|
+
return Paragraph("Flow Cytometry Simulation Report", style)
|
|
64
|
+
|
|
65
|
+
def get_flow_cell_section(self):
|
|
66
|
+
"""Returns a Paragraph or Table for the FlowCell section."""
|
|
67
|
+
style = getSampleStyleSheet()['Heading2']
|
|
68
|
+
|
|
69
|
+
table_data = [['Attribute', 'Value']]
|
|
70
|
+
table_data += self.flow_cell.get_properties()
|
|
71
|
+
|
|
72
|
+
table = Table(table_data, hAlign='LEFT')
|
|
73
|
+
table.setStyle(self.get_table_style())
|
|
74
|
+
|
|
75
|
+
return Paragraph("Section 1: FlowCell Parameters", style), table
|
|
76
|
+
|
|
77
|
+
def get_image_from(self, plot_function: object, figure_size, scale: float = 1, align: str = 'RIGHT') -> Image:
|
|
78
|
+
# Generate and save plot image for the scatterer distribution
|
|
79
|
+
plot_filename = self.temporary_folder / f"{id(plot_function)}.png"
|
|
80
|
+
plot_function(show=False, figure_size=figure_size)
|
|
81
|
+
plt.savefig(plot_filename)
|
|
82
|
+
plt.close()
|
|
83
|
+
|
|
84
|
+
# Create an image object to include in the PDF
|
|
85
|
+
image = Image(
|
|
86
|
+
filename=plot_filename,
|
|
87
|
+
hAlign=align,
|
|
88
|
+
height=figure_size[1] * scale * inch,
|
|
89
|
+
width=figure_size[0] * scale * inch
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
return image
|
|
93
|
+
|
|
94
|
+
def get_scatterer_section(self):
|
|
95
|
+
"""Returns a Table for the scatterer distribution section."""
|
|
96
|
+
style = getSampleStyleSheet()['Heading2']
|
|
97
|
+
|
|
98
|
+
df = self.scatterer.populations[0].dataframe.pint.dequantify().reset_index().describe(percentiles=[])
|
|
99
|
+
df = df[['Time']]
|
|
100
|
+
print(df)
|
|
101
|
+
|
|
102
|
+
table = df2table(df)
|
|
103
|
+
|
|
104
|
+
# df = self.scatterer.dataframe#.reset_index()
|
|
105
|
+
# _report = df.pint.dequantify().describe(percentiles=[])
|
|
106
|
+
# print(_report)
|
|
107
|
+
# import pandas as pd
|
|
108
|
+
# report = pd.DataFrame()
|
|
109
|
+
# report['Attribute'] = _report['index']
|
|
110
|
+
# report['Index'] = _report['Index']
|
|
111
|
+
# report['Time'] = _report['Time']
|
|
112
|
+
# print(report)
|
|
113
|
+
|
|
114
|
+
# table = df2table(report)
|
|
115
|
+
|
|
116
|
+
table.setStyle(self.get_table_style())
|
|
117
|
+
|
|
118
|
+
self.get_image_from(self.scatterer.plot, figure_size=(8, 8), scale=0.5)
|
|
119
|
+
|
|
120
|
+
# combined_table = Table([[table, image]], hAlign='LEFT')
|
|
121
|
+
combined_table = Table([[table]], hAlign='LEFT')
|
|
122
|
+
|
|
123
|
+
combined_table.setStyle(TableStyle([
|
|
124
|
+
('VALIGN', (0, 0), (-1, -1), 'TOP'), # Align both the table and image at the top
|
|
125
|
+
]))
|
|
126
|
+
|
|
127
|
+
return Paragraph("Section 2: Scatterer populations", style), combined_table
|
|
128
|
+
|
|
129
|
+
def get_detector_section(self):
|
|
130
|
+
"""Returns a Table for the detector properties."""
|
|
131
|
+
style = getSampleStyleSheet()['Heading2']
|
|
132
|
+
detector_0, detector_1 = self.analyzer.cytometer.detectors
|
|
133
|
+
properties_0 = detector_0.get_properties()
|
|
134
|
+
properties_1 = detector_1.get_properties()
|
|
135
|
+
|
|
136
|
+
# Create table data
|
|
137
|
+
table_data = [['Attribute', f'Detector {detector_0.name}', f'Detector {detector_1.name}']]
|
|
138
|
+
table_data += [[p0[0], p0[1], p1[1]] for p0, p1 in zip(properties_0, properties_1)]
|
|
139
|
+
|
|
140
|
+
# Create and return the table
|
|
141
|
+
table = Table(table_data, hAlign='LEFT')
|
|
142
|
+
table.setStyle(self.get_table_style())
|
|
143
|
+
|
|
144
|
+
image_0 = self.get_image_from(detector_0.plot, align='CENTER', figure_size=(6, 3))
|
|
145
|
+
image_1 = self.get_image_from(detector_1.plot, align='CENTER', figure_size=(6, 3))
|
|
146
|
+
|
|
147
|
+
return Paragraph("Section 3: Detectors parameters", style), table, image_0, image_1
|
|
148
|
+
|
|
149
|
+
def get_analyzer_section(self) -> None:
|
|
150
|
+
"""Adds a section displaying analyzer properties."""
|
|
151
|
+
style = getSampleStyleSheet()['Heading2']
|
|
152
|
+
|
|
153
|
+
image = self.get_image_from(self.analyzer.plot_peak, align='CENTER', figure_size=(7, 6), scale=1)
|
|
154
|
+
|
|
155
|
+
return Paragraph("Section 4: Peak Analyzer parameters", style), image
|
|
156
|
+
|
|
157
|
+
def get_table_style(self) -> TableStyle:
|
|
158
|
+
"""Returns a consistent table style."""
|
|
159
|
+
return TableStyle([
|
|
160
|
+
('BACKGROUND', (0, 0), (-1, 0), colors.grey),
|
|
161
|
+
('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
|
|
162
|
+
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
|
|
163
|
+
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
|
|
164
|
+
('FONTSIZE', (0, 0), (-1, -1), 10),
|
|
165
|
+
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
|
|
166
|
+
('BACKGROUND', (0, 1), (-1, -1), colors.beige),
|
|
167
|
+
('GRID', (0, 0), (-1, -1), 1, colors.black),
|
|
168
|
+
])
|
|
169
|
+
|
|
170
|
+
def add_watermark(self, canvas, doc):
|
|
171
|
+
"""Adds a semi-transparent watermark to the current page."""
|
|
172
|
+
canvas.saveState()
|
|
173
|
+
canvas.setFont("Helvetica-Bold", 50)
|
|
174
|
+
canvas.setFillColorRGB(0.9, 0.9, 0.9) # Light grey color for watermark
|
|
175
|
+
canvas.setFillAlpha(0.3) # Transparency level (0.0 fully transparent, 1.0 fully opaque)
|
|
176
|
+
|
|
177
|
+
# Position the watermark in the center of the page
|
|
178
|
+
width, height = A4
|
|
179
|
+
canvas.translate(width / 2, height / 2)
|
|
180
|
+
canvas.rotate(45)
|
|
181
|
+
canvas.drawCentredString(0, 0, "FLOWCYPY REPORT") # Watermark text
|
|
182
|
+
|
|
183
|
+
canvas.restoreState()
|
|
184
|
+
|
|
185
|
+
def generate_report(self):
|
|
186
|
+
"""Generates the PDF report, applying a background to every page."""
|
|
187
|
+
# Use SimpleDocTemplate from Platypus
|
|
188
|
+
|
|
189
|
+
if self.temporary_folder.exists():
|
|
190
|
+
shutil.rmtree(self.temporary_folder)
|
|
191
|
+
|
|
192
|
+
self.temporary_folder.mkdir()
|
|
193
|
+
|
|
194
|
+
doc = SimpleDocTemplate(
|
|
195
|
+
self.output_filename,
|
|
196
|
+
pagesize=A4,
|
|
197
|
+
showBoundary=1,
|
|
198
|
+
leftMargin=0.5 * inch,
|
|
199
|
+
rightMargin=0.5 * inch,
|
|
200
|
+
topMargin=0.5 * inch,
|
|
201
|
+
bottomMargin=1.5 * inch
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
spacer = Spacer(1, 0.2 * inch)
|
|
205
|
+
|
|
206
|
+
story = [
|
|
207
|
+
self.get_header(), spacer,
|
|
208
|
+
*self.get_flow_cell_section(), spacer,
|
|
209
|
+
*self.get_scatterer_section(), spacer,
|
|
210
|
+
*self.get_detector_section(), spacer,
|
|
211
|
+
*self.get_analyzer_section()
|
|
212
|
+
]
|
|
213
|
+
|
|
214
|
+
# Create a page template with the background and header/footer functions
|
|
215
|
+
frame = Frame(
|
|
216
|
+
doc.leftMargin,
|
|
217
|
+
doc.bottomMargin,
|
|
218
|
+
doc.width,
|
|
219
|
+
doc.height,
|
|
220
|
+
id='normal'
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
template = PageTemplate(
|
|
224
|
+
id='background',
|
|
225
|
+
frames=frame,
|
|
226
|
+
onPage=self.add_background,
|
|
227
|
+
onPageEnd=self.add_footer,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
doc.addPageTemplates([template])
|
|
231
|
+
|
|
232
|
+
# Build the PDF
|
|
233
|
+
doc.build(story)
|
|
234
|
+
print(f"PDF report saved as {self.output_filename}")
|
|
235
|
+
|
|
236
|
+
shutil.rmtree(self.temporary_folder)
|