openflo 2.6.0__tar.gz
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-2.6.0/CHANGELOG.md +2370 -0
- openflo-2.6.0/LICENSE.txt +31 -0
- openflo-2.6.0/MANIFEST.in +9 -0
- openflo-2.6.0/PKG-INFO +450 -0
- openflo-2.6.0/README.md +389 -0
- openflo-2.6.0/pyproject.toml +238 -0
- openflo-2.6.0/scripts/bench_accelerators.py +294 -0
- openflo-2.6.0/scripts/bench_louvain_restarts.py +120 -0
- openflo-2.6.0/scripts/bench_snn_prune.py +118 -0
- openflo-2.6.0/scripts/bench_ui_responsiveness.py +174 -0
- openflo-2.6.0/scripts/bench_umap_knn.py +177 -0
- openflo-2.6.0/scripts/lowest_direct_requirements.py +81 -0
- openflo-2.6.0/scripts/make_synthetic_dataset.py +34 -0
- openflo-2.6.0/scripts/migrate_session.py +71 -0
- openflo-2.6.0/scripts/mutate_controls.py +283 -0
- openflo-2.6.0/scripts/preflight.py +72 -0
- openflo-2.6.0/scripts/publish_public.py +198 -0
- openflo-2.6.0/scripts/run_analyses.py +303 -0
- openflo-2.6.0/scripts/smoke_test.py +86 -0
- openflo-2.6.0/scripts/theme_audit.py +279 -0
- openflo-2.6.0/setup.cfg +4 -0
- openflo-2.6.0/src/openflo/__init__.py +286 -0
- openflo-2.6.0/src/openflo/_console.py +39 -0
- openflo-2.6.0/src/openflo/_golden.json +53 -0
- openflo-2.6.0/src/openflo/annotate.py +213 -0
- openflo-2.6.0/src/openflo/async_task.py +78 -0
- openflo-2.6.0/src/openflo/audit.py +144 -0
- openflo-2.6.0/src/openflo/calibration.py +112 -0
- openflo-2.6.0/src/openflo/capabilities.py +116 -0
- openflo-2.6.0/src/openflo/cli.py +2354 -0
- openflo-2.6.0/src/openflo/comp_qc.py +144 -0
- openflo-2.6.0/src/openflo/compare.py +362 -0
- openflo-2.6.0/src/openflo/compliance.py +150 -0
- openflo-2.6.0/src/openflo/density.py +200 -0
- openflo-2.6.0/src/openflo/diagnostics.py +245 -0
- openflo-2.6.0/src/openflo/diffexp.py +255 -0
- openflo-2.6.0/src/openflo/dr_compare.py +160 -0
- openflo-2.6.0/src/openflo/editor_analysis.py +579 -0
- openflo-2.6.0/src/openflo/editor_audit.py +37 -0
- openflo-2.6.0/src/openflo/editor_autoclean.py +428 -0
- openflo-2.6.0/src/openflo/editor_autogate.py +184 -0
- openflo-2.6.0/src/openflo/editor_base.py +31 -0
- openflo-2.6.0/src/openflo/editor_channels.py +83 -0
- openflo-2.6.0/src/openflo/editor_chrome.py +451 -0
- openflo-2.6.0/src/openflo/editor_clipboard.py +514 -0
- openflo-2.6.0/src/openflo/editor_compute.py +218 -0
- openflo-2.6.0/src/openflo/editor_console.py +156 -0
- openflo-2.6.0/src/openflo/editor_dnd.py +632 -0
- openflo-2.6.0/src/openflo/editor_downsample.py +152 -0
- openflo-2.6.0/src/openflo/editor_export.py +408 -0
- openflo-2.6.0/src/openflo/editor_figure.py +201 -0
- openflo-2.6.0/src/openflo/editor_gatetools.py +783 -0
- openflo-2.6.0/src/openflo/editor_gating.py +1083 -0
- openflo-2.6.0/src/openflo/editor_grouping.py +85 -0
- openflo-2.6.0/src/openflo/editor_help.py +237 -0
- openflo-2.6.0/src/openflo/editor_lifecycle.py +604 -0
- openflo-2.6.0/src/openflo/editor_load.py +544 -0
- openflo-2.6.0/src/openflo/editor_loadpool.py +434 -0
- openflo-2.6.0/src/openflo/editor_menu.py +268 -0
- openflo-2.6.0/src/openflo/editor_mode.py +114 -0
- openflo-2.6.0/src/openflo/editor_plot.py +1619 -0
- openflo-2.6.0/src/openflo/editor_populations.py +458 -0
- openflo-2.6.0/src/openflo/editor_session.py +776 -0
- openflo-2.6.0/src/openflo/editor_slider.py +208 -0
- openflo-2.6.0/src/openflo/editor_stats.py +218 -0
- openflo-2.6.0/src/openflo/editor_template.py +300 -0
- openflo-2.6.0/src/openflo/editor_tools.py +473 -0
- openflo-2.6.0/src/openflo/editor_tree.py +509 -0
- openflo-2.6.0/src/openflo/editor_undo.py +98 -0
- openflo-2.6.0/src/openflo/editor_update.py +116 -0
- openflo-2.6.0/src/openflo/fcs_export.py +87 -0
- openflo-2.6.0/src/openflo/gatetree.py +232 -0
- openflo-2.6.0/src/openflo/gating.py +165 -0
- openflo-2.6.0/src/openflo/gating_helpers.py +187 -0
- openflo-2.6.0/src/openflo/gpu_accel.py +295 -0
- openflo-2.6.0/src/openflo/gui.py +2436 -0
- openflo-2.6.0/src/openflo/inspect_fcs.py +26 -0
- openflo-2.6.0/src/openflo/interop.py +200 -0
- openflo-2.6.0/src/openflo/paths.py +78 -0
- openflo-2.6.0/src/openflo/pipeline.py +5720 -0
- openflo-2.6.0/src/openflo/plotmath.py +191 -0
- openflo-2.6.0/src/openflo/prefs.py +56 -0
- openflo-2.6.0/src/openflo/preview.py +236 -0
- openflo-2.6.0/src/openflo/provenance.py +337 -0
- openflo-2.6.0/src/openflo/py.typed +0 -0
- openflo-2.6.0/src/openflo/report.py +104 -0
- openflo-2.6.0/src/openflo/scales.py +85 -0
- openflo-2.6.0/src/openflo/selftest.py +288 -0
- openflo-2.6.0/src/openflo/selftest_controls.py +644 -0
- openflo-2.6.0/src/openflo/session_format.py +81 -0
- openflo-2.6.0/src/openflo/spectral.py +305 -0
- openflo-2.6.0/src/openflo/stats.py +417 -0
- openflo-2.6.0/src/openflo/synthetic.py +735 -0
- openflo-2.6.0/src/openflo/template_library/README.md +92 -0
- openflo-2.6.0/src/openflo/template_library/cleanup_acquisition_qc.json +43 -0
- openflo-2.6.0/src/openflo/template_library/cleanup_minimal.json +35 -0
- openflo-2.6.0/src/openflo/template_library/cleanup_standard.json +67 -0
- openflo-2.6.0/src/openflo/template_library/cleanup_strict.json +67 -0
- openflo-2.6.0/src/openflo/template_library/example_panel.json +15 -0
- openflo-2.6.0/src/openflo/theme.py +230 -0
- openflo-2.6.0/src/openflo/tool_window.py +160 -0
- openflo-2.6.0/src/openflo/trajectory.py +171 -0
- openflo-2.6.0/src/openflo/tree_ids.py +63 -0
- openflo-2.6.0/src/openflo/ui_abscounts.py +74 -0
- openflo-2.6.0/src/openflo/ui_annotation.py +182 -0
- openflo-2.6.0/src/openflo/ui_audit.py +222 -0
- openflo-2.6.0/src/openflo/ui_autogate.py +179 -0
- openflo-2.6.0/src/openflo/ui_axis_config.py +157 -0
- openflo-2.6.0/src/openflo/ui_calibration.py +208 -0
- openflo-2.6.0/src/openflo/ui_cell_cycle.py +76 -0
- openflo-2.6.0/src/openflo/ui_comp.py +530 -0
- openflo-2.6.0/src/openflo/ui_compare.py +348 -0
- openflo-2.6.0/src/openflo/ui_diff.py +248 -0
- openflo-2.6.0/src/openflo/ui_embedding.py +92 -0
- openflo-2.6.0/src/openflo/ui_expression.py +326 -0
- openflo-2.6.0/src/openflo/ui_figure_layout.py +121 -0
- openflo-2.6.0/src/openflo/ui_figure_window.py +55 -0
- openflo-2.6.0/src/openflo/ui_flowsom_tree.py +156 -0
- openflo-2.6.0/src/openflo/ui_fmo.py +142 -0
- openflo-2.6.0/src/openflo/ui_frequency.py +598 -0
- openflo-2.6.0/src/openflo/ui_group_stats.py +91 -0
- openflo-2.6.0/src/openflo/ui_inspect.py +248 -0
- openflo-2.6.0/src/openflo/ui_logic.py +75 -0
- openflo-2.6.0/src/openflo/ui_methods.py +87 -0
- openflo-2.6.0/src/openflo/ui_preferences.py +268 -0
- openflo-2.6.0/src/openflo/ui_preview.py +292 -0
- openflo-2.6.0/src/openflo/ui_sample_qc.py +183 -0
- openflo-2.6.0/src/openflo/ui_spectral_qc.py +196 -0
- openflo-2.6.0/src/openflo/ui_spectral_unmix.py +116 -0
- openflo-2.6.0/src/openflo/ui_statistics.py +349 -0
- openflo-2.6.0/src/openflo/ui_synth.py +309 -0
- openflo-2.6.0/src/openflo/ui_tips.py +82 -0
- openflo-2.6.0/src/openflo/ui_trajectory.py +248 -0
- openflo-2.6.0/src/openflo/ui_voltage.py +345 -0
- openflo-2.6.0/src/openflo/update.py +244 -0
- openflo-2.6.0/src/openflo/voltage.py +377 -0
- openflo-2.6.0/src/openflo/warmup.py +94 -0
- openflo-2.6.0/src/openflo/workspace.py +3079 -0
- openflo-2.6.0/src/openflo.egg-info/PKG-INFO +450 -0
- openflo-2.6.0/src/openflo.egg-info/SOURCES.txt +298 -0
- openflo-2.6.0/src/openflo.egg-info/dependency_links.txt +1 -0
- openflo-2.6.0/src/openflo.egg-info/entry_points.txt +10 -0
- openflo-2.6.0/src/openflo.egg-info/requires.txt +45 -0
- openflo-2.6.0/src/openflo.egg-info/top_level.txt +1 -0
- openflo-2.6.0/templates/testtemplate.json +69 -0
- openflo-2.6.0/tests/__init__.py +0 -0
- openflo-2.6.0/tests/conftest.py +201 -0
- openflo-2.6.0/tests/test_abscounts.py +47 -0
- openflo-2.6.0/tests/test_annotate.py +128 -0
- openflo-2.6.0/tests/test_apply_region_gates.py +112 -0
- openflo-2.6.0/tests/test_async_task.py +70 -0
- openflo-2.6.0/tests/test_audit.py +120 -0
- openflo-2.6.0/tests/test_autoclean_counts.py +119 -0
- openflo-2.6.0/tests/test_autoclean_doublets_scale.py +140 -0
- openflo-2.6.0/tests/test_autoclean_methods.py +372 -0
- openflo-2.6.0/tests/test_autogate.py +206 -0
- openflo-2.6.0/tests/test_autogate_reproducible.py +193 -0
- openflo-2.6.0/tests/test_boolean_gate_fails_closed.py +109 -0
- openflo-2.6.0/tests/test_calibration.py +128 -0
- openflo-2.6.0/tests/test_capabilities.py +56 -0
- openflo-2.6.0/tests/test_cell_cycle.py +149 -0
- openflo-2.6.0/tests/test_cli_cluster.py +58 -0
- openflo-2.6.0/tests/test_cli_e2e.py +207 -0
- openflo-2.6.0/tests/test_cli_unmix.py +128 -0
- openflo-2.6.0/tests/test_clipboard_move_marker.py +209 -0
- openflo-2.6.0/tests/test_cluster_labels.py +49 -0
- openflo-2.6.0/tests/test_cluster_reproducible.py +107 -0
- openflo-2.6.0/tests/test_clustering_reproducible.py +131 -0
- openflo-2.6.0/tests/test_comp_editor.py +55 -0
- openflo-2.6.0/tests/test_comp_matrix_csv_quoting.py +56 -0
- openflo-2.6.0/tests/test_comp_qc.py +108 -0
- openflo-2.6.0/tests/test_compare.py +120 -0
- openflo-2.6.0/tests/test_compare_all.py +94 -0
- openflo-2.6.0/tests/test_compare_conditions_absent_clusters.py +115 -0
- openflo-2.6.0/tests/test_compensation.py +131 -0
- openflo-2.6.0/tests/test_compliance.py +165 -0
- openflo-2.6.0/tests/test_console_encoding.py +110 -0
- openflo-2.6.0/tests/test_cytonorm.py +177 -0
- openflo-2.6.0/tests/test_cytonorm_qc_undefined.py +102 -0
- openflo-2.6.0/tests/test_degenerate_scatter_gating.py +95 -0
- openflo-2.6.0/tests/test_density.py +117 -0
- openflo-2.6.0/tests/test_diagnostics.py +95 -0
- openflo-2.6.0/tests/test_diagnostics_selftest_gate.py +107 -0
- openflo-2.6.0/tests/test_dialog_tooltips.py +342 -0
- openflo-2.6.0/tests/test_diff_abundance_small_n.py +113 -0
- openflo-2.6.0/tests/test_diffabundance.py +116 -0
- openflo-2.6.0/tests/test_diffexp.py +113 -0
- openflo-2.6.0/tests/test_doctor_tk_check.py +92 -0
- openflo-2.6.0/tests/test_dr_compare.py +54 -0
- openflo-2.6.0/tests/test_drag_tree_refresh.py +118 -0
- openflo-2.6.0/tests/test_editor_mixins.py +89 -0
- openflo-2.6.0/tests/test_ellipsoid_quadrant.py +285 -0
- openflo-2.6.0/tests/test_embedding.py +100 -0
- openflo-2.6.0/tests/test_embeddings.py +73 -0
- openflo-2.6.0/tests/test_exceptions.py +25 -0
- openflo-2.6.0/tests/test_exit_autosave.py +131 -0
- openflo-2.6.0/tests/test_fast_graph.py +119 -0
- openflo-2.6.0/tests/test_fcs_export.py +118 -0
- openflo-2.6.0/tests/test_figure_layout.py +127 -0
- openflo-2.6.0/tests/test_flowsom.py +95 -0
- openflo-2.6.0/tests/test_flowsom_mst.py +49 -0
- openflo-2.6.0/tests/test_gate_editor_helpers.py +565 -0
- openflo-2.6.0/tests/test_gate_impl_parity.py +112 -0
- openflo-2.6.0/tests/test_gate_import_fixes.py +68 -0
- openflo-2.6.0/tests/test_gate_mask_cache.py +111 -0
- openflo-2.6.0/tests/test_gate_nonfinite_events.py +107 -0
- openflo-2.6.0/tests/test_gate_partition.py +126 -0
- openflo-2.6.0/tests/test_gate_replication_parent.py +165 -0
- openflo-2.6.0/tests/test_gatetree.py +92 -0
- openflo-2.6.0/tests/test_gating.py +253 -0
- openflo-2.6.0/tests/test_gating_helpers.py +155 -0
- openflo-2.6.0/tests/test_golden_regression.py +127 -0
- openflo-2.6.0/tests/test_gpu_accel.py +206 -0
- openflo-2.6.0/tests/test_group_stats.py +203 -0
- openflo-2.6.0/tests/test_group_summary_sd.py +56 -0
- openflo-2.6.0/tests/test_grouping.py +219 -0
- openflo-2.6.0/tests/test_gui_smoke.py +1318 -0
- openflo-2.6.0/tests/test_gui_toolings.py +84 -0
- openflo-2.6.0/tests/test_gui_ux.py +721 -0
- openflo-2.6.0/tests/test_histogram_offscale_note.py +97 -0
- openflo-2.6.0/tests/test_histogram_resolution.py +115 -0
- openflo-2.6.0/tests/test_import_is_lazy.py +73 -0
- openflo-2.6.0/tests/test_interop.py +145 -0
- openflo-2.6.0/tests/test_interop_h5ad_roundtrip.py +107 -0
- openflo-2.6.0/tests/test_label_align.py +148 -0
- openflo-2.6.0/tests/test_legend_placement.py +138 -0
- openflo-2.6.0/tests/test_leiden.py +83 -0
- openflo-2.6.0/tests/test_load_no_copy.py +89 -0
- openflo-2.6.0/tests/test_loader_queue.py +119 -0
- openflo-2.6.0/tests/test_log_histogram_range.py +126 -0
- openflo-2.6.0/tests/test_log_transform_nonpositive.py +113 -0
- openflo-2.6.0/tests/test_mem_tiny_clusters.py +117 -0
- openflo-2.6.0/tests/test_no_plausible_fills.py +250 -0
- openflo-2.6.0/tests/test_normalise_groups_idempotent.py +82 -0
- openflo-2.6.0/tests/test_not_gate_excludes_unmeasured.py +117 -0
- openflo-2.6.0/tests/test_panel_pairs.py +145 -0
- openflo-2.6.0/tests/test_paths.py +66 -0
- openflo-2.6.0/tests/test_plot_density.py +65 -0
- openflo-2.6.0/tests/test_plotmath.py +102 -0
- openflo-2.6.0/tests/test_polygon_gating.py +158 -0
- openflo-2.6.0/tests/test_population_stats_geomean.py +101 -0
- openflo-2.6.0/tests/test_preferences_wiring.py +88 -0
- openflo-2.6.0/tests/test_prefs.py +53 -0
- openflo-2.6.0/tests/test_prepare_unit_tags.py +167 -0
- openflo-2.6.0/tests/test_preview_quadrants.py +102 -0
- openflo-2.6.0/tests/test_propagate_downsample_order.py +147 -0
- openflo-2.6.0/tests/test_provenance.py +148 -0
- openflo-2.6.0/tests/test_provenance_unreadable_entries.py +78 -0
- openflo-2.6.0/tests/test_qc.py +220 -0
- openflo-2.6.0/tests/test_qc_ignores_derived_columns.py +127 -0
- openflo-2.6.0/tests/test_qc_integer_channels.py +204 -0
- openflo-2.6.0/tests/test_quality_correctness.py +44 -0
- openflo-2.6.0/tests/test_raw_not_aliased.py +104 -0
- openflo-2.6.0/tests/test_report.py +78 -0
- openflo-2.6.0/tests/test_resume_and_transfer_hygiene.py +153 -0
- openflo-2.6.0/tests/test_scales.py +39 -0
- openflo-2.6.0/tests/test_scales_logicle.py +96 -0
- openflo-2.6.0/tests/test_seed_phrases.py +102 -0
- openflo-2.6.0/tests/test_selftest.py +45 -0
- openflo-2.6.0/tests/test_selftest_controls.py +190 -0
- openflo-2.6.0/tests/test_session.py +401 -0
- openflo-2.6.0/tests/test_session_continuity.py +131 -0
- openflo-2.6.0/tests/test_session_json_is_valid.py +112 -0
- openflo-2.6.0/tests/test_session_migration_machinery.py +139 -0
- openflo-2.6.0/tests/test_session_sidecar_collision.py +195 -0
- openflo-2.6.0/tests/test_spectral.py +302 -0
- openflo-2.6.0/tests/test_spectral_degenerate_reference.py +93 -0
- openflo-2.6.0/tests/test_spectral_unmix_nonfinite.py +96 -0
- openflo-2.6.0/tests/test_stats.py +698 -0
- openflo-2.6.0/tests/test_stats_multigroup.py +191 -0
- openflo-2.6.0/tests/test_survivor_boundaries.py +104 -0
- openflo-2.6.0/tests/test_synthetic.py +238 -0
- openflo-2.6.0/tests/test_templates.py +64 -0
- openflo-2.6.0/tests/test_theme.py +82 -0
- openflo-2.6.0/tests/test_tool_window.py +91 -0
- openflo-2.6.0/tests/test_trajectory.py +137 -0
- openflo-2.6.0/tests/test_trajectory_nonfinite.py +157 -0
- openflo-2.6.0/tests/test_transform_nonfinite.py +82 -0
- openflo-2.6.0/tests/test_transforms.py +65 -0
- openflo-2.6.0/tests/test_tree_ids.py +36 -0
- openflo-2.6.0/tests/test_ui_compare.py +36 -0
- openflo-2.6.0/tests/test_ui_dialogs_smoke.py +184 -0
- openflo-2.6.0/tests/test_ui_inspect.py +78 -0
- openflo-2.6.0/tests/test_ui_logic.py +75 -0
- openflo-2.6.0/tests/test_ui_preview.py +39 -0
- openflo-2.6.0/tests/test_ui_sample_qc.py +97 -0
- openflo-2.6.0/tests/test_ui_synth.py +50 -0
- openflo-2.6.0/tests/test_ui_voltage.py +102 -0
- openflo-2.6.0/tests/test_umap_embedding.py +180 -0
- openflo-2.6.0/tests/test_undo_redo.py +123 -0
- openflo-2.6.0/tests/test_update.py +85 -0
- openflo-2.6.0/tests/test_voltage.py +199 -0
- openflo-2.6.0/tests/test_warmup.py +210 -0
- openflo-2.6.0/tests/test_workspace.py +956 -0
- openflo-2.6.0/tests/test_workspace_prep_offthread.py +207 -0
- openflo-2.6.0/tests/test_write_fcs.py +53 -0
- openflo-2.6.0/tests/test_wsp_extract.py +69 -0
- openflo-2.6.0/tests/test_wsp_partial_matrix.py +90 -0
- openflo-2.6.0/tests/test_wsp_population_names.py +100 -0
- openflo-2.6.0/tests/test_wsp_writer.py +353 -0
|
@@ -0,0 +1,2370 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to OpenFlo are documented in this file.
|
|
4
|
+
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
|
|
5
|
+
this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [2.6.0] - 2026-09-10
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- **Seed phrases.** The run seed accepts a word or phrase as well as a number,
|
|
13
|
+
so a run can be named (`pilot run 3`) instead of remembered. Hashed with
|
|
14
|
+
blake2b rather than `hash()`, because Python randomises string hashing per
|
|
15
|
+
process: a `hash()`-based phrase reproduces perfectly inside one session and
|
|
16
|
+
silently stops between sessions, which surfaces as unexplained drift in
|
|
17
|
+
results rather than as an error. The workspace seed field previously ran
|
|
18
|
+
through an int-only validator that discarded unparseable input without
|
|
19
|
+
saying so.
|
|
20
|
+
|
|
21
|
+
- **Embedding kernels compile off the critical path.** UMAP sets `cache=True`
|
|
22
|
+
on none of its 114 numba kernels, so every process that embeds recompiles
|
|
23
|
+
them — 18 to 29 seconds — and the workspace runs each unit in a fresh child.
|
|
24
|
+
A run clusters first, and clustering is compiled C that releases the GIL, so
|
|
25
|
+
the compile now happens alongside it. Measured on 30k events, Leiden + UMAP
|
|
26
|
+
in one child: 52.2s to 46.3s, with clustering time unchanged and every
|
|
27
|
+
result checksum identical. The embedding dialog warms on open too, so
|
|
28
|
+
choosing methods pays for the compile instead of Run stalling.
|
|
29
|
+
|
|
30
|
+
- **"Fast graph" — an optional pruned neighbour graph, default off.** Links
|
|
31
|
+
only kNN pairs, as PhenoGraph and Seurat do, instead of every pair sharing a
|
|
32
|
+
neighbour. On separable populations it wins on every measure and is 6x
|
|
33
|
+
faster (8 clusters vs 6 where 8 were planted, ARI 0.9545 vs 0.9487,
|
|
34
|
+
homogeneity 0.9220 vs 0.8964, Leiden 0.8s vs 5.0s). On heavily overlapping
|
|
35
|
+
populations the two disagree substantially and pruned scores worse on ARI —
|
|
36
|
+
it splits populations the dense graph merges. Off by default because
|
|
37
|
+
switching would change every clustering result already produced;
|
|
38
|
+
`scripts/bench_snn_prune.py` regenerates the comparison.
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
- **`run_louvain` default `restarts` 20 → 5.** The old default was justified
|
|
42
|
+
by a docstring table that did not survive re-measurement. ARI at 1, 5 and 20
|
|
43
|
+
restarts sits within 0.0014 on 20,000 events, so no count in that range is a
|
|
44
|
+
measured optimum; 5 keeps some of the exploration restarts exist for at a
|
|
45
|
+
quarter of what 20 cost. The docstring now says outright that 1 loses almost
|
|
46
|
+
nothing measurable.
|
|
47
|
+
|
|
48
|
+
### Fixed
|
|
49
|
+
- **The public-mirror scrub gate could pass a leak.** A file that was not
|
|
50
|
+
valid UTF-8 was skipped rather than scanned, so a forbidden token in a
|
|
51
|
+
latin-1 file passed untouched; and the email allowlist was matched against
|
|
52
|
+
the whole line, so one allowlisted token shadowed every address beside it.
|
|
53
|
+
Both closed, with binary-by-content files skipped deliberately so the new
|
|
54
|
+
fallback cannot decode a `.pyd` into spurious hits.
|
|
55
|
+
|
|
56
|
+
- **The GUI guard only protected tests that remembered to call it.**
|
|
57
|
+
`OPENFLO_REQUIRE_GUI=1` exists so a CI leg cannot go green having tested no
|
|
58
|
+
GUI. Measured with Tk deliberately broken, 55 GUI tests still skipped
|
|
59
|
+
silently. It is now a pytest hook that sees every skip however the test
|
|
60
|
+
wrote it, and probes Tk before converting anything so an xdist race still
|
|
61
|
+
skips.
|
|
62
|
+
|
|
63
|
+
- **README told everyone to clone a private repository.** The install
|
|
64
|
+
instructions pointed at `ChironTheCentaur/openflo`, which is private, and
|
|
65
|
+
the public mirror carried that line verbatim. Now points at the public
|
|
66
|
+
mirror, with relative links made absolute so they resolve on PyPI.
|
|
67
|
+
|
|
68
|
+
- The "Fast graph" checkbox pushed the run panel's widest row past its width
|
|
69
|
+
cap and clipped the toolbar on Linux, where font metrics are wider than on
|
|
70
|
+
Windows.
|
|
71
|
+
|
|
72
|
+
- `run_louvain`'s docstring reported measurements taken on a prototype that
|
|
73
|
+
partitioned a different graph; every figure was wrong. `_snn_jaccard_graph`
|
|
74
|
+
described itself as "the PhenoGraph / Seurat construction", which it is not
|
|
75
|
+
— those bound the edge set at n*k, while this links every pair sharing a
|
|
76
|
+
neighbour (6,106,544 edges against 297,138 at 20k events, k=30).
|
|
77
|
+
|
|
78
|
+
- The comment justifying the threaded `job.pkl` write claimed it was
|
|
79
|
+
disk-bound. It is roughly half CPU, and that half holds the GIL. The fix
|
|
80
|
+
works for a different reason — pickle releases the GIL for numpy's large
|
|
81
|
+
buffer writes — now measured directly instead of inferred.
|
|
82
|
+
|
|
83
|
+
### Reverted
|
|
84
|
+
- **Parallel UMAP neighbour search.** Building the kNN with
|
|
85
|
+
`pynndescent(n_jobs=-1)` and passing `precomputed_knn` is about 10% faster
|
|
86
|
+
and was reverted: pynndescent's parallel NN-descent is deterministic only at
|
|
87
|
+
a fixed numba thread count, so a seeded embedding became machine-dependent.
|
|
88
|
+
An exact kNN keeps both properties but measured 1.03x at 100k events — the
|
|
89
|
+
neighbour search is not the bottleneck, the seeded layout optimisation is.
|
|
90
|
+
|
|
91
|
+
## [2.5.0] - 2026-09-09
|
|
92
|
+
|
|
93
|
+
### Added
|
|
94
|
+
- **`FlowSample.run_louvain()` — Louvain community detection that is actually
|
|
95
|
+
reproducible.** PhenoGraph's Louvain shells out to the Blondel binaries,
|
|
96
|
+
which seed themselves `srand(time(NULL) + getpid())`; the PID half is
|
|
97
|
+
assigned by the OS, so that path cannot be pinned from outside the process.
|
|
98
|
+
igraph — already a pinned dependency — implements Louvain in-process with a
|
|
99
|
+
settable RNG, so here the seed is ours.
|
|
100
|
+
|
|
101
|
+
`restarts` preserves what makes PhenoGraph's Louvain good rather than merely
|
|
102
|
+
fast. A single Louvain run lands in whatever local optimum its node ordering
|
|
103
|
+
leads to; PhenoGraph re-runs it ~28 times and keeps the best modularity. This
|
|
104
|
+
does the same with a **deterministic seed sequence** (`random_state`,
|
|
105
|
+
`random_state + 1`, …) — the exploration is preserved and the answer is still
|
|
106
|
+
identical every time.
|
|
107
|
+
|
|
108
|
+
Measured on 20k events, 8 overlapping populations, k=30, against the
|
|
109
|
+
PhenoGraph binary (57.3s, Q=0.5887, 11 clusters, ARI 0.3187 vs planted
|
|
110
|
+
truth, **not** reproducible):
|
|
111
|
+
|
|
112
|
+
| restarts | time | Q | ARI vs truth |
|
|
113
|
+
|---|---|---|---|
|
|
114
|
+
| 1 | 2.6s | 0.5770 | 0.3217 |
|
|
115
|
+
| 5 | 13.4s | 0.5841 | 0.3393 |
|
|
116
|
+
| **20** (default) | **47.5s** | **0.5877** | **0.3576** |
|
|
117
|
+
| 40 | 92.9s | 0.5877 | 0.3576 (converged) |
|
|
118
|
+
|
|
119
|
+
At the default it matches the binary's modularity, finds the same number of
|
|
120
|
+
clusters, scores **better** against the planted truth, runs slightly faster —
|
|
121
|
+
and is reproducible. No subprocess, no clock, no PID, and no per-platform
|
|
122
|
+
native shim.
|
|
123
|
+
|
|
124
|
+
### Changed
|
|
125
|
+
- The shared-nearest-neighbour / Jaccard graph construction is extracted into
|
|
126
|
+
`_snn_jaccard_graph`, used by both `run_leiden` and `run_louvain`. They now
|
|
127
|
+
differ only in how the graph is *partitioned*; a difference in how it was
|
|
128
|
+
*built* would have made their results quietly incomparable.
|
|
129
|
+
|
|
130
|
+
### Fixed
|
|
131
|
+
- The workspace-sizing test asserted pixel counts that held only on Windows —
|
|
132
|
+
the same toolbar needs 343px on Linux against 336px on Windows, so it failed
|
|
133
|
+
CI on py3.11. It now asserts what the width floor is responsible for (the
|
|
134
|
+
panel receives everything the 60% cap allows) rather than a measurement taken
|
|
135
|
+
on one machine.
|
|
136
|
+
|
|
137
|
+
## [2.4.9] - 2026-09-09
|
|
138
|
+
|
|
139
|
+
### Changed
|
|
140
|
+
- **Clustering is now reproducible by default.** PhenoGraph's default community
|
|
141
|
+
detection is Louvain, run through the Blondel reference *binaries* shipped
|
|
142
|
+
inside the package. Those expose no seed at all — `community.exe` accepts only
|
|
143
|
+
`-w -p -q -l -v -h`, and are driven in a restart loop whose stopping rule
|
|
144
|
+
includes `(time.time() - tic) < time_limit`. So the result depended on both
|
|
145
|
+
an unpinnable RNG **and how fast your machine was**.
|
|
146
|
+
|
|
147
|
+
The seed is `srand(time(NULL) + getpid())` (`main_community.cpp:121` in the
|
|
148
|
+
Blondel v0.2 source the binaries are built from), feeding a shuffle of the
|
|
149
|
+
node traversal order in `one_level()` (`community.cpp:295-302`). **The PID
|
|
150
|
+
half is assigned by the OS**, which rules out pinning the result by injecting
|
|
151
|
+
a fixed or stepped sequence of clock values: **20 runs on identical input,
|
|
152
|
+
all finishing inside one second, produced 20 distinct partitions** — same
|
|
153
|
+
`time(NULL)`, different PIDs.
|
|
154
|
+
|
|
155
|
+
Measured on 50 000 events with overlapping populations: two runs of the
|
|
156
|
+
default, same data and same `random_state`, agreed only to **ARI 0.76** —
|
|
157
|
+
the labels genuinely differed. An analysis that changes when you re-run it is
|
|
158
|
+
hard to put in a methods section.
|
|
159
|
+
|
|
160
|
+
The default is now PhenoGraph's *seeded Leiden* backend, which calls
|
|
161
|
+
`leidenalg.find_partition(..., seed=...)` in-process: deterministic by
|
|
162
|
+
construction, no subprocess, no timing-dependent loop. It was also **~1.5x
|
|
163
|
+
faster** (63s vs 95s on that fixture) and equivalent against planted ground
|
|
164
|
+
truth (ARI 0.4905 vs 0.4990).
|
|
165
|
+
|
|
166
|
+
**This changes results.** Leiden is the corrected form of Louvain — it cannot
|
|
167
|
+
emit internally disconnected communities — not a reimplementation of it: on
|
|
168
|
+
ambiguous data the two agreed only to ARI 0.74, giving 7 clusters against 9.
|
|
169
|
+
Pass `reproducible=False` (or `--no-reproducible`) to restore Louvain for
|
|
170
|
+
reproducing prior output, matching published PhenoGraph results, or using the
|
|
171
|
+
GPU path (cuGraph's Louvain is likewise unseeded). `--reproducible` is kept as
|
|
172
|
+
an accepted no-op so existing scripts keep working.
|
|
173
|
+
|
|
174
|
+
### Fixed
|
|
175
|
+
- **`import openflo.cli` no longer reconfigures `sys.stdout`.** It applied the
|
|
176
|
+
UTF-8 console guard as an *import side effect*, changing the stream's error
|
|
177
|
+
handler for the whole process. That broke pytest-xdist outright — workers talk
|
|
178
|
+
to the controller over stdout via execnet, so importing this module inside a
|
|
179
|
+
worker corrupted the channel and killed it with `EOFError: expected 1 bytes,
|
|
180
|
+
got 0`, taking an unrelated test down with it. The same hazard applied to any
|
|
181
|
+
notebook or embedding host importing it as a library. The guard now runs only
|
|
182
|
+
in `main()`, which owns the process's streams — and still covers `--help`,
|
|
183
|
+
which the import-time call did not.
|
|
184
|
+
|
|
185
|
+
## [2.4.8] - 2026-09-09
|
|
186
|
+
|
|
187
|
+
### Changed
|
|
188
|
+
- **Loading is ~3x faster and uses half the memory.** Every sample was copied
|
|
189
|
+
twice on load — once when pandas materialised the event array, and again to
|
|
190
|
+
give `data` its own copy of `raw`. Neither was needed. Measured on 8 files of
|
|
191
|
+
2M events x 18 channels: **2.25s / +2304 MB → 0.75s / +1152 MB**. Projected
|
|
192
|
+
to a 25-file session: **7.0s / 7.2 GB → 2.3s / 3.6 GB**. A single 2M x 18
|
|
193
|
+
file loads in 0.08s, down from 0.34s.
|
|
194
|
+
|
|
195
|
+
The memory halving matters more than the seconds. Holding 7 GB of resident
|
|
196
|
+
frames is where a machine starts paging, and paging is what makes loading
|
|
197
|
+
*feel* slow long after the CPU work is done.
|
|
198
|
+
|
|
199
|
+
How: flowio returns an `array.array` supporting the buffer protocol, so
|
|
200
|
+
`np.asarray` was already a free view — `pd.DataFrame(..., copy=False)` stops
|
|
201
|
+
pandas duplicating a buffer nothing else references. And `data` is now a
|
|
202
|
+
SHALLOW copy of `raw`, which under pandas ≥ 3 copy-on-write is a distinct
|
|
203
|
+
object that duplicates a block only when written.
|
|
204
|
+
|
|
205
|
+
`raw` still holds the pristine detector values the `.fcs` export depends on:
|
|
206
|
+
`data` is written in place in a dozen places (compensation and transforms
|
|
207
|
+
among them) and none of those writes reach `raw`. Pinned by
|
|
208
|
+
`tests/test_load_no_copy.py`, which fails if anyone later "simplifies" the
|
|
209
|
+
shallow copy into an alias.
|
|
210
|
+
|
|
211
|
+
No numbers change: golden 8/8, controls 22/22, dtypes still float32.
|
|
212
|
+
|
|
213
|
+
- **Ellipsoid gates use flowutils' compiled `gating_c`** — bit-identical
|
|
214
|
+
(0 of 1,000,000 events differ, same inclusive-edge rule) and ~1.4x faster,
|
|
215
|
+
and it puts polygon and ellipsoid geometry in one library.
|
|
216
|
+
|
|
217
|
+
## [2.4.7] - 2026-09-09
|
|
218
|
+
|
|
219
|
+
### Fixed
|
|
220
|
+
- **Adjacent interval and rectangle gates silently LOST every event on their
|
|
221
|
+
shared boundary.** Both kinds used fully-open bounds (`lo < x < hi`), so two
|
|
222
|
+
neighbouring gates — `[0,10]` and `[10,20]` — each excluded a value of
|
|
223
|
+
exactly 10 and those events vanished from *both* populations. Frequencies
|
|
224
|
+
then failed to sum. Measured on integer `$DATATYPE I` data: **14.3% of
|
|
225
|
+
events lost** between two adjacent intervals, and a pair of rects covering
|
|
226
|
+
`x ∈ [0,20]` captured only 77.5% of it. Bounds are now half-open `[lo, hi)`,
|
|
227
|
+
matching Gating-ML's RectangleGate convention and the polygon rule fixed in
|
|
228
|
+
2.4.6, so adjacent gates partition with no gaps and no overlaps.
|
|
229
|
+
- **The same region gated differently as a rect than as a polygon** — 823 of
|
|
230
|
+
20 000 integer events on a 10×10 square. Both now use the same convention
|
|
231
|
+
and agree exactly.
|
|
232
|
+
- **Quadrant gates no longer need the `nextafter` workaround.** The Gating-ML
|
|
233
|
+
quadrant importer nudged each divider down by one ULP so a strict `>` would
|
|
234
|
+
still keep events sitting exactly on it. Half-open bounds do that properly,
|
|
235
|
+
so the hack is removed — and it would now actively *cause* a double-count.
|
|
236
|
+
Verified with 3 410 events on a divider (including a pile-up at exactly
|
|
237
|
+
zero, i.e. `arcsinh(0)`): all four quadrants tile exactly, summing to 100%.
|
|
238
|
+
|
|
239
|
+
**Who is affected:** continuous float data is unchanged (0 of 200 000 events
|
|
240
|
+
differ), so ordinary FCS gating and the golden baseline are untouched. This
|
|
241
|
+
only moves events that land on exact coordinates — integer `$DATATYPE I`
|
|
242
|
+
channels — and there they were previously being dropped. `threshold` gates
|
|
243
|
+
are deliberately unchanged (`> value`): a threshold is a cut, not a region,
|
|
244
|
+
and its NOT-complement already partitions exactly.
|
|
245
|
+
|
|
246
|
+
### Changed
|
|
247
|
+
- `tests/test_gating.py` — the test that pinned "interval is strictly between"
|
|
248
|
+
now pins the half-open rule, and gains three property tests: adjacent gates
|
|
249
|
+
partition, a rect equals the identical polygon, and quadrants tile without
|
|
250
|
+
the removed hack. Reverting the fix turns all four red.
|
|
251
|
+
- GUI tests now distinguish a genuinely broken Tk from a transient one. The
|
|
252
|
+
fail-closed guard added alongside 2.4.6 could turn an intermittent Tk
|
|
253
|
+
start-up race under `pytest-xdist` into a red build; it now retries once and
|
|
254
|
+
only fails when Tk is really unavailable, so a silent coverage hole is still
|
|
255
|
+
caught without crying wolf.
|
|
256
|
+
|
|
257
|
+
## [2.4.6] - 2026-09-09
|
|
258
|
+
|
|
259
|
+
### Fixed
|
|
260
|
+
- **Polygon gates no longer double-count events on a shared boundary.** Gating
|
|
261
|
+
used `matplotlib.path.Path.contains_points`, whose treatment of points lying
|
|
262
|
+
exactly ON an edge is wrong for gating in three ways, all measured rather
|
|
263
|
+
than argued:
|
|
264
|
+
- **It double-counts a shared edge.** Two gates meeting on a line both
|
|
265
|
+
claimed every point on it. Over a quadrant split of integer-valued events,
|
|
266
|
+
902 of 20 000 landed in two populations at once and the four quadrants
|
|
267
|
+
summed to more than 100%.
|
|
268
|
+
- **It depended on winding.** The same square drawn clockwise and
|
|
269
|
+
anticlockwise gave different answers on its boundary — so the same gate
|
|
270
|
+
could include different events depending on the direction the user happened
|
|
271
|
+
to drag the mouse.
|
|
272
|
+
- **Its rule was not self-consistent**: for an axis-aligned square it
|
|
273
|
+
included three edges and excluded the bottom; for the same square rotated
|
|
274
|
+
45° it included all four.
|
|
275
|
+
|
|
276
|
+
Gating now uses flowutils' compiled `gating_c`, which implements the standard
|
|
277
|
+
half-open convention — a point on a "lower/left" edge is inside, one on an
|
|
278
|
+
"upper/right" edge is outside — so adjacent gates tile with no gaps and no
|
|
279
|
+
overlaps, which is the property quadrant gating depends on.
|
|
280
|
+
|
|
281
|
+
**Who is affected:** on continuous float data the two rules agree *exactly*
|
|
282
|
+
(0 of 200 000 events differed), so ordinary FCS gating is unchanged and the
|
|
283
|
+
golden baseline did not move. They diverge only where events land on exact
|
|
284
|
+
coordinates — integer `$DATATYPE I` channels, where 6.96% of events sit on a
|
|
285
|
+
gate line and matplotlib was the rule double-counting them. If you gate
|
|
286
|
+
integer-valued channels with adjacent or quadrant gates, your population
|
|
287
|
+
counts change, and they change to the correct values.
|
|
288
|
+
|
|
289
|
+
### Changed
|
|
290
|
+
- Polygon gating is **5–12x faster** as a side effect: at 1M events a normal
|
|
291
|
+
gate went 172 → 32 ms and a tight gate 163 → 14 ms. The bounding-box
|
|
292
|
+
prefilter added in 2.4.5 has been REMOVED — with the faster crossing test,
|
|
293
|
+
building the prefilter mask costs more than it saves (48 ms vs 32 ms).
|
|
294
|
+
- `tests/test_polygon_prefilter.py` is now `tests/test_polygon_gating.py` and
|
|
295
|
+
asserts the correctness *properties* — partitioning, winding invariance,
|
|
296
|
+
vertex-order and translation invariance — rather than agreement with any
|
|
297
|
+
particular library, so it stays meaningful if the backend changes again.
|
|
298
|
+
|
|
299
|
+
## [2.4.5] - 2026-09-09
|
|
300
|
+
|
|
301
|
+
### Changed
|
|
302
|
+
- **Polygon gating is ~3x faster on a real gate hierarchy, with bit-identical
|
|
303
|
+
results.** Polygon gates were the most expensive step on the default path
|
|
304
|
+
(~172 ms per gate per million events), and a hierarchy evaluates one per gate
|
|
305
|
+
per descendant. A point outside the polygon's bounding box cannot be inside
|
|
306
|
+
it, so those are now rejected with two comparisons per axis instead of a full
|
|
307
|
+
crossing test; everything that survives goes through the same matplotlib
|
|
308
|
+
call. Measured on a 12-gate hierarchy over 500k events: **873 ms → 265 ms**,
|
|
309
|
+
masks identical. The gain scales with how tight the gate is — 13x for a gate
|
|
310
|
+
covering ~4% of the plot, 1.6x at 50% — which is the right way round, since
|
|
311
|
+
tight child gates deep in a tree are evaluated most often.
|
|
312
|
+
|
|
313
|
+
This is a pure speed change: `tests/test_polygon_prefilter.py` pins the
|
|
314
|
+
results against matplotlib on vertices, edge midpoints, collinear points,
|
|
315
|
+
concave notches, clockwise winding and flow-scale magnitudes, because a
|
|
316
|
+
polygon gate decides which cells are in a population.
|
|
317
|
+
|
|
318
|
+
### Notes
|
|
319
|
+
- **`flowutils.gating.points_in_polygon` was evaluated and deliberately NOT
|
|
320
|
+
adopted**, despite being ~5x faster again and already compiled and already a
|
|
321
|
+
dependency. It disagrees with matplotlib on points lying exactly ON a
|
|
322
|
+
boundary — vertices, edge midpoints, collinear points — which is not
|
|
323
|
+
hypothetical for integer `$DATATYPE I` channels, whose events land on exact
|
|
324
|
+
coordinates. Adopting it would silently move boundary events between
|
|
325
|
+
populations and change every downstream frequency, so it needs a deliberate
|
|
326
|
+
re-baseline rather than a drop-in swap. The reasoning is recorded at the call
|
|
327
|
+
site and in the tests so the trade-off is visible to whoever next looks for
|
|
328
|
+
speed here.
|
|
329
|
+
|
|
330
|
+
## [2.4.4] - 2026-09-09
|
|
331
|
+
|
|
332
|
+
### Fixed
|
|
333
|
+
- **The secondary windows had no hover help at all.** The editor gained
|
|
334
|
+
tooltips early; the thirty `ui_*` dialogs never did — an audit found **zero**
|
|
335
|
+
tooltip calls against ~105 interactive controls, so Preferences,
|
|
336
|
+
Compensation, Frequencies, Auto-gate, Statistics and the rest offered nothing
|
|
337
|
+
on hover. That is the wrong way round: the main window's controls are seen
|
|
338
|
+
constantly and learned by repetition, while a dialog's options are met once a
|
|
339
|
+
month and forgotten in between. Every actionable control in every dialog now
|
|
340
|
+
carries help that says what it *does* and what changes if you use it —
|
|
341
|
+
%Parent vs %Total, Transparent vs Translucent export backgrounds, why the
|
|
342
|
+
non-parametric test is the default, what the FMO percentile trades off.
|
|
343
|
+
Nothing was wrong with the tooltip machinery itself; it was simply never
|
|
344
|
+
called outside the editor.
|
|
345
|
+
- **The docked Pipeline Workspace opened clipped, and squeezed the plot.** Its
|
|
346
|
+
`Group ▾` menubutton lost its arrow, and `res`, `PHATE` and `Reproducible`
|
|
347
|
+
were cut off at the panel edge. Three causes: the status line had no
|
|
348
|
+
`wraplength`, so it reported its whole 456 px of text as its required width
|
|
349
|
+
and — being the widest child — made that the panel's requirement; the toolbar
|
|
350
|
+
was gridded without `sticky`, so it centred and lost content off *both* edges
|
|
351
|
+
at once; and the reveal used a guessed 320 px floor when the toolbar needs
|
|
352
|
+
336. The floor is now measured from the panel itself, so it stays correct as
|
|
353
|
+
controls change.
|
|
354
|
+
- **Docking the workspace no longer takes its width out of the plot.** At the
|
|
355
|
+
default window size that left the plot column ~512 px against control rows
|
|
356
|
+
needing ~754, clipping the axis combos, `Auto-gate` and `Show cleaned-out
|
|
357
|
+
events` — controls the user never asked to give up. The window now grows when
|
|
358
|
+
the screen has room, and shares only when it does not.
|
|
359
|
+
|
|
360
|
+
### Added
|
|
361
|
+
- `tests/test_dialog_tooltips.py` — asserts every actionable dialog control has
|
|
362
|
+
hover help, and that revealing the workspace clips neither its own toolbar nor
|
|
363
|
+
the plot's controls. All three tests were verified to fail against the code
|
|
364
|
+
they replaced.
|
|
365
|
+
|
|
366
|
+
## [2.4.3] - 2026-09-08
|
|
367
|
+
|
|
368
|
+
### Fixed
|
|
369
|
+
- **`openflo-selftest` crashed instead of reporting on a legacy Windows
|
|
370
|
+
console.** Its results table prints check marks, which cp1252 cannot encode,
|
|
371
|
+
so the command whose whole purpose is to say whether an install reproduces
|
|
372
|
+
reference behaviour raised `UnicodeEncodeError` and reported nothing. The
|
|
373
|
+
same fault was found in `openflo-doctor --help` **despite** that module
|
|
374
|
+
having a guard — the guard ran after `parse_args`, and argparse prints help
|
|
375
|
+
and exits from inside it — and in `openflo-compare`, `openflo-voltage` and
|
|
376
|
+
`openflo-synth`, which had no guard at all. Every entry point now calls a
|
|
377
|
+
shared `force_utf8_streams()` before building its parser.
|
|
378
|
+
- **The declared `psutil` floor could not be installed on Linux.** psutil
|
|
379
|
+
5.9.0-5.9.3 publish Linux wheels only up to cp310, so Python 3.11+ had to
|
|
380
|
+
build it from source, which needs a C compiler. The floor is now 5.9.4, the
|
|
381
|
+
first release shipping a `cp36-abi3` wheel that covers every supported
|
|
382
|
+
Python.
|
|
383
|
+
- **The declared `matplotlib` floor could not be resolved at all.** matplotlib
|
|
384
|
+
3.8.0-3.8.3 declare `numpy<2`, which cannot be satisfied alongside this
|
|
385
|
+
project's `numpy>=2.0`. The floor is now 3.8.4, the release that dropped the
|
|
386
|
+
cap.
|
|
387
|
+
|
|
388
|
+
### Changed
|
|
389
|
+
- **The eight shared core dependencies are declared as ranges, not exact
|
|
390
|
+
pins** (numpy, pandas, scipy, scikit-learn, matplotlib, seaborn, openpyxl,
|
|
391
|
+
psutil), so `pip install openflo` can coexist with an environment that
|
|
392
|
+
already has a scientific stack rather than downgrading it. Each floor was
|
|
393
|
+
found by testing, and the evidence is recorded per-dependency in
|
|
394
|
+
`docs/PYPI_READINESS.md`. `requirements.txt` still pins the exact tested
|
|
395
|
+
stack — that is what CI and `openflo-doctor` check against, and it is
|
|
396
|
+
unchanged. The six leaf dependencies nobody else competes for (FlowIO,
|
|
397
|
+
FlowUtils, PhenoGraph, umap-learn, igraph, leidenalg) remain exact pins,
|
|
398
|
+
because their versions move OpenFlo's numbers.
|
|
399
|
+
|
|
400
|
+
### Added
|
|
401
|
+
- **CI installs the built wheel at both edges of every declared range** — a new
|
|
402
|
+
`install` job across {ubuntu, windows} x {floor dependencies on Python 3.11,
|
|
403
|
+
newest resolvable on 3.12}. It builds the wheel, installs it the way a user
|
|
404
|
+
does (no editable install, no `--no-deps`), and then requires the installed
|
|
405
|
+
artifact to reproduce the golden baseline and pass the response controls.
|
|
406
|
+
This closes two gaps: an editable install imports from `src/`, so missing
|
|
407
|
+
package data was invisible, and nothing previously installed the floor of any
|
|
408
|
+
declared range. The floor pins are generated from `pyproject.toml` by
|
|
409
|
+
`scripts/lowest_direct_requirements.py`, so they cannot drift from what is
|
|
410
|
+
declared. All three bugs fixed above were found by this job.
|
|
411
|
+
|
|
412
|
+
## [2.4.2] - 2026-09-08
|
|
413
|
+
|
|
414
|
+
### Fixed
|
|
415
|
+
- **Starting a run no longer stalls on labelling events.** `prepare_unit` tags
|
|
416
|
+
every event with its source group and sample — one short string repeated
|
|
417
|
+
across a whole member. Assigning those as object-dtype columns materialised
|
|
418
|
+
millions of Python string references and was, on a 3M-row unit, **415 ms of
|
|
419
|
+
the function's 531 ms** (the concat itself was 80 ms), then paid again when
|
|
420
|
+
the frame is pickled for the child process. As categoricals: 604 → 111 ms of
|
|
421
|
+
UI-blocking work, 730 → 414 MB in memory, and the job file 3044 → 803 ms to
|
|
422
|
+
write. The tag values are unchanged.
|
|
423
|
+
- **Downsample-propagate gave a different answer depending on which sample
|
|
424
|
+
finished loading first.** The option trims every loaded sample to the smallest
|
|
425
|
+
enabled one — but each arriving sample was compared against the floor of
|
|
426
|
+
whatever had loaded *so far*, and that order comes from a thread pool.
|
|
427
|
+
Measured on four samples of 50k/40k/30k/20k events: loading them
|
|
428
|
+
largest-first trimmed **nothing at all**, smallest-first trimmed **everything
|
|
429
|
+
to 20k**, and an interleaved order gave a mix. The same session resumed twice
|
|
430
|
+
could hold different event counts, and every frequency computed from them
|
|
431
|
+
would differ. The trim now happens once over the whole set, sharing its
|
|
432
|
+
definition with the toggle that documents the behaviour.
|
|
433
|
+
- **A log histogram threw away events it could have shown.** The log floor was
|
|
434
|
+
`max(lo, hi * 1e-6)`, which raised a perfectly good *positive* lower bound to
|
|
435
|
+
six decades below the top of the range — on an all-positive channel spanning
|
|
436
|
+
1e-3 to 1e6 that put **half the events off the bottom of the plot**, silently.
|
|
437
|
+
The clamp is only needed when there is no positive lower bound to use. What a
|
|
438
|
+
log axis genuinely cannot show — non-positive values, routinely a third of a
|
|
439
|
+
compensated channel — is now stated on the figure itself, so an exported panel
|
|
440
|
+
carries it too, and the note stays silent when nothing is hidden.
|
|
441
|
+
|
|
442
|
+
### Changed
|
|
443
|
+
- **Starting a pipeline run no longer freezes the window while it stages.**
|
|
444
|
+
`_launch_next` built the run unit AND serialised it to `job.pkl` on the Tk
|
|
445
|
+
thread — the code even called it "cheap (main)". The write dominates:
|
|
446
|
+
measured here, 569 ms for a 20-sample x 200k-event run (336 MB) and 1264 ms
|
|
447
|
+
for 8 x 1M (672 MB), all of it blocking the UI. The write now happens on a
|
|
448
|
+
worker and the child process launches when it completes.
|
|
449
|
+
**Only the write moved.** `prepare_unit` deliberately stays on the Tk thread:
|
|
450
|
+
it reads the editor's loaded samples, which the user can mutate, so running
|
|
451
|
+
it concurrently would introduce the same read-during-write race that was
|
|
452
|
+
fixed in the plot path. Its share is the smaller one (concat + tagging, 250 of
|
|
453
|
+
the 1720 ms at 8 x 1M) and removing it needs a snapshot design, not a thread.
|
|
454
|
+
|
|
455
|
+
## [2.4.1] - 2026-09-08
|
|
456
|
+
|
|
457
|
+
### Upgrading — results that legitimately change
|
|
458
|
+
|
|
459
|
+
This release corrects analyses, so re-running work done on an earlier version
|
|
460
|
+
can give different numbers. Nothing needs migrating: the session format is
|
|
461
|
+
unchanged and old sessions open normally. What follows is where to expect a
|
|
462
|
+
difference, and in which direction.
|
|
463
|
+
|
|
464
|
+
- **Event counts go UP where auto-clean was removing events for no reason.**
|
|
465
|
+
Four separate causes, each measured on a sample with no actual fault: an
|
|
466
|
+
integer-valued channel (`$DATATYPE I`) lost **38%** to reported drift; one
|
|
467
|
+
dead or unused detector removed **100%**; a sample QC'd after clustering or
|
|
468
|
+
FMO positivity lost **12.4%** (a cluster label) to **49.8%** (a positivity
|
|
469
|
+
flag); and a coarsely quantised viability channel cost **46%** of an all-live
|
|
470
|
+
sample. Those events are now kept, so any frequency computed from them
|
|
471
|
+
shifts.
|
|
472
|
+
- **Gates on a LOG-scaled channel select FEWER events.** A non-positive value
|
|
473
|
+
has no logarithm and is now `NaN` rather than `0.0`. Previously such events
|
|
474
|
+
were placed at raw intensity 1.0 — above every genuinely positive event
|
|
475
|
+
dimmer than 1.0 — so a negative-side gate was collecting them. They now drop
|
|
476
|
+
out of gates, medians and plots. If a marker-positive frequency falls, this
|
|
477
|
+
is the likely reason, and the old number was the wrong one.
|
|
478
|
+
- **Condition-comparison group means go DOWN for patchy populations.** A
|
|
479
|
+
cluster absent from a sample now contributes 0% instead of being left out of
|
|
480
|
+
the average. A population found in one of three samples at 10% was reported
|
|
481
|
+
as 10%; it is now 3.3%. The table also gains an `n_samples` column, and
|
|
482
|
+
`sd_pct` is now a real number where it used to be `NaN`.
|
|
483
|
+
- **MEM scores go UP when a very small cluster was present.** A cluster with no
|
|
484
|
+
measurable spread was taking the top of the scale and compressing every other
|
|
485
|
+
population; measured, three real populations recovered from 4, -5, -5 back to
|
|
486
|
+
8, -10, -9. Marker labels can change as a result.
|
|
487
|
+
- **Doublets are removed from transformed scatter.** If FSC-A/FSC-H had been
|
|
488
|
+
arcsinh- or logicle-transformed before auto-clean ran, the doublet filter was
|
|
489
|
+
a no-op and kept them all.
|
|
490
|
+
- **A "NOT" gate no longer contains events it could not measure.** The
|
|
491
|
+
complement population shrinks, and a marker's positive and negative
|
|
492
|
+
populations no longer sum to the whole sample when some events have no
|
|
493
|
+
reading — which is the honest arithmetic.
|
|
494
|
+
|
|
495
|
+
Sessions saved by an earlier version still load, including their processed-data
|
|
496
|
+
sidecars: sample names that had to be sanitised now get a different sidecar
|
|
497
|
+
filename, and the loader looks for both.
|
|
498
|
+
|
|
499
|
+
### Changed
|
|
500
|
+
- **Five more, in file formats and the auto-clean filters.** A FlowJo
|
|
501
|
+
workspace import *discarded every population name* — the name lives on the
|
|
502
|
+
enclosing `<Population>` element, not the `<Gate>` inside it — so a
|
|
503
|
+
40-population workspace arrived fully unnamed while its geometry survived
|
|
504
|
+
intact, which is why a round trip looked like it worked. A compensation
|
|
505
|
+
matrix whose *channel name contained a comma* (a `$PnS` description like
|
|
506
|
+
"CD4 PE-Cy7, clone SK3" is ordinary) wrote a file this same module then
|
|
507
|
+
refused to read. Standards-conformant Gating-ML `-INF` bounds reached the
|
|
508
|
+
session file as `-Infinity`, which *RFC 8259 forbids* — valid to Python,
|
|
509
|
+
rejected by `JSON.parse` and `jq`. An *unusable reference spectrum* (a
|
|
510
|
+
single stain dimmer than the unstained control clips to all-zero) was
|
|
511
|
+
reported as cosine similarity 0.0 against every other fluor, i.e.
|
|
512
|
+
"maximally distinct, trivially unmixable", so the panel was declared free of
|
|
513
|
+
problematic pairs *because* one fluor had no spectrum. And the *auto-clean
|
|
514
|
+
doublet window was a no-op on transformed scatter*: it is a fixed fraction
|
|
515
|
+
of the median ratio, but that ratio's spread collapses from 14.5% to 1.7%
|
|
516
|
+
after an arcsinh bake, so all 4,000 planted doublets were retained where
|
|
517
|
+
linear scatter lost all 4,000.
|
|
518
|
+
- **Seven more places where the wrong events were selected, deleted or
|
|
519
|
+
counted.** Every one was reproduced and measured before it was touched.
|
|
520
|
+
*Acquisition QC read our own analysis output as a detector*: the denylist had
|
|
521
|
+
fallen behind the columns the app writes, so a `leiden` column made the margin
|
|
522
|
+
filter delete the whole top-numbered cluster (12.4% of a clean sample) and an
|
|
523
|
+
FMO `<channel>_pos` flag deleted every marker-positive event (49.8%).
|
|
524
|
+
*Two samples could be handed the same session sidecar* — `Tube 01 Rep
|
|
525
|
+
A` and `Tube_01_Rep_A` sanitised to one filename — and because
|
|
526
|
+
restore prefers the sidecar over the raw FCS, one sample silently came back
|
|
527
|
+
holding the other's events, clusters and compensated values.
|
|
528
|
+
*A cluster absent from a sample was treated as unmeasured rather than 0%*, so
|
|
529
|
+
a population found in 1 of 3 samples at 10% was published as the group's 10%
|
|
530
|
+
instead of 3.3%.
|
|
531
|
+
*A boolean gate that could not resolve its operands admitted everything*,
|
|
532
|
+
turning a "NOT X" population into the entire sample (20,000 events where
|
|
533
|
+
10,004 was correct); it now fails closed, and `apply_region_gates` passes the
|
|
534
|
+
gate list so operands actually resolve.
|
|
535
|
+
*A NOT gate adopted every event it could not measure* — 2,000 events with no
|
|
536
|
+
reading made up 20% of a CD3-negative population, while the two gates still
|
|
537
|
+
summed to the sample total so the result looked self-consistent.
|
|
538
|
+
*The `log` transform folded every non-positive value onto 0.0*, i.e. raw
|
|
539
|
+
intensity 1.0, ranking the dimmest events above every genuine positive below
|
|
540
|
+
1.0 and mis-assigning 32,620 of 100,000 events; its inverse separately
|
|
541
|
+
destroyed every value at or below raw 1.0 on a round trip, which runs
|
|
542
|
+
whenever a channel's scale is changed.
|
|
543
|
+
*A comb was mistaken for a bimodal distribution*: an integer channel spread
|
|
544
|
+
over 256 bins leaves most of them structurally empty, and a zero bin between
|
|
545
|
+
two comb teeth satisfies the "is this really bimodal" test, so the auto-clean
|
|
546
|
+
viability filter deleted 46% of an all-live sample — whether it fired at all
|
|
547
|
+
decided by how the bins happened to line up.
|
|
548
|
+
- **Batch-correction QC reports what it cannot measure (hardening, not a live
|
|
549
|
+
bug).** `CytoNorm.qc` returned `0.0` — the *best* score on a lower-is-better
|
|
550
|
+
scale — for a channel with no usable events, and raised `Distribution can't
|
|
551
|
+
be empty` when no event was finite in every channel. Both are now NaN with a
|
|
552
|
+
warning. To be precise about the scope: neither was reachable from the
|
|
553
|
+
shipped call paths, because both callers pass `qc()` exactly the events they
|
|
554
|
+
just fitted and `fit()` rejects that input first. Measured distances for real
|
|
555
|
+
data are unchanged.
|
|
556
|
+
- **A cluster with no spread no longer sets the scale for the whole MEM
|
|
557
|
+
table.** MEM scores a population as `|median shift| + IQR_ref/IQR_pop - 1`
|
|
558
|
+
and then rescales so the largest |score| maps to 10. A population with no
|
|
559
|
+
measurable spread — a single event, or every event identical — has an IQR of
|
|
560
|
+
exactly 0, which the epsilon guard turned into "perfectly tight", the most
|
|
561
|
+
that term can reward. Since the table is divided by its own largest value,
|
|
562
|
+
one such cluster compressed every real call in it: a single stray event given
|
|
563
|
+
its own label pushed three well-separated populations from 10, -10, -7 down
|
|
564
|
+
to 4, -5, -5. The spread term now drops out when there is no spread to
|
|
565
|
+
measure, and such a population is scored on its median shift alone.
|
|
566
|
+
Deliberately unchanged: a population sitting exactly at the median of a
|
|
567
|
+
multimodal reference has a genuinely unstable sign under "vs all other
|
|
568
|
+
cells", which is the metric's own ambiguity rather than a defect.
|
|
569
|
+
- **Three more places where a degenerate input was answered with a
|
|
570
|
+
confident number.** The FMO
|
|
571
|
+
preview substituted `-999` for a missing per-axis threshold and then drew
|
|
572
|
+
four quadrant percentages from it, two of them defined by a boundary nobody
|
|
573
|
+
measured — and on a compensated channel whose dim population sits near
|
|
574
|
+
-2000, that sentinel fell *inside* the data and split it 67.9 / 7.0 / 23.2 /
|
|
575
|
+
2.0. And a FlowJo workspace declaring spillover for only some detectors was
|
|
576
|
+
accepted with the rest silently left uncompensated, because the acceptance
|
|
577
|
+
check counted coefficients rather than channels. All three now report what
|
|
578
|
+
they do not know, and the uncovered detectors are named.
|
|
579
|
+
- **Acquisition QC no longer punishes integer-valued channels, dead detectors,
|
|
580
|
+
or quiet time bins.** All three came from one line: the robust outlier band
|
|
581
|
+
was `median(|v - med|) + 1e-10`, and that epsilon silently redefined "no
|
|
582
|
+
measurable spread" as "infinitely sensitive". On an integer channel
|
|
583
|
+
(`$DATATYPE I`, very common) more than half the per-bin medians tie with the
|
|
584
|
+
overall median, so the MAD is exactly 0 — and a clean, drift-free sample lost
|
|
585
|
+
**38% of its events** to reported "drift", while the identical data stored as
|
|
586
|
+
float lost none. The scale floor now comes from each caller's own data (a
|
|
587
|
+
channel's quantisation step; sqrt(N) counting noise for an event count)
|
|
588
|
+
rather than from the deviations being judged, which also stops ordinary
|
|
589
|
+
Poisson scatter in low-count bins reading as a flow-rate fault. Where no
|
|
590
|
+
scale exists in either place, nothing is called an outlier.
|
|
591
|
+
Alongside it, the margin/saturation filter skipped its own degenerate case:
|
|
592
|
+
a channel that is constant across the file has no ceiling to pile up
|
|
593
|
+
against, but every event counted as "at the max" — and because margins are
|
|
594
|
+
OR-ed across the panel, **one unused or disabled detector deleted 100% of an
|
|
595
|
+
otherwise healthy sample**. Integer and float storage of the same data now
|
|
596
|
+
produce identical QC verdicts in both directions, and real faults — a 400-unit
|
|
597
|
+
clog, a saturated channel, a 7x count burst — are still caught.
|
|
598
|
+
- **Differential abundance now shows the sample count per group, and says so
|
|
599
|
+
when the design cannot support its p-values.** The GLM borrows a dispersion
|
|
600
|
+
shared across populations, so it still returns a p-value with ONE sample per
|
|
601
|
+
group — and the table rendered that as significance stars with no `n`
|
|
602
|
+
anywhere on screen, making a 1-vs-1 comparison look identical to a replicated
|
|
603
|
+
one in a figure users export directly. The numbers are unchanged; `n` per
|
|
604
|
+
group is now a column, and fewer than three samples in the smaller group
|
|
605
|
+
draws an explicit note that the result is exploratory rather than evidence of
|
|
606
|
+
a difference.
|
|
607
|
+
|
|
608
|
+
### Added
|
|
609
|
+
- **MEM annotation has a response control.** A planted marker-high population
|
|
610
|
+
must score that marker positive, swapping which population carries the
|
|
611
|
+
phenotype must swap the sign, a marker drawn identically for both populations
|
|
612
|
+
must score near zero, and a stray one-event cluster must not compress the
|
|
613
|
+
real scores.
|
|
614
|
+
- **The response controls are now attacked, not just run.**
|
|
615
|
+
`scripts/mutate_controls.py` breaks each subsystem on purpose — compensation
|
|
616
|
+
never applied, a gate ignoring its vertices, clustering collapsed to one
|
|
617
|
+
cluster, QC removing a fixed 10% regardless of input — and checks that some
|
|
618
|
+
control notices. All twelve attacks are caught. It paid for itself
|
|
619
|
+
immediately by catching a containment check that could not fail, and the
|
|
620
|
+
allow-list guard was re-keyed by enclosing function after a new fallback was
|
|
621
|
+
found inheriting an unrelated entry's excuse.
|
|
622
|
+
- **Gating has a response control.** A polygon must recover the fraction
|
|
623
|
+
planted inside it, an empty region must come back empty, a child gate must be
|
|
624
|
+
the intersection with its parent, and the same events must be selected when
|
|
625
|
+
the rows are shuffled or the index does not start at zero.
|
|
626
|
+
- **Acquisition QC now has a response control.** The golden baseline and the
|
|
627
|
+
response controls between them never touched `AcquisitionQC` — the stage that
|
|
628
|
+
runs on every sample, and the one that turned out to hold three live bugs.
|
|
629
|
+
Four relational checks were added: a clean run is left alone, events removed
|
|
630
|
+
tracks the span of a planted drift excursion, and the verdict is unchanged
|
|
631
|
+
both by rounding the data and by the presence of a dead detector. The last
|
|
632
|
+
two come back 45% and 100% apart against the previous release.
|
|
633
|
+
- **A regression guard for the "plausible number for an unknown" bug.** Six
|
|
634
|
+
shipped defects in this codebase share one shape: a guard correctly notices
|
|
635
|
+
the input is degenerate, then substitutes a value that reads as a successful
|
|
636
|
+
measurement rather than one meaning *unknown* — r² = 1.0 for an undefined
|
|
637
|
+
fit, a corrupt event at the end of a trajectory, `sd = 0` for one replicate,
|
|
638
|
+
an entire sample deleted. A test now scans the analysis modules for that
|
|
639
|
+
shape and fails on anything new, with a reviewed allow-list carrying a
|
|
640
|
+
specific reason per site. It states its own limits (it only catches
|
|
641
|
+
comparison-style degeneracy checks and literal fallbacks), checks that the
|
|
642
|
+
scanner still matches the shape it was written for, and fails if an
|
|
643
|
+
allow-list entry goes stale.
|
|
644
|
+
|
|
645
|
+
### Fixed
|
|
646
|
+
- **A trajectory is no longer rooted on an arbitrary cell.** `robust_root`
|
|
647
|
+
returned index 0 when the root channel had no finite values, so pseudotime
|
|
648
|
+
still came back looking like a trajectory while its origin and direction were
|
|
649
|
+
meaningless. It now refuses, matching the `empty X` check beside it; the
|
|
650
|
+
dialog already reports the error.
|
|
651
|
+
- **Unusable scatter no longer deletes the whole sample.** `filter_doublets`
|
|
652
|
+
built its acceptance window from the median FSC-A/FSC-H ratio and substituted
|
|
653
|
+
`0.0` when no event had a usable one — making the window `[0, 0]`, which
|
|
654
|
+
matches nothing. A sample whose FSC-H is non-positive throughout was reduced
|
|
655
|
+
to **zero events**, with no error and no warning. Doublets cannot be
|
|
656
|
+
identified without the ratio, so the filter is now skipped and reported,
|
|
657
|
+
which is what the missing-channel branch directly above it already did. The
|
|
658
|
+
cell-cycle singlet gate had the identical fallback, where the effect was every
|
|
659
|
+
cell scored `NA` and an empty cell-cycle result.
|
|
660
|
+
- **A group of one replicate no longer reports `sd = 0`.** The sample standard
|
|
661
|
+
deviation divides by n-1, so it is undefined for a single value —
|
|
662
|
+
`compare_groups` returned `0.0`, a confident claim of "this group had no
|
|
663
|
+
variability", rendered straight into the group summary as `sd=0`. It is now
|
|
664
|
+
NaN. A group that genuinely is constant still reports 0, because that is
|
|
665
|
+
measured rather than assumed.
|
|
666
|
+
- **A corrupt event is no longer placed at the end of the trajectory, and no
|
|
667
|
+
longer contaminates the trend curve.** Two halves of the same problem.
|
|
668
|
+
`_geodesic_pseudotime` fills unreachable cells with the MAXIMUM distance — a
|
|
669
|
+
deliberate, documented choice for a genuinely disconnected component — but an
|
|
670
|
+
event with a `NaN` or `inf` coordinate is also unreachable, so it inherited
|
|
671
|
+
that fill and plotted at pseudotime 1.0, indistinguishable from the most
|
|
672
|
+
differentiated cell in the sample. Such events are now excluded and returned
|
|
673
|
+
as NaN; a real disconnected component keeps its documented fill. Then
|
|
674
|
+
`pseudotime_trends` binned them anyway: `searchsorted` sorts NaN **last**, so
|
|
675
|
+
every unknown-pseudotime event was clipped into the FINAL bin, dragging the
|
|
676
|
+
terminal point of the published "expression vs pseudotime" curve toward
|
|
677
|
+
whatever they happened to express — measured, three such events at 1000 moved
|
|
678
|
+
a last-bin mean from 1.0 to 600.4. Empty bins are still NaN rather than zero.
|
|
679
|
+
- **A degenerate MESF calibration no longer reports a perfect fit.** When every
|
|
680
|
+
bead peak is assigned the same value — a data-entry mistake — r² is
|
|
681
|
+
mathematically undefined, and `fit_mesf_calibration` returned **1.0**: the
|
|
682
|
+
strongest possible "this calibration is good" signal, attached to a fit whose
|
|
683
|
+
slope is ~0 and which would turn every converted value into the intercept.
|
|
684
|
+
The dialog showed `R²=1.0000` and the user could apply it to every loaded
|
|
685
|
+
sample. r² is now NaN in that case, and the calibration dialog refuses the
|
|
686
|
+
fit and explains why. A real calibration is unchanged (the golden's
|
|
687
|
+
`calibration.r2` still reads exactly 1.0), and a genuinely poor fit still
|
|
688
|
+
reports r² well below 1 — the job the docstring claims for it.
|
|
689
|
+
|
|
690
|
+
## [2.4.0] - 2026-09-05
|
|
691
|
+
|
|
692
|
+
### Added
|
|
693
|
+
- **Response controls — `openflo-selftest --controls`.** A second synthetic
|
|
694
|
+
suite that answers the question the golden baseline cannot. The golden pins
|
|
695
|
+
seven numbers from one fixed dataset, so it proves *the answer did not
|
|
696
|
+
change* — an implementation that ignored its input and returned those seven
|
|
697
|
+
constants would pass all seven. The response controls instead assert
|
|
698
|
+
*relationships between an input you vary and the output produced*: a
|
|
699
|
+
spill-free negative control that must come back at zero, a spillover
|
|
700
|
+
titration that must track its planted dose, a doublet titration, a planted
|
|
701
|
+
composition shift that must be found with the correct sign, a ctrl-vs-ctrl
|
|
702
|
+
null comparison that must find nothing, a compensation-APPLIER pair
|
|
703
|
+
(identity is a no-op; the true signal is recovered at every spill level —
|
|
704
|
+
the estimator checks cannot see an applier bug, which is where the 2.2.1
|
|
705
|
+
transpose lived), and a clustering pair that scores
|
|
706
|
+
purity against the true populations then shows it collapsing under a
|
|
707
|
+
permutation control. **Nothing in the suite is a pinned number**, so there is
|
|
708
|
+
no baseline to update and nothing to re-freeze — a failure is always a real
|
|
709
|
+
behaviour change. `--all` runs both suites.
|
|
710
|
+
Demonstrated to catch two sabotages the golden reports as 7/7 green: a
|
|
711
|
+
transposed spillover matrix (the v2.2.1 bug class, which corrupted every real
|
|
712
|
+
compensated analysis) and a hardcoded estimator.
|
|
713
|
+
|
|
714
|
+
### Added
|
|
715
|
+
- **GPU backend picker** (Preferences -> Performance). `gpu_backend` was read
|
|
716
|
+
at startup and in the Preferences dialog but written by NOTHING, so the
|
|
717
|
+
portable PyTorch backend (NVIDIA / AMD / Intel / Apple, incl. DirectML on
|
|
718
|
+
native Windows) could only be selected through the undocumented
|
|
719
|
+
`OPENFLO_GPU_BACKEND` environment variable — a shipped feature wired at one
|
|
720
|
+
end and unreachable from the UI. The picker offers auto / CuPy / PyTorch /
|
|
721
|
+
off, persists the choice, re-probes immediately and reports the resolved
|
|
722
|
+
device. A meta-test now fails if any preference is read-but-never-written or
|
|
723
|
+
written-but-never-read, so this class cannot recur.
|
|
724
|
+
|
|
725
|
+
- **Geometric mean and robust CV in the statistics table.** Fluorescence is
|
|
726
|
+
approximately log-normal, so the geometric mean — not the arithmetic mean —
|
|
727
|
+
is the central tendency FlowJo reports and papers cite; its absence meant
|
|
728
|
+
anyone reconciling OpenFlo numbers against FlowJo was comparing different
|
|
729
|
+
statistics. `GeoMean` is computed on positive values only (compensated data
|
|
730
|
+
legitimately contains negatives, and clamping them to a floor would bias the
|
|
731
|
+
result upward). `rCV` is the MAD-based robust CV, which unlike the ordinary
|
|
732
|
+
CV survives the outliers real data carries. Both are opt-in; the default
|
|
733
|
+
column set is unchanged.
|
|
734
|
+
|
|
735
|
+
### Fixed
|
|
736
|
+
- **The golden baseline's compensation metric never ran compensation.** It
|
|
737
|
+
wrote the generator's own planted spillover matrix to a CSV, read it straight
|
|
738
|
+
back, and asserted the value it had just written — a metric labelled
|
|
739
|
+
"Compensation APC -> APC-Fire spill" that could not observe compensation
|
|
740
|
+
being broken, and which therefore stayed green through the 2.2.1 transpose
|
|
741
|
+
bug that corrupted every real compensated analysis. It now applies
|
|
742
|
+
compensation to data with a known spillover baked in and reports two numbers
|
|
743
|
+
that cannot both be satisfied by a degenerate answer: `residual_err` (max
|
|
744
|
+
error against the true signal, relative to signal scale) and
|
|
745
|
+
`signal_retained` (which a "zero everything" implementation would fail while
|
|
746
|
+
flattering the residual). Verified to catch a transposed matrix, a zeroing
|
|
747
|
+
implementation, and a no-op. **Baseline keys changed**: `compensation.apc_leak`
|
|
748
|
+
is replaced by `compensation.residual_err` + `compensation.signal_retained`.
|
|
749
|
+
- **`--update` could bless a bug as the baseline.** Re-pinning rewrites every
|
|
750
|
+
golden value from the current run, so running it while a bug was live
|
|
751
|
+
recorded the breakage as correct — permanently. It now runs the response
|
|
752
|
+
controls first and REFUSES if any fail, since a relational control failing
|
|
753
|
+
means an output has stopped tracking its input and there is nothing
|
|
754
|
+
legitimate to re-pin. `--force` overrides for a verified intentional change.
|
|
755
|
+
- **Auto-gate proposals were fit on the display subsample, so they changed on
|
|
756
|
+
every app restart.** All three methods called `_get_df` with its default
|
|
757
|
+
`downsample=True`, which caps the frame at the SMALLEST loaded sample's size
|
|
758
|
+
and draws it with `random_state = hash((name, x, y, cap))`. Python randomises
|
|
759
|
+
`str` hashing per process, so the same file yielded a different gate each
|
|
760
|
+
launch — measured seeds for one identical expression across three processes:
|
|
761
|
+
1618484138 / 2221425230 / 432000015 (stable only under `PYTHONHASHSEED=0`).
|
|
762
|
+
Worse, loading a small compensation control alongside a 500k-event sample
|
|
763
|
+
collapsed the cap to the control's size, fitting the big sample on ~1% of its
|
|
764
|
+
events; `auto_singlet_gate`'s `frac_kept` / `ratio_cv`, which drive the
|
|
765
|
+
clean-vs-REVIEW verdict, described that subsample rather than the sample.
|
|
766
|
+
All three now fit on the full frame — `gmm_ellipse_gates` already does its
|
|
767
|
+
own seeded 20k draw, and the other two are cheap O(n) statistics that are
|
|
768
|
+
more accurate on complete data.
|
|
769
|
+
- **A cross-instance move could delete samples the destination never took.**
|
|
770
|
+
Pasting a staged move wrote the `<move_id>.done` completion marker the moment
|
|
771
|
+
the FCS loads were *queued*, and the source treats that marker as authority
|
|
772
|
+
to delete its copies. Any path the destination skipped or failed to load
|
|
773
|
+
still cost the source the sample AND its gate tree, while the destination
|
|
774
|
+
applied nothing. The common trigger was benign: sending a sample to a window
|
|
775
|
+
that already had that `.fcs` open, which the loader skips as "already
|
|
776
|
+
loaded" — and the skip notice was itself overwritten by "Pulling N
|
|
777
|
+
sample(s)", so the move looked successful. The marker is now written only
|
|
778
|
+
once every accepted path has landed and lists the PATHS actually taken
|
|
779
|
+
(names are per-instance; collision disambiguation renames them per window);
|
|
780
|
+
the source removes only those, reports what it kept, and treats an
|
|
781
|
+
unreadable marker as "took nothing" rather than deleting on ambiguous
|
|
782
|
+
evidence. Samples already open at the destination are left in place and
|
|
783
|
+
named in the status line.
|
|
784
|
+
- **A non-finite value no longer becomes a real, very negative measurement.**
|
|
785
|
+
The biexponential backends map every non-finite input to the BOTTOM of the
|
|
786
|
+
scale (-1.0) — `NaN`, `+inf` and `-inf` alike. That is silent corruption
|
|
787
|
+
twice over: a saturated reading is rendered and gated as the DIMMEST event in
|
|
788
|
+
the sample, and, worse, a `NaN` becomes a *finite* coordinate, which defeats
|
|
789
|
+
the `dropna` that protects the plot and the gate masks — so the event goes on
|
|
790
|
+
to count as a real measurement in every population, median and frequency.
|
|
791
|
+
`asinh` always propagated `NaN` correctly, so the behaviour also differed by
|
|
792
|
+
transform. `logicle` and `hyperlog` now match it. Finite values are
|
|
793
|
+
bit-identical, and the golden baseline is unchanged.
|
|
794
|
+
- **A non-dict session file no longer crashes startup.** The resume read was
|
|
795
|
+
guarded, but the `len(data.get('samples', []))` immediately after it was not
|
|
796
|
+
— so a file whose JSON top level is an array, string or number parsed fine
|
|
797
|
+
and then raised an uncaught `AttributeError` while the editor was still
|
|
798
|
+
starting. Latent (nothing in the tree writes such a file), but
|
|
799
|
+
`_find_resumable_session` appends the legacy `last_session.flowsession`
|
|
800
|
+
unconditionally, which is how a foreign-format file would arrive.
|
|
801
|
+
- **A refused resume no longer leaves the session directory pointing at it.**
|
|
802
|
+
`_session_dir` / `_session_data_dir` were assigned *before* the schema check,
|
|
803
|
+
so an autosave rejected as "written by a newer OpenFlo" left both addressing
|
|
804
|
+
the rejected file. They are now set only after the session is accepted.
|
|
805
|
+
- **`~/.openflo/transfer` is pruned.** A `<move_id>.done` marker is normally
|
|
806
|
+
consumed by the source instance, but not when that instance has exited or
|
|
807
|
+
cancelled the move — and nothing ever removed the leftovers, so they
|
|
808
|
+
accumulated for the life of the install. Now pruned after a day, mirroring
|
|
809
|
+
`_prune_autosaves`. Zero-byte files, so this is file-count hygiene rather
|
|
810
|
+
than space.
|
|
811
|
+
- **The FMO gating dialog states its precondition instead of raising.** Opened
|
|
812
|
+
without an active sample it did `editor._samples[None]` and died with a bare
|
|
813
|
+
`KeyError: None` from its constructor. The launcher already refuses in that
|
|
814
|
+
case, so this was defence in depth rather than a live path — found by a new
|
|
815
|
+
sweep that drives every dialog in a nearly-empty editor, which is where the
|
|
816
|
+
other two dialog crashers this cycle also lived.
|
|
817
|
+
- **One bad event no longer destroys a whole sample's spectral unmixing.**
|
|
818
|
+
`unmix` solves every event in a single least-squares factorisation, so a
|
|
819
|
+
single non-finite detector reading contaminated the entire solution: one
|
|
820
|
+
`inf` event turned **all** events' abundances into NaN. (A `NaN` event
|
|
821
|
+
happened to stay contained — an accident of how the SVD propagates, not a
|
|
822
|
+
guarantee.) Non-finite events are now held out and returned as NaN, the
|
|
823
|
+
remaining events solve exactly, and the count is logged. v2.2.3 hardened the
|
|
824
|
+
reference spectra against non-finite values; this is the matching guard on
|
|
825
|
+
the event data, which was overlooked. Non-finite reference spectra now also
|
|
826
|
+
raise a clear error instead of numpy's opaque "SVD did not converge".
|
|
827
|
+
- **`.h5ad` export was completely broken and nothing caught it.** Writing an
|
|
828
|
+
AnnData file raised `RuntimeError: allow_write_nullable_strings is False`
|
|
829
|
+
before producing anything: pandas 3.x backs string columns with
|
|
830
|
+
`StringArray`, and anndata >= 0.11 refuses to write those without an opt-in —
|
|
831
|
+
so the export failed on the observation index alone. Every use of Sample QC →
|
|
832
|
+
"Export AnnData (.h5ad)…" and of `openflo.write_h5ad` was affected. String
|
|
833
|
+
columns and indices are now coerced to plain object dtype, which is the
|
|
834
|
+
representation anndata has always written and does not depend on a global
|
|
835
|
+
setting or an anndata version. The gap was structural: `to_anndata` was
|
|
836
|
+
tested in memory while the file write had no coverage at all, so a value that
|
|
837
|
+
serialised correctly and one that could not be serialised looked identical.
|
|
838
|
+
There is now a real round-trip test — write a file, read it back with
|
|
839
|
+
anndata, and check the matrix, sample labels, cluster columns, marker names
|
|
840
|
+
and recorded `dropped_markers` all survive.
|
|
841
|
+
- **An unreadable audit entry no longer vanishes from the Methods paragraph.**
|
|
842
|
+
`methods_paragraph` builds text meant to be pasted into a manuscript, and it
|
|
843
|
+
silently skipped any audit entry that was not a record (and silently replaced
|
|
844
|
+
malformed `details` with an empty one). The result was a step the user
|
|
845
|
+
actually performed being absent from their published description of what they
|
|
846
|
+
did, with nothing saying so. The drop still happens — a corrupt record holds
|
|
847
|
+
nothing to recover — but it is now reported inside the paragraph itself,
|
|
848
|
+
where it cannot be published without being noticed. Clean trails produce
|
|
849
|
+
clean prose, unchanged.
|
|
850
|
+
- **`_normalise_groups` is now idempotent.** Normalising an already-normalised
|
|
851
|
+
group silently discarded per-sample FMO overrides: the first pass flattens
|
|
852
|
+
`[{'name': 'd', 'fmo_set': 'X'}, 'e']` to `['d', 'e']` and records the
|
|
853
|
+
override in `sample_fmo`, and a second pass — seeing plain strings —
|
|
854
|
+
overwrote that map with the group default. The sample would then be gated
|
|
855
|
+
against the WRONG FMO control, silently. No shipped path normalised twice, so
|
|
856
|
+
this was latent; but three call sites carry a comment telling the next
|
|
857
|
+
developer to "normalise first" (the fix for a different bug in the same
|
|
858
|
+
area), which actively invites a fourth. Overrides now survive a repeat pass,
|
|
859
|
+
while the group default still reaches samples that lack one.
|
|
860
|
+
- **Auto-gate now fits on the population it attaches to.** In Display=filter
|
|
861
|
+
mode `apply_gates_var` is set, so the frame `_get_df` returns is the union of
|
|
862
|
+
every ENABLED gate's chain — while `_add_gate` parents the result on the
|
|
863
|
+
SELECTED row, which is `None` (root) when a sample row is selected. Filtering
|
|
864
|
+
down to lymphocytes and running auto-gate therefore fit ellipses on
|
|
865
|
+
lymphocytes and attached them at root, where they also captured monocytes and
|
|
866
|
+
debris, with nothing reporting that the fit domain differed from the applied
|
|
867
|
+
domain. Auto-gate resolves the destination parent once and fits on exactly
|
|
868
|
+
that population. `_get_df` gained an explicit `gate_parent` argument for
|
|
869
|
+
callers that need a specific population rather than "what is on screen".
|
|
870
|
+
- **A failed autosave on exit is now reported instead of vanishing.** `_on_close`
|
|
871
|
+
detached the in-app console's log sinks and *then* wrote the autosave,
|
|
872
|
+
reporting failure with a `print()` into a sink nothing could receive, moments
|
|
873
|
+
before `destroy()`. Disk full at exit meant the session was lost, the next
|
|
874
|
+
launch had nothing to resume, and no message ever reached the user. The
|
|
875
|
+
autosave now runs before the sinks are detached and raises a dialog on
|
|
876
|
+
failure — the last moment the state still exists. The sidecar-failure flag
|
|
877
|
+
that `File → Save session` already warns about is honoured here too, so an
|
|
878
|
+
autosave that silently degraded to raw-FCS-only (losing clusters, embeddings
|
|
879
|
+
and FMO gates on resume) now says which samples were affected.
|
|
880
|
+
- **A second Send no longer strands the first batch's move flags.** Staging a
|
|
881
|
+
new move overwrote `_pending_move` without clearing the previous one, and
|
|
882
|
+
`_mark_pending_move` only ADDS to the flagged set while both clearing paths
|
|
883
|
+
clear the *new* batch's names — so the first batch's rows kept the ✄
|
|
884
|
+
pending-move indicator for the rest of the session, with two 700 ms poll
|
|
885
|
+
loops running in parallel. A new Send now supersedes the old one properly and
|
|
886
|
+
cancels its poll.
|
|
887
|
+
- **A gate replicated to other samples now measures the same population.**
|
|
888
|
+
With "-> all shown" on (the default), `_add_gate_multi` forced every replica
|
|
889
|
+
to `parent_id = None` while the active sample's copy kept the selected
|
|
890
|
+
parent. So with a parent gate selected, sample A got `Singlets` nested under
|
|
891
|
+
`Lymphocytes` and samples B..N got `Singlets` at ROOT — one gate name
|
|
892
|
+
measuring a different population per sample, which silently invalidates the
|
|
893
|
+
cross-sample frequency comparison the fan-out exists to enable. Replicas are
|
|
894
|
+
re-parented by population PATH (ids are per-sample and meaningless on a
|
|
895
|
+
target); a sample that genuinely lacks that population still gets a root
|
|
896
|
+
gate, but is now named in the status line instead of being quietly wrong.
|
|
897
|
+
- **Auto-gate provenance recorded only the active sample** while the proposal
|
|
898
|
+
was applied to every displayed one; the audit entry now carries the full
|
|
899
|
+
applied-to list.
|
|
900
|
+
- **FlowJo-comparison sweeps share one gate-mask cache.** `compare_wsp` walks
|
|
901
|
+
every population in a workspace against the same sample, each re-evaluating
|
|
902
|
+
its whole ancestor chain — the same redundancy already fixed in
|
|
903
|
+
`population_stats` and `_get_df`.
|
|
904
|
+
- **Sample QC's `_xy` was assigned only in `_compute`**, after `_D`, so an
|
|
905
|
+
exception between them left `_D` set and `_xy` missing while `_draw` read
|
|
906
|
+
both — the same class as the `ui_voltage._chan_lookup` landmine. Initialised
|
|
907
|
+
at construction and guarded in `_draw`.
|
|
908
|
+
- **Sample QC crashed when opened with nothing selected.** `_markers()` reads
|
|
909
|
+
`names[0]` and both of its callers invoked it *before* their own length
|
|
910
|
+
check, so an empty selection raised an unguarded `IndexError` inside an
|
|
911
|
+
`after()` callback — swallowed by Tk, leaving the window blank with no error.
|
|
912
|
+
- **Voltage dialog's `_chan_lookup`** was only assigned inside
|
|
913
|
+
`_populate_channels`, which returns early when the first FCS is unreadable;
|
|
914
|
+
`_selected_channel()` reads it and was safe only by call order. Now
|
|
915
|
+
initialised at construction.
|
|
916
|
+
|
|
917
|
+
### Changed
|
|
918
|
+
- **Population statistics are 5-7x faster on real gating hierarchies.**
|
|
919
|
+
`cumulative_gate_mask` gained opt-in memoisation, and `population_stats` now
|
|
920
|
+
shares one cache across the collection pass, so a gate reuses its parent's
|
|
921
|
+
cumulative mask instead of re-walking and re-evaluating the whole ancestor
|
|
922
|
+
chain. This matters because a polygon gate costs ~250x a threshold gate
|
|
923
|
+
(`Path.contains_points` over every event) and real hierarchies are
|
|
924
|
+
polygon-based: over 200k events a 32-gate panel drops from 1.82 s to 0.36 s
|
|
925
|
+
and a 60-gate panel from 4.72 s to 0.67 s. Masks are unchanged — verified
|
|
926
|
+
identical for nested trees, cycles, missing parents and injected overrides.
|
|
927
|
+
Callers that omit `cache` keep the previous allocate-fresh behaviour exactly.
|
|
928
|
+
- **Dragging a 1-D gate no longer rebuilds the gate tree on every mousemove.**
|
|
929
|
+
The line-gate drag and the histogram slider both rebuilt the whole
|
|
930
|
+
samples-and-gates tree per tick. That rebuild is O(total rows) across every
|
|
931
|
+
*loaded FCS file*, not just the gate being dragged — about 8 ms for a
|
|
932
|
+
20-file workspace with 30 gates each (621 rows), which at drag rate is
|
|
933
|
+
roughly half of wall-clock spent rebuilding a tree nobody was looking at.
|
|
934
|
+
Both now refresh once at end-of-drag, which is what every other drag kind
|
|
935
|
+
already did (and what the code's own comment said it did). The gate itself
|
|
936
|
+
still updates every tick, so the plot tracks the cursor as before.
|
|
937
|
+
- **Replotting is ~23% faster on a multi-file workspace.** `_get_df` OR-ed the
|
|
938
|
+
cumulative mask of every enabled gate, each re-walking and re-evaluating its
|
|
939
|
+
whole ancestor chain; it now shares one call-local cache. Profiling an 8-file
|
|
940
|
+
x 50k-event x 12-gate workspace, `_replot()` drops from 1034 ms to 797 ms and
|
|
941
|
+
gate-mask time halves. (Profiling also showed matplotlib's `tight_layout` and
|
|
942
|
+
`loc='best'` legend placement dominate what remains — see the note in
|
|
943
|
+
DESIGN_NOTES rather than a code change, since legend position is a visual
|
|
944
|
+
decision.)
|
|
945
|
+
- **New preference: legend position** (Preferences -> Appearance), and with it
|
|
946
|
+
a ~1.9x faster replot by default. matplotlib's automatic (`loc='best'`)
|
|
947
|
+
placement tests candidate corners against EVERY plotted point, so it costs
|
|
948
|
+
~1.1 s on 8 samples x 50k events — and it ran on every replot. Three modes:
|
|
949
|
+
*Automatic (reuse position)* — the new default — solves the placement once
|
|
950
|
+
per plot shape, remembers where it landed in axes coordinates and reuses it
|
|
951
|
+
until the axes or sample set change; *Automatic (recompute each time)* — the
|
|
952
|
+
previous behaviour; and *Always top-right* — cheapest, but can sit on top of
|
|
953
|
+
data. Measured on an 8-file x 50k-event x 12-gate workspace: 808 ms per
|
|
954
|
+
replot recomputing, 435 ms reusing, 440 ms fixed — so the default now costs
|
|
955
|
+
what a fixed corner costs while keeping the placement that automatic
|
|
956
|
+
mode chose.
|
|
957
|
+
- `make_compensation_controls` accepts a `leaks` mapping so the planted
|
|
958
|
+
spillover can be varied (or removed entirely) rather than being fixed.
|
|
959
|
+
- `immunophenotyping_sample(return_labels=True)` additionally returns the TRUE
|
|
960
|
+
per-event population, so a clustering result can be *scored* rather than
|
|
961
|
+
merely counted. The labels ride through the existing shuffle as an extra
|
|
962
|
+
column — a row-wise Fisher-Yates cannot observe a column, so the seeded RNG
|
|
963
|
+
stream is untouched and every generator output stays bit-identical (asserted
|
|
964
|
+
by a test, including the `batch_gain` and `fmo` paths). Reading this as a
|
|
965
|
+
ground truth revealed that the golden's `leiden_n = 18` against 6 true
|
|
966
|
+
populations is benign over-clustering, not error: homogeneity is 1.00, so the
|
|
967
|
+
extra clusters are pure sub-clusters that never mix populations.
|
|
968
|
+
|
|
969
|
+
## [2.3.0] - 2026-09-03
|
|
970
|
+
|
|
971
|
+
### Added
|
|
972
|
+
- **Theme audit harness** (`scripts/theme_audit.py`). Builds the editor under a
|
|
973
|
+
chosen theme on a withdrawn root, walks every widget, combobox popdown and
|
|
974
|
+
figure facecolor, and flags near-white surfaces — so a light-leaking widget is
|
|
975
|
+
caught mechanically rather than by eye. Wired into the test suite as a Midnight
|
|
976
|
+
no-white regression.
|
|
977
|
+
|
|
978
|
+
### Changed
|
|
979
|
+
- **Refreshed GUI chrome ("sleek").** The `light` / `dark` / `midnight` themes
|
|
980
|
+
are restyled to a hand-tuned, dependency-free look: a near-black / off-white
|
|
981
|
+
palette with a cool accent, flat surfaces, hairline borders tied to the
|
|
982
|
+
palette, more generous padding, muted headings and tabs, and accent-on-focus.
|
|
983
|
+
Check and radio indicators are flat and understated. Theme names and the
|
|
984
|
+
Preferences picker are unchanged.
|
|
985
|
+
- **Auto-clean now follows the standard gating order.** The doublet FSC-A/FSC-H
|
|
986
|
+
ratio median was computed over *all* events, debris included. When debris
|
|
987
|
+
cleaning is also enabled, the ratio window is now centred on the
|
|
988
|
+
debris-removed (cell-sized) population, matching the conventional hierarchy.
|
|
989
|
+
AND-of-methods semantics are unchanged. **This can shift doublet-gate results
|
|
990
|
+
on runs that enable both methods.**
|
|
991
|
+
- **The unclustered / noise bucket is kept and named everywhere.** The `-1`
|
|
992
|
+
sentinel (non-finite-channel events, PhenoGraph outliers, unassignable
|
|
993
|
+
sub-sample rows) previously surfaced as a bare `-1`, as a `C-1` bar label, or
|
|
994
|
+
was dropped outright depending on the path. It is now retained and labelled
|
|
995
|
+
"Unclustered (noise)" — via one shared `pipeline.cluster_label()` used by both
|
|
996
|
+
the pipeline stats and the GUI — across per-sample frequency exports, the
|
|
997
|
+
cluster heatmap, group-vs-group comparisons and the annotate dialog. Frequency
|
|
998
|
+
exports gained a leading `population` label column. Real clusters' counts and
|
|
999
|
+
percentages are unchanged. The imported noise population is drawn in a neutral
|
|
1000
|
+
grey so it does not read as a real population.
|
|
1001
|
+
- **FlowSOM and cell-cycle unassigned buckets** are likewise kept and labelled
|
|
1002
|
+
("Unassigned (noise)"; `NA` for cell cycle) instead of being silently dropped,
|
|
1003
|
+
and category populations use friendly names ("FlowSOM metacluster 3") rather
|
|
1004
|
+
than the raw storage token ("flowsom_meta 3").
|
|
1005
|
+
- **Workspace compensation column**: an item with no compensation is flagged
|
|
1006
|
+
with "⚠" alone, now clearly distinct from a group's neutral "—" (no override).
|
|
1007
|
+
- **Prism grouped export** keeps replicates with a missing row/column factor
|
|
1008
|
+
under an explicit `(unassigned)` level instead of dropping them from the table.
|
|
1009
|
+
- **Faster grouped runs.** Preparing a run unit copied every member's full event
|
|
1010
|
+
frame just to tag it with its group and sample — N large copies before the
|
|
1011
|
+
concatenation's own copy, a multi-second UI freeze on a grouped run. The tags
|
|
1012
|
+
are now applied once, after the concatenation. The output is identical and the
|
|
1013
|
+
source frames are no longer touched.
|
|
1014
|
+
|
|
1015
|
+
### Fixed
|
|
1016
|
+
- **Exported population `.fcs` files could contain the wrong events.** The
|
|
1017
|
+
cumulative-gate walk in the population export swallowed every exception, so a
|
|
1018
|
+
gate that failed to evaluate was silently dropped and the exported file held
|
|
1019
|
+
the *un-restricted superset* — silent data corruption in a file users take
|
|
1020
|
+
downstream. A failed gate now skips that population, and the skip is surfaced
|
|
1021
|
+
in the status line.
|
|
1022
|
+
- **A gate that errored admitted every event.** `_evaluate_gate_on` returned an
|
|
1023
|
+
all-True mask on failure, an inflated superset that read as success. It now
|
|
1024
|
+
fails closed (all-False), so the population empties visibly. Valid gates and
|
|
1025
|
+
the "channel missing → skip" paths are unaffected.
|
|
1026
|
+
- **A failed per-channel transform left that channel in raw scale** among its
|
|
1027
|
+
logicle siblings, so gates and plots compared against raw values. Failures are
|
|
1028
|
+
now recorded and logged prominently, listing the affected channels.
|
|
1029
|
+
- **Preferences could be wiped.** `write_pref` did a read-modify-write, and
|
|
1030
|
+
`read_prefs()` returns `{}` on *any* error — so a single transient file lock
|
|
1031
|
+
(antivirus, search indexer) persisted a one-key file, discarding every other
|
|
1032
|
+
setting. A read error now aborts the write instead of clobbering; only a
|
|
1033
|
+
missing or corrupt file resets. Writes are atomic.
|
|
1034
|
+
- **Sessions are written atomically.** A mid-write failure previously truncated
|
|
1035
|
+
the existing good session or autosave.
|
|
1036
|
+
- **Session sidecar failures were invisible.** A processed-data sidecar write
|
|
1037
|
+
failure was printed to stdout while the save reported clean success — the
|
|
1038
|
+
sample then silently reloaded from raw FCS with its clusters, UMAP and FMO
|
|
1039
|
+
gates gone. The save now warns and names the samples that lost computed
|
|
1040
|
+
results.
|
|
1041
|
+
- **Session display state was lost on a slow resume.** The restore was a single
|
|
1042
|
+
600 ms one-shot that cleared unconditionally, so any sample finishing later
|
|
1043
|
+
kept its default (unchecked) plot state and lost its saved axis. It now
|
|
1044
|
+
re-applies while samples are still loading.
|
|
1045
|
+
- **`PI3K` was mis-detected as the PI DNA stain.** The `pi` token boundary
|
|
1046
|
+
allowed an adjacent digit, so a PI3K signalling channel was auto-selected as
|
|
1047
|
+
the DNA channel.
|
|
1048
|
+
- **CLI `--gates` threshold overrides were silently dropped** for any group with
|
|
1049
|
+
an empty FMO set — that is, every by-day auto-group — while `--export-wsp`
|
|
1050
|
+
still recorded them, so the two paths disagreed. Overrides now apply to every
|
|
1051
|
+
sample. Malformed or incomplete gate dicts are reported rather than discarded
|
|
1052
|
+
in silence.
|
|
1053
|
+
- **CLI `--gates` help documented a form the parser rejects.** Copying the
|
|
1054
|
+
documented `{"BV421-A":0.5}` example ran the analysis **ungated**; the help now
|
|
1055
|
+
shows the accepted JSON-list form.
|
|
1056
|
+
- **`--export-wsp` resolved zero samples** for a comma-string or per-sample-dict
|
|
1057
|
+
`samples` spec (it iterated the string or dict). It now normalises groups
|
|
1058
|
+
first, honours each group's `trial_dir` so by-day filename collisions pick the
|
|
1059
|
+
right day's FCS, and allocates gate ids in two passes so a child-before-parent
|
|
1060
|
+
gate nests correctly instead of re-rooting. The panel probe had the same
|
|
1061
|
+
un-normalised-groups bug and silently dropped the panel.
|
|
1062
|
+
- **Compliance records verified as valid when a signed file was deleted** — the
|
|
1063
|
+
missing file was dropped from the checked set, and `all([])` is `True`. The
|
|
1064
|
+
check is now fail-closed.
|
|
1065
|
+
- **KDE density crashed** on a single-event or constant-channel gate (it had no
|
|
1066
|
+
degenerate-input guard); it now falls back to flat density so the scatter still
|
|
1067
|
+
draws.
|
|
1068
|
+
- **t-SNE was silently skipped for 3–5 events** — the perplexity clamp floor of
|
|
1069
|
+
5.0 exceeded *n*, so the call still raised.
|
|
1070
|
+
- **Stale grouping in the frequency dialog.** The Group-by combo, Tokens entry
|
|
1071
|
+
and Parametric checkbox did not trigger a rebuild, so "Diff. abundance…" and
|
|
1072
|
+
"Compare all…" could run the GLM on the previous grouping while the UI showed a
|
|
1073
|
+
new one.
|
|
1074
|
+
- **All-NaN channels produced garbage p-values** presented as valid in group
|
|
1075
|
+
stats; non-finite values are now filtered before the per-group median.
|
|
1076
|
+
- **A clustering worker could be read mid-write.** The plot redraw could observe
|
|
1077
|
+
sample DataFrames while a clustering or embedding worker mutated them off the
|
|
1078
|
+
Tk thread (transient `KeyError` / torn read); the redraw now defers until the
|
|
1079
|
+
run completes.
|
|
1080
|
+
- **Undo could not revert a gate toggle.** Toggling a gate or auto-clean method's
|
|
1081
|
+
enabled checkbox changes real gating output but took no undo snapshot.
|
|
1082
|
+
- **A 1-D gate re-commit silently re-enabled a disabled gate** or dropped its
|
|
1083
|
+
name — the replace path preserved only the colour, not the enabled/name/open
|
|
1084
|
+
state.
|
|
1085
|
+
- **FlowJo comparison could read as valid on uncompensated data.** A
|
|
1086
|
+
compensation failure left every gate evaluated against uncompensated events
|
|
1087
|
+
with no per-row flag; rows now carry an explicit "uncompensated" error.
|
|
1088
|
+
- **Sample-distance MDS collapsed onto the origin.** An undefined pair (no shared
|
|
1089
|
+
usable markers) scored 0.0, which reads as "identical"; it is now `NaN`, and
|
|
1090
|
+
the embedding places those samples at the edge while still returning finite
|
|
1091
|
+
coordinates.
|
|
1092
|
+
- **AnnData and distance-matrix exports** use the shared-marker intersection
|
|
1093
|
+
consistently (heterogeneous samples no longer raise), record which markers were
|
|
1094
|
+
omitted (in `adata.uns['dropped_markers']`) instead of silently shrinking the
|
|
1095
|
+
panel, and add a labelled sibling `obs` column alongside the raw integer id.
|
|
1096
|
+
- **Cross-platform paths and encoding.** Session `rel_path` and `processed_csv`
|
|
1097
|
+
are stored forward-slash and normalised on read, so a Windows-authored
|
|
1098
|
+
`.flowsession` relinks its FCS on Linux and macOS; the voltage-titration CSV is
|
|
1099
|
+
opened as UTF-8 (it was the only text `open()` without it, giving cp1252
|
|
1100
|
+
mojibake on non-ASCII channel names); the watch-folder seen-set is keyed on the
|
|
1101
|
+
exact filename, so two case-distinct files no longer collapse on Linux.
|
|
1102
|
+
- **A legacy workspace with deletion gaps could overwrite an existing item** —
|
|
1103
|
+
the `_mseq` / `_gseq` fallback resumed from the item *count* rather than the
|
|
1104
|
+
maximum id suffix, re-minting a live id.
|
|
1105
|
+
- **Frequency and heatmap exports were hardcoded to the `cluster` column**, so
|
|
1106
|
+
Leiden and FlowSOM label columns got no noise-labelled export; both now take a
|
|
1107
|
+
`label_col` (the default preserves the previous behaviour).
|
|
1108
|
+
- **Smaller dialog and export guards**: Log scale with Min ≤ 0 is rejected
|
|
1109
|
+
instead of producing an empty axis; the FMO percentile is validated in [0, 100]
|
|
1110
|
+
up front (out of range previously surfaced as a misleading "nothing added");
|
|
1111
|
+
quadrant counts guard an empty frame (`ZeroDivisionError`); a 0.0 annotation
|
|
1112
|
+
score with a non-positive threshold no longer raises `KeyError`; a zero-drop
|
|
1113
|
+
gate no longer shows a "drops 0" suffix; non-separable voltage SI/rCV render
|
|
1114
|
+
`n/a` rather than a bare `nan` in both stdout and the CSV; `write_fcs` logs a
|
|
1115
|
+
count of the non-finite cells it zeroed; and a group comparison that produced
|
|
1116
|
+
no rows now says so instead of writing nothing silently.
|
|
1117
|
+
- **Theming completeness.** About 25 hint, status and caption labels moved from
|
|
1118
|
+
hardcoded greys to a palette-tracked `Muted.TLabel` token (dark greys were
|
|
1119
|
+
invisible on the dark panel); free matplotlib artists that the figure theming
|
|
1120
|
+
cannot reach — significance brackets, empty-state text, FlowSOM spokes, and the
|
|
1121
|
+
gating-tree diagram's connectors and node labels — now take theme-correct ink;
|
|
1122
|
+
Treeview bad/warn row tints are theme-matched (previously an unreadable light
|
|
1123
|
+
band under Midnight); a colourless gate row no longer renders black-on-dark;
|
|
1124
|
+
and the populations context menu, the voltage dialog's initial paint and the
|
|
1125
|
+
progress-bar trough no longer leak light. A live theme switch now re-themes
|
|
1126
|
+
already-open dialogs instead of leaving them on the old palette.
|
|
1127
|
+
- **Space utilisation.** The frequency summary gained a vertical scrollbar — long
|
|
1128
|
+
multi-group / BH-pairwise output was previously truncated invisibly in a fixed
|
|
1129
|
+
six-line box — and the compensation spillover matrix stretches to the canvas
|
|
1130
|
+
width instead of jamming into the top-left corner.
|
|
1131
|
+
- **A closed tool window retained its figure and cached DataFrame** until it was
|
|
1132
|
+
reopened; the registry slot is now released on destroy.
|
|
1133
|
+
- **A long-lived log-drain timer** on a destroyed window could reschedule itself
|
|
1134
|
+
forever (a latent leak).
|
|
1135
|
+
- Corrected 14 docstrings and comments that contradicted the code — including
|
|
1136
|
+
`gate_to_mask`'s missing-channel behaviour (all-True for geometric gates,
|
|
1137
|
+
all-False for cluster and category gates), `WspWriter`'s round-trip scope (only
|
|
1138
|
+
boolean is out of scope), the compensation editor's four-step auto-import order
|
|
1139
|
+
and non-modal behaviour, `multi_group_test`'s Friedman truncation, and
|
|
1140
|
+
`_sample_group_label`'s longest-match token rule.
|
|
1141
|
+
|
|
1142
|
+
### Removed
|
|
1143
|
+
- The unused `tqdm` dependency, which was declared as a core requirement but
|
|
1144
|
+
never imported.
|
|
1145
|
+
|
|
1146
|
+
### Security
|
|
1147
|
+
- **FlowJo `.wsp` parsing rejects a DTD or `ENTITY` declaration** before parsing,
|
|
1148
|
+
closing the stdlib ElementTree entity-expansion denial of service ("billion
|
|
1149
|
+
laughs" / quadratic blowup). No XXE file read was possible, and no new
|
|
1150
|
+
dependency was added.
|
|
1151
|
+
- **Session relink is contained to the session directory.** `processed_csv` is
|
|
1152
|
+
always app-generated and relative, so absolute or `..`-escaping values are now
|
|
1153
|
+
rejected, closing an arbitrary-file-read vector. `rel_path` still permits
|
|
1154
|
+
legitimate cross-directory projects but warns when it resolves outside the
|
|
1155
|
+
session tree.
|
|
1156
|
+
|
|
1157
|
+
## [2.2.4] - 2026-07-02
|
|
1158
|
+
|
|
1159
|
+
### Changed
|
|
1160
|
+
- **Test-coverage hardening — no runtime changes.** A ~40-test sweep from a
|
|
1161
|
+
"looks-tested-but-core-untested" audit now pins the *core behavior* of
|
|
1162
|
+
components that previously had only periphery coverage (parse / IO round-trip /
|
|
1163
|
+
sign / monotonicity) — the same class of gap that let the 2.2.1
|
|
1164
|
+
compensation-transpose bug ship. Newly pinned: FCS per-cell row pairing +
|
|
1165
|
+
external-read fallback, gate interval + multi-gate AND masks +
|
|
1166
|
+
cumulative/override semantics, density event x/y alignment + KDE-vs-SciPy +
|
|
1167
|
+
smoothing floor, spectral autofluorescence + bright-event selection + detector
|
|
1168
|
+
ordering, effect-size magnitude, workspace↔FlowJo comparison arithmetic + XML
|
|
1169
|
+
gate parsing + inventory walk, clustering purity + subsample assignment +
|
|
1170
|
+
non-finite handling, trajectory geodesic property + bin means, CytoNorm
|
|
1171
|
+
per-metacluster proportion preservation + QC magnitude, calibration r² + peak
|
|
1172
|
+
fallbacks, voltage stats (robust CV, arcsinh split, channel resolution). **No
|
|
1173
|
+
source changed; every closure confirmed the existing code correct (zero bugs
|
|
1174
|
+
found).** The compensation-transpose shape of bug would now be caught across
|
|
1175
|
+
the codebase.
|
|
1176
|
+
|
|
1177
|
+
## [2.2.3] - 2026-07-02
|
|
1178
|
+
|
|
1179
|
+
### Fixed
|
|
1180
|
+
- **Defensive robustness hardening (code-review sweep).** A batch of guards that
|
|
1181
|
+
turn edge-case crashes / silent-wrong results into graceful handling:
|
|
1182
|
+
- Compensation: a singular spillover matrix is skipped with a warning instead
|
|
1183
|
+
of raising; compensation-QC rejects non-finite (NaN/inf) matrices; a
|
|
1184
|
+
malformed FCS `$SPILL` keyword is reported + ignored rather than silently
|
|
1185
|
+
read as "no compensation".
|
|
1186
|
+
- Spectral unmixing: non-finite events in a single-stain control no longer
|
|
1187
|
+
poison its reference spectrum; a zero-norm spectrum reads self-similarity 1.
|
|
1188
|
+
- CytoNorm: clear errors for `n_quantiles < 2` and for a batch missing a
|
|
1189
|
+
normalized channel (were cryptic downstream crashes).
|
|
1190
|
+
- Session restore: a corrupt per-channel display range is skipped instead of
|
|
1191
|
+
aborting the whole load; auto-saved sessions are schema-migrated on resume
|
|
1192
|
+
(same as a manual open).
|
|
1193
|
+
- FlowJo import: gates on natively-compensated channels (FlowJo's `<PE-A>`
|
|
1194
|
+
bracket form) now match the data instead of selecting everything; the
|
|
1195
|
+
region/CLI gate filter evaluates all gate kinds (ellipsoid / cluster /
|
|
1196
|
+
category / boolean), not just lines and polygons.
|
|
1197
|
+
- Auto-clean / cell-cycle: no more spurious divide-by-zero warnings; the
|
|
1198
|
+
cell-cycle width-channel lookup matches lowercase `-w`/`-h` columns.
|
|
1199
|
+
|
|
1200
|
+
## [2.2.2] - 2026-07-02
|
|
1201
|
+
|
|
1202
|
+
### Fixed
|
|
1203
|
+
- **Compensation-QC "strong pairs"** now includes spillover exactly at the 0.10
|
|
1204
|
+
threshold (a strict comparison previously excluded it).
|
|
1205
|
+
- **Spectral unmixing condition number** reports `inf` for an underdetermined
|
|
1206
|
+
panel (more fluorophores than detectors) instead of a misleadingly-small value.
|
|
1207
|
+
- **Differential abundance** proportions use each sample's true total event count
|
|
1208
|
+
as the library size; nested/overlapping populations previously inflated it and
|
|
1209
|
+
understated the displayed percentages.
|
|
1210
|
+
- **Workspace ↔ FlowJo comparison** no longer collapses populations that share a
|
|
1211
|
+
name (e.g. quadrant Q1–Q4, copied gates) — each is compared against its own gate.
|
|
1212
|
+
- **CytoNorm** models whose batch label contains `|` (e.g. a POSIX path) reload
|
|
1213
|
+
correctly instead of failing.
|
|
1214
|
+
- **Batch run outputs** no longer silently overwrite one another when two run
|
|
1215
|
+
units sanitise to the same filename (colliding labels are suffixed); a failed
|
|
1216
|
+
subprocess launch no longer leaks a temp directory.
|
|
1217
|
+
- **Friedman test** no longer suppresses a perfectly-concordant (maximally
|
|
1218
|
+
significant) result.
|
|
1219
|
+
- **Histogram (symlog view)**: bin spacing and the axis scale now share one
|
|
1220
|
+
anchor, removing uneven bins.
|
|
1221
|
+
|
|
1222
|
+
## [2.2.1] - 2026-07-01
|
|
1223
|
+
|
|
1224
|
+
### Fixed
|
|
1225
|
+
- **Compensation was applying the transposed inverse spillover.** The internal
|
|
1226
|
+
apply step computed `data @ inv(M).T` instead of `data @ inv(M)`, which left
|
|
1227
|
+
asymmetric spillover uncorrected and corrupted otherwise-clean channels —
|
|
1228
|
+
affecting every compensated dataset with a non-symmetric spillover matrix (i.e.
|
|
1229
|
+
essentially all real data). **This is a correctness fix: compensated values,
|
|
1230
|
+
and everything derived from them (transforms, clustering, gating, exported
|
|
1231
|
+
populations), will change — for the better.** Re-run compensation on affected
|
|
1232
|
+
analyses; the stored spillover matrix itself is unchanged, so sessions reload
|
|
1233
|
+
fine and simply recompute.
|
|
1234
|
+
- **Differential abundance could report enrichment backwards.** The results table
|
|
1235
|
+
labelled the two groups (and the log2FC sign) by sample-load order while the
|
|
1236
|
+
GLM fitted them in the count matrix's (alphabetical) column order; the table
|
|
1237
|
+
now matches the fitted direction.
|
|
1238
|
+
- **Histogram highlight mode could crash** (`ValueError`) when highlighting two or
|
|
1239
|
+
more gates/samples at once.
|
|
1240
|
+
|
|
1241
|
+
## [2.2.0] - 2026-07-01
|
|
1242
|
+
|
|
1243
|
+
### Added
|
|
1244
|
+
- **Reproducible clustering.** PhenoGraph's default Louvain community detection
|
|
1245
|
+
is not seed-reproducible (its community binary is time-seeded), so cluster
|
|
1246
|
+
labels could differ run-to-run. A new opt-in **Reproducible** mode routes
|
|
1247
|
+
PhenoGraph to its seeded Leiden backend so a re-run gives identical clusters —
|
|
1248
|
+
available in the Cluster dialog, the Pipeline Workspace Run bar, and the CLI
|
|
1249
|
+
(`--reproducible`). Off by default, so default Louvain results are unchanged;
|
|
1250
|
+
Leiden, FlowSOM, and all embeddings were already deterministic.
|
|
1251
|
+
|
|
1252
|
+
### Fixed
|
|
1253
|
+
- **FlowJo import — max-only 1-D gates are no longer dropped.** A `RectangleGate`
|
|
1254
|
+
with only an upper bound (`x < hi`, no min) was silently discarded and its
|
|
1255
|
+
child populations re-parented to the grandparent, quietly loosening every
|
|
1256
|
+
descendant; it now imports as a bounded interval, keeping the constraint.
|
|
1257
|
+
- **Quadrant gates tile the plane.** Events landing exactly on a divider (e.g. a
|
|
1258
|
+
value at 0 on an arcsinh axis) fell into no quadrant; the four quadrants are
|
|
1259
|
+
now a true partition.
|
|
1260
|
+
- **Gate editor — invisible handles are no longer grabbable.** In histogram mode
|
|
1261
|
+
(or for a degenerate polygon) a 2-D gate's hidden handles could be dragged,
|
|
1262
|
+
silently corrupting its bounds; hit-testing is now tied to what's actually
|
|
1263
|
+
drawn. Quadrant shift-click-add also honors compound modifiers (e.g. Ctrl+Shift).
|
|
1264
|
+
- **Auto-clean — freezing a valley-mode debris gate keeps granulocytes.** Freezing
|
|
1265
|
+
or copying it collapsed the 2-D FSC×SSC rescue into a 1-D floor, dropping
|
|
1266
|
+
low-FSC/high-SSC granulocytes; it now pins both thresholds and reproduces the
|
|
1267
|
+
full 2-D cut identically across samples.
|
|
1268
|
+
- **Autosave no longer leaks disk.** Orphaned autosave `_data` sidecar folders
|
|
1269
|
+
(processed event-table CSVs) were never pruned; they are now removed alongside
|
|
1270
|
+
their session file.
|
|
1271
|
+
- **Batch runs report empty clustering as a failure.** A FlowSOM/Leiden run below
|
|
1272
|
+
its event floor produced no labels yet was reported as a successful 0-cluster
|
|
1273
|
+
run; it is now surfaced as an error.
|
|
1274
|
+
- **Loader no longer grows `sys.path`.** Each FCS load prepended a duplicate path
|
|
1275
|
+
entry from the worker threads; the redundant insert was removed.
|
|
1276
|
+
|
|
1277
|
+
## [2.1.0] - 2026-07-01
|
|
1278
|
+
|
|
1279
|
+
### Added
|
|
1280
|
+
- **Portable session relink.** Saved sessions now record each sample's file
|
|
1281
|
+
basename and a path relative to the session file, so a project that's been
|
|
1282
|
+
moved, copied, or opened on another machine re-finds its raw FCS once it sits
|
|
1283
|
+
beside the `.flowsession` — resolving stored absolute path → session-relative
|
|
1284
|
+
path → basename in the session folder. Additive to the session format (older
|
|
1285
|
+
sessions keep opening; the fields live inside each sample entry).
|
|
1286
|
+
- **Choose clustering markers.** The Cluster dialog now has a marker picker that
|
|
1287
|
+
restricts PhenoGraph / Leiden / FlowSOM to a chosen subset of fluorochrome
|
|
1288
|
+
channels; leaving all selected keeps the previous "all markers" behaviour.
|
|
1289
|
+
- **Cell-cycle gating controls.** The cell-cycle dialog exposes the doublet-cut
|
|
1290
|
+
strength (`k`) and the singlet-tolerance window, which were previously fixed.
|
|
1291
|
+
|
|
1292
|
+
### Changed
|
|
1293
|
+
- **Tighter self-test reproducibility contract.** The golden self-test
|
|
1294
|
+
tolerances were tightened to match each metric's real reproducibility — the
|
|
1295
|
+
bit-exact metrics (auto-clean debris/doublets and the compensation spill) to
|
|
1296
|
+
exact, and MESF calibration slope/R² to 1e-3 — so behavioural drift is caught
|
|
1297
|
+
far sooner. Desktop and web baselines are kept in lockstep. Platform-variable
|
|
1298
|
+
metrics (viability, Leiden count) intentionally stay loose.
|
|
1299
|
+
|
|
1300
|
+
## [2.0.1] - 2026-07-01
|
|
1301
|
+
|
|
1302
|
+
### Fixed
|
|
1303
|
+
- **Compensated samples now reattach correctly on session reopen.** A sample
|
|
1304
|
+
that was compensated but not clustered/embedded wasn't persisted to the
|
|
1305
|
+
processed-data sidecar, so reopening a session reloaded the raw (uncompensated)
|
|
1306
|
+
FCS and any gate/population drawn in compensated space selected the wrong
|
|
1307
|
+
events. Compensated samples are now persisted like any other computed result.
|
|
1308
|
+
|
|
1309
|
+
## [2.0.0] - 2026-06-30
|
|
1310
|
+
|
|
1311
|
+
### Added
|
|
1312
|
+
- **Vendor-portable GPU acceleration (PyTorch backend).** The opt-in load-math
|
|
1313
|
+
acceleration (compensation matmul, logicle LUT interp, arcsinh) now runs on a
|
|
1314
|
+
PyTorch backend alongside CuPy, reaching **AMD (ROCm), Intel (XPU), Apple
|
|
1315
|
+
(MPS), and any Direct3D-12 GPU on Windows via DirectML** — not just NVIDIA.
|
|
1316
|
+
Backend preference `auto|cupy|torch|off` (env `OPENFLO_GPU_BACKEND`);
|
|
1317
|
+
Preferences shows the detected device. Extras: `[gpu-torch]`, `[gpu-dml]`.
|
|
1318
|
+
Still OFF by default with the exact numpy fallback, so the golden baseline is
|
|
1319
|
+
unchanged.
|
|
1320
|
+
- **RAPIDS 26.06 GPU-clustering image.** `docker/Dockerfile.rapids` moves to the
|
|
1321
|
+
RAPIDS 26.06 base, which ships numpy 2.4.6 (our exact pin); cuDF/cuML/cuGraph
|
|
1322
|
+
import, `leidenalg==0.11.0` keeps Leiden golden-exact, golden 7/7 in-container.
|
|
1323
|
+
pandas 3.0.3 remains an upstream cuDF wall (documented in `docs/RAPIDS_SHIM.md`).
|
|
1324
|
+
- **Configurable loader concurrency + priority.** The background file-load pool
|
|
1325
|
+
now sizes itself from your CPU & RAM (instead of a fixed 2), with an override
|
|
1326
|
+
in **Edit ▸ Preferences ▸ Performance** (“Concurrent file loaders”, Auto or
|
|
1327
|
+
1–8, persisted). The pool is a priority queue: the active / first-rendered
|
|
1328
|
+
sample loads first so its plot appears soonest, and loader threads run at
|
|
1329
|
+
lower OS priority so a big batch doesn't starve the UI.
|
|
1330
|
+
|
|
1331
|
+
### Changed
|
|
1332
|
+
- **Loading feedback now covers session resume, not just Add-FCS.** Reopening a
|
|
1333
|
+
session paints a muted “⏳ name” row for *every* sample up front (grouped by
|
|
1334
|
+
trial) before any file is read, so a large session fills the tree immediately
|
|
1335
|
+
instead of looking frozen. Processed samples (workspace results with
|
|
1336
|
+
clusters/UMAP) now load on the same background pool as raw FCS — the window
|
|
1337
|
+
stays responsive even with large sidecars — each row swapping to its real
|
|
1338
|
+
entry as it lands. Still-loading rows prefer the trial recorded in the session.
|
|
1339
|
+
|
|
1340
|
+
### Fixed
|
|
1341
|
+
- **Auto-clean now applies to every selected sample**, not just the
|
|
1342
|
+
active/last-selected one.
|
|
1343
|
+
- **Toggling an auto-clean gate/method refreshes the cleaned-out-events overlay**
|
|
1344
|
+
— previously, in “all” display mode it only redrew gate lines, leaving the red
|
|
1345
|
+
removed-events dots stale.
|
|
1346
|
+
- **Right-click menu rendering on dark themes** — the context menu is now themed
|
|
1347
|
+
(incl. `disabledforeground`), so a greyed-out Paste no longer looks garbled.
|
|
1348
|
+
- **Auto-clean removal is clearer** — the menu item reads “Remove auto-clean
|
|
1349
|
+
gate”, and the debris method row shows its effective cut
|
|
1350
|
+
(`[beads]` / `[valley]` / `[beads→valley: no bead file]`) so switching modes
|
|
1351
|
+
with no bead file in the run isn't a silent no-op.
|
|
1352
|
+
|
|
1353
|
+
## [1.6.0] - 2026-06-25
|
|
1354
|
+
|
|
1355
|
+
### Added
|
|
1356
|
+
- **Help ▸ Run diagnostics…** — an install health check for when something
|
|
1357
|
+
behaves oddly or an install looks corrupted. Reports whether core
|
|
1358
|
+
dependencies match their pinned versions, which optional engines are present,
|
|
1359
|
+
and whether the seeded behavioural self-test still reproduces the golden
|
|
1360
|
+
baseline. It runs in a *separate* process, so a genuinely broken install (or a
|
|
1361
|
+
native-library crash) is reported instead of taking the editor down. Also
|
|
1362
|
+
available standalone for when the GUI won't start: `openflo-doctor`,
|
|
1363
|
+
`python -m openflo.diagnostics` (`--json` / `--quick`), or
|
|
1364
|
+
`scripts/diagnose.bat` / `scripts/diagnose.sh`.
|
|
1365
|
+
|
|
1366
|
+
### Changed
|
|
1367
|
+
- **Faster startup.** The Pipeline Workspace panel (~100 ms to build) is now
|
|
1368
|
+
constructed lazily on first reveal instead of eagerly at window open, so a
|
|
1369
|
+
session that never opens it doesn't pay for it.
|
|
1370
|
+
- **Loading feedback for multi-file / large loads.** Every queued FCS now shows
|
|
1371
|
+
as a muted “⏳ name” row in the samples tree *immediately* and stays there
|
|
1372
|
+
until that file finishes — previously the placeholders were wiped on the first
|
|
1373
|
+
file's load and samples popped in one-by-one, which made a big load look
|
|
1374
|
+
stalled. The first sample still renders as soon as it's ready; the rest remain
|
|
1375
|
+
visibly “loading” (with the existing N/N progress bar) until each lands.
|
|
1376
|
+
|
|
1377
|
+
### Tested
|
|
1378
|
+
- New `tests/test_editor_mixins.py` guards the editor mixin decomposition:
|
|
1379
|
+
every `editor_*` module imports standalone, every `*Mixin` is actually mixed
|
|
1380
|
+
into `ViewGateEditorWindow`, no two mixins shadow each other's method names,
|
|
1381
|
+
and the `gui` back-compat re-exports stay present. New `tests/test_diagnostics.py`
|
|
1382
|
+
covers the health check.
|
|
1383
|
+
|
|
1384
|
+
## [1.5.0] - 2026-06-25
|
|
1385
|
+
|
|
1386
|
+
### Changed
|
|
1387
|
+
- **Internal: `ViewGateEditorWindow` fully decomposed into mixins (no behaviour
|
|
1388
|
+
change).** The remaining ~190 editor methods moved out of `gui.py` into 24
|
|
1389
|
+
focused `editor_*` mixin modules (analysis, tools, autoclean, autogate,
|
|
1390
|
+
clipboard, update, figure, slider, template, drag-drop, chrome, gate-tools,
|
|
1391
|
+
compute, stats, populations, load-pool, downsample, mode, grouping, channels,
|
|
1392
|
+
lifecycle, audit; plot renderers folded into `PlotMixin`, undo checkpoints
|
|
1393
|
+
into `UndoMixin`). All subclass the shared `editor_base.EditorMixin`. `gui.py`
|
|
1394
|
+
is now ~1.8k lines (was ~7.4k): the constructor, two class-attribute
|
|
1395
|
+
classmethods, and tooltip glue. `messagebox`/`filedialog`/`_LOAD_POOL_SIZE`
|
|
1396
|
+
are re-exported from `gui` for back-compat. Pyright 0 errors, ruff clean, full
|
|
1397
|
+
suite green (745 passed), golden baseline 7/7.
|
|
1398
|
+
|
|
1399
|
+
## [1.4.5] - 2026-06-25
|
|
1400
|
+
|
|
1401
|
+
### Changed
|
|
1402
|
+
- **Internal: editor decomposition continues (no behaviour change).** The
|
|
1403
|
+
log/console pane and the Help-menu dialogs moved out of `ViewGateEditorWindow`
|
|
1404
|
+
into `editor_console` / `editor_help` mixins. (Further editor mixins in
|
|
1405
|
+
progress.)
|
|
1406
|
+
|
|
1407
|
+
## [1.4.4] - 2026-06-25
|
|
1408
|
+
|
|
1409
|
+
### Changed
|
|
1410
|
+
- **Internal: `gui.py` decomposed (no behaviour change).** The ~16k-line GUI
|
|
1411
|
+
monolith was reduced ~26% by extracting pure logic and self-contained
|
|
1412
|
+
windows into focused modules: `ui_logic`, `gating`, `tree_ids`, `plotmath`,
|
|
1413
|
+
`density`, `scales`, `paths` (pure, headless-tested), `prefs` + `theme`
|
|
1414
|
+
(shared palette/figure/prefs helpers), and ~24 `ui_*.py` dialog/window
|
|
1415
|
+
modules. Dialog modules now depend on the small shared modules rather than
|
|
1416
|
+
importing the whole GUI — faster imports, cleaner dependency graph. Pinned
|
|
1417
|
+
ruff/pyright so local and CI lint identically.
|
|
1418
|
+
|
|
1419
|
+
### Fixed
|
|
1420
|
+
- Voltage-optimization dialog rendered a white plot on first open under the
|
|
1421
|
+
Midnight theme (now themed at build time).
|
|
1422
|
+
- Compensation-matrix values were near-illegible on Midnight (theme-aware
|
|
1423
|
+
cell colours; zeros muted, used values carry the header colour).
|
|
1424
|
+
|
|
1425
|
+
## [1.4.3] - 2026-06-25
|
|
1426
|
+
|
|
1427
|
+
### Fixed
|
|
1428
|
+
- **Help → Environment no longer freezes the UI.** The engine probe used to
|
|
1429
|
+
*import* each backend (umap/phate/… are slow to import) on the Tk thread; it
|
|
1430
|
+
now checks presence with `find_spec` and reads versions from metadata — no
|
|
1431
|
+
heavy import. Git SHA for the provenance stamp is cached too.
|
|
1432
|
+
- **Pop-up figures honour the Midnight theme.** Voltage, Trajectory, and the
|
|
1433
|
+
other analysis dialogs rendered a white plot under the dark Midnight theme;
|
|
1434
|
+
dark pop-ups now follow either the "Dark figures in pop-ups" toggle *or* the
|
|
1435
|
+
Midnight theme.
|
|
1436
|
+
- **Display modes greyed out without real gates.** "Highlight gated" / "Filter
|
|
1437
|
+
to gated" are disabled (and a stale selection falls back to "All events")
|
|
1438
|
+
when the active sample has no positive gates, so they can't keep drawing
|
|
1439
|
+
gates that were deleted. Auto-clean gates don't count as real gates.
|
|
1440
|
+
|
|
1441
|
+
### Added
|
|
1442
|
+
- **Help → Environment** — lists which analysis engines (FlowIO, UMAP,
|
|
1443
|
+
PhenoGraph, Leiden, TriMap, PaCMAP, PHATE, AnnData, drag-and-drop, …) are
|
|
1444
|
+
installed, with version and a copy-paste `pip install 'openflo[extra]'` hint
|
|
1445
|
+
for anything missing — so a greyed-out method or skipped run is explained.
|
|
1446
|
+
- **Provenance footer on exported figures** — every saved figure carries a
|
|
1447
|
+
subtle `OpenFlo <version> (<git sha>)` stamp for reproducible, paper-ready
|
|
1448
|
+
output. Toggle in Edit → Preferences → Export.
|
|
1449
|
+
|
|
1450
|
+
## [1.4.2] - 2026-06-24
|
|
1451
|
+
|
|
1452
|
+
### Added
|
|
1453
|
+
- **Session format versioning + auto-migration.** `.flowsession` files now
|
|
1454
|
+
carry a schema version; opening an older one auto-upgrades it (with a status
|
|
1455
|
+
note), and one written by a newer OpenFlo is refused rather than mis-read.
|
|
1456
|
+
New **File → Upgrade saved session…** and a headless
|
|
1457
|
+
`scripts/migrate_session.py` upgrade files without opening them.
|
|
1458
|
+
- **Save-format continuity test** (`tests/test_session_continuity.py`) locks
|
|
1459
|
+
the session schema (keys + version) against `openflo.session_format`, so a
|
|
1460
|
+
downstream-visible format change fails the suite until it's made
|
|
1461
|
+
intentional: bump the version, add a migration, and note it here.
|
|
1462
|
+
- **Newer-version alerts for workspaces & recipes.** Saved workspaces and run
|
|
1463
|
+
recipes now record the OpenFlo version that wrote them; loading one produced
|
|
1464
|
+
by a newer build (newer schema or newer app version) warns that some
|
|
1465
|
+
features may not load and suggests updating OpenFlo (Help → Check for
|
|
1466
|
+
updates…).
|
|
1467
|
+
|
|
1468
|
+
## [1.4.1] - 2026-06-24
|
|
1469
|
+
|
|
1470
|
+
### Added
|
|
1471
|
+
- **Keyboard shortcuts across the gating loop** — `Ctrl+F` find,
|
|
1472
|
+
`Ctrl+0` reset view, `Ctrl++`/`Ctrl+-` zoom, `F5` replot, `Esc` cancel zoom
|
|
1473
|
+
tool, `Ctrl+1/2/3` display mode (all / highlight / filter), `Ctrl+,`
|
|
1474
|
+
Preferences, `Ctrl+Shift+S` save plot image, `F9` Pipeline Workspace,
|
|
1475
|
+
`` Ctrl+` `` log/console, `Ctrl+T` Statistics. Menu accelerators and the
|
|
1476
|
+
Help → Keyboard shortcuts reference updated to match.
|
|
1477
|
+
- **Swap X↔Y axes** — a `⇄` button between the axis pickers; each axis keeps
|
|
1478
|
+
its own scale/range.
|
|
1479
|
+
- **Selected-gate readout** — selecting a gate shows its event count and
|
|
1480
|
+
**% of parent** (% of all events for a root gate) in the status bar;
|
|
1481
|
+
selecting a sample shows its total event count.
|
|
1482
|
+
- **Type-to-filter channel pickers** — the X / Y / Color combos narrow as you
|
|
1483
|
+
type and snap to the matching channel on commit (helps with large panels).
|
|
1484
|
+
- **First-run empty state** — the empty canvas offers clickable starting
|
|
1485
|
+
points (Add FCS / Load example / Open session) and a drag-and-drop hint.
|
|
1486
|
+
|
|
1487
|
+
### Fixed
|
|
1488
|
+
- **Clear gate** now hints "Ctrl+Z to undo" in the status bar.
|
|
1489
|
+
|
|
1490
|
+
## [1.4.0] - 2026-06-24
|
|
1491
|
+
|
|
1492
|
+
### Added
|
|
1493
|
+
- **Pipeline Workspace v2** — batch co-embedded clustering over groups of
|
|
1494
|
+
samples, now with:
|
|
1495
|
+
- **Clustering method** dropdown (PhenoGraph / Leiden / FlowSOM) with a
|
|
1496
|
+
**Leiden resolution** control and FlowSOM meta-cluster count.
|
|
1497
|
+
- **Full embedding set** — UMAP / t-SNE / PHATE / TriMap / PaCMAP, run
|
|
1498
|
+
together or selectively, with optional concatenation across the group.
|
|
1499
|
+
- **Save / Load recipe** — persist a run configuration as JSON and reload it.
|
|
1500
|
+
- **Import results as populations** — load a processed run's events CSV back
|
|
1501
|
+
into the editor as a sample.
|
|
1502
|
+
- **Batch over folder** — run the active recipe across every FCS in a folder.
|
|
1503
|
+
- **Marker picker** and **per-group parameter overrides**.
|
|
1504
|
+
- **Watch folder** — auto-load new FCS files as they appear.
|
|
1505
|
+
- **CLI clustering parity** — `--cluster-method`, `--resolution`,
|
|
1506
|
+
`--n-metaclusters` for Leiden / FlowSOM / PhenoGraph runs.
|
|
1507
|
+
|
|
1508
|
+
### Changed
|
|
1509
|
+
- **UI polish** — toolbar buttons stack into two rows (no overflow on wide
|
|
1510
|
+
screens), shortened the workspace tree's sample/population column, renamed
|
|
1511
|
+
the plot-controls **Workspace** button to **Pipeline**, and re-laid-out the
|
|
1512
|
+
group-parameters dialog on a clean grid.
|
|
1513
|
+
|
|
1514
|
+
### Fixed
|
|
1515
|
+
- Right-click menu **Paste** label rendered garbled on dark menus (themed
|
|
1516
|
+
`disabledForeground`).
|
|
1517
|
+
- Interior pane-resize lag — the matplotlib canvas no longer re-rasters on
|
|
1518
|
+
every pixel of a sash drag; it freezes during the drag and does one clean
|
|
1519
|
+
replot on release.
|
|
1520
|
+
- Dragging a pane no longer exposes a white strip — the canvas backing matches
|
|
1521
|
+
the chrome background.
|
|
1522
|
+
|
|
1523
|
+
## [1.3.0] - 2026-06-24
|
|
1524
|
+
|
|
1525
|
+
### Added
|
|
1526
|
+
- **Backend workflows surfaced in the GUI**
|
|
1527
|
+
- **Voltage optimization** (Tools) — PMT / stain-index titration with
|
|
1528
|
+
per-channel recommendations.
|
|
1529
|
+
- **Compare FlowJo workspace** (Tools) — re-apply a `.wsp` and compare gate
|
|
1530
|
+
counts vs FlowJo, with CSV export.
|
|
1531
|
+
- **Generate dataset** (File) — synthetic datasets (PBMC / differentiation /
|
|
1532
|
+
cell-cycle / spectral / beads), loaded in-app.
|
|
1533
|
+
- **Quick preview** (File) — raw single-sample density-scatter QC.
|
|
1534
|
+
- **FCS inspector** (Tools) — raw channels / keywords / spillover viewer.
|
|
1535
|
+
- **Plot navigation** — Zoom-to tool (drag a rectangle; greys the gating
|
|
1536
|
+
tools while active), centered ⌂/⛶/+/- bar, middle-drag pan, wheel zoom.
|
|
1537
|
+
- **Dark figures in pop-ups** (View) — preview + export of every analysis
|
|
1538
|
+
figure window on a dark background; plus a **Midnight** dark-plot theme and a
|
|
1539
|
+
**New windows open at** corner toggle.
|
|
1540
|
+
- **Flow-cytometry tools**
|
|
1541
|
+
- **Singlet gate** (Edit → Add singlet gate) — FSC-A vs FSC-H singlet
|
|
1542
|
+
polygon from the robust height/area band.
|
|
1543
|
+
- **FMO gating** (Edit → FMO gating…) — map markers to FMO controls; places
|
|
1544
|
+
threshold gates at the FMO percentile.
|
|
1545
|
+
- **Compensation QC** (Tools → Compensation QC…) — spillover heatmap +
|
|
1546
|
+
metrics for the active sample's matrix.
|
|
1547
|
+
- **Absolute counts** (Tools → Absolute counts…) — counting-bead cells/µL.
|
|
1548
|
+
- **Gating-tree diagram** (Tools → Gating tree diagram…).
|
|
1549
|
+
- **Embedding comparison** (Analyze → Compare embeddings…) — UMAP / t-SNE /
|
|
1550
|
+
PHATE side by side.
|
|
1551
|
+
- **Research / stats**
|
|
1552
|
+
- **Group comparison** (Analyze → Group comparison…) — Kruskal-Wallis +
|
|
1553
|
+
pairwise Mann-Whitney (BH) + Cliff's δ across trial groups.
|
|
1554
|
+
- **Methods & provenance** (Analyze → Methods & provenance…) — a paper-ready
|
|
1555
|
+
methods paragraph (from the audit trail + citations) and a reproducibility
|
|
1556
|
+
run manifest.
|
|
1557
|
+
- **Export populations as FCS** (Tools → Export populations (FCS)…) — each
|
|
1558
|
+
gated population to its own FCS 3.1 file.
|
|
1559
|
+
- **App**
|
|
1560
|
+
- **Preferences** dialog (Edit → Preferences…); **Documentation** and
|
|
1561
|
+
**Keyboard shortcuts** in Help.
|
|
1562
|
+
- **Plot pan/zoom** — middle-drag pans, scroll-wheel zooms (left-click stays
|
|
1563
|
+
gating); View → Reset plot view.
|
|
1564
|
+
- **Find box** above the sample/gate tree; **periodic autosave** (5 min).
|
|
1565
|
+
|
|
1566
|
+
## [1.2.4] - 2026-06-24
|
|
1567
|
+
|
|
1568
|
+
### Added
|
|
1569
|
+
- **File → Load example dataset** — generates and loads a small synthetic
|
|
1570
|
+
PBMC dataset (2 groups × 2 donors), so OpenFlo can be tried with no FCS
|
|
1571
|
+
files of your own.
|
|
1572
|
+
- **File → Save plot as image…** — export the current plot directly to
|
|
1573
|
+
PNG / SVG / PDF (white background, 300 dpi).
|
|
1574
|
+
|
|
1575
|
+
## [1.2.3] - 2026-06-24
|
|
1576
|
+
|
|
1577
|
+
### Added
|
|
1578
|
+
- **Global error handling.** Unhandled UI errors now flag the status bar and
|
|
1579
|
+
auto-reveal the log/console (instead of failing silently). A **tokenised**
|
|
1580
|
+
error report (Help → Report a problem…) is written for submission: file
|
|
1581
|
+
paths, sample names, usernames and emails are replaced with stable tokens,
|
|
1582
|
+
and the token→value key is kept in a separate LOCAL file that is never meant
|
|
1583
|
+
to be submitted.
|
|
1584
|
+
- **Keyboard shortcuts** with menu accelerators: Ctrl+O (open session),
|
|
1585
|
+
Ctrl+S (save), Ctrl+E (export .wsp), Ctrl+W (close), Ctrl+Shift+A (add FCS),
|
|
1586
|
+
F1 (About).
|
|
1587
|
+
- **Window size/position** is remembered across launches (validated on-screen).
|
|
1588
|
+
- **File → Open Recent** — the last sessions you opened or saved.
|
|
1589
|
+
|
|
1590
|
+
## [1.2.2] - 2026-06-24
|
|
1591
|
+
|
|
1592
|
+
### Added
|
|
1593
|
+
- **One-step setup scripts** (`setup.bat` / `setup.sh`) that create the `.venv`
|
|
1594
|
+
and install OpenFlo + all dependencies; the `openflo-gui` launchers run them
|
|
1595
|
+
automatically on first launch if the environment is missing.
|
|
1596
|
+
|
|
1597
|
+
### Fixed
|
|
1598
|
+
- Startup session-restore no longer hard-crashes when the data dependencies
|
|
1599
|
+
(FlowIO, etc.) aren't installed — it reports the missing dependency clearly
|
|
1600
|
+
and opens an empty session instead of failing the whole window.
|
|
1601
|
+
|
|
1602
|
+
## [1.2.1] - 2026-06-24
|
|
1603
|
+
|
|
1604
|
+
### Added
|
|
1605
|
+
- **Light / Dark / Midnight themes** (View → Theme), persisted across
|
|
1606
|
+
sessions. Light and Dark keep the scatter/plot light (flow-cytometry norm);
|
|
1607
|
+
**Midnight** darkens the plot canvas too — figure, axes, ticks, labels,
|
|
1608
|
+
spines, grid, legend and the backgate legend.
|
|
1609
|
+
- **App icon** — a flow-cytometry density-scatter mark replaces Tk's default
|
|
1610
|
+
feather in the title bar / taskbar.
|
|
1611
|
+
- **Dropdown-menu help** in the status bar (per entry, as you navigate).
|
|
1612
|
+
- **Resizable, pop-out panels.** Samples & Gates | Plot | Pipeline Workspace
|
|
1613
|
+
are draggable panes, and both side panels float into their own window and
|
|
1614
|
+
re-dock.
|
|
1615
|
+
- **Hover tooltips** on the plot controls, gate tools, and action buttons,
|
|
1616
|
+
toggleable via View → Show hover tips.
|
|
1617
|
+
- **Per-population density scaling** for overlays, with a clickable backgate
|
|
1618
|
+
legend (on/off · density · colour) that is draggable and collapsible.
|
|
1619
|
+
- Cell-cycle results group under a collapsed container and persist across
|
|
1620
|
+
session save/restore.
|
|
1621
|
+
|
|
1622
|
+
### Changed
|
|
1623
|
+
- **Mode** and **Downsample** are now dropdowns; mode-specific options
|
|
1624
|
+
(KDE / contour scatter & outliers / Hist-Y) appear only when relevant, and
|
|
1625
|
+
**Max points** is shown and applied only while downsampling is enabled.
|
|
1626
|
+
- Gate-tree heading expands/collapses all groups; control bars regrouped so
|
|
1627
|
+
each section aligns to its column.
|
|
1628
|
+
|
|
1629
|
+
### Fixed
|
|
1630
|
+
- Session results (clusters / UMAP) recover after a dropped processed-data
|
|
1631
|
+
sidecar pointer; backgating clustered populations works through the new
|
|
1632
|
+
collapsed group containers.
|
|
1633
|
+
- Downsampling **Off** now truly draws every event (Max points no longer
|
|
1634
|
+
silently caps when downsampling is off).
|
|
1635
|
+
- Refreshed the README/limitations (Auto-gate offers reviewable scored
|
|
1636
|
+
proposals; it is not disabled) and added a prominent citation request for
|
|
1637
|
+
research use (README banner + About dialog; MIT unchanged). Renamed
|
|
1638
|
+
LICENSE → LICENSE.txt so it opens with a double-click.
|
|
1639
|
+
|
|
1640
|
+
## [1.1.0] - 2026-06-23
|
|
1641
|
+
|
|
1642
|
+
### Added
|
|
1643
|
+
- **Built-in template library picker (ease-of-use).** The editor's template
|
|
1644
|
+
button is now a **Templates ▾** menu that lists every bundled template by its
|
|
1645
|
+
friendly name (the `cleanup_*` recipes first) plus your own saved templates —
|
|
1646
|
+
apply one in a click, no file navigation. The curated library now ships
|
|
1647
|
+
*inside* the package (`openflo/template_library/`, package data) so it's
|
|
1648
|
+
available to installed users, not just source checkouts; user-saved templates
|
|
1649
|
+
still live in the editor's writable dir and shadow same-named shipped ones.
|
|
1650
|
+
- **One-click cross-group comparison + volcano plot.** The Frequencies window
|
|
1651
|
+
gains a **Compare all…** button that compares *every* population across the
|
|
1652
|
+
current grouping in a single pass (instead of stepping through populations one
|
|
1653
|
+
at a time), Benjamini-Hochberg-correcting across populations. Results open in a
|
|
1654
|
+
new window with a sortable table (per-group means, log2 fold-change, adjusted
|
|
1655
|
+
p, stars) beside a **volcano plot** (log2FC vs −log10 adjusted-p, significant
|
|
1656
|
+
populations highlighted and labelled), with full-table CSV and figure export.
|
|
1657
|
+
New pure `openflo.stats.compare_all_features` (runs `compare_groups` over all
|
|
1658
|
+
features, BH across them) and `volcano_data`, both exported. The volcano needs
|
|
1659
|
+
the two-group case; with >2 groups the table still shows the omnibus
|
|
1660
|
+
Kruskal-Wallis / ANOVA result.
|
|
1661
|
+
- **End-user self-test + seeded data generator (regression baseline for
|
|
1662
|
+
everyone).** Two new console entry points let users — not just contributors —
|
|
1663
|
+
reproduce and regression-check behavior on data they don't have to provide:
|
|
1664
|
+
`openflo-synth` writes the full seeded synthetic dataset (now including the
|
|
1665
|
+
`beads/` size-calibration file), and **`openflo-selftest`** runs that data
|
|
1666
|
+
through the core feature paths (auto-clean debris/viability/doublets, Leiden
|
|
1667
|
+
clustering, MESF calibration, compensation) and compares each metric to a
|
|
1668
|
+
committed golden baseline (`openflo/_golden.json`), printing a PASS/FAIL table
|
|
1669
|
+
— so after pulling an update or editing code you can instantly see whether any
|
|
1670
|
+
feature's behavior changed (`--update` refreshes the baseline after an
|
|
1671
|
+
intended change; `--json` dumps raw metrics). The same golden file backs the
|
|
1672
|
+
pytest continuity tests, so the CLI and CI share one source of truth. Tests
|
|
1673
|
+
ship in the sdist (`MANIFEST.in`). New `openflo.selftest`.
|
|
1674
|
+
- **Bead-calibrated debris removal + dead-cell (viability) auto-cleaning.** The
|
|
1675
|
+
auto-clean gate's **Debris** method now defaults to an *absolute-size* cut:
|
|
1676
|
+
when a size-calibration bead sample is loaded (name contains bead / rainbow /
|
|
1677
|
+
calibration), its median FSC-A anchors a µm ruler and events below
|
|
1678
|
+
`min_um` (default 4 µm, bead diameter default 8 µm) are dropped
|
|
1679
|
+
(`FSC-A ≥ min_um · bead_FSC / bead_um`) — a reproducible absolute-size ruler
|
|
1680
|
+
that, with a sub-cell `min_um` (≈4 µm), removes only genuine sub-cellular
|
|
1681
|
+
fragments and keeps small real cells (lymphocytes). With no bead file it
|
|
1682
|
+
falls back to a **2-D FSC-A × SSC-A scatter gate** matching the standard
|
|
1683
|
+
manual debris polygon (debris = low FSC AND low SSC; granular low-FSC/high-SSC
|
|
1684
|
+
cells are rescued when they form a separate lobe) — never a 1-D cut that would
|
|
1685
|
+
bisect a real population. A new **Dead cells (viability dye)** method
|
|
1686
|
+
finds the live/dead stain by name (`find_viability_channel`: Live/Dead,
|
|
1687
|
+
Zombie, Ghost, FVS/FVD, 7-AAD, PI, DAPI, …) and drops the high-signal dead
|
|
1688
|
+
population at a genuine bimodal valley (`_bimodal_valley`; no-op on an
|
|
1689
|
+
all-live, unimodal sample). Right-clicking the Debris or Dead-cells method
|
|
1690
|
+
rows switches mode (Beads ↔ Auto valley), sets bead / min size, re-detects
|
|
1691
|
+
the bead reference, or pins the viability channel. (FSC-A stays linear and
|
|
1692
|
+
the dye is logicle-transformed in the editor, so both cuts are
|
|
1693
|
+
scale-correct.) `openflo.pipeline.find_viability_channel`. The synthetic
|
|
1694
|
+
dataset gains a `beads/` size-calibration file (single tight 8 µm population
|
|
1695
|
+
matched to a real instrument's FSC scale; `openflo.synthetic.size_bead_sample`
|
|
1696
|
+
/ `make_size_beads`) so bead-mode debris is testable headlessly, plus locked
|
|
1697
|
+
continuity reference drops (seeded ≈7 % debris / 8 % dead / 5 % doublets) that
|
|
1698
|
+
flag any future change to the cleaning maths. A method that removes **nothing**
|
|
1699
|
+
now explains why on its tree row (`autoclean_method_diagnostic`: "no viability
|
|
1700
|
+
dye detected", "FSC-A is unimodal — no low-debris mode; load size beads",
|
|
1701
|
+
"the high-signal population is the majority — not treated as dead") instead of
|
|
1702
|
+
a silent 0-drop. Both the debris valley and the viability split use the strict
|
|
1703
|
+
bimodal-valley detector, so a **unimodal** channel is never bisected (a clean
|
|
1704
|
+
single population — beads, a comp control, a pre-gated sample — correctly
|
|
1705
|
+
yields 0 drops rather than a spurious half-cut).
|
|
1706
|
+
- **Compliance / sign-off layer (tamper-evident, 21 CFR Part 11-style).** On
|
|
1707
|
+
top of the audit trail, the **History** window gains **Sign & export record…**
|
|
1708
|
+
and **Verify record…**. Signing builds an integrity *manifest* — SHA-256 of
|
|
1709
|
+
every loaded data file plus a hash of the audit trail and the software
|
|
1710
|
+
version — and attaches an **electronic signature** (signer, meaning, time)
|
|
1711
|
+
bound to that manifest's hash; it writes a signed JSON record + a Markdown
|
|
1712
|
+
copy. Verifying re-hashes everything and flags any signature whose content
|
|
1713
|
+
changed after signing (data edited, audit altered) — so the record is
|
|
1714
|
+
tamper-evident. New pure `openflo.compliance` (`build_manifest`,
|
|
1715
|
+
`sign_manifest`, `verify_record`, `record_to_markdown`), exported. (Scope:
|
|
1716
|
+
tamper-evidence + attributable sign-off, not access control — it complements,
|
|
1717
|
+
not replaces, a controlled-access environment.)
|
|
1718
|
+
- **Fluorescence calibration to standardized units (MESF / ABC).** A
|
|
1719
|
+
**Calibration…** dialog detects the bead-population peaks in a channel
|
|
1720
|
+
(k-means on log intensity), takes each peak's assigned MESF/ABC value from
|
|
1721
|
+
the bead datasheet, fits `value = slope·MFI + intercept` (with R²), and
|
|
1722
|
+
applies it across all samples as a plottable `MESF:<marker>` column — the
|
|
1723
|
+
fluorescence sibling of the existing FSC→µm bead-size calibration. New pure
|
|
1724
|
+
`openflo.calibration` (`detect_bead_peaks`, `fit_mesf_calibration`,
|
|
1725
|
+
`apply_calibration`), exported. The synthetic dataset now includes a
|
|
1726
|
+
`calibration/` rainbow-bead FCS + `mesf_peaks.csv`.
|
|
1727
|
+
- **t-SNE and PHATE embeddings.** The Cluster dialog's UMAP checkbox is now an
|
|
1728
|
+
**Embedding** picker — UMAP / t-SNE / TriMap / PaCMAP / PHATE / none. t-SNE
|
|
1729
|
+
ships in core deps (scikit-learn; perplexity auto-clamped, subsampled); PHATE
|
|
1730
|
+
(diffusion-based, great for continuous / trajectory structure) is an optional
|
|
1731
|
+
`embed` extra. New `FlowSample.run_tsne` / `run_phate` write `TSNE1/2` /
|
|
1732
|
+
`PHATE1/2`; the view switches to the chosen embedding's axes after clustering
|
|
1733
|
+
(only if it produced columns, so an uninstalled backend degrades gracefully).
|
|
1734
|
+
- **Sample QC (EMD + MDS) and AnnData interop.** A **Sample QC…** window
|
|
1735
|
+
computes a pairwise **Earth-Mover's-distance** matrix between the enabled
|
|
1736
|
+
samples (mean over markers of the 1-D Wasserstein distance, pooled-SD scaled)
|
|
1737
|
+
and an **MDS** embedding — batch effects and outlier samples show up as
|
|
1738
|
+
separated points (coloured by trial). Exports the distance matrix, the
|
|
1739
|
+
figure, and an **AnnData `.h5ad`** (events × markers, with `sample` + any
|
|
1740
|
+
`leiden`/`cluster`/`flowsom_meta`/`pseudotime` columns in `obs`) for the
|
|
1741
|
+
scanpy / single-cell Python ecosystem. New pure `openflo.interop`
|
|
1742
|
+
(`sample_distance_matrix`, `mds_embed`, `to_anndata`, `write_h5ad`); AnnData
|
|
1743
|
+
is an optional `interop` extra (`pip install 'openflo[interop]'`).
|
|
1744
|
+
- **FlowSOM star-tree visualization.** A **SOM tree…** button draws the iconic
|
|
1745
|
+
FlowSOM plot: the SOM nodes laid out on their **minimal spanning tree**, each
|
|
1746
|
+
rendered as a **star glyph** of its per-marker prototype profile, coloured by
|
|
1747
|
+
metacluster, with node size ∝ event count — plus a reference star (marker →
|
|
1748
|
+
spoke) and a metacluster legend. PNG/PDF/SVG export. New pure
|
|
1749
|
+
`openflo.pipeline.flowsom_mst` / `flowsom_layout` (scipy MST + igraph layout,
|
|
1750
|
+
exported).
|
|
1751
|
+
- **Rigorous differential abundance (negative-binomial GLM).** A **Diff.
|
|
1752
|
+
abundance…** button in the Frequencies window runs a diffcyt-DA-edgeR-style
|
|
1753
|
+
test: each population's per-sample counts are modelled with a negative-
|
|
1754
|
+
binomial GLM, `log(library size)` as offset (so it accounts for sequencing-
|
|
1755
|
+
depth / composition), a shared method-of-moments dispersion (edgeR
|
|
1756
|
+
common-dispersion-style, stable with few samples), a Wald test on the group
|
|
1757
|
+
coefficient and BH correction — replacing Mann-Whitney-on-fractions for the
|
|
1758
|
+
abundance question. Results table (log2FC, per-group %, p, adjusted p, stars)
|
|
1759
|
+
with CSV export. New pure `openflo.diffexp.differential_abundance` (scipy
|
|
1760
|
+
only — no statsmodels), exported.
|
|
1761
|
+
- **Automated population annotation (MEM + reference table).** An
|
|
1762
|
+
**Annotate…** window turns numeric clusters into biological labels.
|
|
1763
|
+
**MEM** (Marker Enrichment Modeling, Diggins 2017) computes a quantitative
|
|
1764
|
+
per-marker enrichment score for each cluster vs the rest (capturing both the
|
|
1765
|
+
median shift and the IQR change), yielding labels like `CD3+5 CD4+3 CD8-6`.
|
|
1766
|
+
A **reference cell-type table** (`CD4 T: CD3+ CD4+ CD8-`, ACDC/Scyan style)
|
|
1767
|
+
then assigns each cluster a best-matching name (weighting the defining
|
|
1768
|
+
positive markers so a shared negative can't win), written back onto the
|
|
1769
|
+
populations and the cluster-label store. Exports the MEM table. New pure
|
|
1770
|
+
`openflo.annotate` (`mem_scores`, `mem_label`, `population_states`,
|
|
1771
|
+
`parse_signature_table`, `annotate_by_reference`), exported.
|
|
1772
|
+
- **Synthetic example dataset generator** (`openflo.synthetic` +
|
|
1773
|
+
`scripts/make_synthetic_dataset.py`). A generic, regenerable dataset — not
|
|
1774
|
+
tied to any one study — that between its sub-datasets exercises every feature:
|
|
1775
|
+
a **PBMC immunophenotyping** set (CD3/CD4/CD8/CD19/CD56/CD14 lineages, the
|
|
1776
|
+
marquee generic example) for gating / clustering / Leiden / UMAP / frequencies
|
|
1777
|
+
/ expression / heatmap / report; a **3-batch variant** with a technical gain
|
|
1778
|
+
shift for CytoNorm batch correction; **FMO controls**; a **cell-cycle**
|
|
1779
|
+
(DNA-content G1/S/G2-M) set; **conventional-compensation** single-stain
|
|
1780
|
+
controls with a known spillover matrix + a sibling `compensation.csv`; the
|
|
1781
|
+
**differentiation** time-course for trajectory; and **spectral** controls for
|
|
1782
|
+
unmixing/QC. Pure (numpy/pandas; FlowIO to write FCS), tested, and gitignored
|
|
1783
|
+
output.
|
|
1784
|
+
- **One-click analysis report (HTML).** An **Analysis report (HTML)…** button
|
|
1785
|
+
bundles the whole session into a single, portable, self-contained `.html`
|
|
1786
|
+
file (images embedded as base64 data URIs — no sidecar files): metadata
|
|
1787
|
+
header, sample & gate summary, the current plot, the population-statistics
|
|
1788
|
+
table, a cluster × marker median-expression heatmap (column z-scored, when a
|
|
1789
|
+
`leiden` / `cluster` / `flowsom_meta` column exists), and the full provenance
|
|
1790
|
+
/ audit trail. Opens in the browser on save. New pure `openflo.report`
|
|
1791
|
+
(`build_html_report`, `df_to_html_table`, `figure_to_data_uri`), exported.
|
|
1792
|
+
- **Leiden clustering.** The current field-standard for high-dimensional
|
|
1793
|
+
spectral cytometry, alongside the existing Phenograph and FlowSOM. The
|
|
1794
|
+
Cluster dialog gains a **Leiden** method with a **resolution** control
|
|
1795
|
+
(higher → more, finer clusters); it builds a shared-nearest-neighbour
|
|
1796
|
+
(Jaccard) graph — the Phenograph/Seurat construction, so communities track
|
|
1797
|
+
real populations — and partitions it with `leidenalg` (RBConfiguration).
|
|
1798
|
+
Writes a ``leiden`` column imported as populations; large samples are graph-
|
|
1799
|
+
partitioned on a subsample and the rest assigned by nearest neighbour.
|
|
1800
|
+
`FlowSample.run_leiden`. (`igraph` + `leidenalg` were already declared
|
|
1801
|
+
dependencies.)
|
|
1802
|
+
- **Export gated population as FCS.** Right-click any gated population →
|
|
1803
|
+
*Export population as FCS…* writes that population's events to a standalone,
|
|
1804
|
+
re-importable `.fcs` (FlowJo / FCS Express). Exports the sample's **raw**
|
|
1805
|
+
detector values when they're row-aligned with the gated events (so the file
|
|
1806
|
+
isn't in transformed coordinates), else the processed data, and carries the
|
|
1807
|
+
antibody labels through as `$PnS`. New pure `openflo.pipeline.write_fcs`
|
|
1808
|
+
(FlowIO-backed, exported) zeroes non-finite cells and supports
|
|
1809
|
+
channel subset/reorder.
|
|
1810
|
+
- **Marker-expression distributions (violin / ridgeline) by group.** An
|
|
1811
|
+
**Expression…** window pools each enabled sample's per-cell values for a
|
|
1812
|
+
chosen marker (resolved across fluors by antibody label), groups samples by a
|
|
1813
|
+
factor (trial/day, comp-vs-samples, or a name token), and draws a **violin**
|
|
1814
|
+
or **ridgeline** plot per group. Significance comes from a per-SAMPLE-median
|
|
1815
|
+
comparison (each sample a replicate, not each cell) — also what the GraphPad
|
|
1816
|
+
Prism Column export contains. New pure `openflo.stats.group_kde` (KDE per
|
|
1817
|
+
group over a shared grid) backs the ridgeline and is exported/tested.
|
|
1818
|
+
- **Trajectory / pseudotime (GUI + backend).** A **Trajectory…** tool orders
|
|
1819
|
+
cells along a differentiation trajectory: a symmetric kNN graph over the
|
|
1820
|
+
enabled samples' shared fluor channels (concatenated, so a day-series becomes
|
|
1821
|
+
one continuous trajectory), with pseudotime = geodesic distance from a root
|
|
1822
|
+
cell chosen at the extreme of a marker (e.g. CD34-high progenitors as t=0).
|
|
1823
|
+
It writes a ``pseudotime`` column to every sample (selectable as a plot
|
|
1824
|
+
colour) and draws each marker's mean expression along pseudotime — the
|
|
1825
|
+
CD34-down / CD11b-up maturation curve — with CSV / Prism XY / figure export.
|
|
1826
|
+
Backend (`openflo.trajectory`: `compute_pseudotime`, `robust_root`,
|
|
1827
|
+
`pseudotime_trends`) is pure (numpy/scipy/sklearn), subsamples large data for
|
|
1828
|
+
the graph and propagates by nearest neighbour, and is exported.
|
|
1829
|
+
- **Population frequencies & group comparison (GUI + backend) with GraphPad
|
|
1830
|
+
Prism export.** A new **Frequencies…** window collects each sample's
|
|
1831
|
+
per-population frequency, groups samples by a factor (trial/day, comp-vs-
|
|
1832
|
+
samples, or a name token like `Stim`/`Ctrl`), and for a chosen population +
|
|
1833
|
+
metric (%Parent / %Total / Count) draws a box+strip comparison with
|
|
1834
|
+
significance annotations plus an all-population overview. Statistics pick the
|
|
1835
|
+
right test automatically — Mann-Whitney U / Welch t for two groups,
|
|
1836
|
+
Kruskal-Wallis / one-way ANOVA + BH-adjusted pairwise post-hoc for more
|
|
1837
|
+
(`openflo.stats.compare_groups`). Exports: tidy CSV, **Prism Column** and
|
|
1838
|
+
**Prism Grouped** tables (columns = groups, rows = replicates; ragged groups
|
|
1839
|
+
padded — paste straight into GraphPad Prism), a stats summary, and the figure
|
|
1840
|
+
(White/Transparent/Translucent). Backend (`compare_groups`, `to_prism_column`,
|
|
1841
|
+
`to_prism_grouped`, `p_to_stars`) is pure and exported.
|
|
1842
|
+
- **Spectral unmixing QC + CLI batch-unmix.** New diagnostics for how
|
|
1843
|
+
trustworthy an unmix is: a spectral **similarity matrix** (cosine between
|
|
1844
|
+
reference spectra — flags fluorophore pairs too collinear to resolve), the
|
|
1845
|
+
**condition number** of the spectra matrix, and the **Spillover Spread
|
|
1846
|
+
Matrix** (SSM, Nguyen 2013 / Cytek — the spreading error each single-stain
|
|
1847
|
+
injects into every other fluor). After an Unmix the GUI opens a **Spectral
|
|
1848
|
+
QC** window with similarity + SSM heatmaps, the flagged similar / high-spread
|
|
1849
|
+
pairs, and Markdown / PNG export; the condition number and similar-pair
|
|
1850
|
+
count are recorded in the audit trail. New CLI mode `--unmix` builds
|
|
1851
|
+
reference spectra from `--unmix-controls` (a fluor→FCS JSON map, optional
|
|
1852
|
+
`unstained`), unmixes `--unmix-input` FCS into per-fluor CSVs, and writes
|
|
1853
|
+
`spectral_qc.{md,json}` + `reference_spectra.png` — so unmixing is no longer
|
|
1854
|
+
GUI-only. Backend: `spectral_similarity_matrix`, `spectral_condition_number`,
|
|
1855
|
+
`spillover_spread_matrix`, `unmixing_qc` (pure numpy, exported).
|
|
1856
|
+
- **Provenance / audit trail (GUI + backend).** A new append-only
|
|
1857
|
+
`AuditLog` records the meaningful operations of an analysis session in
|
|
1858
|
+
order — sample load (with path, event count, compensation source),
|
|
1859
|
+
transforms, cleaning, gate add/remove, auto-gate proposals (with their
|
|
1860
|
+
quality scores), clustering, batch normalization (with before/after QC
|
|
1861
|
+
distance), spectral unmixing, figure export and session reload. A
|
|
1862
|
+
**History…** button opens a live viewer that exports the trail to
|
|
1863
|
+
**Markdown** (a methods-section-ready table with an OpenFlo-version
|
|
1864
|
+
header), **CSV**, or **JSON**. The trail is embedded in the saved session
|
|
1865
|
+
and restored on load, so the record of *how* a result was produced travels
|
|
1866
|
+
with it. Pure/stdlib backend (`openflo.audit`), fully unit-tested.
|
|
1867
|
+
- **Trustworthy automated gating (GUI + backend).** The **Auto-gate** button
|
|
1868
|
+
(previously disabled — its single-contour heuristic mis-placed gates) now
|
|
1869
|
+
opens a dialog offering three well-posed, reviewable methods, each reported
|
|
1870
|
+
with a quality score in the status bar:
|
|
1871
|
+
- *Singlet gate* — a robust FSC-A/FSC-H ratio band (median ± k·MAD) emitted
|
|
1872
|
+
as a polygon; reports the fraction kept and ratio CV (`auto_singlet_gate`).
|
|
1873
|
+
- *Find populations (GMM ellipses)* — fits a Gaussian mixture on the current
|
|
1874
|
+
X/Y plot, picks the component count by BIC, and emits one **ellipsoid gate**
|
|
1875
|
+
per population at a chi-square coverage radius, each tagged with its weight
|
|
1876
|
+
and a separation score so overlapping (untrustworthy) splits are flagged
|
|
1877
|
+
(`gmm_ellipse_gates`).
|
|
1878
|
+
- *1-D threshold* — the existing valley/Otsu split.
|
|
1879
|
+
Every proposal is added as an ordinary undoable gate to accept / tweak /
|
|
1880
|
+
delete — review, not auto-apply. `describe_gate` now names polygon/rect gates
|
|
1881
|
+
and renders ellipsoid gates (previously shown as `? ellipsoid`).
|
|
1882
|
+
- **Multi-panel figure layout / export (GUI).** A **Figure…** button assembles
|
|
1883
|
+
the current plot into a publication-style small-multiples figure: one panel
|
|
1884
|
+
per sample (current channels), one panel per channel pair (samples overlaid),
|
|
1885
|
+
a samples × pairs grid, or a single panel. Channel pairs accept marker labels
|
|
1886
|
+
or channel names (e.g. `CD34/CD11b, CD11b/CD45`). Each panel reuses the live
|
|
1887
|
+
rendering pipeline (mode, density/colour, axis scales, gates) via an
|
|
1888
|
+
axes-swap (`_render_into`), so panels match the on-screen plot exactly. A
|
|
1889
|
+
preview window saves to PNG / PDF / SVG / TIFF at 300 dpi, with a
|
|
1890
|
+
**background** option — White (default), Transparent, or Translucent
|
|
1891
|
+
(50%) — for placing publication figures on a coloured page / poster.
|
|
1892
|
+
- **Spectral unmixing workflow (GUI).** An **Unmix** button designates loaded
|
|
1893
|
+
single-stain controls (→ fluorophore) + an unstained control, builds the
|
|
1894
|
+
reference spectra (with an autofluorescence endmember) and unmixes every
|
|
1895
|
+
other loaded sample into per-fluor `U:` abundance channels (OLS, optional
|
|
1896
|
+
non-negative) that become plottable/gateable, plus a spectrum-signature
|
|
1897
|
+
plot. Wraps the `spectral.py` backend.
|
|
1898
|
+
- **Batch correction (CytoNorm).** The flow-cytometry standard for removing
|
|
1899
|
+
technical batch/acquisition variation: FlowSOM-metacluster the pooled data,
|
|
1900
|
+
then per metacluster + channel quantile-normalize each batch onto a shared
|
|
1901
|
+
goal distribution. One engine, two modes — `goal` (CytoNorm 2.0, control-
|
|
1902
|
+
free, default) and `controls` (classic, fit on per-batch controls; CLI-only).
|
|
1903
|
+
The fitted model serializes and applies to new samples; a QC report gives
|
|
1904
|
+
per-channel Wasserstein before/after. GUI: a **Batch-norm** button (2.0,
|
|
1905
|
+
groups by trial/day). CLI: `--batch-correct` with `--cytonorm-mode` /
|
|
1906
|
+
`--cytonorm-control` / `--cytonorm-metaclusters`.
|
|
1907
|
+
- **Backgating.** Right-click a gate/population → *Backgate (show on plot)*
|
|
1908
|
+
projects its events, coloured, on top of the current plot — so you can see
|
|
1909
|
+
where a downstream population/cluster sits on any axes. Multi-select gives
|
|
1910
|
+
several colours + a legend; *Clear backgating* removes them.
|
|
1911
|
+
- **Auto-clean drop-count readout.** Each auto-clean gate row in the tree now
|
|
1912
|
+
shows how many events the recipe removes — `autocleaned sample — drops N
|
|
1913
|
+
(X%)` — with a per-method breakdown under it (each method's standalone
|
|
1914
|
+
contribution, shown even when toggled off so you can preview it). Computed on
|
|
1915
|
+
the full sample and cached by data identity + recipe signature.
|
|
1916
|
+
- **Staining-panel `.xlsx` → channel labels (CLI).** `--panel <file>` (or
|
|
1917
|
+
`--panel auto`, which searches the trial folders and a few ancestor levels)
|
|
1918
|
+
reads a CD↔fluorophore sheet and maps each fluorophore to its detector
|
|
1919
|
+
channel, merged with `--labels`. New `read_staining_panel` / `find_panel_xlsx`.
|
|
1920
|
+
- **Per-group marker-pair scatters (CLI).** Every group now emits, for each
|
|
1921
|
+
pair in `--pairs` (default CD34/CD11b, CD11b/CD45, CD34/CD45), an *overlay*
|
|
1922
|
+
(all samples on one axes, coloured by sample) and a *grid* (one density panel
|
|
1923
|
+
per sample, shared limits). New `save_group_pair_scatters`.
|
|
1924
|
+
- **Adjustable plot point cap.** A **Max points** control (presets + free
|
|
1925
|
+
entry, `250k`/`All` accepted) replaces the fixed 60 k scatter cap; drives
|
|
1926
|
+
scatter / pseudocolor / contour, updates the tree's shown/total counts, and
|
|
1927
|
+
persists in the session.
|
|
1928
|
+
- **“Show cleaned-out events” overlay.** A plot-control toggle that draws the
|
|
1929
|
+
events the auto-clean recipe removes *in red, on top* of whatever's plotted —
|
|
1930
|
+
computed on the full sample and **bypassing the display cap**, so a small
|
|
1931
|
+
error rate stays visible against the full population instead of being
|
|
1932
|
+
subsampled away. Scatter modes overlay red dots (with a count); histogram
|
|
1933
|
+
mode overlays the removed events' channel distribution scaled to be visible.
|
|
1934
|
+
Reflects the current recipe and persists in the session.
|
|
1935
|
+
|
|
1936
|
+
### Changed
|
|
1937
|
+
- **Config-driven batch runner.** `scripts/run_analyses.py` reads a JSON
|
|
1938
|
+
config (default the git-ignored `private/analysis_config.json`; see
|
|
1939
|
+
`scripts/analysis_config.example.json`) describing analyses via reusable
|
|
1940
|
+
`group_by` strategies — no data paths baked into tracked code. `--dry-run`
|
|
1941
|
+
resolves + verifies groups without clustering. Keeps the tracked tree
|
|
1942
|
+
generic so real experiment paths stay in `private/` (git-ignored).
|
|
1943
|
+
- **Smoother density rendering.** Pseudocolor samples each event's colour by
|
|
1944
|
+
**cubic** interpolation of the smoothed density field (C2-continuous, so no
|
|
1945
|
+
per-cell colour blocks *or* residual bin-grid box facets), with an adaptive
|
|
1946
|
+
smoothing floor, and colours via `PowerNorm` so large samples no longer wash
|
|
1947
|
+
out to one flat hue. Histograms render as kernel-smoothed filled curves
|
|
1948
|
+
instead of chunky step bars. Contour density is zero-padded so every level
|
|
1949
|
+
closes.
|
|
1950
|
+
- **GUI caps BLAS threads at startup** (mirroring the CLI) so OpenBLAS can't
|
|
1951
|
+
exhaust memory and abort the console-less launch under pressure.
|
|
1952
|
+
- **Auto-clean gate.** A new **Auto-clean** button adds an *“autocleaned
|
|
1953
|
+
sample”* recipe gate (a collapsible group of toggleable cleaning methods —
|
|
1954
|
+
debris, doublets, margin/saturation, flow-rate bubbles/clogs, signal drift).
|
|
1955
|
+
It stores the *calculation*, not coordinates: its mask is the AND of the
|
|
1956
|
+
enabled methods, recomputed from each sample's own data, so copying it to
|
|
1957
|
+
other samples re-runs the cleaning per sample rather than reusing one
|
|
1958
|
+
sample's geometry. Build downstream gates under it to gate on cleaned events.
|
|
1959
|
+
Not FlowJo-representable — WSP export drops it and re-roots any children.
|
|
1960
|
+
- **Folder drag-and-drop import.** Dropping a folder recurses into its
|
|
1961
|
+
`.fcs`/`.wsp` files; dropping a parent of several trial folders imports each
|
|
1962
|
+
independently. A bounded background load queue (fixed worker pool) replaces
|
|
1963
|
+
one-thread-per-file so large folder drops can't exhaust memory, with a
|
|
1964
|
+
determinate progress bar showing *N/M loaded*.
|
|
1965
|
+
- **Histogram Y-axis selector** — Fraction (default) / Count / % of Max. Raw
|
|
1966
|
+
Count honours the auto-downsample toggle (and bypasses the scatter-only 60k
|
|
1967
|
+
cap) so counts are truthful.
|
|
1968
|
+
- **Event counts in the Samples & Gates tree.** Each sample row shows its
|
|
1969
|
+
event count, displayed as `shown/total` when auto-downsampling scales it to
|
|
1970
|
+
the smallest sample (and updating when the toggle changes).
|
|
1971
|
+
- **Auto-clean parameter dialog.** Double-click an auto-clean gate (or method
|
|
1972
|
+
row), or right-click → *Edit auto-clean parameters…*, to tune each method's
|
|
1973
|
+
enabled flag and parameters (bin counts, MAD thresholds, doublet tolerance,
|
|
1974
|
+
an optional manual debris FSC cutoff).
|
|
1975
|
+
- **Auto-clean masks are cached** per (sample data, recipe) and reused across
|
|
1976
|
+
replots — recomputed only when the data or recipe changes — so gating on
|
|
1977
|
+
cleaned events stays responsive on large samples. The mask is computed on the
|
|
1978
|
+
full sample data (a per-acquisition property), so filter and highlight views
|
|
1979
|
+
flag the same events even when a plotted axis is sparse (e.g. an embedding).
|
|
1980
|
+
|
|
1981
|
+
### Changed
|
|
1982
|
+
- **Imported day groups split into Comps + Samples subgroups.** When a day
|
|
1983
|
+
group contains compensation controls (names matching comp / control /
|
|
1984
|
+
(un)stained), the tree shows a *Samples* sub-header (expanded) and a *Comps*
|
|
1985
|
+
sub-header (collapsed by default); each subgroup's ✓ toggles its members'
|
|
1986
|
+
display. Days without comps list samples directly as before.
|
|
1987
|
+
- **Imported gates load disabled.** Gates brought in with no explicit enabled
|
|
1988
|
+
flag (e.g. from a `.wsp`) start unchecked, so a freshly-loaded sample isn't a
|
|
1989
|
+
wall of active toggles. A restored session's gates keep their saved state.
|
|
1990
|
+
- **Drag samples between groups.** A sample row can now be dragged to another
|
|
1991
|
+
day, or between the Comps and Samples subgroups, to fix a mis-import or for
|
|
1992
|
+
convenience (a manual Comps/Samples choice overrides the name-based guess).
|
|
1993
|
+
Multi-selection is honoured, and the regrouping persists in saved sessions.
|
|
1994
|
+
- **“Clear all” keeps auto-clean gates by default**, with a checkbox in the
|
|
1995
|
+
confirm dialog to also clear them — so a bulk gate wipe doesn't discard the
|
|
1996
|
+
cleaning foundation.
|
|
1997
|
+
- **Folder grouping is now by collection “Day N”.** `derive_trial_name` scans
|
|
1998
|
+
ancestor folders for a `Day N` token (at whatever depth it sits) and groups
|
|
1999
|
+
by it, falling back to the grandparent folder when absent; day groups sort
|
|
2000
|
+
numerically. Samples whose filenames repeat across days are disambiguated
|
|
2001
|
+
(e.g. `… [Day 9]`) so identical names no longer silently overwrite one
|
|
2002
|
+
another.
|
|
2003
|
+
- **“Clear all” now clears all gates but keeps the samples** (undoable),
|
|
2004
|
+
reversing the 1.0.0 behaviour where it removed every sample. **Clear** now
|
|
2005
|
+
acts on the selection: a gate (cascade), a sample's gates, or a whole
|
|
2006
|
+
trial's gates — never removing samples (use **Remove** for that).
|
|
2007
|
+
|
|
2008
|
+
## [1.0.0] — 2026-05-29
|
|
2009
|
+
|
|
2010
|
+
First public release.
|
|
2011
|
+
|
|
2012
|
+
### Fixed
|
|
2013
|
+
- **UMAP/TriMap runs no longer flash a console window** on Windows — the
|
|
2014
|
+
per-unit worker subprocess (and the Cancel `taskkill`) launch with
|
|
2015
|
+
`CREATE_NO_WINDOW`.
|
|
2016
|
+
- **"Clear all" now actually clears the panel.** It previously only emptied
|
|
2017
|
+
the active sample's gates; it now removes every loaded sample and all gates
|
|
2018
|
+
(confirmed, since sample removal isn't undoable).
|
|
2019
|
+
- The **Auto-gate** button is greyed out for now — its density heuristic
|
|
2020
|
+
mis-placed gates often enough to be untrustworthy.
|
|
2021
|
+
|
|
2022
|
+
### Changed
|
|
2023
|
+
- **Repo layout consolidated.** Loose root scripts moved into folders —
|
|
2024
|
+
`smoke_test.py` → `scripts/`, `HANDOFF.md` → `docs/`; the top level now
|
|
2025
|
+
keeps only standard docs, config, and launchers.
|
|
2026
|
+
- **Statistics is strictly population-based.** The window accepts only gate /
|
|
2027
|
+
population rows — dragged from the Samples & Gates panel or from a *gated*
|
|
2028
|
+
Pipeline Workspace item — never whole samples or trials. The two Import
|
|
2029
|
+
buttons (**Import S&G gates** / **Import workspace**) REPLACE the current
|
|
2030
|
+
set; dragging a gate APPENDS. A **Source** column tags each row
|
|
2031
|
+
`editor` / `workspace` / `editor+workspace`.
|
|
2032
|
+
- **Editor bottom-left buttons unified.** Clear / Clear all / Copy / Pops are
|
|
2033
|
+
now equal-width and compact (↶/↷ stay as small icon buttons), making room
|
|
2034
|
+
for the new log pane.
|
|
2035
|
+
- Selecting a **trial** row and pressing **Delete** (or **Remove**, or
|
|
2036
|
+
right-click → *Remove trial*) now clears that trial's samples and gates
|
|
2037
|
+
(confirmed).
|
|
2038
|
+
- **Pipeline default grouping is now by day, not a fixed two-group split.**
|
|
2039
|
+
With no `--groups`/`--samples`, OpenFlo discovers every folder that
|
|
2040
|
+
directly holds FCS files — point it at a single PARENT and each
|
|
2041
|
+
sub-folder becomes its own day/group, sampled independently and
|
|
2042
|
+
compared across days in one analysis. Folder names are tidied to
|
|
2043
|
+
`Day N` when a day token is present; duplicate day names are
|
|
2044
|
+
disambiguated by parent. Explicit `--groups` and the legacy
|
|
2045
|
+
`--samples` split still work; `DEFAULT_GROUPS` remains the final
|
|
2046
|
+
fallback.
|
|
2047
|
+
- **Per-sample FMO assignment.** A group's `samples` entry may be a
|
|
2048
|
+
plain string (inherits the group's `fmo_set`) or
|
|
2049
|
+
`{'name', 'fmo_set'}` to point one sample at a different FMO control
|
|
2050
|
+
set. FMO thresholds + both run modes resolve per sample. Compensation
|
|
2051
|
+
and antibody labels were already per-sample-automatic (each FCS's
|
|
2052
|
+
`$SPILL` / `$PnS`).
|
|
2053
|
+
- **The gate editor is now the entire GUI.** `openflo-gui` opens straight
|
|
2054
|
+
into the editor (it owns a hidden Tk root); closing it exits. Pipelines
|
|
2055
|
+
run from the editor's docked **Pipeline Workspace** — drag samples /
|
|
2056
|
+
gated populations in and Run. The separate pipeline-config window was
|
|
2057
|
+
removed (see *Removed*).
|
|
2058
|
+
- **Pipeline Workspace runs Phenograph + UMAP + TriMap per RUN UNIT, each
|
|
2059
|
+
in its own subprocess.** A *unit* is a **group's samples co-embedded into
|
|
2060
|
+
one UMAP** (events tagged by source sample); a **Concatenate** toggle
|
|
2061
|
+
merges all groups into a single UMAP so groups compare in one embedding
|
|
2062
|
+
(FlowJo-style); loose items run on their own. Each run writes a
|
|
2063
|
+
cluster-frequency CSV, a **cluster × group/sample composition CSV**, and
|
|
2064
|
+
embedding PNGs coloured by cluster *and* by source. Embeddings use the
|
|
2065
|
+
proper per-marker channels (height/width detector duplicates dropped) on
|
|
2066
|
+
an up-front subsample. A native crash / hang / OOM is isolated to that
|
|
2067
|
+
child (the GUI survives); **Cancel** terminates the job's whole process
|
|
2068
|
+
tree; a crashed/OOM unit is requeued once at a lower event cap, then
|
|
2069
|
+
skipped. The editor's Undo button also reverts workspace edits;
|
|
2070
|
+
workspaces save/load to JSON; a Results viewer shows the outputs.
|
|
2071
|
+
|
|
2072
|
+
### Removed
|
|
2073
|
+
- **The legacy pipeline run-plan / staging window (the `App` class) and
|
|
2074
|
+
its in-process + subprocess run engine.** It was discontinued — most of
|
|
2075
|
+
its features were unreliable (crashes / restart loops). ~3,900 lines
|
|
2076
|
+
removed; its role is taken by the Pipeline Workspace. Also dropped the
|
|
2077
|
+
now-unused Windows Job-object / memory-watchdog / GPU-probe
|
|
2078
|
+
infrastructure and `tests/test_run_plan.py`.
|
|
2079
|
+
|
|
2080
|
+
### Added
|
|
2081
|
+
- **Collapsible in-app log pane.** A "Show log" toggle at the bottom of the
|
|
2082
|
+
editor's left column reveals a small terminal that mirrors stdout/stderr
|
|
2083
|
+
(diagnostics, tracebacks) without needing a console; "Clear log" empties it.
|
|
2084
|
+
- **Pipeline Workspace item drag.** Drag an item between groups — or onto
|
|
2085
|
+
empty space to pop it back to the top level — to fix a mis-drop. Dragging a
|
|
2086
|
+
*gated* item onto an open Statistics window adds its population.
|
|
2087
|
+
- **In-editor clustering + the full cluster→name→use loop.** A "Cluster…"
|
|
2088
|
+
button runs Phenograph or FlowSOM (+ optional UMAP) on loaded samples in
|
|
2089
|
+
a worker thread, then auto-imports the result as populations and switches
|
|
2090
|
+
the plot to the UMAP coloured by the label. Population import is now
|
|
2091
|
+
generic — the "Populations…" menu detects any present label column
|
|
2092
|
+
(`cluster`, `flowsom_meta`, cell-cycle phases) and offers import + rename
|
|
2093
|
+
for each. Clustered/UMAP'd data can also be brought in from outside via
|
|
2094
|
+
**"Load CSV…"** (`FlowSample.from_dataframe` ingests a pipeline
|
|
2095
|
+
`*_processed.csv`, preserving cluster/UMAP/flowsom columns); derived
|
|
2096
|
+
columns are auto-excluded from marker lists.
|
|
2097
|
+
- **Spectral unmixing.** New `openflo.spectral`: `build_reference_spectra`
|
|
2098
|
+
turns single-stain (+ unstained autofluorescence) controls into a
|
|
2099
|
+
reference spectra matrix; `unmix` solves per-event fluorophore abundances
|
|
2100
|
+
by least squares (OLS, optional non-negativity); `apply_unmixing` adds
|
|
2101
|
+
one abundance column per fluor to a sample. For full-spectrum cytometers
|
|
2102
|
+
(Cytek Aurora, BD S8) where compensation alone doesn't apply.
|
|
2103
|
+
- **Differential abundance / expression.** New `openflo.diffexp`:
|
|
2104
|
+
`differential_test` (Mann-Whitney U + log2 fold-change + Benjamini-
|
|
2105
|
+
Hochberg FDR) over per-sample feature values, with `cluster_abundance`
|
|
2106
|
+
and `marker_expression` builders that turn two groups of samples into the
|
|
2107
|
+
per-sample feature dicts. The diffcyt/OMIQ-style comparison OpenFlo
|
|
2108
|
+
lacked.
|
|
2109
|
+
- **FlowSOM clustering + metaclustering.** `FlowSample.run_flowsom()` trains
|
|
2110
|
+
a self-organizing map over the marker space, assigns each event to a node,
|
|
2111
|
+
and agglomerates nodes into metaclusters — writing `flowsom` (node) and
|
|
2112
|
+
`flowsom_meta` (metacluster) columns. Compact, dependency-free
|
|
2113
|
+
(numpy + sklearn), fast on large files.
|
|
2114
|
+
- **More transforms + per-channel transform editor.** `transform_values` /
|
|
2115
|
+
`inverse_transform_values` add **arcsinh** and **hyperlog** (and a linear
|
|
2116
|
+
pass-through) alongside logicle/log, with FlowJo's t/m/w/a knobs (arcsinh
|
|
2117
|
+
uses an intuitive `cofactor`). A "Transforms…" editor in the GUI re-maps
|
|
2118
|
+
each channel's transform across all loaded samples by inverting the
|
|
2119
|
+
current one and applying the new — no re-compensation needed.
|
|
2120
|
+
- **Boolean gates (AND / OR / NOT).** New `boolean` gate kind combining
|
|
2121
|
+
other gates' cumulative masks (cycle-guarded). Build one from the gate
|
|
2122
|
+
tree's right-click menu ("Create boolean gate…"); it toggles, highlights,
|
|
2123
|
+
filters, and feeds the stats table like any population. Dropped from
|
|
2124
|
+
`.wsp` export with a lossy-export note.
|
|
2125
|
+
- **Automated density-based gating (auto-gate).** `auto_threshold` (valley
|
|
2126
|
+
between the two density modes, else Otsu) and `auto_polygon_gate` (a
|
|
2127
|
+
contour around the dominant 2-D density mode). An "Auto-gate" button
|
|
2128
|
+
proposes a threshold (histogram) or polygon (2-D) for the active sample
|
|
2129
|
+
to accept or tweak.
|
|
2130
|
+
- **Undo / redo in the gate editor.** Ctrl+Z / Ctrl+Y (and ↶/↷ buttons)
|
|
2131
|
+
over a snapshot history of the gate state. Every structural change —
|
|
2132
|
+
add, delete, drag, reparent, paste, cluster/cell-cycle import,
|
|
2133
|
+
annotate — is one undoable step (mutations in a single gesture coalesce);
|
|
2134
|
+
bulk session/template loads don't pollute the history.
|
|
2135
|
+
- **Cell-cycle recognition (DNA content).** `FlowSample.cell_cycle()`
|
|
2136
|
+
auto-detects a DNA-stain channel (PI / DAPI / FxCycle / 7-AAD / Hoechst /
|
|
2137
|
+
DRAQ5 / …; `find_dna_channel`), optionally pre-gates singlets on the
|
|
2138
|
+
DNA-A vs `-W`/`-H` ratio (doublet exclusion), then models the histogram
|
|
2139
|
+
(`analyze_dna`): locates the G1 peak and the G2/M peak at ~2× DNA,
|
|
2140
|
+
estimates each peak's robust spread, and assigns every event a phase
|
|
2141
|
+
(G1 / S / G2M / sub-G1 / >G2M) → %G1/%S/%G2M. Writes a categorical
|
|
2142
|
+
`cell_cycle` column. In the editor, a "Cell cycle…" button runs it on
|
|
2143
|
+
the active (or all) sample(s), surfaces each phase as a selectable
|
|
2144
|
+
population (new `category` gate kind), and shows a DNA histogram +
|
|
2145
|
+
phase-percentage window.
|
|
2146
|
+
- **Acquisition QC now detects clogs, bubbles, and saturation.**
|
|
2147
|
+
`AcquisitionQC` gained two detectors beyond the existing signal-drift
|
|
2148
|
+
one: **flow-rate anomalies** (time bins whose event count is a MAD
|
|
2149
|
+
outlier, plus empty interior bins — clog collapses and bubble gaps/
|
|
2150
|
+
bursts) and **margin/saturation events** (per-event removal of pile-ups
|
|
2151
|
+
at a channel's ceiling). All three combine into one clean-event index;
|
|
2152
|
+
`qc.report` breaks down removals by category. A clean acquisition trips
|
|
2153
|
+
none of them.
|
|
2154
|
+
- **TriMap and PaCMAP dimensionality reduction.** `FlowSample.run_trimap()`
|
|
2155
|
+
and `run_pacmap()` mirror `run_umap` (shared `_embedding_input` /
|
|
2156
|
+
`_store_embedding` helpers), writing `TRIMAP1/2` and `PACMAP1/2`. Both
|
|
2157
|
+
are optional (`pip install openflo[embed]`) and degrade gracefully when
|
|
2158
|
+
not installed. They preserve global structure better than UMAP on some
|
|
2159
|
+
panels.
|
|
2160
|
+
- **Voltage titration / Stain Index tool.** New `openflo.voltage` module +
|
|
2161
|
+
`openflo-voltage` CLI: point it at a titration series (one FCS per PMT
|
|
2162
|
+
voltage) and a channel, and it reads `$PnV` per detector, auto-splits the
|
|
2163
|
+
negative/positive populations (2-component GMM), computes per-voltage
|
|
2164
|
+
Stain Index = (med⁺−med⁻)/(2·rSD⁻) and the robust CV of the negative,
|
|
2165
|
+
and recommends the lowest voltage on the SI plateau. Generalized — any
|
|
2166
|
+
channel, any file set; pure metric layer is independently importable as
|
|
2167
|
+
`VoltageTitration`.
|
|
2168
|
+
- **Plot axes resolve by antibody label per sample.** When overlaying
|
|
2169
|
+
samples whose marker sits on different fluorophores, picking an axis
|
|
2170
|
+
(a detector from the global panel) now resolves to *each* sample's own
|
|
2171
|
+
detector by antibody label (`_axis_alias_for_sample`), so the samples
|
|
2172
|
+
overlay on a common label axis instead of being dropped. The chosen
|
|
2173
|
+
name is aliased onto the sample's own column (the original detector
|
|
2174
|
+
column stays, so per-sample gate masks are unaffected); a sample that
|
|
2175
|
+
lacks the marker entirely is simply skipped. Completes the label-first
|
|
2176
|
+
follow-up to gate-by-label retargeting.
|
|
2177
|
+
- **Clusters as selectable, annotatable populations in the editor.** A
|
|
2178
|
+
"Clusters…" button imports each clustering label (the pipeline's
|
|
2179
|
+
`cluster` column) as a root population — a new `cluster` gate kind
|
|
2180
|
+
whose mask is `cluster == id` (`gate_to_mask`; a missing column selects
|
|
2181
|
+
nothing rather than no-op all-True). Imported populations toggle,
|
|
2182
|
+
highlight, filter, and feed the statistics table like any gate. An
|
|
2183
|
+
"Annotate clusters…" dialog names them with phenotypes, persisted in
|
|
2184
|
+
the session's `cluster_labels` slot and shown as the population name.
|
|
2185
|
+
Cluster populations have no FlowJo geometry, so the `.wsp` export
|
|
2186
|
+
drops them with a clear lossy-export warning.
|
|
2187
|
+
- **Gate templates retarget by antibody label.** Saving a template now
|
|
2188
|
+
stamps each gate's channel with its antibody label; applying that
|
|
2189
|
+
template to a sample where the marker sits on a *different* detector
|
|
2190
|
+
retargets the gate to that sample's detector (`relabel_gate_for_sample`).
|
|
2191
|
+
So a CD11b gate applies wherever CD11b lives in each sample, across
|
|
2192
|
+
panels — compensation is unaffected (only which column the gate
|
|
2193
|
+
reads changes).
|
|
2194
|
+
- **Per-sample FMO override in the config GUI.** A group's samples
|
|
2195
|
+
field accepts `name:FMOset` to point one sample at a different FMO
|
|
2196
|
+
control set (e.g. `m1, m2:Late, m3`); `_get_groups` emits the
|
|
2197
|
+
per-sample dict form the pipeline resolves. A hint documents the
|
|
2198
|
+
syntax.
|
|
2199
|
+
- **Cross-sample label-first tying + common-fluor warning.** The same
|
|
2200
|
+
antibody can sit on a different fluorophore across samples/days, so
|
|
2201
|
+
cross-sample analysis now aligns by antibody **label**, not detector
|
|
2202
|
+
(compensation stays keyed on detectors — each sample compensates its
|
|
2203
|
+
own `$SPILL`). New `openflo.pipeline` utilities: `align_fluor_labels`
|
|
2204
|
+
(common labels + per-sample label→detector + missing map),
|
|
2205
|
+
`common_fluor_warning`, and `concatenate_by_label` (merge samples on
|
|
2206
|
+
the common label set, renaming each sample's fluors to labels). The
|
|
2207
|
+
statistics table now names per-channel columns by **each sample's own**
|
|
2208
|
+
label, so a marker on different fluors merges into one column
|
|
2209
|
+
(`Median CD11b`) across samples. The editor flags a non-common fluor
|
|
2210
|
+
panel on sample load and in the Statistics window; non-common labels
|
|
2211
|
+
are simply blank where absent.
|
|
2212
|
+
- **Population statistics table (FlowJo-style).** A "Statistics…" window
|
|
2213
|
+
in the editor tabulates, per sample × population (gate node, evaluated
|
|
2214
|
+
as the cumulative gate chain): Count, %Parent, %Total, and per-channel
|
|
2215
|
+
Median / Mean / CV. Columns are modular (checkbox toggles); the table
|
|
2216
|
+
exports to analysis-ready CSV. Populations show a FlowJo-style path
|
|
2217
|
+
(`Cells/Singlets/CD11b+`). Computed on full sample data, not the plot
|
|
2218
|
+
downsample.
|
|
2219
|
+
- **Full FlowJo gate parity — ellipsoid + quadrant.** `WspReader`
|
|
2220
|
+
parses `EllipsoidGate` (mean + covariance + distanceSquare) and
|
|
2221
|
+
`QuadrantGate` (two dividers → 4 linked rects); `WspWriter` emits
|
|
2222
|
+
both (collapsing a `quad_set` rect group back into one QuadrantGate);
|
|
2223
|
+
`gate_to_mask` evaluates ellipsoids via squared Mahalanobis distance.
|
|
2224
|
+
Round-trip is self-consistent (our writer ↔ reader); FlowJo v10's
|
|
2225
|
+
exact serialization still needs validation against a real file.
|
|
2226
|
+
- **Editor: ellipsoid rendering + interactive Ellipse tool.** Ellipses
|
|
2227
|
+
render (rotated too, via covariance eigendecomposition); a new
|
|
2228
|
+
Ellipse tool draws them, and the Edit tool moves / resizes (drag rim)
|
|
2229
|
+
/ rotates (drag grip) them.
|
|
2230
|
+
- **`.flowsession` save/load.** Captures the full editor state —
|
|
2231
|
+
samples (by path + colour + plot-enabled), per-sample gates at full
|
|
2232
|
+
fidelity (incl. ellipsoid / quadrant / colour / enabled), per-channel
|
|
2233
|
+
scale + range, plot mode, channel labels, downsample toggles, and a
|
|
2234
|
+
reserved `cluster_labels` slot. Autosaves to
|
|
2235
|
+
`~/.openflo/last_session.flowsession` on editor close and offers to
|
|
2236
|
+
resume it on next open. Save/Load Session buttons in the editor.
|
|
2237
|
+
- **Batch template application.** "Load Template…" now pops a dialog to
|
|
2238
|
+
choose which loaded samples to apply to (multiselect + select all /
|
|
2239
|
+
none) and whether to **overwrite** each target's gates or **add to**
|
|
2240
|
+
them. Previously a template loaded into the active sample only. Gates
|
|
2241
|
+
referencing channels a target sample lacks are reported in a
|
|
2242
|
+
post-apply warning (they install but sit inert).
|
|
2243
|
+
- **Lossy-export warning.** Exporting to `.wsp` now checks for
|
|
2244
|
+
OpenFlo-only state the FlowJo schema can't hold (custom per-channel
|
|
2245
|
+
axis scales / ranges, disabled gates, cluster labels) and warns
|
|
2246
|
+
before writing, offering to save a full `.flowsession` instead.
|
|
2247
|
+
Gates + compensation always survive, so a plain gating export
|
|
2248
|
+
doesn't nag.
|
|
2249
|
+
- **End-to-end CLI tests** (`tests/test_cli_e2e.py`). Two tiers:
|
|
2250
|
+
fast `--help`-based wiring checks (always run — catch console-
|
|
2251
|
+
script breakage + flag-parsing regressions); and a full-pipeline
|
|
2252
|
+
subprocess run against the synthetic FCS, opt-in via
|
|
2253
|
+
`OPENFLO_RUN_SLOW_TESTS=1` (it runs Phenograph + UMAP, ~35 s warm
|
|
2254
|
+
but timing-sensitive under load, so it's gated like the real-data
|
|
2255
|
+
fixtures rather than making the default suite flaky).
|
|
2256
|
+
- **WSP per-sample extract tests** (`tests/test_wsp_writer.py`) —
|
|
2257
|
+
exercise the `extract_gates(sample_node=...)` kwarg added
|
|
2258
|
+
during the gate-editor WSP-ingest work. Multi-sample synthetic
|
|
2259
|
+
workspace, per-sample subsetting, parent_id chain preservation,
|
|
2260
|
+
default-walk regression.
|
|
2261
|
+
- **OSS infrastructure** — `.github/ISSUE_TEMPLATE/` (bug + feature
|
|
2262
|
+
+ config routing questions to Discussions), `PULL_REQUEST_TEMPLATE`
|
|
2263
|
+
with a "Scientific impact" section, `.pre-commit-config.yaml`
|
|
2264
|
+
(trailing-whitespace, EOF, large-files, ruff check + format),
|
|
2265
|
+
`environment.yml` (conda mirror with optional RAPIDS).
|
|
2266
|
+
- **`docs/algorithms.md`** — ~250 lines covering compensation
|
|
2267
|
+
sources + optimizer heuristics, logicle T/M/W/A defaults with
|
|
2268
|
+
FlowJo parity notes, FMO threshold rationale, Phenograph k
|
|
2269
|
+
rule-of-thumb table, subsample + KD-tree-assign trick, GPU
|
|
2270
|
+
determinism caveats, UMAP defaults, what the pipeline is NOT
|
|
2271
|
+
good at. Cites Parks 2006, Levine 2015, McInnes 2018, Roederer
|
|
2272
|
+
2011. README links via a new `## Algorithms` section.
|
|
2273
|
+
- **README "Common workflows" section** — three concrete examples
|
|
2274
|
+
(single-sample GUI exploration, multi-trial batch run with
|
|
2275
|
+
`--groups` + `--fmo-sets` + `--export-wsp`, `openflo-compare`
|
|
2276
|
+
against a FlowJo workspace).
|
|
2277
|
+
- **Vulture dead-code config** in `pyproject.toml` `[tool.vulture]`
|
|
2278
|
+
with documented false-positive exclusions for PEP-562 hooks,
|
|
2279
|
+
public API surface, and ctypes Structure fields.
|
|
2280
|
+
|
|
2281
|
+
### Changed
|
|
2282
|
+
- **`pipeline.py` lazy-imports `matplotlib.pyplot`.** Moved from
|
|
2283
|
+
module-top to local imports inside the 5 plot methods. Saves
|
|
2284
|
+
~300 ms on `import openflo.pipeline` (1050 ms → 750 ms) —
|
|
2285
|
+
matters for the gate editor / compare tool / any WSP-only
|
|
2286
|
+
caller. PEP-562 hook now exposes `pipeline.plt` for external
|
|
2287
|
+
callers that still want the bare attribute.
|
|
2288
|
+
- **Gate editor write paths surface failures visibly.**
|
|
2289
|
+
`_save_template`, `_export_flowjo_wsp`, and `_apply_save_gates`
|
|
2290
|
+
now `messagebox.showerror` on failure in addition to the
|
|
2291
|
+
status-bar message. Silent data loss after a Save dialog is
|
|
2292
|
+
worse than the alert pop-up.
|
|
2293
|
+
- **Removed the `_LazyFlowio` proxy** from `gui.py`. Replaced
|
|
2294
|
+
with a function-local `import flowio` at the single call site
|
|
2295
|
+
in `_inspect_channels_for_labels`. Same lazy effect; 16 fewer
|
|
2296
|
+
lines; no `# type: ignore[assignment]` workaround.
|
|
2297
|
+
|
|
2298
|
+
## [0.2.0] — 2026-05-27
|
|
2299
|
+
|
|
2300
|
+
### Added
|
|
2301
|
+
- **Gate editor: Edit tool** with modifier-key gestures — left-drag to
|
|
2302
|
+
move a vertex/line, shift+drag to translate the whole gate,
|
|
2303
|
+
right-click on a polygon vertex to delete (refuses below 3 verts),
|
|
2304
|
+
right-click on an edge to insert, alt+left-click anywhere to drop
|
|
2305
|
+
a vertex into the polygon under the cursor. Per-tool gesture hint
|
|
2306
|
+
shown below the tool selector.
|
|
2307
|
+
- **Per-channel axis scale + range.** ⚙ buttons next to the X/Y combos
|
|
2308
|
+
open a dialog: Linear / Symlog / Log scale, plus optional custom
|
|
2309
|
+
(min, max) range. State is keyed by channel name so swapping the
|
|
2310
|
+
X combo to a different channel picks up that channel's saved
|
|
2311
|
+
preference. Symlog `linthresh` is data-driven (5th percentile of
|
|
2312
|
+
|nonzero|, floor 1e-6).
|
|
2313
|
+
- **Auto-downsample toggles.** "Auto-downsample display to smallest
|
|
2314
|
+
sample" (default ON) caps every plotted sample at the smallest
|
|
2315
|
+
loaded sample's size for honest overlay comparisons; underlying
|
|
2316
|
+
data is untouched. "…and propagate to data" (default OFF) actually
|
|
2317
|
+
trims `FlowSample.data` so clustering / stats see the trimmed set.
|
|
2318
|
+
Seeded so the same subsample renders across replots.
|
|
2319
|
+
- **WSP ingest in the gate editor's Add-FCS button.** Picking a `.wsp`
|
|
2320
|
+
walks each `<Sample>`, resolves its `<DataSet uri="...">` to a
|
|
2321
|
+
local FCS path (tries as-is, then the WSP's own directory, then
|
|
2322
|
+
the editor's `fcs_dir`), queues the FCS for load, and stages the
|
|
2323
|
+
sample's gate subtree to attach as the FCS finishes parsing.
|
|
2324
|
+
- `WspReader.extract_gates(*, sample_node=...)` — opt-in per-sample
|
|
2325
|
+
walk that reuses the existing parsers. Default behaviour
|
|
2326
|
+
unchanged.
|
|
2327
|
+
- **Log-spaced histogram bins** when a channel's axis scale is `log`
|
|
2328
|
+
(linear / symlog continue to use linear-spaced bins). New
|
|
2329
|
+
`_hist_bin_edges` helper clamps non-positive lower bounds to a
|
|
2330
|
+
small positive floor and falls back to linear when the clamped
|
|
2331
|
+
range degenerates.
|
|
2332
|
+
- Comprehensive unit tests for the new gate-editor helpers
|
|
2333
|
+
(`tests/test_gate_editor_helpers.py`, 47 tests) — covers
|
|
2334
|
+
`_gid_from_hit`, polygon vertex add/delete/find, downsample floor,
|
|
2335
|
+
axis scale apply path, and log-spaced bin edges.
|
|
2336
|
+
|
|
2337
|
+
### Changed
|
|
2338
|
+
- Compensation matrix actually round-trips through the workspace
|
|
2339
|
+
export. Both `gui._export_flowjo_wsp` and
|
|
2340
|
+
`cli._export_pipeline_workspace` now call `WspWriter.set_compensation`;
|
|
2341
|
+
`FlowSample._apply_comp` persists the matrix on
|
|
2342
|
+
`self.comp_matrix` / `self.comp_channels` so callers can read it
|
|
2343
|
+
back. Two new regression tests in `tests/test_wsp_writer.py`.
|
|
2344
|
+
- `OptimizeCompensationDialog._autofill_from_dir` — the auto-detect
|
|
2345
|
+
for single-stain control files now uses an ordered candidate
|
|
2346
|
+
tokenizer (joined form first, then each dash-separated part
|
|
2347
|
+
longest-first) instead of the naïve "first dash-separated token"
|
|
2348
|
+
heuristic. `PE-Cy7-A` now produces tokens `['pecy7', 'cy7']`
|
|
2349
|
+
instead of just `['pe']`. Ambiguous and unmatched channels surface
|
|
2350
|
+
in the status bar.
|
|
2351
|
+
|
|
2352
|
+
### Fixed
|
|
2353
|
+
- **Sample-name collision across groups.** Per-sample tasks were keyed
|
|
2354
|
+
by bare sample name in the dispatcher, so two groups (e.g. two day
|
|
2355
|
+
folders) containing identically-named FCS (`sample_1.fcs`)
|
|
2356
|
+
silently bucketed both results into one group and dropped the other.
|
|
2357
|
+
Tasks are now keyed by group+name. Surfaced + guarded by the by-day
|
|
2358
|
+
e2e test.
|
|
2359
|
+
- **Histogram blank rendering on wide-range fluor data.** Non-finite
|
|
2360
|
+
values (NaN / ±inf) silently made matplotlib's hist skip entries;
|
|
2361
|
+
auto-ranging across samples with vastly different scales (one
|
|
2362
|
+
logicle ~0–1, one raw 0–262144) collapsed the narrow-range sample
|
|
2363
|
+
into a single bin at zero. Now filters non-finite up-front and
|
|
2364
|
+
pins all samples to a shared bin grid built from the union of
|
|
2365
|
+
robust per-sample percentile ranges.
|
|
2366
|
+
|
|
2367
|
+
## [0.1.0] — 2026-05-27
|
|
2368
|
+
Baseline version captured for the first OSS-ready release. See git log for
|
|
2369
|
+
the full pre-OSS feature set (compensation editor, WSP round-trip, GUI
|
|
2370
|
+
gate editor, comparison tool, GPU clustering, seeded reproducibility).
|