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,525 @@
1
+ """Domain-owned preparation and Altair rendering for dual-reporter triptychs.
2
+
3
+ The capability accepts explicit tidy data and has no workbench, record-store,
4
+ or notebook-lifecycle dependencies.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from typing import Any
11
+
12
+ import numpy as np
13
+ import pandas as pd
14
+
15
+ from reader_workbench.domains.plate_reader.plots.common import descriptive_mean_resampling_interval
16
+
17
+ DEFAULT_TRIPTYCH_PANEL_SIZE = 260
18
+ DEFAULT_TRIPTYCH_SPACING = 16
19
+ DEFAULT_TRAJECTORY_INTERVAL_MASS = 0.95
20
+ DEFAULT_TRAJECTORY_RESAMPLES = 300
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class DualReporterTriptychData:
25
+ od600_time: pd.DataFrame
26
+ ratio_time: pd.DataFrame
27
+ snapshot_stats: pd.DataFrame
28
+ snapshot_points: pd.DataFrame
29
+ treatment_order: tuple[str, ...]
30
+ missing_treatments: tuple[str, ...]
31
+ snapshot_time: float
32
+ growth_channel: str
33
+ ratio_channel: str
34
+ snapshot_channel: str
35
+ trajectory_interval_mass: float
36
+
37
+
38
+ def build_triptych_data(
39
+ df: pd.DataFrame,
40
+ *,
41
+ time_col: str,
42
+ treatment_col: str,
43
+ growth_channel: str = "OD600",
44
+ ratio_channel: str = "YFP/CFP",
45
+ snapshot_channel: str | None = None,
46
+ snapshot_time: float,
47
+ treatment_order: list[str] | tuple[str, ...] | None = None,
48
+ time_atol: float = 1e-9,
49
+ trajectory_interval_mass: float = DEFAULT_TRAJECTORY_INTERVAL_MASS,
50
+ trajectory_resamples: int = DEFAULT_TRAJECTORY_RESAMPLES,
51
+ ) -> DualReporterTriptychData:
52
+ """Prepare the neutral dual-reporter triptych data contract.
53
+
54
+ This is intentionally independent of four-state vector semantics. It only requires a
55
+ tidy dual-reporter dataframe with channels for growth, ratio kinetics, and
56
+ a snapshot channel.
57
+ """
58
+ snapshot_channel = snapshot_channel or ratio_channel
59
+ if not 0.0 < float(trajectory_interval_mass) < 1.0:
60
+ raise ValueError("dual_reporter_triptych: trajectory_interval_mass must lie strictly between 0 and 1")
61
+ if int(trajectory_resamples) < 1:
62
+ raise ValueError("dual_reporter_triptych: trajectory_resamples must be positive")
63
+ required_channels = [growth_channel, ratio_channel, snapshot_channel]
64
+ _require_columns(df, ["channel", "value", time_col, treatment_col], where="dual_reporter_triptych")
65
+
66
+ work = df.copy()
67
+ work[time_col] = pd.to_numeric(work[time_col], errors="coerce")
68
+ work["value"] = pd.to_numeric(work["value"], errors="coerce")
69
+ work = work.dropna(subset=[time_col, "value", treatment_col, "channel"])
70
+ if work.empty:
71
+ raise ValueError("dual_reporter_triptych: no usable rows after numeric/time cleanup")
72
+ work[treatment_col] = work[treatment_col].astype(str)
73
+ work["channel"] = work["channel"].astype(str)
74
+
75
+ order = _resolve_treatment_order(work, treatment_col=treatment_col, treatment_order=treatment_order)
76
+ if treatment_order is not None:
77
+ work = work[work[treatment_col].isin(order)].copy()
78
+ if work.empty:
79
+ raise ValueError("dual_reporter_triptych: no rows match the requested treatment order")
80
+ _require_channels(work, required_channels)
81
+ observed = set(work[treatment_col].dropna().astype(str).unique().tolist())
82
+ missing_treatments = tuple(value for value in order if value not in observed)
83
+
84
+ summary_options = {
85
+ "time_col": time_col,
86
+ "treatment_col": treatment_col,
87
+ "order": order,
88
+ "interval_mass": float(trajectory_interval_mass),
89
+ "resamples": int(trajectory_resamples),
90
+ }
91
+ od600_time = _summarize_time(work, channel=growth_channel, **summary_options)
92
+ ratio_time = _summarize_time(work, channel=ratio_channel, **summary_options)
93
+ snapshot_stats, snapshot_points = _summarize_snapshot(
94
+ work,
95
+ channel=snapshot_channel,
96
+ time_col=time_col,
97
+ treatment_col=treatment_col,
98
+ snapshot_time=float(snapshot_time),
99
+ order=order,
100
+ time_atol=float(time_atol),
101
+ )
102
+ return DualReporterTriptychData(
103
+ od600_time=od600_time,
104
+ ratio_time=ratio_time,
105
+ snapshot_stats=snapshot_stats,
106
+ snapshot_points=snapshot_points,
107
+ treatment_order=tuple(order),
108
+ missing_treatments=missing_treatments,
109
+ snapshot_time=float(snapshot_time),
110
+ growth_channel=str(growth_channel),
111
+ ratio_channel=str(ratio_channel),
112
+ snapshot_channel=str(snapshot_channel),
113
+ trajectory_interval_mass=float(trajectory_interval_mass),
114
+ )
115
+
116
+
117
+ def build_dual_reporter_triptych_chart(
118
+ *,
119
+ alt: Any,
120
+ pd_module: Any,
121
+ data: DualReporterTriptychData,
122
+ time_col: str,
123
+ treatment_col: str,
124
+ acquisition_transition_time_h: float | None = None,
125
+ width: int = DEFAULT_TRIPTYCH_PANEL_SIZE,
126
+ height: int | None = None,
127
+ spacing: int = DEFAULT_TRIPTYCH_SPACING,
128
+ treatment_title: str | None = None,
129
+ ) -> Any:
130
+ if data.od600_time.empty:
131
+ raise ValueError("dual_reporter_triptych: no OD600 time-series data available")
132
+ if data.ratio_time.empty:
133
+ raise ValueError("dual_reporter_triptych: no ratio time-series data available")
134
+ if data.snapshot_stats.empty:
135
+ raise ValueError("dual_reporter_triptych: no snapshot data available")
136
+
137
+ order = list(data.treatment_order)
138
+ panel_height = int(height if height is not None else width)
139
+ color = alt.Color(
140
+ f"{treatment_col}:N",
141
+ sort=order,
142
+ scale=alt.Scale(domain=order),
143
+ legend=alt.Legend(orient="bottom", title=treatment_title or _display_column_title(treatment_col)),
144
+ )
145
+
146
+ od600_chart = _time_chart(
147
+ alt=alt,
148
+ pd_module=pd_module,
149
+ frame=data.od600_time,
150
+ time_col=time_col,
151
+ treatment_col=treatment_col,
152
+ y_title=data.growth_channel,
153
+ color=color,
154
+ order=order,
155
+ treatment_title=treatment_title or _display_column_title(treatment_col),
156
+ snapshot_time=data.snapshot_time,
157
+ acquisition_transition_time_h=acquisition_transition_time_h,
158
+ width=width,
159
+ height=panel_height,
160
+ )
161
+ ratio_chart = _time_chart(
162
+ alt=alt,
163
+ pd_module=pd_module,
164
+ frame=data.ratio_time,
165
+ time_col=time_col,
166
+ treatment_col=treatment_col,
167
+ y_title=data.ratio_channel,
168
+ color=color,
169
+ order=order,
170
+ treatment_title=treatment_title or _display_column_title(treatment_col),
171
+ snapshot_time=data.snapshot_time,
172
+ acquisition_transition_time_h=acquisition_transition_time_h,
173
+ width=width,
174
+ height=panel_height,
175
+ )
176
+ snapshot_chart = _snapshot_chart(
177
+ alt=alt,
178
+ frame=data.snapshot_stats,
179
+ points=data.snapshot_points,
180
+ treatment_col=treatment_col,
181
+ y_title=f"{data.snapshot_channel} snapshot",
182
+ order=order,
183
+ treatment_title=treatment_title or _display_column_title(treatment_col),
184
+ width=width,
185
+ height=panel_height,
186
+ )
187
+ return (
188
+ alt.hconcat(od600_chart, ratio_chart, snapshot_chart, spacing=spacing)
189
+ .resolve_scale(color="shared", strokeDash="shared")
190
+ .configure(background="white")
191
+ .configure_view(fill="white")
192
+ .configure_axis(
193
+ domain=True,
194
+ domainColor="black",
195
+ domainWidth=1,
196
+ tickColor="black",
197
+ labelColor="black",
198
+ titleColor="black",
199
+ labelFontSize=12,
200
+ titleFontSize=13,
201
+ )
202
+ .configure_legend(labelColor="black", titleColor="black", labelFontSize=12, titleFontSize=12)
203
+ .configure_title(color="black", fontSize=14)
204
+ .configure_text(color="black", fontSize=12)
205
+ )
206
+
207
+
208
+ def summarize_design_context(
209
+ df: pd.DataFrame,
210
+ *,
211
+ primary_col: str,
212
+ primary_value: object,
213
+ preferred_columns: tuple[str, ...] = ("design_id_alias", "design_id", "id", "sequence", "strain", "medium"),
214
+ max_value_chars: int = 84,
215
+ ) -> list[tuple[str, str]]:
216
+ """Return compact identity rows for a selected design/genotype."""
217
+
218
+ rows = [(str(primary_col), _compact_context_value(primary_value, max_chars=max_value_chars))]
219
+ seen = {str(primary_col)}
220
+ for column in preferred_columns:
221
+ column = str(column)
222
+ if column in seen or column not in df.columns:
223
+ continue
224
+ values = _unique_context_values(df[column])
225
+ if not values:
226
+ continue
227
+ if len(values) == 1:
228
+ display = _compact_context_value(values[0], max_chars=max_value_chars)
229
+ else:
230
+ display = f"{len(values)} values: {_compact_context_value(values[0], max_chars=max_value_chars)}"
231
+ rows.append((column, display))
232
+ seen.add(column)
233
+ return rows
234
+
235
+
236
+ def choose_time(times: list[float] | tuple[float, ...], target: float | None, mode: str) -> float | None:
237
+ if not times:
238
+ return None
239
+ time_list = sorted(float(t) for t in times)
240
+ if target is None:
241
+ return time_list[-1]
242
+ target = float(target)
243
+ if mode == "exact":
244
+ for time_value in time_list:
245
+ if abs(time_value - target) <= 1e-12:
246
+ return time_value
247
+ return None
248
+ if mode == "nearest":
249
+ return min(time_list, key=lambda time_value: abs(time_value - target))
250
+ if mode == "last_before":
251
+ candidates = [time_value for time_value in time_list if time_value <= target]
252
+ return max(candidates) if candidates else None
253
+ if mode == "first_after":
254
+ candidates = [time_value for time_value in time_list if time_value >= target]
255
+ return min(candidates) if candidates else None
256
+ raise ValueError("dual_reporter_triptych: time mode must be nearest, last_before, first_after, or exact")
257
+
258
+
259
+ def _unique_context_values(series: pd.Series) -> list[str]:
260
+ values: list[str] = []
261
+ seen: set[str] = set()
262
+ for value in series.dropna().tolist():
263
+ text = str(value).strip()
264
+ if not text or text.casefold() == "nan" or text in seen:
265
+ continue
266
+ values.append(text)
267
+ seen.add(text)
268
+ return values
269
+
270
+
271
+ def _compact_context_value(value: object, *, max_chars: int) -> str:
272
+ text = str(value).strip()
273
+ if len(text) <= max_chars:
274
+ return text
275
+ side = max(8, (max_chars - 3) // 2)
276
+ return f"{text[:side]}...{text[-side:]}"
277
+
278
+
279
+ def _require_columns(df: pd.DataFrame, columns: list[str], *, where: str) -> None:
280
+ missing = [column for column in columns if column not in df.columns]
281
+ if missing:
282
+ raise ValueError(f"{where}: missing column(s): {', '.join(missing)}")
283
+
284
+
285
+ def _require_channels(df: pd.DataFrame, channels: list[str]) -> None:
286
+ available = {str(value) for value in df["channel"].dropna().unique().tolist()}
287
+ missing = [channel for channel in channels if str(channel) not in available]
288
+ if missing:
289
+ options = ", ".join(sorted(available))
290
+ raise ValueError(f"dual_reporter_triptych: requested channel(s) not found: {missing}. Available: {options}")
291
+
292
+
293
+ def _resolve_treatment_order(
294
+ df: pd.DataFrame,
295
+ *,
296
+ treatment_col: str,
297
+ treatment_order: list[str] | tuple[str, ...] | None,
298
+ ) -> list[str]:
299
+ observed = [str(value) for value in df[treatment_col].dropna().unique().tolist()]
300
+ if treatment_order is not None:
301
+ order = []
302
+ seen = set()
303
+ for value in treatment_order:
304
+ item = str(value)
305
+ if item not in seen:
306
+ order.append(item)
307
+ seen.add(item)
308
+ if not order:
309
+ raise ValueError("dual_reporter_triptych: treatment_order must not be empty when provided")
310
+ return order
311
+ return sorted(observed)
312
+
313
+
314
+ def _sort_by_treatment(
315
+ df: pd.DataFrame, *, treatment_col: str, order: list[str], extra_sort: list[str]
316
+ ) -> pd.DataFrame:
317
+ if df.empty:
318
+ return df
319
+ order_map = {value: idx for idx, value in enumerate(order)}
320
+ sorted_df = df.copy()
321
+ sorted_df["__treatment_order"] = sorted_df[treatment_col].astype(str).map(order_map).fillna(len(order_map))
322
+ sorted_df = sorted_df.sort_values(["__treatment_order", *extra_sort]).drop(columns=["__treatment_order"])
323
+ return sorted_df.reset_index(drop=True)
324
+
325
+
326
+ def _summarize_time(
327
+ df: pd.DataFrame,
328
+ *,
329
+ channel: str,
330
+ time_col: str,
331
+ treatment_col: str,
332
+ order: list[str],
333
+ interval_mass: float,
334
+ resamples: int,
335
+ ) -> pd.DataFrame:
336
+ work = df[df["channel"].astype(str) == str(channel)].copy()
337
+ if work.empty:
338
+ return pd.DataFrame(columns=[time_col, treatment_col, "y_mean", "y_sd", "y_n", "y_lo", "y_hi"])
339
+ rng = np.random.default_rng(0)
340
+ records: list[dict[str, object]] = []
341
+ for (time_value, treatment), series in work.groupby([time_col, treatment_col], dropna=False, sort=True)["value"]:
342
+ values = series.to_numpy(dtype=float, copy=False)
343
+ mean, lower, upper = descriptive_mean_resampling_interval(
344
+ values,
345
+ interval_mass=interval_mass,
346
+ resamples=resamples,
347
+ rng=rng,
348
+ )
349
+ records.append(
350
+ {
351
+ time_col: time_value,
352
+ treatment_col: treatment,
353
+ "y_mean": mean,
354
+ "y_sd": float(np.std(values, ddof=1)) if len(values) > 1 else 0.0,
355
+ "y_n": len(values),
356
+ "y_lo": lower,
357
+ "y_hi": upper,
358
+ }
359
+ )
360
+ stats = pd.DataFrame.from_records(records)
361
+ return _sort_by_treatment(stats, treatment_col=treatment_col, order=order, extra_sort=[time_col])
362
+
363
+
364
+ def _summarize_snapshot(
365
+ df: pd.DataFrame,
366
+ *,
367
+ channel: str,
368
+ time_col: str,
369
+ treatment_col: str,
370
+ snapshot_time: float,
371
+ order: list[str],
372
+ time_atol: float,
373
+ ) -> tuple[pd.DataFrame, pd.DataFrame]:
374
+ work = df[df["channel"].astype(str) == str(channel)].copy()
375
+ if work.empty:
376
+ empty_stats = pd.DataFrame(columns=[treatment_col, "y_mean", "y_sd", "y_n", "y_lo", "y_hi"])
377
+ empty_points = pd.DataFrame(columns=[treatment_col, "value", "observation_index"])
378
+ return empty_stats, empty_points
379
+ mask = (work[time_col] - float(snapshot_time)).abs() <= float(time_atol)
380
+ snapped = work[mask].copy()
381
+ if snapped.empty:
382
+ empty_stats = pd.DataFrame(columns=[treatment_col, "y_mean", "y_sd", "y_n", "y_lo", "y_hi"])
383
+ empty_points = pd.DataFrame(columns=[treatment_col, "value", "observation_index"])
384
+ return empty_stats, empty_points
385
+ stats = snapped.groupby(treatment_col, dropna=False)["value"].agg(["mean", "std", "count"]).reset_index()
386
+ stats = stats.rename(columns={"mean": "y_mean", "std": "y_sd", "count": "y_n"})
387
+ stats["y_sd"] = stats["y_sd"].fillna(0.0)
388
+ stats["y_lo"] = stats["y_mean"] - stats["y_sd"]
389
+ stats["y_hi"] = stats["y_mean"] + stats["y_sd"]
390
+ stats = _sort_by_treatment(stats, treatment_col=treatment_col, order=order, extra_sort=[])
391
+ snapped["__source_order"] = np.arange(len(snapped), dtype=int)
392
+ stable_identity = [column for column in ("position", "well", "source_name", "sheet_name") if column in snapped]
393
+ point_columns = [treatment_col, "value", *stable_identity, "__source_order"]
394
+ points = _sort_by_treatment(
395
+ snapped[point_columns].copy(),
396
+ treatment_col=treatment_col,
397
+ order=order,
398
+ extra_sort=[*stable_identity, "__source_order"],
399
+ )
400
+ points["observation_index"] = points.groupby(treatment_col, sort=False).cumcount()
401
+ points = points.drop(columns="__source_order")
402
+ return stats, points
403
+
404
+
405
+ def _time_chart(
406
+ *,
407
+ alt: Any,
408
+ pd_module: Any,
409
+ frame: pd.DataFrame,
410
+ time_col: str,
411
+ treatment_col: str,
412
+ y_title: str,
413
+ color: Any,
414
+ order: list[str],
415
+ treatment_title: str,
416
+ snapshot_time: float,
417
+ acquisition_transition_time_h: float | None,
418
+ width: int,
419
+ height: int,
420
+ ) -> Any:
421
+ dash_patterns = ([1, 0], [7, 2], [2, 2], [8, 2, 2, 2])
422
+ dash_range = [dash_patterns[index % len(dash_patterns)] for index in range(len(order))]
423
+ base = alt.Chart(frame).encode(
424
+ x=alt.X(f"{time_col}:Q", title="Time (h)", axis=alt.Axis(labelOverlap=False)),
425
+ color=color,
426
+ )
427
+ band = base.mark_area(opacity=0.2).encode(
428
+ y=alt.Y("y_lo:Q", title=y_title),
429
+ y2=alt.Y2("y_hi:Q"),
430
+ tooltip=_time_tooltips(alt, time_col=time_col, treatment_col=treatment_col),
431
+ )
432
+ line = base.mark_line().encode(
433
+ y=alt.Y("y_mean:Q", title=y_title),
434
+ strokeDash=alt.StrokeDash(
435
+ f"{treatment_col}:N",
436
+ sort=order,
437
+ scale=alt.Scale(
438
+ domain=order,
439
+ range=dash_range,
440
+ ),
441
+ legend=alt.Legend(orient="bottom", title=treatment_title),
442
+ ),
443
+ tooltip=_time_tooltips(alt, time_col=time_col, treatment_col=treatment_col),
444
+ )
445
+ layers = [band, line]
446
+ y_max = frame["y_hi"].max()
447
+ if pd_module.isna(y_max):
448
+ y_max = frame["y_mean"].max()
449
+ if pd_module.isna(y_max):
450
+ y_max = 0.0
451
+ rule_df = pd_module.DataFrame({time_col: [float(snapshot_time)], "y": [float(y_max)]})
452
+ layers.append(alt.Chart(rule_df).mark_rule(color="black").encode(x=alt.X(f"{time_col}:Q")))
453
+
454
+ if acquisition_transition_time_h is not None:
455
+ try:
456
+ transition_time = float(acquisition_transition_time_h)
457
+ except (TypeError, ValueError):
458
+ transition_time = None
459
+ if transition_time is not None and not pd_module.isna(transition_time):
460
+ transition_df = pd_module.DataFrame({time_col: [transition_time]})
461
+ layers.append(
462
+ alt.Chart(transition_df).mark_rule(color="#5F5F5F", strokeDash=[6, 4]).encode(x=alt.X(f"{time_col}:Q"))
463
+ )
464
+
465
+ return alt.layer(*layers).properties(width=width, height=height, title=y_title)
466
+
467
+
468
+ def _snapshot_chart(
469
+ *,
470
+ alt: Any,
471
+ frame: pd.DataFrame,
472
+ points: pd.DataFrame,
473
+ treatment_col: str,
474
+ y_title: str,
475
+ order: list[str],
476
+ treatment_title: str,
477
+ width: int,
478
+ height: int,
479
+ ) -> Any:
480
+ axis = alt.Axis(labelLimit=0, labelOverlap=False, labelAngle=0, title=treatment_title)
481
+ base = alt.Chart(frame).encode(
482
+ x=alt.X(f"{treatment_col}:N", sort=order, scale=alt.Scale(domain=order), axis=axis),
483
+ y=alt.Y("y_mean:Q", title=y_title),
484
+ tooltip=[
485
+ alt.Tooltip(f"{treatment_col}:N", title="Treatment"),
486
+ alt.Tooltip("y_mean:Q", title="Mean"),
487
+ alt.Tooltip("y_sd:Q", title="SD"),
488
+ alt.Tooltip("y_n:Q", title="N"),
489
+ ],
490
+ )
491
+ layers = [
492
+ base.mark_rule(color="black").encode(y=alt.Y("y_lo:Q"), y2=alt.Y2("y_hi:Q")),
493
+ base.mark_tick(color="black", orient="horizontal", size=8, thickness=1.5).encode(y=alt.Y("y_lo:Q")),
494
+ base.mark_tick(color="black", orient="horizontal", size=8, thickness=1.5).encode(y=alt.Y("y_hi:Q")),
495
+ base.mark_tick(color="#334155", orient="horizontal", size=24, thickness=2.5),
496
+ ]
497
+ if not points.empty:
498
+ layers.append(
499
+ alt.Chart(points)
500
+ .mark_point(filled=True, fill="white", stroke="#94a3b8", strokeWidth=1.2, size=52)
501
+ .encode(
502
+ x=alt.X(f"{treatment_col}:N", sort=order, scale=alt.Scale(domain=order), axis=axis),
503
+ xOffset=alt.XOffset("observation_index:O"),
504
+ y=alt.Y("value:Q"),
505
+ tooltip=[
506
+ alt.Tooltip(f"{treatment_col}:N", title="Treatment"),
507
+ alt.Tooltip("value:Q", title="Value"),
508
+ ],
509
+ )
510
+ )
511
+ return alt.layer(*layers).properties(width=width, height=height, title=y_title)
512
+
513
+
514
+ def _display_column_title(value: str) -> str:
515
+ return str(value).replace("_", " ").strip().capitalize()
516
+
517
+
518
+ def _time_tooltips(alt: Any, *, time_col: str, treatment_col: str) -> list[Any]:
519
+ return [
520
+ alt.Tooltip(f"{time_col}:Q", title="Time (h)"),
521
+ alt.Tooltip(f"{treatment_col}:N", title="Treatment"),
522
+ alt.Tooltip("y_mean:Q", title="Mean"),
523
+ alt.Tooltip("y_sd:Q", title="SD"),
524
+ alt.Tooltip("y_n:Q", title="N"),
525
+ ]