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,138 @@
1
+ """Logic-symmetry prep helper: pick one time per (design x batch x treatment)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+
8
+ from reader_workbench.domains.logic.treatment_columns import choose_treatment_column, normalize_treatment_series
9
+
10
+
11
+ def _choose_time(times: np.ndarray, *, mode: str, target: float | None, tol: float) -> float:
12
+ times = np.asarray(sorted(times), dtype=float)
13
+ if times.size == 0:
14
+ raise ValueError("No times available in group")
15
+ if mode == "first":
16
+ return float(times.min())
17
+ if mode == "last":
18
+ return float(times.max())
19
+ if mode == "median":
20
+ med = float(np.median(times))
21
+ return float(times[np.abs(times - med).argmin()])
22
+ if mode == "exact":
23
+ if target is None:
24
+ raise ValueError("prep.mode='exact' requires target_time")
25
+ diffs = np.abs(times - float(target))
26
+ if diffs.min() > tol:
27
+ raise ValueError(f"Requested exact time {target} not present within ±{tol} h")
28
+ return float(times[diffs.argmin()])
29
+ if mode == "nearest":
30
+ if target is None:
31
+ raise ValueError("prep.mode='nearest' requires target_time")
32
+ diffs = np.abs(times - float(target))
33
+ if diffs.min() > tol:
34
+ raise ValueError(f"No time within ±{tol} h of target {target} h")
35
+ return float(times[diffs.argmin()])
36
+ raise ValueError(f"Unknown prep.mode '{mode}'")
37
+
38
+
39
+ def prepare_for_logic_symmetry(
40
+ df: pd.DataFrame,
41
+ *,
42
+ response_channel: str,
43
+ design_by: list[str],
44
+ batch_col: str,
45
+ treatment_map: dict[str, str],
46
+ treatment_column: str | None = None,
47
+ mode: str = "last",
48
+ target_time: float | None = None,
49
+ tolerance: float = 0.51,
50
+ align_corners: bool = False,
51
+ case_sensitive: bool = True,
52
+ time_column: str = "time",
53
+ ) -> pd.DataFrame:
54
+ required = set(design_by + [batch_col, "channel", time_column, "value"])
55
+ missing = [c for c in required if c not in df.columns]
56
+ if missing:
57
+ raise ValueError(f"Input DataFrame missing required columns: {missing}")
58
+
59
+ work = df[df["channel"] == response_channel].copy()
60
+ treatment_col = choose_treatment_column(
61
+ work,
62
+ treatment_map,
63
+ case_sensitive=case_sensitive,
64
+ preferred=treatment_column,
65
+ )
66
+ tvals = list(treatment_map.values())
67
+ if not case_sensitive:
68
+ work["_t_norm"] = normalize_treatment_series(work[treatment_col])
69
+ tvals_norm = [str(v).strip().casefold() for v in tvals]
70
+ work = work[work["_t_norm"].isin(tvals_norm)].copy()
71
+ work["_corner"] = work["_t_norm"].map({str(v).strip().casefold(): k for k, v in treatment_map.items()})
72
+ else:
73
+ work = work[work[treatment_col].astype(str).isin([str(v) for v in tvals])].copy()
74
+ work["_corner"] = work[treatment_col].map({v: k for k, v in treatment_map.items()})
75
+
76
+ if work.empty:
77
+ raise ValueError("No rows remain after filtering to response_channel and treatment_map labels")
78
+
79
+ try:
80
+ pd.to_numeric(work[batch_col])
81
+ except Exception as err:
82
+ raise ValueError(f"Batch column '{batch_col}' must be numeric (0,1,2,...)") from err
83
+
84
+ keys_db = design_by + [batch_col]
85
+ keys_dbt = keys_db + ["_corner"]
86
+
87
+ if align_corners:
88
+ selected_rows = []
89
+ failures = []
90
+ for keys, gdb in work.groupby(keys_db, dropna=False):
91
+ all_times = gdb[time_column].unique().astype(float)
92
+ try:
93
+ anchor = _choose_time(all_times, mode=mode, target=target_time, tol=tolerance)
94
+ except Exception as exc:
95
+ failures.append(f"{dict(zip(keys_db, keys, strict=False))} → {exc}")
96
+ continue
97
+ ok = True
98
+ for _, g in gdb.groupby("_corner", dropna=False):
99
+ tvals = g[time_column].astype(float).values
100
+ if tvals.size == 0:
101
+ ok = False
102
+ break
103
+ diffs = np.abs(tvals - anchor)
104
+ if diffs.min() > tolerance:
105
+ ok = False
106
+ break
107
+ pick_time = float(tvals[diffs.argmin()])
108
+ selected_rows.append(g.loc[g[time_column] == pick_time].iloc[0])
109
+ if not ok:
110
+ failures.append(
111
+ f"{dict(zip(keys_db, keys, strict=False))} → no per-corner time within ±{tolerance} h of anchor={anchor}"
112
+ )
113
+ if failures:
114
+ raise ValueError("logic_symmetry_prep align_corners failed:\n " + "\n ".join(failures[:50]))
115
+ return pd.DataFrame(selected_rows).drop(columns=["_t_norm"], errors="ignore")
116
+
117
+ chosen = []
118
+ failures = []
119
+ for keys, g in work.groupby(keys_dbt, dropna=False):
120
+ times = g[time_column].astype(float).values
121
+ try:
122
+ tsel = _choose_time(times, mode=mode, target=target_time, tol=tolerance)
123
+ except Exception as exc:
124
+ failures.append(f"{dict(zip(keys_dbt, keys, strict=False))} → {exc}")
125
+ continue
126
+ chosen.append((keys, tsel))
127
+
128
+ if failures:
129
+ raise ValueError("logic_symmetry_prep failed:\n " + "\n ".join(failures[:50]))
130
+
131
+ keep = pd.Series(False, index=work.index)
132
+ for keys, tsel in chosen:
133
+ mask = (work[keys_dbt] == pd.Series(keys, index=keys_dbt)).all(axis=1) & (
134
+ work[time_column].astype(float) == float(tsel)
135
+ )
136
+ keep |= mask
137
+
138
+ return work.loc[keep].copy().drop(columns=["_t_norm"], errors="ignore")
@@ -0,0 +1,356 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import matplotlib.pyplot as plt
6
+ import numpy as np
7
+ import pandas as pd
8
+ import seaborn as sns
9
+ from matplotlib.colors import to_hex
10
+ from matplotlib.lines import Line2D
11
+ from matplotlib.patches import Rectangle
12
+
13
+ from .overlay import OverlayStyle, generate_overlay_points
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class VisualConfig:
18
+ xlim: tuple[float, float]
19
+ ylim: tuple[float, float]
20
+ grid: bool
21
+ color: str
22
+ annotate_designs: bool = False
23
+ design_label_col: str | None = None
24
+ label_fontsize: int = 12
25
+ label_offset: float = 0.02
26
+
27
+ # Global font sizing knobs
28
+ axis_label_fontsize: int = 16
29
+ tick_label_fontsize: int = 14
30
+ title_fontsize: int = 18
31
+ legend_fontsize: int = 12
32
+
33
+
34
+ def _color_map_for_hue(series: pd.Series) -> dict[object, str]:
35
+ cats = pd.Categorical(series.astype(str))
36
+ n = len(cats.categories)
37
+ if n == 0:
38
+ return {}
39
+ palette = sns.color_palette("colorblind", n_colors=n)
40
+ return {cat: to_hex(palette[i]) for i, cat in enumerate(cats.categories)}
41
+
42
+
43
+ def _u_to_color(u: float) -> str:
44
+ u = float(np.clip(u, 0.0, 1.0))
45
+ g = 1.0 - u # 0 (white) at no activation, 1 (black) at full
46
+ return to_hex((g, g, g))
47
+
48
+
49
+ def _pixels_per_data(ax) -> tuple[float, float]:
50
+ """
51
+ Return (px_per_data_x, px_per_data_y) for the current axes transform.
52
+ Assumes linear scales (true here).
53
+ """
54
+ x0, y0 = ax.transData.transform((0.0, 0.0))
55
+ x1, _ = ax.transData.transform((1.0, 0.0))
56
+ _, y1 = ax.transData.transform((0.0, 1.0))
57
+ return abs(x1 - x0), abs(y1 - y0)
58
+
59
+
60
+ def _effective_square_cell_h(ax, cell_w_data: float) -> float:
61
+ """
62
+ Given a desired cell width in *data X* units, return the *data Y* height
63
+ that yields a square on screen under the current axes scaling.
64
+ """
65
+ sx, sy = _pixels_per_data(ax) # px per data unit along x and y
66
+ if sy <= 0:
67
+ return cell_w_data # fallback; shouldn't happen
68
+ return float(cell_w_data) * (sx / sy)
69
+
70
+
71
+ def _draw_tile_strip(ax, L: float, A: float, u: list[float], style: OverlayStyle, *, zorder: float = 0.3):
72
+ """Draw a centered 4-square horizontal strip encoding u00,u10,u01,u11.
73
+
74
+ Squares are enforced in DISPLAY space (pixels), while positions are in DATA
75
+ space. We therefore compute the required data-height from the current x/y
76
+ pixel scaling so each tile appears 1:1 regardless of figure aspect.
77
+ """
78
+ cell_w = float(style.tile_cell_w) # in data-X units
79
+ cell_h = _effective_square_cell_h(ax, cell_w) # computed data-Y units
80
+ gap = float(style.tile_gap) # horizontal gap in data-X
81
+
82
+ total_w = 4 * cell_w + 3 * gap
83
+ x0 = float(L) - total_w / 2.0
84
+ y0 = float(A) - cell_h / 2.0
85
+
86
+ # 00,10,01,11 left→right
87
+ for i, uu in enumerate([u[0], u[1], u[2], u[3]]):
88
+ xi = x0 + i * (cell_w + gap)
89
+ rect = Rectangle(
90
+ (xi, y0),
91
+ cell_w,
92
+ cell_h,
93
+ facecolor=_u_to_color(uu),
94
+ edgecolor=style.edge_color,
95
+ linewidth=style.tile_edge_width,
96
+ alpha=style.alpha,
97
+ zorder=zorder,
98
+ )
99
+ ax.add_patch(rect)
100
+
101
+
102
+ def draw_scatter(
103
+ points: pd.DataFrame,
104
+ *,
105
+ hue_col: str | None,
106
+ visuals: VisualConfig,
107
+ dispersion_mode: str,
108
+ overlay_cfg: OverlayStyle | None = None,
109
+ overlay_gate_set: str | None = None,
110
+ title: str | None = None,
111
+ figsize=(7, 6),
112
+ dpi=300,
113
+ ):
114
+ sns.set_style("ticks")
115
+
116
+ plt.close("all")
117
+ fig, ax = plt.subplots(figsize=figsize, dpi=dpi)
118
+ ax.set_xlim(*visuals.xlim)
119
+ ax.set_ylim(*visuals.ylim)
120
+
121
+ # NOTE: Do NOT force equal aspect. This allows wide/tall figures to honor figsize.
122
+ # We keep overlay tiles square by computing their data-height from pixel scaling.
123
+
124
+ ax.set_xlabel("Logic (L)", fontsize=visuals.axis_label_fontsize)
125
+ ax.set_ylabel("Asymmetry (A)", fontsize=visuals.axis_label_fontsize)
126
+
127
+ ax.set_xticks([-1, -0.5, 0, 0.5, 1])
128
+ ax.set_yticks([-1, -0.5, 0, 0.5, 1])
129
+ ax.tick_params(axis="both", which="major", labelsize=visuals.tick_label_fontsize)
130
+
131
+ if visuals.grid:
132
+ ax.grid(True, linestyle="--", alpha=0.25, linewidth=0.6)
133
+ sns.despine(ax=ax, top=True, right=True)
134
+
135
+ # Prepare colors for experimental points
136
+ if hue_col and points[hue_col].notna().any():
137
+ cmap = _color_map_for_hue(points[hue_col])
138
+ colors = points[hue_col].astype(str).map(cmap).fillna(visuals.color)
139
+ else:
140
+ cmap = None
141
+ colors = pd.Series([visuals.color] * len(points), index=points.index)
142
+
143
+ # ——— Ideal overlay (dots or tiles) ———
144
+ if overlay_cfg and overlay_gate_set:
145
+ ol = generate_overlay_points(overlay_gate_set).copy()
146
+ xmin, xmax = visuals.xlim
147
+ ymin, ymax = visuals.ylim
148
+
149
+ if str(overlay_cfg.mode).lower() == "tiles":
150
+ # Precompute the effective (square) data-height for stacking offsets.
151
+ cell_h_eff = _effective_square_cell_h(ax, float(overlay_cfg.tile_cell_w))
152
+
153
+ # Stack multiple strips at the same (L,A)
154
+ for (L, A), g in ol.groupby(["L", "A"], sort=False):
155
+ n = len(g)
156
+ if overlay_cfg.tiles_stack_multiple and n > 1:
157
+ dy = cell_h_eff + overlay_cfg.label_line_height
158
+ offsets = [(i - (n - 1) / 2.0) * dy for i in range(n)]
159
+ else:
160
+ offsets = [0.0] * n
161
+
162
+ for off, (_, row) in zip(offsets, g.iterrows(), strict=False):
163
+ _draw_tile_strip(ax, float(L), float(A) + float(off), list(row["u"]), overlay_cfg, zorder=0.3)
164
+
165
+ # Labels
166
+ if overlay_cfg.show_labels:
167
+ for (L, A), g in ol.groupby(["L", "A"], sort=False):
168
+ labels = sorted(g["label"].astype(str).tolist())
169
+ n = len(labels)
170
+ above = True
171
+ if A + overlay_cfg.label_offset + (n - 1) * overlay_cfg.label_line_height > (ymax - 0.01):
172
+ above = False
173
+ if above:
174
+ y0 = A + overlay_cfg.label_offset
175
+ dy = overlay_cfg.label_line_height
176
+ va = "bottom"
177
+ else:
178
+ y0 = A - overlay_cfg.label_offset
179
+ dy = -overlay_cfg.label_line_height
180
+ va = "top"
181
+
182
+ for i, text in enumerate(labels):
183
+ yy = np.clip(y0 + i * dy, ymin + 0.005, ymax - 0.005)
184
+ ax.text(
185
+ float(L),
186
+ float(yy),
187
+ text,
188
+ fontsize=int(getattr(overlay_cfg, "label_fontsize", 12)),
189
+ ha="center",
190
+ va=va,
191
+ alpha=min(1.0, float(overlay_cfg.alpha) + 0.25),
192
+ zorder=0.35,
193
+ )
194
+ else:
195
+ # Draw one circle at each ideal coordinate.
196
+ for (L, A), g in ol.groupby(["L", "A"], sort=False):
197
+ ax.scatter(
198
+ [L],
199
+ [A],
200
+ s=overlay_cfg.size,
201
+ facecolors=overlay_cfg.face_color,
202
+ edgecolors=overlay_cfg.edge_color,
203
+ alpha=overlay_cfg.alpha,
204
+ marker="o",
205
+ linewidths=0.9,
206
+ zorder=0,
207
+ )
208
+ if overlay_cfg.show_labels:
209
+ labels = sorted(g["label"].astype(str).tolist())
210
+ n = len(labels)
211
+ above = True
212
+ if A + overlay_cfg.label_offset + (n - 1) * overlay_cfg.label_line_height > (ymax - 0.01):
213
+ above = False
214
+ if above:
215
+ y0 = A + overlay_cfg.label_offset
216
+ dy = overlay_cfg.label_line_height
217
+ va = "bottom"
218
+ else:
219
+ y0 = A - overlay_cfg.label_offset
220
+ dy = -overlay_cfg.label_line_height
221
+ va = "top"
222
+ for i, text in enumerate(labels):
223
+ yy = np.clip(y0 + i * dy, ymin + 0.005, ymax - 0.005)
224
+ ax.text(
225
+ float(L),
226
+ float(yy),
227
+ text,
228
+ fontsize=int(getattr(overlay_cfg, "label_fontsize", 12)),
229
+ ha="center",
230
+ va=va,
231
+ alpha=min(1.0, float(overlay_cfg.alpha) + 0.25),
232
+ zorder=0,
233
+ )
234
+
235
+ # ——— Experimental points ———
236
+ for marker, sub in points.groupby("shape_value"):
237
+ if dispersion_mode == "halo":
238
+ k = 1.5
239
+ halo_size = sub["size_value"] * (1.0 + k * sub.get("cv", pd.Series(0.0, index=sub.index)).clip(0, 0.5))
240
+ ax.scatter(
241
+ sub["L"],
242
+ sub["A"],
243
+ s=halo_size,
244
+ c=colors.loc[sub.index],
245
+ alpha=np.minimum(sub["alpha_value"] * 0.35, 0.5),
246
+ marker=marker,
247
+ edgecolor="none",
248
+ linewidths=0.0,
249
+ zorder=1,
250
+ )
251
+
252
+ if dispersion_mode == "bars":
253
+ err = (sub.get("cv", pd.Series(0.0, index=sub.index)).clip(0, 0.5)) * 0.3
254
+ ax.errorbar(
255
+ sub["L"],
256
+ sub["A"],
257
+ xerr=err,
258
+ yerr=err,
259
+ fmt="none",
260
+ ecolor="#9e9e9e",
261
+ elinewidth=0.7,
262
+ capsize=2,
263
+ alpha=0.7,
264
+ zorder=1.4,
265
+ )
266
+
267
+ ax.scatter(
268
+ sub["L"],
269
+ sub["A"],
270
+ s=sub["size_value"],
271
+ c=colors.loc[sub.index],
272
+ alpha=sub["alpha_value"],
273
+ marker=marker,
274
+ edgecolor="k",
275
+ linewidths=0.5,
276
+ zorder=2,
277
+ )
278
+
279
+ if visuals.annotate_designs:
280
+ label_col = visuals.design_label_col
281
+ if label_col is None or label_col not in points.columns:
282
+ label_col = next((c for c in ["genotype", "design", "construct"] if c in points.columns), None)
283
+ if label_col is not None:
284
+ for _, r in points.iterrows():
285
+ txt = r.get(label_col)
286
+ if pd.isna(txt):
287
+ continue
288
+ ax.text(
289
+ float(r["L"]),
290
+ float(r["A"]) + float(visuals.label_offset),
291
+ str(txt),
292
+ fontsize=int(visuals.label_fontsize),
293
+ ha="center",
294
+ va="bottom",
295
+ alpha=0.95,
296
+ zorder=4,
297
+ )
298
+
299
+ first_legend = None
300
+ if cmap is not None:
301
+ handles = []
302
+
303
+ for cat, col in cmap.items():
304
+ handles.append(
305
+ Line2D([0], [0], marker="o", color="none", markerfacecolor=col, label=str(cat), markersize=8)
306
+ )
307
+ first_legend = ax.legend(
308
+ title=hue_col,
309
+ handles=handles,
310
+ loc="best",
311
+ frameon=False,
312
+ prop={"size": visuals.legend_fontsize},
313
+ title_fontsize=visuals.legend_fontsize,
314
+ )
315
+ if first_legend is not None:
316
+ frame = first_legend.get_frame()
317
+ frame.set_facecolor("none")
318
+ frame.set_edgecolor("none")
319
+ frame.set_alpha(0)
320
+
321
+ if points["shape_value"].nunique() > 1:
322
+ sh_handles = []
323
+ for marker in points["shape_value"].unique().tolist():
324
+ sh_handles.append(
325
+ Line2D(
326
+ [0],
327
+ [0],
328
+ marker=marker,
329
+ color="k",
330
+ markerfacecolor="none",
331
+ label=str(marker),
332
+ markersize=8,
333
+ linewidth=0,
334
+ )
335
+ )
336
+ second_legend = ax.legend(
337
+ title=(hue_col or "shape"),
338
+ handles=sh_handles,
339
+ loc="upper left",
340
+ frameon=False,
341
+ prop={"size": visuals.legend_fontsize},
342
+ title_fontsize=visuals.legend_fontsize,
343
+ )
344
+ if second_legend is not None:
345
+ frame = second_legend.get_frame()
346
+ frame.set_facecolor("none")
347
+ frame.set_edgecolor("none")
348
+ frame.set_alpha(0)
349
+ if first_legend is not None:
350
+ ax.add_artist(first_legend)
351
+
352
+ if title:
353
+ ax.set_title(title, fontsize=visuals.title_fontsize)
354
+
355
+ fig.tight_layout()
356
+ return fig, ax
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ import pandas as pd
4
+
5
+
6
+ def normalize_treatment_series(series: pd.Series) -> pd.Series:
7
+ return series.astype(str).str.strip().str.casefold()
8
+
9
+
10
+ def choose_treatment_column(
11
+ df: pd.DataFrame,
12
+ treatment_map: dict[str, str],
13
+ *,
14
+ case_sensitive: bool,
15
+ preferred: str | None = None,
16
+ ) -> str:
17
+ if preferred is not None:
18
+ preferred = str(preferred).strip()
19
+ if not preferred:
20
+ raise ValueError("preferred treatment column must be a non-empty string when provided")
21
+ if preferred not in df.columns:
22
+ available = ", ".join(sorted(df.columns)) or "—"
23
+ raise ValueError(
24
+ f"Configured treatment column {preferred!r} is missing from the dataframe. Available columns: {available}"
25
+ )
26
+ return preferred
27
+
28
+ candidates = [column for column in ("treatment", "treatment_alias") if column in df.columns]
29
+ if not candidates:
30
+ raise ValueError("Neither 'treatment' nor 'treatment_alias' is present in the dataframe.")
31
+
32
+ def _score(column: str) -> int:
33
+ values = df[column].astype(str)
34
+ if case_sensitive:
35
+ wanted = {str(item) for item in treatment_map.values()}
36
+ else:
37
+ wanted = {str(item).strip().casefold() for item in treatment_map.values()}
38
+ values = normalize_treatment_series(values)
39
+ return int(values.isin(list(wanted)).sum())
40
+
41
+ scores = {column: _score(column) for column in candidates}
42
+ return max(scores, key=lambda column: (scores[column], column == "treatment"))
@@ -0,0 +1 @@
1
+ """Plate-reader domain package."""
@@ -0,0 +1,14 @@
1
+ """Plate-reader derived-table and timepoint-selection helpers."""
2
+
3
+ from .fold_change import FoldChangeAnalysisSpec, compute_fold_change_table
4
+ from .four_state_event_window import FourStateEventWindowAnalysisSpec, FourStateEventWindowSourceSpec
5
+ from .timepoints import choose_nearest_time, nearest_time_per_key
6
+
7
+ __all__ = [
8
+ "FoldChangeAnalysisSpec",
9
+ "FourStateEventWindowAnalysisSpec",
10
+ "FourStateEventWindowSourceSpec",
11
+ "choose_nearest_time",
12
+ "compute_fold_change_table",
13
+ "nearest_time_per_key",
14
+ ]