reader-workbench 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (293) hide show
  1. reader_workbench/__init__.py +22 -0
  2. reader_workbench/__main__.py +4 -0
  3. reader_workbench/_version.py +17 -0
  4. reader_workbench/api/__init__.py +74 -0
  5. reader_workbench/api/_record_reads.py +75 -0
  6. reader_workbench/api/artifacts.py +79 -0
  7. reader_workbench/api/facade.py +538 -0
  8. reader_workbench/api/models.py +285 -0
  9. reader_workbench/api/notebooks.py +63 -0
  10. reader_workbench/contracts/__init__.py +18 -0
  11. reader_workbench/contracts/builtins/__init__.py +36 -0
  12. reader_workbench/contracts/builtins/cytometry.py +140 -0
  13. reader_workbench/contracts/builtins/four_state_event_window.py +214 -0
  14. reader_workbench/contracts/builtins/generic.py +21 -0
  15. reader_workbench/contracts/builtins/logic.py +149 -0
  16. reader_workbench/contracts/builtins/plate_reader.py +47 -0
  17. reader_workbench/contracts/catalog.py +257 -0
  18. reader_workbench/contracts/model.py +109 -0
  19. reader_workbench/domains/__init__.py +1 -0
  20. reader_workbench/domains/cytometry/__init__.py +3 -0
  21. reader_workbench/domains/cytometry/analysis/__init__.py +29 -0
  22. reader_workbench/domains/cytometry/analysis/events.py +182 -0
  23. reader_workbench/domains/cytometry/analysis/gating.py +175 -0
  24. reader_workbench/domains/cytometry/analysis/workflow.py +248 -0
  25. reader_workbench/domains/cytometry/io/__init__.py +3 -0
  26. reader_workbench/domains/cytometry/io/fcs.py +135 -0
  27. reader_workbench/domains/cytometry/plots/__init__.py +5 -0
  28. reader_workbench/domains/cytometry/plots/diagnostic.py +155 -0
  29. reader_workbench/domains/logic/__init__.py +3 -0
  30. reader_workbench/domains/logic/crosstalk/__init__.py +3 -0
  31. reader_workbench/domains/logic/crosstalk/pairs.py +661 -0
  32. reader_workbench/domains/logic/four_state_vector/__init__.py +7 -0
  33. reader_workbench/domains/logic/four_state_vector/builder.py +321 -0
  34. reader_workbench/domains/logic/four_state_vector/collection/__init__.py +20 -0
  35. reader_workbench/domains/logic/four_state_vector/collection/checks.py +49 -0
  36. reader_workbench/domains/logic/four_state_vector/collection/constants.py +29 -0
  37. reader_workbench/domains/logic/four_state_vector/collection/model.py +19 -0
  38. reader_workbench/domains/logic/four_state_vector/collection/render.py +383 -0
  39. reader_workbench/domains/logic/four_state_vector/collection/sources.py +185 -0
  40. reader_workbench/domains/logic/four_state_vector/config.py +214 -0
  41. reader_workbench/domains/logic/four_state_vector/diagnostic.py +361 -0
  42. reader_workbench/domains/logic/four_state_vector/heatmap.py +86 -0
  43. reader_workbench/domains/logic/four_state_vector/math.py +191 -0
  44. reader_workbench/domains/logic/four_state_vector/reference.py +85 -0
  45. reader_workbench/domains/logic/four_state_vector/selection.py +228 -0
  46. reader_workbench/domains/logic/four_state_vector/treatment_semantics.py +51 -0
  47. reader_workbench/domains/logic/four_state_vector/validation.py +19 -0
  48. reader_workbench/domains/logic/logic_symmetry/__init__.py +3 -0
  49. reader_workbench/domains/logic/logic_symmetry/encodings.py +93 -0
  50. reader_workbench/domains/logic/logic_symmetry/extract_corners.py +192 -0
  51. reader_workbench/domains/logic/logic_symmetry/main.py +236 -0
  52. reader_workbench/domains/logic/logic_symmetry/metrics.py +96 -0
  53. reader_workbench/domains/logic/logic_symmetry/overlay.py +129 -0
  54. reader_workbench/domains/logic/logic_symmetry/prep.py +138 -0
  55. reader_workbench/domains/logic/logic_symmetry/render.py +356 -0
  56. reader_workbench/domains/logic/treatment_columns.py +42 -0
  57. reader_workbench/domains/plate_reader/__init__.py +1 -0
  58. reader_workbench/domains/plate_reader/analysis/__init__.py +14 -0
  59. reader_workbench/domains/plate_reader/analysis/fold_change.py +474 -0
  60. reader_workbench/domains/plate_reader/analysis/four_state_event_window/__init__.py +21 -0
  61. reader_workbench/domains/plate_reader/analysis/four_state_event_window/aggregation.py +191 -0
  62. reader_workbench/domains/plate_reader/analysis/four_state_event_window/contract_fields.py +50 -0
  63. reader_workbench/domains/plate_reader/analysis/four_state_event_window/contracts.py +320 -0
  64. reader_workbench/domains/plate_reader/analysis/four_state_event_window/design_dispositions.py +54 -0
  65. reader_workbench/domains/plate_reader/analysis/four_state_event_window/disposition_records.py +140 -0
  66. reader_workbench/domains/plate_reader/analysis/four_state_event_window/event_sensitivity.py +27 -0
  67. reader_workbench/domains/plate_reader/analysis/four_state_event_window/materialize.py +274 -0
  68. reader_workbench/domains/plate_reader/analysis/four_state_event_window/observation_resampling.py +97 -0
  69. reader_workbench/domains/plate_reader/analysis/four_state_event_window/reduction.py +62 -0
  70. reader_workbench/domains/plate_reader/analysis/four_state_event_window/seeds.py +15 -0
  71. reader_workbench/domains/plate_reader/analysis/four_state_event_window/sources.py +308 -0
  72. reader_workbench/domains/plate_reader/analysis/four_state_event_window/well_exclusion_validation.py +94 -0
  73. reader_workbench/domains/plate_reader/analysis/four_state_event_window/well_exclusions.py +54 -0
  74. reader_workbench/domains/plate_reader/analysis/timepoints.py +76 -0
  75. reader_workbench/domains/plate_reader/io/__init__.py +6 -0
  76. reader_workbench/domains/plate_reader/io/sample_map.py +65 -0
  77. reader_workbench/domains/plate_reader/io/synergy_h1/__init__.py +6 -0
  78. reader_workbench/domains/plate_reader/io/synergy_h1/_kinetic.py +141 -0
  79. reader_workbench/domains/plate_reader/io/synergy_h1/_parser.py +295 -0
  80. reader_workbench/domains/plate_reader/io/synergy_h1/_shared.py +195 -0
  81. reader_workbench/domains/plate_reader/io/synergy_h1/_snapshot.py +130 -0
  82. reader_workbench/domains/plate_reader/ordering.py +59 -0
  83. reader_workbench/domains/plate_reader/plots/__init__.py +15 -0
  84. reader_workbench/domains/plate_reader/plots/_data.py +29 -0
  85. reader_workbench/domains/plate_reader/plots/common.py +346 -0
  86. reader_workbench/domains/plate_reader/plots/distributions.py +324 -0
  87. reader_workbench/domains/plate_reader/plots/dual_reporter_triptych.py +525 -0
  88. reader_workbench/domains/plate_reader/plots/dual_reporter_triptych_render.py +195 -0
  89. reader_workbench/domains/plate_reader/plots/four_state_event_window/__init__.py +26 -0
  90. reader_workbench/domains/plate_reader/plots/four_state_event_window/diagnostic.py +295 -0
  91. reader_workbench/domains/plate_reader/plots/four_state_event_window/diagnostic_components.py +149 -0
  92. reader_workbench/domains/plate_reader/plots/four_state_event_window/diagnostic_render.py +308 -0
  93. reader_workbench/domains/plate_reader/plots/four_state_event_window/diagnostic_style.py +51 -0
  94. reader_workbench/domains/plate_reader/plots/four_state_event_window/schema.py +8 -0
  95. reader_workbench/domains/plate_reader/plots/four_state_event_window/summary.py +140 -0
  96. reader_workbench/domains/plate_reader/plots/grouping.py +53 -0
  97. reader_workbench/domains/plate_reader/plots/panels/__init__.py +12 -0
  98. reader_workbench/domains/plate_reader/plots/panels/snapshot.py +161 -0
  99. reader_workbench/domains/plate_reader/plots/panels/snapshot_data.py +91 -0
  100. reader_workbench/domains/plate_reader/plots/panels/time_series.py +296 -0
  101. reader_workbench/domains/plate_reader/plots/single_reporter_diagnostic.py +435 -0
  102. reader_workbench/domains/plate_reader/plots/single_reporter_diagnostic_render.py +300 -0
  103. reader_workbench/domains/plate_reader/plots/snapshot_barplot/__init__.py +315 -0
  104. reader_workbench/domains/plate_reader/plots/snapshot_barplot/planning.py +168 -0
  105. reader_workbench/domains/plate_reader/plots/snapshot_heatmap/__init__.py +205 -0
  106. reader_workbench/domains/plate_reader/plots/snapshot_heatmap/inputs.py +103 -0
  107. reader_workbench/domains/plate_reader/plots/time_series.py +317 -0
  108. reader_workbench/domains/plate_reader/plots/ts_and_snap/__init__.py +479 -0
  109. reader_workbench/domains/plate_reader/plots/ts_and_snap/planning.py +283 -0
  110. reader_workbench/domains/time_series/__init__.py +29 -0
  111. reader_workbench/domains/time_series/aggregation.py +60 -0
  112. reader_workbench/domains/time_series/contracts.py +368 -0
  113. reader_workbench/domains/time_series/reduction.py +395 -0
  114. reader_workbench/errors.py +55 -0
  115. reader_workbench/maintenance/__init__.py +6 -0
  116. reader_workbench/maintenance/docs.py +335 -0
  117. reader_workbench/maintenance/model.py +28 -0
  118. reader_workbench/maintenance/release.py +39 -0
  119. reader_workbench/maintenance/skills.py +124 -0
  120. reader_workbench/plotting/__init__.py +20 -0
  121. reader_workbench/plotting/mpl.py +56 -0
  122. reader_workbench/plotting/sinks.py +69 -0
  123. reader_workbench/plotting/style.py +175 -0
  124. reader_workbench/plotting/utils.py +27 -0
  125. reader_workbench/plugins/__init__.py +1 -0
  126. reader_workbench/plugins/catalog.py +33 -0
  127. reader_workbench/plugins/export/__init__.py +0 -0
  128. reader_workbench/plugins/export/_paths.py +21 -0
  129. reader_workbench/plugins/export/csv.py +41 -0
  130. reader_workbench/plugins/export/xlsx.py +44 -0
  131. reader_workbench/plugins/ingest/__init__.py +0 -0
  132. reader_workbench/plugins/ingest/_discovery.py +58 -0
  133. reader_workbench/plugins/ingest/discovery_policy.py +66 -0
  134. reader_workbench/plugins/ingest/flow_cytometer.py +139 -0
  135. reader_workbench/plugins/ingest/synergy_h1.py +234 -0
  136. reader_workbench/plugins/manifests/__init__.py +1 -0
  137. reader_workbench/plugins/manifests/export.py +29 -0
  138. reader_workbench/plugins/manifests/ingest.py +29 -0
  139. reader_workbench/plugins/manifests/plot.py +161 -0
  140. reader_workbench/plugins/manifests/transform.py +172 -0
  141. reader_workbench/plugins/manifests/validator.py +18 -0
  142. reader_workbench/plugins/plot/__init__.py +0 -0
  143. reader_workbench/plugins/plot/_shared.py +55 -0
  144. reader_workbench/plugins/plot/cytometry_diagnostic.py +54 -0
  145. reader_workbench/plugins/plot/distributions.py +58 -0
  146. reader_workbench/plugins/plot/dual_reporter_triptych.py +224 -0
  147. reader_workbench/plugins/plot/four_state_event_window_diagnostic.py +133 -0
  148. reader_workbench/plugins/plot/four_state_event_window_summary.py +53 -0
  149. reader_workbench/plugins/plot/four_state_vector_collection.py +45 -0
  150. reader_workbench/plugins/plot/four_state_vector_diagnostic.py +105 -0
  151. reader_workbench/plugins/plot/four_state_vector_heatmap.py +63 -0
  152. reader_workbench/plugins/plot/logic_symmetry.py +56 -0
  153. reader_workbench/plugins/plot/single_reporter_diagnostic.py +279 -0
  154. reader_workbench/plugins/plot/snapshot_barplot.py +65 -0
  155. reader_workbench/plugins/plot/snapshot_heatmap.py +104 -0
  156. reader_workbench/plugins/plot/time_series.py +114 -0
  157. reader_workbench/plugins/plot/ts_and_snap.py +210 -0
  158. reader_workbench/plugins/transform/__init__.py +0 -0
  159. reader_workbench/plugins/transform/_four_state_vector.py +204 -0
  160. reader_workbench/plugins/transform/_labeling.py +109 -0
  161. reader_workbench/plugins/transform/alias.py +70 -0
  162. reader_workbench/plugins/transform/assay_labels.py +62 -0
  163. reader_workbench/plugins/transform/blank.py +79 -0
  164. reader_workbench/plugins/transform/crosstalk_pairs.py +180 -0
  165. reader_workbench/plugins/transform/cytometry_gating.py +120 -0
  166. reader_workbench/plugins/transform/fold_change.py +79 -0
  167. reader_workbench/plugins/transform/four_state_event_window.py +93 -0
  168. reader_workbench/plugins/transform/four_state_vector.py +62 -0
  169. reader_workbench/plugins/transform/four_state_vector_collection.py +41 -0
  170. reader_workbench/plugins/transform/logic_symmetry.py +67 -0
  171. reader_workbench/plugins/transform/outlier_filter.py +60 -0
  172. reader_workbench/plugins/transform/overflow.py +197 -0
  173. reader_workbench/plugins/transform/ratio.py +237 -0
  174. reader_workbench/plugins/transform/sample_map.py +170 -0
  175. reader_workbench/plugins/transform/sample_metadata.py +94 -0
  176. reader_workbench/plugins/validator/__init__.py +1 -0
  177. reader_workbench/plugins/validator/to_tidy_plus_map.py +155 -0
  178. reader_workbench/protocols/__init__.py +80 -0
  179. reader_workbench/protocols/_builtins_plate_reader_growth.py +179 -0
  180. reader_workbench/protocols/_builtins_plate_reader_variants.py +274 -0
  181. reader_workbench/protocols/builtins.py +1656 -0
  182. reader_workbench/protocols/compiler.py +22 -0
  183. reader_workbench/protocols/compilers/__init__.py +1 -0
  184. reader_workbench/protocols/compilers/common.py +100 -0
  185. reader_workbench/protocols/compilers/cytometry.py +87 -0
  186. reader_workbench/protocols/compilers/generic.py +14 -0
  187. reader_workbench/protocols/compilers/logic.py +245 -0
  188. reader_workbench/protocols/compilers/plate_reader.py +937 -0
  189. reader_workbench/protocols/compilers/plate_reader_pipeline.py +197 -0
  190. reader_workbench/protocols/model.py +1486 -0
  191. reader_workbench/protocols/semantic_coverage.py +234 -0
  192. reader_workbench/runtime/__init__.py +12 -0
  193. reader_workbench/runtime/builtin.py +23 -0
  194. reader_workbench/runtime/model.py +42 -0
  195. reader_workbench/workbench/__init__.py +60 -0
  196. reader_workbench/workbench/assets/__init__.py +22 -0
  197. reader_workbench/workbench/assets/types.py +118 -0
  198. reader_workbench/workbench/audit/__init__.py +5 -0
  199. reader_workbench/workbench/audit/experiments.py +307 -0
  200. reader_workbench/workbench/audit/staging.py +187 -0
  201. reader_workbench/workbench/cli/__init__.py +51 -0
  202. reader_workbench/workbench/cli/_lazy.py +9 -0
  203. reader_workbench/workbench/cli/_records_view.py +150 -0
  204. reader_workbench/workbench/cli/_surface_execution.py +443 -0
  205. reader_workbench/workbench/cli/audit.py +95 -0
  206. reader_workbench/workbench/cli/automation.py +229 -0
  207. reader_workbench/workbench/cli/demo.py +46 -0
  208. reader_workbench/workbench/cli/dop.py +91 -0
  209. reader_workbench/workbench/cli/experiments.py +635 -0
  210. reader_workbench/workbench/cli/helpers.py +232 -0
  211. reader_workbench/workbench/cli/main.py +59 -0
  212. reader_workbench/workbench/cli/maintenance.py +82 -0
  213. reader_workbench/workbench/cli/notebooks.py +260 -0
  214. reader_workbench/workbench/cli/pagination.py +117 -0
  215. reader_workbench/workbench/cli/protocols.py +336 -0
  216. reader_workbench/workbench/cli/shared.py +309 -0
  217. reader_workbench/workbench/cli/surfaces.py +534 -0
  218. reader_workbench/workbench/cli/verification.py +128 -0
  219. reader_workbench/workbench/commands.py +10 -0
  220. reader_workbench/workbench/config/__init__.py +47 -0
  221. reader_workbench/workbench/config/identity.py +13 -0
  222. reader_workbench/workbench/config/load.py +405 -0
  223. reader_workbench/workbench/config/model.py +274 -0
  224. reader_workbench/workbench/context.py +26 -0
  225. reader_workbench/workbench/decl/__init__.py +31 -0
  226. reader_workbench/workbench/decl/build.py +190 -0
  227. reader_workbench/workbench/decl/model.py +81 -0
  228. reader_workbench/workbench/dop/__init__.py +12 -0
  229. reader_workbench/workbench/dop/builtins.py +261 -0
  230. reader_workbench/workbench/dop/model.py +209 -0
  231. reader_workbench/workbench/engine/__init__.py +42 -0
  232. reader_workbench/workbench/engine/_shared.py +76 -0
  233. reader_workbench/workbench/engine/contracts.py +283 -0
  234. reader_workbench/workbench/engine/execution.py +326 -0
  235. reader_workbench/workbench/engine/file_outputs.py +260 -0
  236. reader_workbench/workbench/engine/inputs.py +161 -0
  237. reader_workbench/workbench/engine/invocations.py +507 -0
  238. reader_workbench/workbench/engine/planning.py +72 -0
  239. reader_workbench/workbench/engine/runtime.py +464 -0
  240. reader_workbench/workbench/engine/setup.py +149 -0
  241. reader_workbench/workbench/engine/validation.py +684 -0
  242. reader_workbench/workbench/experiment/__init__.py +47 -0
  243. reader_workbench/workbench/experiment/model.py +381 -0
  244. reader_workbench/workbench/experiments.py +133 -0
  245. reader_workbench/workbench/graph/__init__.py +47 -0
  246. reader_workbench/workbench/graph/nodes.py +102 -0
  247. reader_workbench/workbench/graph/normalize.py +177 -0
  248. reader_workbench/workbench/graph/refs.py +148 -0
  249. reader_workbench/workbench/input_discovery.py +19 -0
  250. reader_workbench/workbench/inspection/__init__.py +3 -0
  251. reader_workbench/workbench/inspection/catalogs.py +128 -0
  252. reader_workbench/workbench/inspection/common.py +92 -0
  253. reader_workbench/workbench/inspection/dop.py +64 -0
  254. reader_workbench/workbench/inspection/experiments.py +449 -0
  255. reader_workbench/workbench/inspection/inventory.py +68 -0
  256. reader_workbench/workbench/inspection/protocols.py +368 -0
  257. reader_workbench/workbench/inspection/readiness.py +333 -0
  258. reader_workbench/workbench/inspection/reports.py +367 -0
  259. reader_workbench/workbench/inspection/results.py +166 -0
  260. reader_workbench/workbench/inspection/runtime.py +287 -0
  261. reader_workbench/workbench/inspection/semantics.py +192 -0
  262. reader_workbench/workbench/inspection/validation.py +30 -0
  263. reader_workbench/workbench/notebooks/__init__.py +17 -0
  264. reader_workbench/workbench/notebooks/_launch_registry.py +112 -0
  265. reader_workbench/workbench/notebooks/_launch_runtime.py +104 -0
  266. reader_workbench/workbench/notebooks/components/__init__.py +21 -0
  267. reader_workbench/workbench/notebooks/components/deliverables.py +403 -0
  268. reader_workbench/workbench/notebooks/components/overview.py +119 -0
  269. reader_workbench/workbench/notebooks/eda.marimo.py.txt +153 -0
  270. reader_workbench/workbench/notebooks/launch.py +274 -0
  271. reader_workbench/workbench/notebooks/presentation.py +136 -0
  272. reader_workbench/workbench/notebooks/scaffold.py +60 -0
  273. reader_workbench/workbench/ontology.py +78 -0
  274. reader_workbench/workbench/paths.py +44 -0
  275. reader_workbench/workbench/ports/__init__.py +31 -0
  276. reader_workbench/workbench/ports/model.py +168 -0
  277. reader_workbench/workbench/records/__init__.py +44 -0
  278. reader_workbench/workbench/records/epoch.py +329 -0
  279. reader_workbench/workbench/records/evidence.py +247 -0
  280. reader_workbench/workbench/records/identity.py +87 -0
  281. reader_workbench/workbench/records/locking.py +185 -0
  282. reader_workbench/workbench/records/model.py +711 -0
  283. reader_workbench/workbench/records/sources.py +73 -0
  284. reader_workbench/workbench/records/store.py +1022 -0
  285. reader_workbench/workbench/records/verification.py +998 -0
  286. reader_workbench/workbench/registry.py +333 -0
  287. reader_workbench/workbench/spec_overrides.py +215 -0
  288. reader_workbench-1.0.0.dist-info/METADATA +91 -0
  289. reader_workbench-1.0.0.dist-info/RECORD +293 -0
  290. reader_workbench-1.0.0.dist-info/WHEEL +5 -0
  291. reader_workbench-1.0.0.dist-info/entry_points.txt +2 -0
  292. reader_workbench-1.0.0.dist-info/licenses/LICENSE +21 -0
  293. reader_workbench-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,168 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+
