openflo 2.6.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.
- openflo/__init__.py +286 -0
- openflo/_console.py +39 -0
- openflo/_golden.json +53 -0
- openflo/annotate.py +213 -0
- openflo/async_task.py +78 -0
- openflo/audit.py +144 -0
- openflo/calibration.py +112 -0
- openflo/capabilities.py +116 -0
- openflo/cli.py +2354 -0
- openflo/comp_qc.py +144 -0
- openflo/compare.py +362 -0
- openflo/compliance.py +150 -0
- openflo/density.py +200 -0
- openflo/diagnostics.py +245 -0
- openflo/diffexp.py +255 -0
- openflo/dr_compare.py +160 -0
- openflo/editor_analysis.py +579 -0
- openflo/editor_audit.py +37 -0
- openflo/editor_autoclean.py +428 -0
- openflo/editor_autogate.py +184 -0
- openflo/editor_base.py +31 -0
- openflo/editor_channels.py +83 -0
- openflo/editor_chrome.py +451 -0
- openflo/editor_clipboard.py +514 -0
- openflo/editor_compute.py +218 -0
- openflo/editor_console.py +156 -0
- openflo/editor_dnd.py +632 -0
- openflo/editor_downsample.py +152 -0
- openflo/editor_export.py +408 -0
- openflo/editor_figure.py +201 -0
- openflo/editor_gatetools.py +783 -0
- openflo/editor_gating.py +1083 -0
- openflo/editor_grouping.py +85 -0
- openflo/editor_help.py +237 -0
- openflo/editor_lifecycle.py +604 -0
- openflo/editor_load.py +544 -0
- openflo/editor_loadpool.py +434 -0
- openflo/editor_menu.py +268 -0
- openflo/editor_mode.py +114 -0
- openflo/editor_plot.py +1619 -0
- openflo/editor_populations.py +458 -0
- openflo/editor_session.py +776 -0
- openflo/editor_slider.py +208 -0
- openflo/editor_stats.py +218 -0
- openflo/editor_template.py +300 -0
- openflo/editor_tools.py +473 -0
- openflo/editor_tree.py +509 -0
- openflo/editor_undo.py +98 -0
- openflo/editor_update.py +116 -0
- openflo/fcs_export.py +87 -0
- openflo/gatetree.py +232 -0
- openflo/gating.py +165 -0
- openflo/gating_helpers.py +187 -0
- openflo/gpu_accel.py +295 -0
- openflo/gui.py +2436 -0
- openflo/inspect_fcs.py +26 -0
- openflo/interop.py +200 -0
- openflo/paths.py +78 -0
- openflo/pipeline.py +5720 -0
- openflo/plotmath.py +191 -0
- openflo/prefs.py +56 -0
- openflo/preview.py +236 -0
- openflo/provenance.py +337 -0
- openflo/py.typed +0 -0
- openflo/report.py +104 -0
- openflo/scales.py +85 -0
- openflo/selftest.py +288 -0
- openflo/selftest_controls.py +644 -0
- openflo/session_format.py +81 -0
- openflo/spectral.py +305 -0
- openflo/stats.py +417 -0
- openflo/synthetic.py +735 -0
- openflo/template_library/README.md +92 -0
- openflo/template_library/cleanup_acquisition_qc.json +43 -0
- openflo/template_library/cleanup_minimal.json +35 -0
- openflo/template_library/cleanup_standard.json +67 -0
- openflo/template_library/cleanup_strict.json +67 -0
- openflo/template_library/example_panel.json +15 -0
- openflo/theme.py +230 -0
- openflo/tool_window.py +160 -0
- openflo/trajectory.py +171 -0
- openflo/tree_ids.py +63 -0
- openflo/ui_abscounts.py +74 -0
- openflo/ui_annotation.py +182 -0
- openflo/ui_audit.py +222 -0
- openflo/ui_autogate.py +179 -0
- openflo/ui_axis_config.py +157 -0
- openflo/ui_calibration.py +208 -0
- openflo/ui_cell_cycle.py +76 -0
- openflo/ui_comp.py +530 -0
- openflo/ui_compare.py +348 -0
- openflo/ui_diff.py +248 -0
- openflo/ui_embedding.py +92 -0
- openflo/ui_expression.py +326 -0
- openflo/ui_figure_layout.py +121 -0
- openflo/ui_figure_window.py +55 -0
- openflo/ui_flowsom_tree.py +156 -0
- openflo/ui_fmo.py +142 -0
- openflo/ui_frequency.py +598 -0
- openflo/ui_group_stats.py +91 -0
- openflo/ui_inspect.py +248 -0
- openflo/ui_logic.py +75 -0
- openflo/ui_methods.py +87 -0
- openflo/ui_preferences.py +268 -0
- openflo/ui_preview.py +292 -0
- openflo/ui_sample_qc.py +183 -0
- openflo/ui_spectral_qc.py +196 -0
- openflo/ui_spectral_unmix.py +116 -0
- openflo/ui_statistics.py +349 -0
- openflo/ui_synth.py +309 -0
- openflo/ui_tips.py +82 -0
- openflo/ui_trajectory.py +248 -0
- openflo/ui_voltage.py +345 -0
- openflo/update.py +244 -0
- openflo/voltage.py +377 -0
- openflo/warmup.py +94 -0
- openflo/workspace.py +3079 -0
- openflo-2.6.0.dist-info/METADATA +450 -0
- openflo-2.6.0.dist-info/RECORD +123 -0
- openflo-2.6.0.dist-info/WHEEL +5 -0
- openflo-2.6.0.dist-info/entry_points.txt +10 -0
- openflo-2.6.0.dist-info/licenses/LICENSE.txt +31 -0
- openflo-2.6.0.dist-info/top_level.txt +1 -0
openflo/__init__.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""OpenFlo — flow cytometry analysis pipeline.
|
|
2
|
+
|
|
3
|
+
Public surface re-exported here so ``from openflo import FlowSample`` works
|
|
4
|
+
without callers needing to know which submodule the symbol lives in. The
|
|
5
|
+
heavyweight submodules (``openflo.gui``, ``openflo.preview``) are NOT
|
|
6
|
+
imported here — importing the package shouldn't load Tk or matplotlib's
|
|
7
|
+
Tk backend.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from importlib.metadata import PackageNotFoundError
|
|
12
|
+
from importlib.metadata import version as _pkg_version
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
# Static type checkers see the public surface up-front. At runtime the
|
|
17
|
+
# ``__getattr__`` hook below resolves these lazily without paying the
|
|
18
|
+
# phenograph / pandas import cost just to ``import openflo``.
|
|
19
|
+
from .annotate import (
|
|
20
|
+
annotate_by_reference,
|
|
21
|
+
mem_label,
|
|
22
|
+
mem_scores,
|
|
23
|
+
parse_signature_table,
|
|
24
|
+
population_states,
|
|
25
|
+
)
|
|
26
|
+
from .audit import AuditLog
|
|
27
|
+
from .calibration import (
|
|
28
|
+
apply_calibration,
|
|
29
|
+
detect_bead_peaks,
|
|
30
|
+
fit_mesf_calibration,
|
|
31
|
+
)
|
|
32
|
+
from .compliance import (
|
|
33
|
+
build_manifest,
|
|
34
|
+
record_to_markdown,
|
|
35
|
+
sign_manifest,
|
|
36
|
+
verify_record,
|
|
37
|
+
)
|
|
38
|
+
from .diffexp import (
|
|
39
|
+
cluster_abundance,
|
|
40
|
+
differential_abundance,
|
|
41
|
+
differential_test,
|
|
42
|
+
marker_expression,
|
|
43
|
+
)
|
|
44
|
+
from .interop import (
|
|
45
|
+
mds_embed,
|
|
46
|
+
sample_distance_matrix,
|
|
47
|
+
to_anndata,
|
|
48
|
+
write_h5ad,
|
|
49
|
+
)
|
|
50
|
+
from .pipeline import (
|
|
51
|
+
ClusteringError,
|
|
52
|
+
CompensationError,
|
|
53
|
+
CytoNorm,
|
|
54
|
+
FcsParseError,
|
|
55
|
+
FlowExperiment,
|
|
56
|
+
FlowSample,
|
|
57
|
+
FMOGater,
|
|
58
|
+
GateError,
|
|
59
|
+
OpenFloError,
|
|
60
|
+
WspParseError,
|
|
61
|
+
WspReader,
|
|
62
|
+
WspWriter,
|
|
63
|
+
concatenate,
|
|
64
|
+
cumulative_gate_mask,
|
|
65
|
+
describe_gate,
|
|
66
|
+
flowsom_layout,
|
|
67
|
+
flowsom_mst,
|
|
68
|
+
gate_to_mask,
|
|
69
|
+
optimize_compensation,
|
|
70
|
+
read_compensation_matrix,
|
|
71
|
+
read_template_gates,
|
|
72
|
+
set_default_palette,
|
|
73
|
+
write_compensation_matrix,
|
|
74
|
+
write_fcs,
|
|
75
|
+
)
|
|
76
|
+
from .report import (
|
|
77
|
+
build_html_report,
|
|
78
|
+
df_to_html_table,
|
|
79
|
+
figure_to_data_uri,
|
|
80
|
+
)
|
|
81
|
+
from .spectral import (
|
|
82
|
+
apply_unmixing,
|
|
83
|
+
build_reference_spectra,
|
|
84
|
+
spectral_condition_number,
|
|
85
|
+
spectral_similarity_matrix,
|
|
86
|
+
spillover_spread_matrix,
|
|
87
|
+
unmix,
|
|
88
|
+
unmixing_qc,
|
|
89
|
+
)
|
|
90
|
+
from .stats import (
|
|
91
|
+
compare_all_features,
|
|
92
|
+
compare_groups,
|
|
93
|
+
group_kde,
|
|
94
|
+
p_to_stars,
|
|
95
|
+
to_prism_column,
|
|
96
|
+
to_prism_grouped,
|
|
97
|
+
volcano_data,
|
|
98
|
+
)
|
|
99
|
+
from .trajectory import (
|
|
100
|
+
compute_pseudotime,
|
|
101
|
+
pseudotime_trends,
|
|
102
|
+
robust_root,
|
|
103
|
+
)
|
|
104
|
+
from .voltage import VoltageTitration
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
__version__ = _pkg_version("openflo")
|
|
108
|
+
except PackageNotFoundError:
|
|
109
|
+
# Package not installed (e.g. running from a source checkout without
|
|
110
|
+
# `pip install -e .`). Fall back to a sentinel so callers can still
|
|
111
|
+
# introspect.
|
|
112
|
+
__version__ = "0.0.0+unknown"
|
|
113
|
+
|
|
114
|
+
# Public surface — submodules are imported lazily on first attribute access
|
|
115
|
+
# (PEP 562), so `import openflo` does not pay for the scientific stack.
|
|
116
|
+
# Re-measured 2026-09-10 (numpy 2.5, pandas 3.0): `import openflo` ~81 ms and
|
|
117
|
+
# loads neither numpy, pandas nor scipy; numpy + pandas alone is ~330 ms and
|
|
118
|
+
# `import openflo.pipeline` ~506 ms. The previous note here said "~150 ms for
|
|
119
|
+
# numpy+pandas", which was true of a lighter stack and is now off by 2x —
|
|
120
|
+
# which is the trouble with a timing in a comment.
|
|
121
|
+
#
|
|
122
|
+
# tests/test_import_is_lazy.py pins the PROPERTY rather than the numbers: one
|
|
123
|
+
# stray top-level `import numpy` here, or in anything this imports, and the
|
|
124
|
+
# laziness is gone with nothing to say so.
|
|
125
|
+
_PUBLIC = {
|
|
126
|
+
# Calibration
|
|
127
|
+
"detect_bead_peaks": "openflo.calibration",
|
|
128
|
+
"fit_mesf_calibration": "openflo.calibration",
|
|
129
|
+
"apply_calibration": "openflo.calibration",
|
|
130
|
+
# Compliance
|
|
131
|
+
"build_manifest": "openflo.compliance",
|
|
132
|
+
"sign_manifest": "openflo.compliance",
|
|
133
|
+
"verify_record": "openflo.compliance",
|
|
134
|
+
"record_to_markdown": "openflo.compliance",
|
|
135
|
+
# Annotation
|
|
136
|
+
"mem_scores": "openflo.annotate",
|
|
137
|
+
"mem_label": "openflo.annotate",
|
|
138
|
+
"population_states": "openflo.annotate",
|
|
139
|
+
"parse_signature_table": "openflo.annotate",
|
|
140
|
+
"annotate_by_reference": "openflo.annotate",
|
|
141
|
+
# Provenance
|
|
142
|
+
"AuditLog": "openflo.audit",
|
|
143
|
+
# Core pipeline
|
|
144
|
+
"FlowSample": "openflo.pipeline",
|
|
145
|
+
"FMOGater": "openflo.pipeline",
|
|
146
|
+
"FlowExperiment": "openflo.pipeline",
|
|
147
|
+
"CytoNorm": "openflo.pipeline",
|
|
148
|
+
"concatenate": "openflo.pipeline",
|
|
149
|
+
# IO
|
|
150
|
+
"WspReader": "openflo.pipeline",
|
|
151
|
+
"WspWriter": "openflo.pipeline",
|
|
152
|
+
"read_template_gates": "openflo.pipeline",
|
|
153
|
+
"read_compensation_matrix": "openflo.pipeline",
|
|
154
|
+
"write_compensation_matrix": "openflo.pipeline",
|
|
155
|
+
"optimize_compensation": "openflo.pipeline",
|
|
156
|
+
# Gates
|
|
157
|
+
"describe_gate": "openflo.pipeline",
|
|
158
|
+
"gate_to_mask": "openflo.pipeline",
|
|
159
|
+
"cumulative_gate_mask": "openflo.pipeline",
|
|
160
|
+
# Defaults / config
|
|
161
|
+
"set_default_palette": "openflo.pipeline",
|
|
162
|
+
"write_fcs": "openflo.pipeline",
|
|
163
|
+
"flowsom_mst": "openflo.pipeline",
|
|
164
|
+
"flowsom_layout": "openflo.pipeline",
|
|
165
|
+
# Tools
|
|
166
|
+
"VoltageTitration": "openflo.voltage",
|
|
167
|
+
"differential_test": "openflo.diffexp",
|
|
168
|
+
"differential_abundance": "openflo.diffexp",
|
|
169
|
+
"cluster_abundance": "openflo.diffexp",
|
|
170
|
+
"marker_expression": "openflo.diffexp",
|
|
171
|
+
"sample_distance_matrix": "openflo.interop",
|
|
172
|
+
"mds_embed": "openflo.interop",
|
|
173
|
+
"to_anndata": "openflo.interop",
|
|
174
|
+
"write_h5ad": "openflo.interop",
|
|
175
|
+
"build_reference_spectra": "openflo.spectral",
|
|
176
|
+
"unmix": "openflo.spectral",
|
|
177
|
+
"apply_unmixing": "openflo.spectral",
|
|
178
|
+
"spectral_similarity_matrix": "openflo.spectral",
|
|
179
|
+
"spectral_condition_number": "openflo.spectral",
|
|
180
|
+
"spillover_spread_matrix": "openflo.spectral",
|
|
181
|
+
"unmixing_qc": "openflo.spectral",
|
|
182
|
+
"build_html_report": "openflo.report",
|
|
183
|
+
"df_to_html_table": "openflo.report",
|
|
184
|
+
"figure_to_data_uri": "openflo.report",
|
|
185
|
+
"compare_groups": "openflo.stats",
|
|
186
|
+
"compare_all_features": "openflo.stats",
|
|
187
|
+
"volcano_data": "openflo.stats",
|
|
188
|
+
"group_kde": "openflo.stats",
|
|
189
|
+
"to_prism_column": "openflo.stats",
|
|
190
|
+
"to_prism_grouped": "openflo.stats",
|
|
191
|
+
"p_to_stars": "openflo.stats",
|
|
192
|
+
"compute_pseudotime": "openflo.trajectory",
|
|
193
|
+
"pseudotime_trends": "openflo.trajectory",
|
|
194
|
+
"robust_root": "openflo.trajectory",
|
|
195
|
+
# Exception hierarchy
|
|
196
|
+
"OpenFloError": "openflo.pipeline",
|
|
197
|
+
"FcsParseError": "openflo.pipeline",
|
|
198
|
+
"CompensationError": "openflo.pipeline",
|
|
199
|
+
"WspParseError": "openflo.pipeline",
|
|
200
|
+
"GateError": "openflo.pipeline",
|
|
201
|
+
"ClusteringError": "openflo.pipeline",
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def __getattr__(name):
|
|
206
|
+
mod_name = _PUBLIC.get(name)
|
|
207
|
+
if mod_name is None:
|
|
208
|
+
raise AttributeError(f"module 'openflo' has no attribute {name!r}")
|
|
209
|
+
import importlib
|
|
210
|
+
mod = importlib.import_module(mod_name)
|
|
211
|
+
obj = getattr(mod, name)
|
|
212
|
+
globals()[name] = obj # cache
|
|
213
|
+
return obj
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# Build __all__ as a plain literal so pyright can verify it. Keep in sync
|
|
217
|
+
# with _PUBLIC above.
|
|
218
|
+
__all__ = [
|
|
219
|
+
"AuditLog",
|
|
220
|
+
"ClusteringError",
|
|
221
|
+
"annotate_by_reference",
|
|
222
|
+
"apply_calibration",
|
|
223
|
+
"build_manifest",
|
|
224
|
+
"CompensationError",
|
|
225
|
+
"CytoNorm",
|
|
226
|
+
"FcsParseError",
|
|
227
|
+
"FlowExperiment",
|
|
228
|
+
"FlowSample",
|
|
229
|
+
"FMOGater",
|
|
230
|
+
"GateError",
|
|
231
|
+
"OpenFloError",
|
|
232
|
+
"VoltageTitration",
|
|
233
|
+
"WspParseError",
|
|
234
|
+
"WspReader",
|
|
235
|
+
"WspWriter",
|
|
236
|
+
"apply_unmixing",
|
|
237
|
+
"build_html_report",
|
|
238
|
+
"build_reference_spectra",
|
|
239
|
+
"cluster_abundance",
|
|
240
|
+
"compare_groups",
|
|
241
|
+
"compare_all_features",
|
|
242
|
+
"volcano_data",
|
|
243
|
+
"compute_pseudotime",
|
|
244
|
+
"concatenate",
|
|
245
|
+
"cumulative_gate_mask",
|
|
246
|
+
"describe_gate",
|
|
247
|
+
"detect_bead_peaks",
|
|
248
|
+
"df_to_html_table",
|
|
249
|
+
"differential_abundance",
|
|
250
|
+
"differential_test",
|
|
251
|
+
"figure_to_data_uri",
|
|
252
|
+
"fit_mesf_calibration",
|
|
253
|
+
"flowsom_layout",
|
|
254
|
+
"flowsom_mst",
|
|
255
|
+
"gate_to_mask",
|
|
256
|
+
"group_kde",
|
|
257
|
+
"marker_expression",
|
|
258
|
+
"mds_embed",
|
|
259
|
+
"mem_label",
|
|
260
|
+
"mem_scores",
|
|
261
|
+
"optimize_compensation",
|
|
262
|
+
"p_to_stars",
|
|
263
|
+
"parse_signature_table",
|
|
264
|
+
"population_states",
|
|
265
|
+
"pseudotime_trends",
|
|
266
|
+
"read_compensation_matrix",
|
|
267
|
+
"read_template_gates",
|
|
268
|
+
"record_to_markdown",
|
|
269
|
+
"robust_root",
|
|
270
|
+
"sample_distance_matrix",
|
|
271
|
+
"set_default_palette",
|
|
272
|
+
"sign_manifest",
|
|
273
|
+
"spectral_condition_number",
|
|
274
|
+
"spectral_similarity_matrix",
|
|
275
|
+
"spillover_spread_matrix",
|
|
276
|
+
"to_anndata",
|
|
277
|
+
"to_prism_column",
|
|
278
|
+
"to_prism_grouped",
|
|
279
|
+
"unmix",
|
|
280
|
+
"unmixing_qc",
|
|
281
|
+
"verify_record",
|
|
282
|
+
"write_compensation_matrix",
|
|
283
|
+
"write_fcs",
|
|
284
|
+
"write_h5ad",
|
|
285
|
+
"__version__",
|
|
286
|
+
]
|
openflo/_console.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""One-line guard against the Windows console killing a CLI.
|
|
2
|
+
|
|
3
|
+
Every command-line entry point here prints characters outside cp1252 — check
|
|
4
|
+
and cross marks in the self-test table, arrows in the titration output, `Δ` in
|
|
5
|
+
the comparison report, superscripts in the synthetic marker names. A legacy
|
|
6
|
+
Windows console encodes stdout as cp1252, so printing any of them raises
|
|
7
|
+
``UnicodeEncodeError`` and the command dies having reported nothing.
|
|
8
|
+
|
|
9
|
+
That is worst on exactly the commands a new user runs first: `openflo-selftest`
|
|
10
|
+
crashed mid-table instead of saying whether the install reproduces reference
|
|
11
|
+
behaviour, and `openflo-doctor --help` crashed on the arrow in its own epilog.
|
|
12
|
+
|
|
13
|
+
Call this FIRST in ``main()`` — before building the argument parser. Argparse
|
|
14
|
+
prints ``--help`` and exits from inside ``parse_args``, so a guard placed after
|
|
15
|
+
that line does not protect the help text (which is how the doctor's crash
|
|
16
|
+
survived having the guard at all).
|
|
17
|
+
|
|
18
|
+
`openflo.cli` does the same thing at import time, which is fine for a module
|
|
19
|
+
that is only ever a CLI. This is a function so that importing, say,
|
|
20
|
+
``openflo.selftest`` as a library does not silently reconfigure the caller's
|
|
21
|
+
streams.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import sys
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def force_utf8_streams() -> None:
|
|
29
|
+
"""Re-encode stdout/stderr as UTF-8, replacing anything unencodable.
|
|
30
|
+
|
|
31
|
+
Deliberately best-effort: a stream that does not support ``reconfigure``
|
|
32
|
+
(a pytest capture buffer, a pipe wrapper, an embedded interpreter) is left
|
|
33
|
+
alone rather than failing the command that was about to run.
|
|
34
|
+
"""
|
|
35
|
+
for stream in (sys.stdout, sys.stderr):
|
|
36
|
+
try:
|
|
37
|
+
stream.reconfigure(encoding='utf-8', errors='replace') # type: ignore[union-attr]
|
|
38
|
+
except Exception: # noqa: BLE001
|
|
39
|
+
pass
|
openflo/_golden.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Golden behavior baseline for `openflo-selftest` and the pytest continuity suite. Values are computed from the SEEDED synthetic generators (openflo.synthetic), so they must reproduce run-to-run. A metric drifting outside its tolerance means a feature's behavior changed. Update these numbers ONLY for an intended change (regenerate via `openflo-selftest --update`). The tight 1e-9 tolerances on count/literal metrics (debris/doublets/compensation) rely on the numpy==2.4.6 pin holding on every synonymous stack (incl. RAPIDS 26.06) \u2014 the RNG stream + pure-float64 ops are then bit-identical, so a numpy bump requires re-baselining. calibration.slope/r2 sit at 1e-3 (KMeans peaks are stable; only LAPACK lstsq wiggles ~1e-12). viability_pct (logicle C-ext + scipy histogram valley) and leiden_n (leidenalg/igraph version) are genuinely platform-variable and stay loose by design. compensation.* APPLY compensation to data with a known spillover baked in and check the true signal is recovered: residual_err is the max error relative to the signal (a transposed matmul leaves this large) and signal_retained guards the degenerate 'zero everything' answer that would flatter the residual. The previous apc_leak metric only round-tripped the generator's own matrix through a CSV and ran no compensation code, so it stayed green through the 2.2.1 transpose bug.",
|
|
3
|
+
"metrics": {
|
|
4
|
+
"autoclean.debris_pct": {
|
|
5
|
+
"value": 7.05,
|
|
6
|
+
"tol": 1e-09,
|
|
7
|
+
"label": "Auto-clean debris removed (bead 4 \u00b5m)",
|
|
8
|
+
"unit": "%"
|
|
9
|
+
},
|
|
10
|
+
"autoclean.viability_pct": {
|
|
11
|
+
"value": 7.97,
|
|
12
|
+
"tol": 0.6,
|
|
13
|
+
"label": "Auto-clean dead cells removed (viability)",
|
|
14
|
+
"unit": "%"
|
|
15
|
+
},
|
|
16
|
+
"autoclean.doublets_pct": {
|
|
17
|
+
"value": 5.0,
|
|
18
|
+
"tol": 1e-09,
|
|
19
|
+
"label": "Auto-clean doublets removed",
|
|
20
|
+
"unit": "%"
|
|
21
|
+
},
|
|
22
|
+
"cluster.leiden_n": {
|
|
23
|
+
"value": 18,
|
|
24
|
+
"tol": 2,
|
|
25
|
+
"label": "Leiden clusters (PBMC, res 0.5)",
|
|
26
|
+
"unit": "count"
|
|
27
|
+
},
|
|
28
|
+
"calibration.slope": {
|
|
29
|
+
"value": 1.9978,
|
|
30
|
+
"tol": 0.001,
|
|
31
|
+
"label": "MESF calibration slope (MESF = 2\u00b7MFI + 100)",
|
|
32
|
+
"unit": ""
|
|
33
|
+
},
|
|
34
|
+
"calibration.r2": {
|
|
35
|
+
"value": 1.0,
|
|
36
|
+
"tol": 0.001,
|
|
37
|
+
"label": "MESF calibration R\u00b2",
|
|
38
|
+
"unit": ""
|
|
39
|
+
},
|
|
40
|
+
"compensation.residual_err": {
|
|
41
|
+
"value": 0.0,
|
|
42
|
+
"tol": 1e-09,
|
|
43
|
+
"label": "Compensation residual vs true signal",
|
|
44
|
+
"unit": ""
|
|
45
|
+
},
|
|
46
|
+
"compensation.signal_retained": {
|
|
47
|
+
"value": 1.0,
|
|
48
|
+
"tol": 1e-06,
|
|
49
|
+
"label": "Compensation signal retained",
|
|
50
|
+
"unit": "x"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
openflo/annotate.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Automated population annotation.
|
|
2
|
+
|
|
3
|
+
Two complementary, dependency-light ways to turn numeric cluster IDs into
|
|
4
|
+
biological labels:
|
|
5
|
+
|
|
6
|
+
* **MEM** (Marker Enrichment Modeling; Diggins et al. 2017) — a quantitative
|
|
7
|
+
enrichment score per marker per population, against a reference (the rest of
|
|
8
|
+
the cells by default), yielding labels like ``CD3⁺⁸ CD4⁺⁶ CD8⁻⁴``. Captures
|
|
9
|
+
both the median shift and the spread (IQR) change.
|
|
10
|
+
* **Reference-table** annotation (ACDC / Scyan style) — match each
|
|
11
|
+
population's +/- marker pattern to a user-supplied ``name: CD3+ CD4+ CD8-``
|
|
12
|
+
table and assign the best-fitting cell type.
|
|
13
|
+
|
|
14
|
+
Pure numpy / pandas — no deep learning. The GUI runs these on a clustered
|
|
15
|
+
sample and writes the names back onto the populations.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
import pandas as pd
|
|
23
|
+
|
|
24
|
+
# Subscript/superscript-free signed format keeps cp1252 stdout happy.
|
|
25
|
+
_SIGN = {1: '+', -1: '-'}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def scale_markers(X, lo_pct=1.0, hi_pct=99.0, out_max=10.0):
|
|
29
|
+
"""Scale each marker (column) to ``[0, out_max]`` using robust percentiles
|
|
30
|
+
(``lo_pct``→0, ``hi_pct``→out_max), clipped — so the MEM magnitude term is
|
|
31
|
+
comparable across markers on different intensity scales."""
|
|
32
|
+
X = np.asarray(X, dtype=float)
|
|
33
|
+
lo = np.nanpercentile(X, lo_pct, axis=0)
|
|
34
|
+
hi = np.nanpercentile(X, hi_pct, axis=0)
|
|
35
|
+
rng = np.where(hi > lo, hi - lo, 1.0)
|
|
36
|
+
return np.clip((X - lo) / rng, 0.0, 1.0) * out_max
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _iqr(a):
|
|
40
|
+
a = a[np.isfinite(a)]
|
|
41
|
+
if a.size == 0:
|
|
42
|
+
return 0.0
|
|
43
|
+
return float(np.percentile(a, 75) - np.percentile(a, 25))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def mem_scores(data, labels, markers, reference=None, scale=True, eps=0.5):
|
|
47
|
+
"""Marker Enrichment Modeling scores.
|
|
48
|
+
|
|
49
|
+
``data`` : DataFrame or array (cells × len(markers)).
|
|
50
|
+
``labels`` : per-cell population label (array-like).
|
|
51
|
+
``markers`` : marker names (columns / order of ``data``).
|
|
52
|
+
``reference`` : a label to use as the reference population for every
|
|
53
|
+
comparison; ``None`` (default) uses "all other cells" per population.
|
|
54
|
+
|
|
55
|
+
For each population *p* and marker *m*::
|
|
56
|
+
|
|
57
|
+
MAG = median_p − median_ref
|
|
58
|
+
raw = |MAG| + IQR_ref / IQR_p − 1 (floored at 0)
|
|
59
|
+
MEM = sign(MAG) · raw
|
|
60
|
+
|
|
61
|
+
Scores are then globally rescaled so the largest |MEM| maps to 10 (the MEM
|
|
62
|
+
convention). Returns a DataFrame indexed by population, columns = markers,
|
|
63
|
+
of (rounded) MEM values in ``[-10, 10]``."""
|
|
64
|
+
if isinstance(data, pd.DataFrame):
|
|
65
|
+
X = data[list(markers)].to_numpy(dtype=float)
|
|
66
|
+
else:
|
|
67
|
+
X = np.asarray(data, dtype=float)
|
|
68
|
+
labels = np.asarray(labels)
|
|
69
|
+
if scale:
|
|
70
|
+
X = scale_markers(X)
|
|
71
|
+
pops = [p for p in pd.unique(labels) if p is not None and p == p] # noqa: PLR0124
|
|
72
|
+
pops = sorted(pops, key=str)
|
|
73
|
+
|
|
74
|
+
rows = {}
|
|
75
|
+
for p in pops:
|
|
76
|
+
in_p = labels == p
|
|
77
|
+
if int(in_p.sum()) == 0:
|
|
78
|
+
continue
|
|
79
|
+
ref_mask = (labels == reference) if reference is not None else ~in_p
|
|
80
|
+
if int(ref_mask.sum()) == 0:
|
|
81
|
+
ref_mask = ~in_p
|
|
82
|
+
vals = np.empty(len(markers))
|
|
83
|
+
for j in range(len(markers)):
|
|
84
|
+
pj, rj = X[in_p, j], X[ref_mask, j]
|
|
85
|
+
mag = float(np.nanmedian(pj) - np.nanmedian(rj))
|
|
86
|
+
iqr_p = _iqr(pj)
|
|
87
|
+
# The IQR ratio is the "this population is tighter than the rest"
|
|
88
|
+
# half of the score. A population with NO measurable spread — a
|
|
89
|
+
# single event, or every event identical — has an IQR of exactly
|
|
90
|
+
# 0, and `+ eps` turned that into "perfectly tight", which the
|
|
91
|
+
# ratio rewards. Because MEM then rescales globally so the largest
|
|
92
|
+
# |score| maps to 10, one such cluster set the scale for the whole
|
|
93
|
+
# table: measured, a single-event cluster pushed three real
|
|
94
|
+
# populations from 10 down to 4. No measurable spread supports no
|
|
95
|
+
# claim about spread, so the term drops out and the score rests on
|
|
96
|
+
# the median shift alone.
|
|
97
|
+
ratio = (_iqr(rj) + eps) / (iqr_p + eps) if iqr_p > 0 else 1.0
|
|
98
|
+
raw = abs(mag) + ratio - 1.0
|
|
99
|
+
vals[j] = np.sign(mag) * max(raw, 0.0)
|
|
100
|
+
rows[p] = vals
|
|
101
|
+
|
|
102
|
+
mem = pd.DataFrame(rows, index=pd.Index(markers)).T # pops × markers
|
|
103
|
+
mx = float(np.nanmax(np.abs(mem.to_numpy()))) if mem.size else 0.0
|
|
104
|
+
if mx > 0:
|
|
105
|
+
mem = (mem / mx * 10.0).round(0)
|
|
106
|
+
return mem
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def mem_label(mem_row, markers=None, threshold=2.0, max_markers=8):
|
|
110
|
+
"""Build a MEM text label from one population's score row (a Series or
|
|
111
|
+
array): markers sorted by descending |score|, kept above ``threshold``,
|
|
112
|
+
formatted ``CD3+8 CD4+6 CD8-4``. Returns '' when nothing is enriched."""
|
|
113
|
+
if isinstance(mem_row, pd.Series):
|
|
114
|
+
items = [(m, float(v)) for m, v in mem_row.items()]
|
|
115
|
+
else:
|
|
116
|
+
items = list(zip(markers or [], [float(v) for v in mem_row],
|
|
117
|
+
strict=True))
|
|
118
|
+
items = [(m, v) for m, v in items if abs(v) >= threshold]
|
|
119
|
+
items.sort(key=lambda t: abs(t[1]), reverse=True)
|
|
120
|
+
items = items[:max_markers]
|
|
121
|
+
return ' '.join(f"{m}{_SIGN.get(int(np.sign(v)), '')}{abs(int(round(v)))}"
|
|
122
|
+
for m, v in items)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def population_states(mem, threshold=3.0):
|
|
126
|
+
"""Reduce a MEM DataFrame to per-population +/- marker states
|
|
127
|
+
(``{population: {marker: +1 | -1}}``) for reference matching: a marker is
|
|
128
|
+
``+1`` above ``+threshold``, ``-1`` below ``-threshold``, omitted if
|
|
129
|
+
ambiguous."""
|
|
130
|
+
states = {}
|
|
131
|
+
for pop, row in mem.iterrows():
|
|
132
|
+
st = {}
|
|
133
|
+
for m, v in row.items():
|
|
134
|
+
if v >= threshold:
|
|
135
|
+
st[m] = 1
|
|
136
|
+
elif v <= -threshold:
|
|
137
|
+
st[m] = -1
|
|
138
|
+
states[pop] = st
|
|
139
|
+
return states
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def parse_signature_table(text):
|
|
143
|
+
"""Parse a reference cell-type table into ``{name: {marker: +1 | -1}}``.
|
|
144
|
+
|
|
145
|
+
One cell type per line, ``Name: CD3+ CD4+ CD8-`` (or comma/whitespace
|
|
146
|
+
separated). A marker token ending in ``+``/``hi`` is required-positive,
|
|
147
|
+
``-``/``lo`` required-negative. Blank lines and ``#`` comments are ignored.
|
|
148
|
+
"""
|
|
149
|
+
table = {}
|
|
150
|
+
for line in (text or '').splitlines():
|
|
151
|
+
line = line.strip()
|
|
152
|
+
if not line or line.startswith('#'):
|
|
153
|
+
continue
|
|
154
|
+
if ':' in line:
|
|
155
|
+
name, rest = line.split(':', 1)
|
|
156
|
+
else:
|
|
157
|
+
parts = line.split(None, 1)
|
|
158
|
+
if len(parts) != 2:
|
|
159
|
+
continue
|
|
160
|
+
name, rest = parts
|
|
161
|
+
name = name.strip()
|
|
162
|
+
sig = {}
|
|
163
|
+
for tok in re.split(r'[,\s]+', rest.strip()):
|
|
164
|
+
if not tok:
|
|
165
|
+
continue
|
|
166
|
+
m = re.match(r'^(.*?)(\+\+?|--?|hi|lo|high|low)$', tok,
|
|
167
|
+
flags=re.IGNORECASE)
|
|
168
|
+
if not m:
|
|
169
|
+
continue
|
|
170
|
+
marker, sign = m.group(1).strip(), m.group(2).lower()
|
|
171
|
+
sig[marker] = -1 if sign.startswith(('-', 'lo', 'low')) else 1
|
|
172
|
+
if name and sig:
|
|
173
|
+
table[name] = sig
|
|
174
|
+
return table
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def annotate_by_reference(states, table, min_score=1):
|
|
178
|
+
"""Assign each population the best-matching cell type from ``table``.
|
|
179
|
+
|
|
180
|
+
Scoring weights the *defining* (positive) markers: a matched required-
|
|
181
|
+
positive marker is worth +2, a matched required-negative +1, and a
|
|
182
|
+
contradiction (the population has the opposite sign) −2. A cell type is only
|
|
183
|
+
eligible if at least one of its positive markers is present in the
|
|
184
|
+
population (so a type can't win on a shared negative like ``CD3-`` alone).
|
|
185
|
+
Returns ``{population: {name, score, n_match, n_required}}``; ``name`` is
|
|
186
|
+
``'unknown'`` when no eligible type scores ≥ ``min_score``. ``states`` comes
|
|
187
|
+
from :func:`population_states`."""
|
|
188
|
+
out = {}
|
|
189
|
+
for pop, st in states.items():
|
|
190
|
+
best = ('unknown', 0, 0, 0)
|
|
191
|
+
for name, sig in table.items():
|
|
192
|
+
pos = [m for m, w in sig.items() if w == 1]
|
|
193
|
+
score = 0
|
|
194
|
+
n_match = 0
|
|
195
|
+
pos_hit = 0
|
|
196
|
+
for marker, want in sig.items():
|
|
197
|
+
have = st.get(marker, 0)
|
|
198
|
+
if have == want:
|
|
199
|
+
n_match += 1
|
|
200
|
+
score += 2 if want == 1 else 1
|
|
201
|
+
pos_hit += (want == 1)
|
|
202
|
+
elif have == -want:
|
|
203
|
+
score -= 2
|
|
204
|
+
# A type with positive requirements must hit at least one of them.
|
|
205
|
+
if pos and pos_hit == 0:
|
|
206
|
+
continue
|
|
207
|
+
if score > best[1]:
|
|
208
|
+
best = (name, score, n_match, len(sig))
|
|
209
|
+
name, score, n_match, n_req = best
|
|
210
|
+
out[pop] = {'name': name if score >= min_score else 'unknown',
|
|
211
|
+
'score': int(score), 'n_match': int(n_match),
|
|
212
|
+
'n_required': int(n_req)}
|
|
213
|
+
return out
|
openflo/async_task.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Run a blocking job off the Tk thread and marshal the result back.
|
|
2
|
+
|
|
3
|
+
A single helper that replaces the ad-hoc ``threading.Thread(target=work).start()``
|
|
4
|
+
+ ``self.after(0, lambda: …)`` pattern scattered across the editor and its
|
|
5
|
+
dialogs. The work runs on a daemon thread; ``on_done`` / ``on_error`` /
|
|
6
|
+
``on_finally`` are delivered on the Tk event loop via ``widget.after``, so they
|
|
7
|
+
can safely touch widgets. ``widget`` only needs an ``after(ms, fn)`` method.
|
|
8
|
+
|
|
9
|
+
Why a free function (not just a mixin method): the editor uses it through a
|
|
10
|
+
``ComputeMixin.run_async`` wrapper that also drives the busy bar, but the
|
|
11
|
+
standalone tool windows (ui_voltage / ui_synth / ui_compare / ui_statistics …)
|
|
12
|
+
are plain ``tk.Toplevel`` subclasses with no access to that mixin — they call
|
|
13
|
+
this directly.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import threading
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run_async(widget, work, on_done=None, on_error=None, on_finally=None):
|
|
21
|
+
"""Run ``work()`` on a daemon thread; deliver its return value to
|
|
22
|
+
``on_done(result)`` — or any exception to ``on_error(exc)`` — back on
|
|
23
|
+
``widget``'s Tk thread. ``on_finally()`` always runs last on the Tk thread
|
|
24
|
+
(use it to stop a spinner). Returns the started ``Thread``.
|
|
25
|
+
|
|
26
|
+
All callbacks are optional and best-effort: if the widget is gone (window
|
|
27
|
+
closed mid-run) the ``after`` call is swallowed rather than raising on the
|
|
28
|
+
worker thread."""
|
|
29
|
+
def _worker():
|
|
30
|
+
try:
|
|
31
|
+
result = work()
|
|
32
|
+
except Exception as exc: # noqa: BLE001
|
|
33
|
+
def _fail(e=exc):
|
|
34
|
+
# The window may have been closed while we computed: its `after`
|
|
35
|
+
# callback can still fire but its child widgets are gone, so
|
|
36
|
+
# touching them raises a stale-command TclError. Skip the
|
|
37
|
+
# callbacks entirely if the widget no longer exists.
|
|
38
|
+
if not _alive(widget):
|
|
39
|
+
return
|
|
40
|
+
try:
|
|
41
|
+
if on_error is not None:
|
|
42
|
+
on_error(e)
|
|
43
|
+
finally:
|
|
44
|
+
if on_finally is not None:
|
|
45
|
+
on_finally()
|
|
46
|
+
_post(widget, _fail)
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
def _ok(r=result):
|
|
50
|
+
if not _alive(widget):
|
|
51
|
+
return
|
|
52
|
+
try:
|
|
53
|
+
if on_done is not None:
|
|
54
|
+
on_done(r)
|
|
55
|
+
finally:
|
|
56
|
+
if on_finally is not None:
|
|
57
|
+
on_finally()
|
|
58
|
+
_post(widget, _ok)
|
|
59
|
+
|
|
60
|
+
t = threading.Thread(target=_worker, daemon=True)
|
|
61
|
+
t.start()
|
|
62
|
+
return t
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _alive(widget):
|
|
66
|
+
"""True if the widget still exists (or doesn't expose winfo_exists)."""
|
|
67
|
+
try:
|
|
68
|
+
return bool(widget.winfo_exists())
|
|
69
|
+
except Exception:
|
|
70
|
+
return True
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _post(widget, fn):
|
|
74
|
+
"""Schedule ``fn`` on the widget's Tk thread; ignore a dead widget."""
|
|
75
|
+
try:
|
|
76
|
+
widget.after(0, fn)
|
|
77
|
+
except Exception:
|
|
78
|
+
pass
|