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.
Files changed (300) hide show
  1. openflo-2.6.0/CHANGELOG.md +2370 -0
  2. openflo-2.6.0/LICENSE.txt +31 -0
  3. openflo-2.6.0/MANIFEST.in +9 -0
  4. openflo-2.6.0/PKG-INFO +450 -0
  5. openflo-2.6.0/README.md +389 -0
  6. openflo-2.6.0/pyproject.toml +238 -0
  7. openflo-2.6.0/scripts/bench_accelerators.py +294 -0
  8. openflo-2.6.0/scripts/bench_louvain_restarts.py +120 -0
  9. openflo-2.6.0/scripts/bench_snn_prune.py +118 -0
  10. openflo-2.6.0/scripts/bench_ui_responsiveness.py +174 -0
  11. openflo-2.6.0/scripts/bench_umap_knn.py +177 -0
  12. openflo-2.6.0/scripts/lowest_direct_requirements.py +81 -0
  13. openflo-2.6.0/scripts/make_synthetic_dataset.py +34 -0
  14. openflo-2.6.0/scripts/migrate_session.py +71 -0
  15. openflo-2.6.0/scripts/mutate_controls.py +283 -0
  16. openflo-2.6.0/scripts/preflight.py +72 -0
  17. openflo-2.6.0/scripts/publish_public.py +198 -0
  18. openflo-2.6.0/scripts/run_analyses.py +303 -0
  19. openflo-2.6.0/scripts/smoke_test.py +86 -0
  20. openflo-2.6.0/scripts/theme_audit.py +279 -0
  21. openflo-2.6.0/setup.cfg +4 -0
  22. openflo-2.6.0/src/openflo/__init__.py +286 -0
  23. openflo-2.6.0/src/openflo/_console.py +39 -0
  24. openflo-2.6.0/src/openflo/_golden.json +53 -0
  25. openflo-2.6.0/src/openflo/annotate.py +213 -0
  26. openflo-2.6.0/src/openflo/async_task.py +78 -0
  27. openflo-2.6.0/src/openflo/audit.py +144 -0
  28. openflo-2.6.0/src/openflo/calibration.py +112 -0
  29. openflo-2.6.0/src/openflo/capabilities.py +116 -0
  30. openflo-2.6.0/src/openflo/cli.py +2354 -0
  31. openflo-2.6.0/src/openflo/comp_qc.py +144 -0
  32. openflo-2.6.0/src/openflo/compare.py +362 -0
  33. openflo-2.6.0/src/openflo/compliance.py +150 -0
  34. openflo-2.6.0/src/openflo/density.py +200 -0
  35. openflo-2.6.0/src/openflo/diagnostics.py +245 -0
  36. openflo-2.6.0/src/openflo/diffexp.py +255 -0
  37. openflo-2.6.0/src/openflo/dr_compare.py +160 -0
  38. openflo-2.6.0/src/openflo/editor_analysis.py +579 -0
  39. openflo-2.6.0/src/openflo/editor_audit.py +37 -0
  40. openflo-2.6.0/src/openflo/editor_autoclean.py +428 -0
  41. openflo-2.6.0/src/openflo/editor_autogate.py +184 -0
  42. openflo-2.6.0/src/openflo/editor_base.py +31 -0
  43. openflo-2.6.0/src/openflo/editor_channels.py +83 -0
  44. openflo-2.6.0/src/openflo/editor_chrome.py +451 -0
  45. openflo-2.6.0/src/openflo/editor_clipboard.py +514 -0
  46. openflo-2.6.0/src/openflo/editor_compute.py +218 -0
  47. openflo-2.6.0/src/openflo/editor_console.py +156 -0
  48. openflo-2.6.0/src/openflo/editor_dnd.py +632 -0
  49. openflo-2.6.0/src/openflo/editor_downsample.py +152 -0
  50. openflo-2.6.0/src/openflo/editor_export.py +408 -0
  51. openflo-2.6.0/src/openflo/editor_figure.py +201 -0
  52. openflo-2.6.0/src/openflo/editor_gatetools.py +783 -0
  53. openflo-2.6.0/src/openflo/editor_gating.py +1083 -0
  54. openflo-2.6.0/src/openflo/editor_grouping.py +85 -0
  55. openflo-2.6.0/src/openflo/editor_help.py +237 -0
  56. openflo-2.6.0/src/openflo/editor_lifecycle.py +604 -0
  57. openflo-2.6.0/src/openflo/editor_load.py +544 -0
  58. openflo-2.6.0/src/openflo/editor_loadpool.py +434 -0
  59. openflo-2.6.0/src/openflo/editor_menu.py +268 -0
  60. openflo-2.6.0/src/openflo/editor_mode.py +114 -0
  61. openflo-2.6.0/src/openflo/editor_plot.py +1619 -0
  62. openflo-2.6.0/src/openflo/editor_populations.py +458 -0
  63. openflo-2.6.0/src/openflo/editor_session.py +776 -0
  64. openflo-2.6.0/src/openflo/editor_slider.py +208 -0
  65. openflo-2.6.0/src/openflo/editor_stats.py +218 -0
  66. openflo-2.6.0/src/openflo/editor_template.py +300 -0
  67. openflo-2.6.0/src/openflo/editor_tools.py +473 -0
  68. openflo-2.6.0/src/openflo/editor_tree.py +509 -0
  69. openflo-2.6.0/src/openflo/editor_undo.py +98 -0
  70. openflo-2.6.0/src/openflo/editor_update.py +116 -0
  71. openflo-2.6.0/src/openflo/fcs_export.py +87 -0
  72. openflo-2.6.0/src/openflo/gatetree.py +232 -0
  73. openflo-2.6.0/src/openflo/gating.py +165 -0
  74. openflo-2.6.0/src/openflo/gating_helpers.py +187 -0
  75. openflo-2.6.0/src/openflo/gpu_accel.py +295 -0
  76. openflo-2.6.0/src/openflo/gui.py +2436 -0
  77. openflo-2.6.0/src/openflo/inspect_fcs.py +26 -0
  78. openflo-2.6.0/src/openflo/interop.py +200 -0
  79. openflo-2.6.0/src/openflo/paths.py +78 -0
  80. openflo-2.6.0/src/openflo/pipeline.py +5720 -0
  81. openflo-2.6.0/src/openflo/plotmath.py +191 -0
  82. openflo-2.6.0/src/openflo/prefs.py +56 -0
  83. openflo-2.6.0/src/openflo/preview.py +236 -0
  84. openflo-2.6.0/src/openflo/provenance.py +337 -0
  85. openflo-2.6.0/src/openflo/py.typed +0 -0
  86. openflo-2.6.0/src/openflo/report.py +104 -0
  87. openflo-2.6.0/src/openflo/scales.py +85 -0
  88. openflo-2.6.0/src/openflo/selftest.py +288 -0
  89. openflo-2.6.0/src/openflo/selftest_controls.py +644 -0
  90. openflo-2.6.0/src/openflo/session_format.py +81 -0
  91. openflo-2.6.0/src/openflo/spectral.py +305 -0
  92. openflo-2.6.0/src/openflo/stats.py +417 -0
  93. openflo-2.6.0/src/openflo/synthetic.py +735 -0
  94. openflo-2.6.0/src/openflo/template_library/README.md +92 -0
  95. openflo-2.6.0/src/openflo/template_library/cleanup_acquisition_qc.json +43 -0
  96. openflo-2.6.0/src/openflo/template_library/cleanup_minimal.json +35 -0
  97. openflo-2.6.0/src/openflo/template_library/cleanup_standard.json +67 -0
  98. openflo-2.6.0/src/openflo/template_library/cleanup_strict.json +67 -0
  99. openflo-2.6.0/src/openflo/template_library/example_panel.json +15 -0
  100. openflo-2.6.0/src/openflo/theme.py +230 -0
  101. openflo-2.6.0/src/openflo/tool_window.py +160 -0
  102. openflo-2.6.0/src/openflo/trajectory.py +171 -0
  103. openflo-2.6.0/src/openflo/tree_ids.py +63 -0
  104. openflo-2.6.0/src/openflo/ui_abscounts.py +74 -0
  105. openflo-2.6.0/src/openflo/ui_annotation.py +182 -0
  106. openflo-2.6.0/src/openflo/ui_audit.py +222 -0
  107. openflo-2.6.0/src/openflo/ui_autogate.py +179 -0
  108. openflo-2.6.0/src/openflo/ui_axis_config.py +157 -0
  109. openflo-2.6.0/src/openflo/ui_calibration.py +208 -0
  110. openflo-2.6.0/src/openflo/ui_cell_cycle.py +76 -0
  111. openflo-2.6.0/src/openflo/ui_comp.py +530 -0
  112. openflo-2.6.0/src/openflo/ui_compare.py +348 -0
  113. openflo-2.6.0/src/openflo/ui_diff.py +248 -0
  114. openflo-2.6.0/src/openflo/ui_embedding.py +92 -0
  115. openflo-2.6.0/src/openflo/ui_expression.py +326 -0
  116. openflo-2.6.0/src/openflo/ui_figure_layout.py +121 -0
  117. openflo-2.6.0/src/openflo/ui_figure_window.py +55 -0
  118. openflo-2.6.0/src/openflo/ui_flowsom_tree.py +156 -0
  119. openflo-2.6.0/src/openflo/ui_fmo.py +142 -0
  120. openflo-2.6.0/src/openflo/ui_frequency.py +598 -0
  121. openflo-2.6.0/src/openflo/ui_group_stats.py +91 -0
  122. openflo-2.6.0/src/openflo/ui_inspect.py +248 -0
  123. openflo-2.6.0/src/openflo/ui_logic.py +75 -0
  124. openflo-2.6.0/src/openflo/ui_methods.py +87 -0
  125. openflo-2.6.0/src/openflo/ui_preferences.py +268 -0
  126. openflo-2.6.0/src/openflo/ui_preview.py +292 -0
  127. openflo-2.6.0/src/openflo/ui_sample_qc.py +183 -0
  128. openflo-2.6.0/src/openflo/ui_spectral_qc.py +196 -0
  129. openflo-2.6.0/src/openflo/ui_spectral_unmix.py +116 -0
  130. openflo-2.6.0/src/openflo/ui_statistics.py +349 -0
  131. openflo-2.6.0/src/openflo/ui_synth.py +309 -0
  132. openflo-2.6.0/src/openflo/ui_tips.py +82 -0
  133. openflo-2.6.0/src/openflo/ui_trajectory.py +248 -0
  134. openflo-2.6.0/src/openflo/ui_voltage.py +345 -0
  135. openflo-2.6.0/src/openflo/update.py +244 -0
  136. openflo-2.6.0/src/openflo/voltage.py +377 -0
  137. openflo-2.6.0/src/openflo/warmup.py +94 -0
  138. openflo-2.6.0/src/openflo/workspace.py +3079 -0
  139. openflo-2.6.0/src/openflo.egg-info/PKG-INFO +450 -0
  140. openflo-2.6.0/src/openflo.egg-info/SOURCES.txt +298 -0
  141. openflo-2.6.0/src/openflo.egg-info/dependency_links.txt +1 -0
  142. openflo-2.6.0/src/openflo.egg-info/entry_points.txt +10 -0
  143. openflo-2.6.0/src/openflo.egg-info/requires.txt +45 -0
  144. openflo-2.6.0/src/openflo.egg-info/top_level.txt +1 -0
  145. openflo-2.6.0/templates/testtemplate.json +69 -0
  146. openflo-2.6.0/tests/__init__.py +0 -0
  147. openflo-2.6.0/tests/conftest.py +201 -0
  148. openflo-2.6.0/tests/test_abscounts.py +47 -0
  149. openflo-2.6.0/tests/test_annotate.py +128 -0
  150. openflo-2.6.0/tests/test_apply_region_gates.py +112 -0
  151. openflo-2.6.0/tests/test_async_task.py +70 -0
  152. openflo-2.6.0/tests/test_audit.py +120 -0
  153. openflo-2.6.0/tests/test_autoclean_counts.py +119 -0
  154. openflo-2.6.0/tests/test_autoclean_doublets_scale.py +140 -0
  155. openflo-2.6.0/tests/test_autoclean_methods.py +372 -0
  156. openflo-2.6.0/tests/test_autogate.py +206 -0
  157. openflo-2.6.0/tests/test_autogate_reproducible.py +193 -0
  158. openflo-2.6.0/tests/test_boolean_gate_fails_closed.py +109 -0
  159. openflo-2.6.0/tests/test_calibration.py +128 -0
  160. openflo-2.6.0/tests/test_capabilities.py +56 -0
  161. openflo-2.6.0/tests/test_cell_cycle.py +149 -0
  162. openflo-2.6.0/tests/test_cli_cluster.py +58 -0
  163. openflo-2.6.0/tests/test_cli_e2e.py +207 -0
  164. openflo-2.6.0/tests/test_cli_unmix.py +128 -0
  165. openflo-2.6.0/tests/test_clipboard_move_marker.py +209 -0
  166. openflo-2.6.0/tests/test_cluster_labels.py +49 -0
  167. openflo-2.6.0/tests/test_cluster_reproducible.py +107 -0
  168. openflo-2.6.0/tests/test_clustering_reproducible.py +131 -0
  169. openflo-2.6.0/tests/test_comp_editor.py +55 -0
  170. openflo-2.6.0/tests/test_comp_matrix_csv_quoting.py +56 -0
  171. openflo-2.6.0/tests/test_comp_qc.py +108 -0
  172. openflo-2.6.0/tests/test_compare.py +120 -0
  173. openflo-2.6.0/tests/test_compare_all.py +94 -0
  174. openflo-2.6.0/tests/test_compare_conditions_absent_clusters.py +115 -0
  175. openflo-2.6.0/tests/test_compensation.py +131 -0
  176. openflo-2.6.0/tests/test_compliance.py +165 -0
  177. openflo-2.6.0/tests/test_console_encoding.py +110 -0
  178. openflo-2.6.0/tests/test_cytonorm.py +177 -0
  179. openflo-2.6.0/tests/test_cytonorm_qc_undefined.py +102 -0
  180. openflo-2.6.0/tests/test_degenerate_scatter_gating.py +95 -0
  181. openflo-2.6.0/tests/test_density.py +117 -0
  182. openflo-2.6.0/tests/test_diagnostics.py +95 -0
  183. openflo-2.6.0/tests/test_diagnostics_selftest_gate.py +107 -0
  184. openflo-2.6.0/tests/test_dialog_tooltips.py +342 -0
  185. openflo-2.6.0/tests/test_diff_abundance_small_n.py +113 -0
  186. openflo-2.6.0/tests/test_diffabundance.py +116 -0
  187. openflo-2.6.0/tests/test_diffexp.py +113 -0
  188. openflo-2.6.0/tests/test_doctor_tk_check.py +92 -0
  189. openflo-2.6.0/tests/test_dr_compare.py +54 -0
  190. openflo-2.6.0/tests/test_drag_tree_refresh.py +118 -0
  191. openflo-2.6.0/tests/test_editor_mixins.py +89 -0
  192. openflo-2.6.0/tests/test_ellipsoid_quadrant.py +285 -0
  193. openflo-2.6.0/tests/test_embedding.py +100 -0
  194. openflo-2.6.0/tests/test_embeddings.py +73 -0
  195. openflo-2.6.0/tests/test_exceptions.py +25 -0
  196. openflo-2.6.0/tests/test_exit_autosave.py +131 -0
  197. openflo-2.6.0/tests/test_fast_graph.py +119 -0
  198. openflo-2.6.0/tests/test_fcs_export.py +118 -0
  199. openflo-2.6.0/tests/test_figure_layout.py +127 -0
  200. openflo-2.6.0/tests/test_flowsom.py +95 -0
  201. openflo-2.6.0/tests/test_flowsom_mst.py +49 -0
  202. openflo-2.6.0/tests/test_gate_editor_helpers.py +565 -0
  203. openflo-2.6.0/tests/test_gate_impl_parity.py +112 -0
  204. openflo-2.6.0/tests/test_gate_import_fixes.py +68 -0
  205. openflo-2.6.0/tests/test_gate_mask_cache.py +111 -0
  206. openflo-2.6.0/tests/test_gate_nonfinite_events.py +107 -0
  207. openflo-2.6.0/tests/test_gate_partition.py +126 -0
  208. openflo-2.6.0/tests/test_gate_replication_parent.py +165 -0
  209. openflo-2.6.0/tests/test_gatetree.py +92 -0
  210. openflo-2.6.0/tests/test_gating.py +253 -0
  211. openflo-2.6.0/tests/test_gating_helpers.py +155 -0
  212. openflo-2.6.0/tests/test_golden_regression.py +127 -0
  213. openflo-2.6.0/tests/test_gpu_accel.py +206 -0
  214. openflo-2.6.0/tests/test_group_stats.py +203 -0
  215. openflo-2.6.0/tests/test_group_summary_sd.py +56 -0
  216. openflo-2.6.0/tests/test_grouping.py +219 -0
  217. openflo-2.6.0/tests/test_gui_smoke.py +1318 -0
  218. openflo-2.6.0/tests/test_gui_toolings.py +84 -0
  219. openflo-2.6.0/tests/test_gui_ux.py +721 -0
  220. openflo-2.6.0/tests/test_histogram_offscale_note.py +97 -0
  221. openflo-2.6.0/tests/test_histogram_resolution.py +115 -0
  222. openflo-2.6.0/tests/test_import_is_lazy.py +73 -0
  223. openflo-2.6.0/tests/test_interop.py +145 -0
  224. openflo-2.6.0/tests/test_interop_h5ad_roundtrip.py +107 -0
  225. openflo-2.6.0/tests/test_label_align.py +148 -0
  226. openflo-2.6.0/tests/test_legend_placement.py +138 -0
  227. openflo-2.6.0/tests/test_leiden.py +83 -0
  228. openflo-2.6.0/tests/test_load_no_copy.py +89 -0
  229. openflo-2.6.0/tests/test_loader_queue.py +119 -0
  230. openflo-2.6.0/tests/test_log_histogram_range.py +126 -0
  231. openflo-2.6.0/tests/test_log_transform_nonpositive.py +113 -0
  232. openflo-2.6.0/tests/test_mem_tiny_clusters.py +117 -0
  233. openflo-2.6.0/tests/test_no_plausible_fills.py +250 -0
  234. openflo-2.6.0/tests/test_normalise_groups_idempotent.py +82 -0
  235. openflo-2.6.0/tests/test_not_gate_excludes_unmeasured.py +117 -0
  236. openflo-2.6.0/tests/test_panel_pairs.py +145 -0
  237. openflo-2.6.0/tests/test_paths.py +66 -0
  238. openflo-2.6.0/tests/test_plot_density.py +65 -0
  239. openflo-2.6.0/tests/test_plotmath.py +102 -0
  240. openflo-2.6.0/tests/test_polygon_gating.py +158 -0
  241. openflo-2.6.0/tests/test_population_stats_geomean.py +101 -0
  242. openflo-2.6.0/tests/test_preferences_wiring.py +88 -0
  243. openflo-2.6.0/tests/test_prefs.py +53 -0
  244. openflo-2.6.0/tests/test_prepare_unit_tags.py +167 -0
  245. openflo-2.6.0/tests/test_preview_quadrants.py +102 -0
  246. openflo-2.6.0/tests/test_propagate_downsample_order.py +147 -0
  247. openflo-2.6.0/tests/test_provenance.py +148 -0
  248. openflo-2.6.0/tests/test_provenance_unreadable_entries.py +78 -0
  249. openflo-2.6.0/tests/test_qc.py +220 -0
  250. openflo-2.6.0/tests/test_qc_ignores_derived_columns.py +127 -0
  251. openflo-2.6.0/tests/test_qc_integer_channels.py +204 -0
  252. openflo-2.6.0/tests/test_quality_correctness.py +44 -0
  253. openflo-2.6.0/tests/test_raw_not_aliased.py +104 -0
  254. openflo-2.6.0/tests/test_report.py +78 -0
  255. openflo-2.6.0/tests/test_resume_and_transfer_hygiene.py +153 -0
  256. openflo-2.6.0/tests/test_scales.py +39 -0
  257. openflo-2.6.0/tests/test_scales_logicle.py +96 -0
  258. openflo-2.6.0/tests/test_seed_phrases.py +102 -0
  259. openflo-2.6.0/tests/test_selftest.py +45 -0
  260. openflo-2.6.0/tests/test_selftest_controls.py +190 -0
  261. openflo-2.6.0/tests/test_session.py +401 -0
  262. openflo-2.6.0/tests/test_session_continuity.py +131 -0
  263. openflo-2.6.0/tests/test_session_json_is_valid.py +112 -0
  264. openflo-2.6.0/tests/test_session_migration_machinery.py +139 -0
  265. openflo-2.6.0/tests/test_session_sidecar_collision.py +195 -0
  266. openflo-2.6.0/tests/test_spectral.py +302 -0
  267. openflo-2.6.0/tests/test_spectral_degenerate_reference.py +93 -0
  268. openflo-2.6.0/tests/test_spectral_unmix_nonfinite.py +96 -0
  269. openflo-2.6.0/tests/test_stats.py +698 -0
  270. openflo-2.6.0/tests/test_stats_multigroup.py +191 -0
  271. openflo-2.6.0/tests/test_survivor_boundaries.py +104 -0
  272. openflo-2.6.0/tests/test_synthetic.py +238 -0
  273. openflo-2.6.0/tests/test_templates.py +64 -0
  274. openflo-2.6.0/tests/test_theme.py +82 -0
  275. openflo-2.6.0/tests/test_tool_window.py +91 -0
  276. openflo-2.6.0/tests/test_trajectory.py +137 -0
  277. openflo-2.6.0/tests/test_trajectory_nonfinite.py +157 -0
  278. openflo-2.6.0/tests/test_transform_nonfinite.py +82 -0
  279. openflo-2.6.0/tests/test_transforms.py +65 -0
  280. openflo-2.6.0/tests/test_tree_ids.py +36 -0
  281. openflo-2.6.0/tests/test_ui_compare.py +36 -0
  282. openflo-2.6.0/tests/test_ui_dialogs_smoke.py +184 -0
  283. openflo-2.6.0/tests/test_ui_inspect.py +78 -0
  284. openflo-2.6.0/tests/test_ui_logic.py +75 -0
  285. openflo-2.6.0/tests/test_ui_preview.py +39 -0
  286. openflo-2.6.0/tests/test_ui_sample_qc.py +97 -0
  287. openflo-2.6.0/tests/test_ui_synth.py +50 -0
  288. openflo-2.6.0/tests/test_ui_voltage.py +102 -0
  289. openflo-2.6.0/tests/test_umap_embedding.py +180 -0
  290. openflo-2.6.0/tests/test_undo_redo.py +123 -0
  291. openflo-2.6.0/tests/test_update.py +85 -0
  292. openflo-2.6.0/tests/test_voltage.py +199 -0
  293. openflo-2.6.0/tests/test_warmup.py +210 -0
  294. openflo-2.6.0/tests/test_workspace.py +956 -0
  295. openflo-2.6.0/tests/test_workspace_prep_offthread.py +207 -0
  296. openflo-2.6.0/tests/test_write_fcs.py +53 -0
  297. openflo-2.6.0/tests/test_wsp_extract.py +69 -0
  298. openflo-2.6.0/tests/test_wsp_partial_matrix.py +90 -0
  299. openflo-2.6.0/tests/test_wsp_population_names.py +100 -0
  300. 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).