8
+ from reader_workbench.domains.plate_reader.ordering import order_levels
9
+ from reader_workbench.domains.plate_reader.plots.grouping import GroupMatch, resolve_groups
10
+
11
+ _PanelBy = Literal["channel", "x", "group"]
12
+
13
+
14
+ def build_figure_groups(
15
+ *,
16
+ stats: pd.DataFrame,
17
+ group_col: str | None,
18
+ panel_by: _PanelBy,
19
+ pool_sets: list[dict[str, list[str]]] | None,
20
+ pool_match: GroupMatch,
21
+ ) -> list[tuple[str, list[str | None]]]:
22
+ if not group_col:
23
+ return [("all", [None])]
24
+
25
+ universe = order_levels(stats[group_col].astype(str).unique().tolist())
26
+ if panel_by == "group":
27
+ if pool_sets:
28
+ resolved = resolve_groups(universe, pool_sets, match=pool_match)
29
+ ordered: list[str] = []
30
+ seen: set[str] = set()
31
+ for _, values in resolved:
32
+ for value in values:
33
+ if value in universe and value not in seen:
34
+ ordered.append(value)
35
+ seen.add(value)
36
+ return [("all", ordered or universe)]
37
+ return [("all", universe)]
38
+
39
+ if pool_sets:
40
+ resolved = resolve_groups(universe, pool_sets, match=pool_match)
41
+ union: list[str] = []
42
+ seen: set[str] = set()
43
+ for _, values in resolved:
44
+ for value in values:
45
+ if value in universe and value not in seen:
46
+ union.append(value)
47
+ seen.add(value)
48
+ return [(group, [group]) for group in (union or universe)]
49
+ return [(group, [group]) for group in universe]
50
+
51
+
52
+ def resolve_panel_configuration(
53
+ *,
54
+ panel_by: _PanelBy,
55
+ members: list[str | None],
56
+ y_list: list[str],
57
+ group_col: str | None,
58
+ channel_select: str | None,
59
+ selected_channel_default: str | None,
60
+ ch_for_file: str | None,
61
+ stats: pd.DataFrame,
62
+ x_col: str,
63
+ ) -> tuple[list[str], str | None]:
64
+ if panel_by == "channel":
65
+ return y_list, None
66
+ if panel_by == "group":
67
+ if not group_col:
68
+ raise ValueError("panel_by='group' requires 'group_on'")
69
+ selected_channel = (
70
+ (str(ch_for_file) if ch_for_file is not None else None) or channel_select or selected_channel_default
71
+ )
72
+ if not selected_channel:
73
+ raise ValueError(
74
+ "panel_by='group' requires an explicit channel: set 'channel_select' or pass a single string to 'y'."
75
+ )
76
+ return [str(member) for member in members], str(selected_channel)
77
+
78
+ selected_channel = channel_select or selected_channel_default
79
+ if not selected_channel:
80
+ raise ValueError(
81
+ "panel_by='x' requires an explicit channel: set 'channel_select' or pass a single string to 'y'."
82
+ )
83
+ sub_stats = stats[stats["channel"].astype(str) == str(selected_channel)]
84
+ x_levels = sub_stats[x_col].astype(str).unique().tolist()
85
+ return order_levels(x_levels), str(selected_channel)
86
+
87
+
88
+ def compute_shared_ylim(
89
+ *,
90
+ stats: pd.DataFrame,
91
+ snapped: pd.DataFrame,
92
+ panels: list[str],
93
+ panel_by: _PanelBy,
94
+ selected_channel: str,
95
+ group_col: str | None,
96
+ x_col: str,
97
+ agg: str,
98
+ dispersion: str,
99
+ ) -> tuple[float | None, float | None]:
100
+ if panel_by not in {"group", "x"} or len(panels) <= 1:
101
+ return None, None
102
+
103
+ def _collect_limits_for_subset(
104
+ sbar_sub: pd.DataFrame,
105
+ srep_sub: pd.DataFrame,
106
+ ) -> tuple[float | None, float | None]:
107
+ values = (
108
+ pd.to_numeric(sbar_sub[agg], errors="coerce").dropna() if not sbar_sub.empty else pd.Series([], dtype=float)
109
+ )
110
+ value_min = float(values.min()) if not values.empty else None
111
+ value_max = float(values.max()) if not values.empty else None
112
+ if dispersion == "sd" and "std" in sbar_sub.columns:
113
+ center = pd.to_numeric(sbar_sub[agg], errors="coerce")
114
+ spread = pd.to_numeric(sbar_sub["std"], errors="coerce")
115
+ bottom = (center - spread).dropna()
116
+ top = (center + spread).dropna()
117
+ if not bottom.empty:
118
+ value_min = min(value_min if value_min is not None else np.inf, float(bottom.min()))
119
+ if not top.empty:
120
+ value_max = max(value_max or -np.inf, float(top.max()))
121
+ elif dispersion == "iqr" and {"q1", "q3"}.issubset(sbar_sub.columns):
122
+ if agg == "median":
123
+ top = pd.to_numeric(sbar_sub["q3"], errors="coerce").dropna()
124
+ if not top.empty:
125
+ value_max = max(value_max or -np.inf, float(top.max()))
126
+ else:
127
+ half = 0.5 * (
128
+ pd.to_numeric(sbar_sub["q3"], errors="coerce") - pd.to_numeric(sbar_sub["q1"], errors="coerce")
129
+ )
130
+ top = (pd.to_numeric(sbar_sub[agg], errors="coerce") + half).dropna()
131
+ if not top.empty:
132
+ value_max = max(value_max or -np.inf, float(top.max()))
133
+ if not srep_sub.empty and "value" in srep_sub.columns:
134
+ observation_values = pd.to_numeric(srep_sub["value"], errors="coerce").dropna()
135
+ if not observation_values.empty:
136
+ value_min = min(value_min if value_min is not None else float("inf"), float(observation_values.min()))
137
+ value_max = max(value_max if value_max is not None else float("-inf"), float(observation_values.max()))
138
+ return value_min, value_max
139
+
140
+ low_values: list[float] = []
141
+ high_values: list[float] = []
142
+ for panel in panels:
143
+ if panel_by == "group":
144
+ sbar_sub = stats[
145
+ (stats["channel"].astype(str) == str(selected_channel)) & (stats[group_col].astype(str) == str(panel))
146
+ ]
147
+ srep_sub = snapped[
148
+ (snapped["channel"].astype(str) == str(selected_channel))
149
+ & (snapped[group_col].astype(str) == str(panel))
150
+ ]
151
+ else:
152
+ sbar_sub = stats[
153
+ (stats["channel"].astype(str) == str(selected_channel)) & (stats[x_col].astype(str) == str(panel))
154
+ ]
155
+ srep_sub = snapped[
156
+ (snapped["channel"].astype(str) == str(selected_channel)) & (snapped[x_col].astype(str) == str(panel))
157
+ ]
158
+ value_min, value_max = _collect_limits_for_subset(sbar_sub, srep_sub)
159
+ if value_min is not None:
160
+ low_values.append(value_min)
161
+ if value_max is not None:
162
+ high_values.append(value_max)
163
+ if not high_values:
164
+ return None, None
165
+ y_lo = min(0.0, float(np.nanmin(low_values))) if low_values else 0.0
166
+ y_hi = float(np.nanmax(high_values))
167
+ pad = 0.05 * (y_hi - y_lo if y_hi > y_lo else max(1.0, y_hi or 1.0))
168
+ return (y_lo - pad if y_lo < 0.0 else 0.0), y_hi + pad
@@ -0,0 +1,205 @@
1
+ """Snapshot heatmap with tidy aggregation and square cells."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import logging
7
+ from contextlib import suppress
8
+ from typing import Any
9
+
10
+ import matplotlib.colors as mcolors
11
+ import matplotlib.pyplot as plt
12
+ import numpy as np
13
+ import pandas as pd
14
+ import polars as pl
15
+ from matplotlib.colors import Colormap, LinearSegmentedColormap
16
+
17
+ from reader_workbench.plotting.sinks import PlotFigure
18
+ from reader_workbench.plotting.style import new_fig_ax, use_style
19
+
20
+ from ...analysis.timepoints import choose_nearest_time
21
+ from ..common import alias_column, plot_figure, pretty_name, warn_if_empty
22
+ from .inputs import (
23
+ SnapshotHeatmapInputs as SnapshotHeatmapInputs,
24
+ )
25
+ from .inputs import (
26
+ prepare_snapshot_heatmap_inputs as prepare_snapshot_heatmap_inputs,
27
+ )
28
+
29
+ __all__ = ["SnapshotHeatmapInputs", "plot_snapshot_heatmap", "prepare_snapshot_heatmap_inputs"]
30
+
31
+
32
+ def _ensure(df: pd.DataFrame, cols: list[str]) -> None:
33
+ missing = [column for column in cols if column not in df.columns]
34
+ if missing:
35
+ raise ValueError(f"snapshot_heatmap: required columns missing: {missing}")
36
+
37
+
38
+ def _apply_explicit_axis_order(
39
+ pivot: pd.DataFrame,
40
+ *,
41
+ axis: str,
42
+ labels: list[str] | None,
43
+ ) -> pd.DataFrame:
44
+ if labels is None:
45
+ return pivot
46
+ if not labels:
47
+ raise ValueError(f"snapshot_heatmap: {axis} order must not be empty")
48
+
49
+ available = list(map(str, pivot.columns if axis == "x" else pivot.index))
50
+ missing = [label for label in labels if label not in available]
51
+ if missing:
52
+ raise ValueError(f"snapshot_heatmap: {axis} order includes missing label(s): {missing}")
53
+
54
+ if axis == "x":
55
+ return pivot[labels]
56
+ return pivot.loc[labels]
57
+
58
+
59
+ def _resolve_cmap(spec: Any) -> Colormap | None:
60
+ if spec is None:
61
+ return None
62
+ if isinstance(spec, mcolors.Colormap):
63
+ return spec
64
+ if isinstance(spec, list | tuple):
65
+ return LinearSegmentedColormap.from_list("custom", list(spec))
66
+ if isinstance(spec, str):
67
+ try:
68
+ return plt.get_cmap(spec)
69
+ except Exception:
70
+ return LinearSegmentedColormap.from_list("custom", ["#FFFFFF", spec])
71
+ return None
72
+
73
+
74
+ def plot_snapshot_heatmap(
75
+ *,
76
+ df: pd.DataFrame,
77
+ channel: str,
78
+ time: float,
79
+ x: str = "treatment",
80
+ y: str = "design_id",
81
+ order_x: list[str] | None = None,
82
+ order_y: list[str] | None = None,
83
+ square: bool = True,
84
+ vmin: float | None = None,
85
+ vmax: float | None = None,
86
+ fig_kwargs: dict[str, Any],
87
+ filename: str | None,
88
+ logger: logging.Logger | None = None,
89
+ ) -> list[PlotFigure]:
90
+ x_col = alias_column(df, x)
91
+ y_col = alias_column(df, y)
92
+ _ensure(df, ["time", "channel", "value", x_col, y_col])
93
+
94
+ work_pl = pl.from_pandas(df)
95
+ time_expr = pl.col("time").cast(pl.Float64, strict=True)
96
+ value_expr = pl.col("value").cast(pl.Float64, strict=False)
97
+ value_expr = pl.when(value_expr.is_nan()).then(None).otherwise(value_expr)
98
+ work_pl = work_pl.with_columns(time_expr.alias("time"), value_expr.alias("value"))
99
+
100
+ rc = (fig_kwargs or {}).get("rc", {})
101
+ tol = (fig_kwargs or {}).get("time_tolerance", rc.get("time_tolerance", None))
102
+ times = (
103
+ work_pl.filter(pl.col("channel").cast(pl.Utf8) == str(channel))
104
+ .select(pl.col("time").fill_null(float("nan")).alias("time"))
105
+ .to_numpy()
106
+ .ravel()
107
+ )
108
+ tsel = choose_nearest_time(
109
+ times,
110
+ target_time=float(time),
111
+ tol=(None if tol is None else float(tol)),
112
+ where="snapshot_heatmap",
113
+ logger=logger,
114
+ )
115
+
116
+ snap_pl = work_pl.filter(
117
+ (pl.col("channel").cast(pl.Utf8) == str(channel)) & ((pl.col("time") - float(tsel)).abs() <= 1e-9)
118
+ )
119
+ if snap_pl.is_empty():
120
+ raise ValueError("snapshot_heatmap: no rows after (channel,time) selection")
121
+
122
+ pivot_pl = (
123
+ snap_pl.group_by([y_col, x_col])
124
+ .agg(pl.col("value").median().alias("value"))
125
+ .sort([y_col, x_col])
126
+ .pivot(values="value", index=y_col, on=x_col, aggregate_function="first")
127
+ )
128
+ pivot = pivot_pl.to_pandas(use_pyarrow_extension_array=False)
129
+ if y_col in pivot.columns:
130
+ pivot = pivot.set_index(y_col)
131
+
132
+ pivot = _apply_explicit_axis_order(pivot, axis="x", labels=order_x)
133
+ pivot = _apply_explicit_axis_order(pivot, axis="y", labels=order_y)
134
+
135
+ if pivot.empty or pivot.shape[0] == 0 or pivot.shape[1] == 0:
136
+ warn_if_empty(pivot, where="snapshot_heatmap", detail="after pivot")
137
+ return []
138
+
139
+ with use_style(rc=(fig_kwargs or {}).get("rc")):
140
+ fig, ax = new_fig_ax(fig_kwargs)
141
+ arr = pivot.to_numpy(dtype=float)
142
+ if not np.isfinite(arr).any():
143
+ raise ValueError("snapshot_heatmap: selected snapshot contains no finite values")
144
+ cmap = _resolve_cmap((fig_kwargs or {}).get("cmap"))
145
+ resolved_vmin = float(np.nanmin(arr)) if vmin is None else float(vmin)
146
+ resolved_vmax = float(np.nanmax(arr)) if vmax is None else float(vmax)
147
+
148
+ im = ax.imshow(
149
+ arr,
150
+ aspect=("equal" if square else "auto"),
151
+ vmin=resolved_vmin,
152
+ vmax=resolved_vmax,
153
+ cmap=cmap,
154
+ )
155
+
156
+ ax.grid(False)
157
+ with suppress(Exception):
158
+ ax.set_facecolor("white")
159
+
160
+ ax.set_xticks(range(arr.shape[1]))
161
+ ax.set_xticklabels(list(map(str, pivot.columns)), rotation=45, ha="right")
162
+ ax.set_yticks(range(arr.shape[0]))
163
+ ax.set_yticklabels(list(map(str, pivot.index)))
164
+ ax.set_xlabel(pretty_name(str(x_col)).title())
165
+ ax.set_ylabel(pretty_name(str(y_col)).title())
166
+ ax.set_title(f"{channel} @ t={tsel:g} h")
167
+
168
+ ax.set_xticks(np.arange(-0.5, arr.shape[1], 1), minor=True)
169
+ ax.set_yticks(np.arange(-0.5, arr.shape[0], 1), minor=True)
170
+ ax.grid(which="minor", color="white", linestyle="-", linewidth=0.5)
171
+ ax.tick_params(which="minor", bottom=False, left=False)
172
+
173
+ shrink = float((fig_kwargs or {}).get("cbar_shrink", 1.0))
174
+ cbar_label: str | None = (fig_kwargs or {}).get("cbar_label")
175
+ fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04, shrink=shrink, label=cbar_label)
176
+
177
+ base = filename or f"snapshot_heatmap__{channel}__t{tsel:g}h"
178
+ y_levels = list(map(str, pivot.index))
179
+ n_geno = len(y_levels)
180
+
181
+ def _short_id(value: str) -> str:
182
+ return hashlib.blake2b(value.encode("utf-8"), digest_size=4).hexdigest()
183
+
184
+ geno_id = _short_id("|".join(sorted(y_levels)))
185
+ x_levels = list(map(str, pivot.columns))
186
+ cmap_name = (
187
+ cmap.name if isinstance(cmap, Colormap) and hasattr(cmap, "name") else "custom" if cmap else "default"
188
+ )
189
+ tol = (fig_kwargs or {}).get("time_tolerance", None)
190
+ fp_payload = "|".join(
191
+ [
192
+ f"ch={channel}",
193
+ f"t={tsel:g}",
194
+ "x=" + ",".join(x_levels),
195
+ "y=" + ",".join(y_levels),
196
+ f"cmap={cmap_name}",
197
+ f"vmin={'nan' if vmin is None else float(vmin)}",
198
+ f"vmax={'nan' if vmax is None else float(vmax)}",
199
+ f"square={bool(square)}",
200
+ f"tol={'' if tol is None else float(tol)}",
201
+ ]
202
+ )
203
+ fp_id = _short_id(fp_payload)
204
+ stub = f"{base}__gy{n_geno}-{geno_id}__fp{fp_id}"
205
+ return [plot_figure(fig=fig, filename=stub, fig_kwargs=fig_kwargs)]
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from collections.abc import Mapping
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+
11
+ from reader_workbench.domains.plate_reader.analysis.timepoints import choose_nearest_time
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class SnapshotHeatmapInputs:
16
+ """Prepared dataframe and rendering values for a snapshot heatmap."""
17
+
18
+ frame: pd.DataFrame
19
+ filename: str | None
20
+ fig_kwargs: dict[str, Any]
21
+
22
+
23
+ def auto_cbar_label(channel: str, value_transform: str | None) -> str:
24
+ transform = (value_transform or "none").lower()
25
+ if channel.startswith("log2FC_"):
26
+ return f"log2FC ({channel.split('_', 1)[1]})"
27
+ if channel.startswith("FC_"):
28
+ return f"FC ({channel.split('_', 1)[1]})"
29
+ if transform == "log2":
30
+ return f"log2({channel})"
31
+ if transform == "log10":
32
+ return f"log10({channel})"
33
+ return channel
34
+
35
+
36
+ def _transform_positive_values(values: pd.Series, *, transform: str) -> pd.Series:
37
+ numeric = pd.to_numeric(values, errors="coerce")
38
+ result = pd.Series(np.nan, index=numeric.index, dtype=float)
39
+ positive = numeric > 0
40
+ if transform == "log2":
41
+ result.loc[positive] = np.log2(numeric.loc[positive])
42
+ return result
43
+ result.loc[positive] = np.log10(numeric.loc[positive])
44
+ return result
45
+
46
+
47
+ def prepare_snapshot_heatmap_inputs(
48
+ *,
49
+ df_in: pd.DataFrame | None,
50
+ fc_in: pd.DataFrame | None,
51
+ channel: str,
52
+ time: float,
53
+ time_tolerance: float,
54
+ value_transform: str | None,
55
+ fig: Mapping[str, Any] | None,
56
+ filename: str | None,
57
+ logger: logging.Logger | None = None,
58
+ ) -> SnapshotHeatmapInputs:
59
+ channel = str(channel)
60
+ wants_fc = channel.startswith("FC_") or channel.startswith("log2FC_")
61
+ fig_kwargs = dict(fig or {})
62
+ fig_kwargs.setdefault("time_tolerance", time_tolerance)
63
+
64
+ if wants_fc:
65
+ if fc_in is None:
66
+ raise ValueError(f"snapshot_heatmap: channel={channel!r} requires a fold_change.v1 input (reads.fc)")
67
+ target = channel.split("_", 1)[1]
68
+ use_col = "log2FC" if channel.startswith("log2FC_") else "FC"
69
+ table = fc_in.copy()
70
+ table = table[table["target"].astype(str) == target].copy()
71
+ if table.empty:
72
+ raise ValueError(f"snapshot_heatmap: no fold_change rows found for target={target!r}")
73
+ chosen_time = choose_nearest_time(
74
+ table["time"],
75
+ target_time=float(time),
76
+ tol=float(time_tolerance),
77
+ where="snapshot_heatmap",
78
+ logger=logger,
79
+ )
80
+ subset = table[pd.to_numeric(table["time"], errors="coerce") == chosen_time].copy()
81
+ subset = subset.rename(columns={use_col: "value"})
82
+ subset["channel"] = channel
83
+ keep = ["time", "channel", "value", "treatment", "design_id", "design_id_alias"]
84
+ df = subset[[column for column in keep if column in subset.columns]].copy()
85
+ resolved_filename = filename or f"snapshot_heatmap__{channel}__t{chosen_time:g}h"
86
+ fig_kwargs.setdefault("cbar_label", auto_cbar_label(channel, None))
87
+ return SnapshotHeatmapInputs(frame=df, filename=resolved_filename, fig_kwargs=fig_kwargs)
88
+
89
+ if df_in is None:
90
+ raise ValueError("snapshot_heatmap: tidy df is required when channel is not FC_/log2FC_-prefixed")
91
+
92
+ df = df_in.copy()
93
+ if value_transform and str(value_transform).lower() in {"log2", "log10"}:
94
+ mask = df["channel"].astype(str) == channel
95
+ df.loc[mask, "value"] = _transform_positive_values(
96
+ df.loc[mask, "value"],
97
+ transform=str(value_transform).lower(),
98
+ )
99
+ fig_kwargs.setdefault("cbar_label", auto_cbar_label(channel, value_transform))
100
+ return SnapshotHeatmapInputs(frame=df, filename=filename, fig_kwargs=fig_kwargs)
101
+
102
+
103
+ __all__ = ["SnapshotHeatmapInputs", "auto_cbar_label", "prepare_snapshot_heatmap_inputs"]