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.
Files changed (123) hide show
  1. openflo/__init__.py +286 -0
  2. openflo/_console.py +39 -0
  3. openflo/_golden.json +53 -0
  4. openflo/annotate.py +213 -0
  5. openflo/async_task.py +78 -0
  6. openflo/audit.py +144 -0
  7. openflo/calibration.py +112 -0
  8. openflo/capabilities.py +116 -0
  9. openflo/cli.py +2354 -0
  10. openflo/comp_qc.py +144 -0
  11. openflo/compare.py +362 -0
  12. openflo/compliance.py +150 -0
  13. openflo/density.py +200 -0
  14. openflo/diagnostics.py +245 -0
  15. openflo/diffexp.py +255 -0
  16. openflo/dr_compare.py +160 -0
  17. openflo/editor_analysis.py +579 -0
  18. openflo/editor_audit.py +37 -0
  19. openflo/editor_autoclean.py +428 -0
  20. openflo/editor_autogate.py +184 -0
  21. openflo/editor_base.py +31 -0
  22. openflo/editor_channels.py +83 -0
  23. openflo/editor_chrome.py +451 -0
  24. openflo/editor_clipboard.py +514 -0
  25. openflo/editor_compute.py +218 -0
  26. openflo/editor_console.py +156 -0
  27. openflo/editor_dnd.py +632 -0
  28. openflo/editor_downsample.py +152 -0
  29. openflo/editor_export.py +408 -0
  30. openflo/editor_figure.py +201 -0
  31. openflo/editor_gatetools.py +783 -0
  32. openflo/editor_gating.py +1083 -0
  33. openflo/editor_grouping.py +85 -0
  34. openflo/editor_help.py +237 -0
  35. openflo/editor_lifecycle.py +604 -0
  36. openflo/editor_load.py +544 -0
  37. openflo/editor_loadpool.py +434 -0
  38. openflo/editor_menu.py +268 -0
  39. openflo/editor_mode.py +114 -0
  40. openflo/editor_plot.py +1619 -0
  41. openflo/editor_populations.py +458 -0
  42. openflo/editor_session.py +776 -0
  43. openflo/editor_slider.py +208 -0
  44. openflo/editor_stats.py +218 -0
  45. openflo/editor_template.py +300 -0
  46. openflo/editor_tools.py +473 -0
  47. openflo/editor_tree.py +509 -0
  48. openflo/editor_undo.py +98 -0
  49. openflo/editor_update.py +116 -0
  50. openflo/fcs_export.py +87 -0
  51. openflo/gatetree.py +232 -0
  52. openflo/gating.py +165 -0
  53. openflo/gating_helpers.py +187 -0
  54. openflo/gpu_accel.py +295 -0
  55. openflo/gui.py +2436 -0
  56. openflo/inspect_fcs.py +26 -0
  57. openflo/interop.py +200 -0
  58. openflo/paths.py +78 -0
  59. openflo/pipeline.py +5720 -0
  60. openflo/plotmath.py +191 -0
  61. openflo/prefs.py +56 -0
  62. openflo/preview.py +236 -0
  63. openflo/provenance.py +337 -0
  64. openflo/py.typed +0 -0
  65. openflo/report.py +104 -0
  66. openflo/scales.py +85 -0
  67. openflo/selftest.py +288 -0
  68. openflo/selftest_controls.py +644 -0
  69. openflo/session_format.py +81 -0
  70. openflo/spectral.py +305 -0
  71. openflo/stats.py +417 -0
  72. openflo/synthetic.py +735 -0
  73. openflo/template_library/README.md +92 -0
  74. openflo/template_library/cleanup_acquisition_qc.json +43 -0
  75. openflo/template_library/cleanup_minimal.json +35 -0
  76. openflo/template_library/cleanup_standard.json +67 -0
  77. openflo/template_library/cleanup_strict.json +67 -0
  78. openflo/template_library/example_panel.json +15 -0
  79. openflo/theme.py +230 -0
  80. openflo/tool_window.py +160 -0
  81. openflo/trajectory.py +171 -0
  82. openflo/tree_ids.py +63 -0
  83. openflo/ui_abscounts.py +74 -0
  84. openflo/ui_annotation.py +182 -0
  85. openflo/ui_audit.py +222 -0
  86. openflo/ui_autogate.py +179 -0
  87. openflo/ui_axis_config.py +157 -0
  88. openflo/ui_calibration.py +208 -0
  89. openflo/ui_cell_cycle.py +76 -0
  90. openflo/ui_comp.py +530 -0
  91. openflo/ui_compare.py +348 -0
  92. openflo/ui_diff.py +248 -0
  93. openflo/ui_embedding.py +92 -0
  94. openflo/ui_expression.py +326 -0
  95. openflo/ui_figure_layout.py +121 -0
  96. openflo/ui_figure_window.py +55 -0
  97. openflo/ui_flowsom_tree.py +156 -0
  98. openflo/ui_fmo.py +142 -0
  99. openflo/ui_frequency.py +598 -0
  100. openflo/ui_group_stats.py +91 -0
  101. openflo/ui_inspect.py +248 -0
  102. openflo/ui_logic.py +75 -0
  103. openflo/ui_methods.py +87 -0
  104. openflo/ui_preferences.py +268 -0
  105. openflo/ui_preview.py +292 -0
  106. openflo/ui_sample_qc.py +183 -0
  107. openflo/ui_spectral_qc.py +196 -0
  108. openflo/ui_spectral_unmix.py +116 -0
  109. openflo/ui_statistics.py +349 -0
  110. openflo/ui_synth.py +309 -0
  111. openflo/ui_tips.py +82 -0
  112. openflo/ui_trajectory.py +248 -0
  113. openflo/ui_voltage.py +345 -0
  114. openflo/update.py +244 -0
  115. openflo/voltage.py +377 -0
  116. openflo/warmup.py +94 -0
  117. openflo/workspace.py +3079 -0
  118. openflo-2.6.0.dist-info/METADATA +450 -0
  119. openflo-2.6.0.dist-info/RECORD +123 -0
  120. openflo-2.6.0.dist-info/WHEEL +5 -0
  121. openflo-2.6.0.dist-info/entry_points.txt +10 -0
  122. openflo-2.6.0.dist-info/licenses/LICENSE.txt +31 -0
  123. 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