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,300 @@
1
+ """Static rendering for the single-reporter diagnostic contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+
7
+ import matplotlib.pyplot as plt
8
+ import numpy as np
9
+ from matplotlib.figure import Figure
10
+ from matplotlib.lines import Line2D
11
+
12
+ from reader_workbench.plotting.style import use_style
13
+
14
+ from .single_reporter_diagnostic import SingleReporterDiagnosticData
15
+
16
+ _DEFAULT_COLORS = ("#334155", "#0f766e", "#2563eb", "#be123c")
17
+ _MARKERS = ("o", "s", "^", "D", "P", "X", "v", "<", ">")
18
+
19
+
20
+ def render_single_reporter_diagnostic(
21
+ data: SingleReporterDiagnosticData,
22
+ *,
23
+ colors: Sequence[str] | None = None,
24
+ figsize: tuple[float, float] = (14.0, 3.7),
25
+ axis_label_size: float = 9.5,
26
+ tick_label_size: float = 8.5,
27
+ legend_fontsize: float = 8.5,
28
+ line_width: float = 1.7,
29
+ point_size: float = 28.0,
30
+ condition_tick_rotation: float = 20.0,
31
+ ) -> Figure:
32
+ """Render normalizer, reporter, ratio, and reduced-condition panels."""
33
+
34
+ order = list(data.condition_order)
35
+ palette = list(colors or _DEFAULT_COLORS)
36
+ if not palette:
37
+ raise ValueError("single_reporter_diagnostic: colors must contain at least one color")
38
+ color_map = {condition: palette[index % len(palette)] for index, condition in enumerate(order)}
39
+ marker_map = {condition: _MARKERS[index % len(_MARKERS)] for index, condition in enumerate(order)}
40
+
41
+ with use_style(
42
+ rc={
43
+ "axes_titleweight": "regular",
44
+ "axes_titlesize": 10.5,
45
+ "axes_labelsize": axis_label_size,
46
+ "font_size": 9.5,
47
+ "legend_fontsize": legend_fontsize,
48
+ "legend_title_fontsize": legend_fontsize,
49
+ "xtick_labelsize": tick_label_size,
50
+ "ytick_labelsize": tick_label_size,
51
+ },
52
+ color_cycle=palette,
53
+ ):
54
+ figure, axes = plt.subplots(1, 4, figsize=figsize, constrained_layout=True)
55
+ figure.set_gid("single-reporter-diagnostic")
56
+ for axis, channel in zip(
57
+ axes[:3],
58
+ (data.normalizer_channel, data.reporter_channel, data.ratio_channel),
59
+ strict=True,
60
+ ):
61
+ _draw_kinetics_panel(
62
+ axis,
63
+ data=data,
64
+ channel=channel,
65
+ color_map=color_map,
66
+ marker_map=marker_map,
67
+ line_width=line_width,
68
+ point_size=point_size,
69
+ )
70
+ axis.set_title(f"{channel} kinetics")
71
+ axis.set_xlabel("Time (h)")
72
+ axis.set_ylabel(channel)
73
+ axis.set_box_aspect(1.0)
74
+
75
+ qc_axis = _draw_reduction_panel(
76
+ axes[3],
77
+ data=data,
78
+ color_map=color_map,
79
+ marker_map=marker_map,
80
+ point_size=point_size,
81
+ condition_tick_rotation=condition_tick_rotation,
82
+ )
83
+ axes[3].set_box_aspect(1.0)
84
+ qc_axis.set_box_aspect(1.0)
85
+
86
+ condition_handles = [
87
+ Line2D(
88
+ [0],
89
+ [0],
90
+ color=color_map[condition],
91
+ marker=marker_map[condition],
92
+ linestyle="-",
93
+ linewidth=line_width,
94
+ markersize=max(4.0, point_size**0.5),
95
+ label=condition,
96
+ )
97
+ for condition in order
98
+ ]
99
+ qc_handle = Line2D(
100
+ [0],
101
+ [0],
102
+ color="#64748b",
103
+ marker="x",
104
+ linestyle="--",
105
+ linewidth=1.1,
106
+ markersize=max(4.0, point_size**0.5),
107
+ label=f"{data.normalizer_channel} QC (right axis)",
108
+ )
109
+ condition_label = _column_label(data.condition_column)
110
+ figure.legend(
111
+ handles=[*condition_handles, qc_handle],
112
+ loc="outside lower center",
113
+ ncols=max(1, min(len(condition_handles) + 1, 5)),
114
+ title=condition_label,
115
+ )
116
+ figure.suptitle(f"{data.group_label}\n{data.selection.label}", fontweight="normal")
117
+ return figure
118
+
119
+
120
+ def _draw_kinetics_panel(
121
+ axis,
122
+ *,
123
+ data: SingleReporterDiagnosticData,
124
+ channel: str,
125
+ color_map: dict[str, str],
126
+ marker_map: dict[str, str],
127
+ line_width: float,
128
+ point_size: float,
129
+ ) -> None:
130
+ channel_rows = data.kinetics[data.kinetics["channel"].astype(str) == str(channel)]
131
+ if channel_rows.empty:
132
+ raise ValueError(f"single_reporter_diagnostic: no kinetics rows for {channel!r}")
133
+
134
+ for condition in data.condition_order:
135
+ selected = channel_rows[channel_rows["__condition"].astype(str) == condition]
136
+ if selected.empty:
137
+ continue
138
+ grouped = selected.groupby(["__segment", data.time_column], sort=True, dropna=False)["value"]
139
+ summary = grouped.agg(
140
+ center=lambda values: _center(
141
+ values,
142
+ statistic=data.observation_aggregation.across_unit_statistic,
143
+ ),
144
+ lower=lambda values: float(np.quantile(np.asarray(values, dtype=float), 0.25)),
145
+ upper=lambda values: float(np.quantile(np.asarray(values, dtype=float), 0.75)),
146
+ ).reset_index()
147
+ for _, segment in summary.groupby("__segment", sort=False):
148
+ segment = segment.sort_values(data.time_column)
149
+ x = segment[data.time_column].to_numpy(dtype=float)
150
+ center = segment["center"].to_numpy(dtype=float)
151
+ color = color_map[condition]
152
+ axis.fill_between(
153
+ x,
154
+ segment["lower"].to_numpy(dtype=float),
155
+ segment["upper"].to_numpy(dtype=float),
156
+ color=color,
157
+ alpha=0.14,
158
+ linewidth=0.0,
159
+ )
160
+ axis.plot(x, center, color=color, linewidth=line_width)
161
+ marker_stride = max(1, len(x) // 12)
162
+ axis.scatter(
163
+ x[::marker_stride],
164
+ center[::marker_stride],
165
+ color=color,
166
+ marker=marker_map[condition],
167
+ s=point_size * 0.55,
168
+ linewidths=0.0,
169
+ zorder=3,
170
+ )
171
+ _mark_selection(axis, data=data)
172
+
173
+
174
+ def _mark_selection(axis, *, data: SingleReporterDiagnosticData) -> None:
175
+ if data.selection.endpoint_time_h is not None:
176
+ line = axis.axvline(
177
+ data.selection.endpoint_time_h,
178
+ color="#111827",
179
+ linewidth=1.0,
180
+ linestyle=(0, (5, 3)),
181
+ zorder=0.8,
182
+ )
183
+ line.set_gid("single-reporter-endpoint")
184
+ return
185
+ assert data.selection.window_h is not None
186
+ lower, upper = data.selection.window_h
187
+ patch = axis.axvspan(lower, upper, color="#d8b365", alpha=0.18, linewidth=0.0, zorder=0.5)
188
+ patch.set_gid("single-reporter-window")
189
+
190
+
191
+ def _draw_reduction_panel(
192
+ axis,
193
+ *,
194
+ data: SingleReporterDiagnosticData,
195
+ color_map: dict[str, str],
196
+ marker_map: dict[str, str],
197
+ point_size: float,
198
+ condition_tick_rotation: float,
199
+ ):
200
+ qc_axis = axis.twinx()
201
+ qc_axis.set_gid("single-reporter-normalizer-qc")
202
+ qc_axis.grid(False)
203
+ qc_axis.spines["right"].set_visible(True)
204
+ qc_axis.spines["right"].set_color("#64748b")
205
+ qc_axis.tick_params(axis="y", colors="#64748b")
206
+
207
+ for index, condition in enumerate(data.condition_order):
208
+ ratio = data.reduced_ratio[data.reduced_ratio["__condition"].astype(str) == condition]
209
+ normalizer = data.reduced_normalizer[data.reduced_normalizer["__condition"].astype(str) == condition]
210
+ ratio_values = ratio["value"].to_numpy(dtype=float)
211
+ normalizer_values = normalizer["value"].to_numpy(dtype=float)
212
+ ratio_offsets = _offsets(len(ratio_values), center=-0.07)
213
+ normalizer_offsets = _offsets(len(normalizer_values), center=0.07)
214
+ color = color_map[condition]
215
+ axis.scatter(
216
+ index + ratio_offsets,
217
+ ratio_values,
218
+ s=point_size,
219
+ facecolors="white",
220
+ edgecolors=color,
221
+ marker=marker_map[condition],
222
+ linewidths=1.1,
223
+ zorder=3,
224
+ )
225
+ ratio_center = _center(
226
+ ratio_values,
227
+ statistic=data.observation_aggregation.across_unit_statistic,
228
+ )
229
+ ratio_line = axis.hlines(ratio_center, index - 0.22, index + 0.02, color=color, linewidth=2.4, zorder=4)
230
+ ratio_line.set_gid("single-reporter-ratio-center")
231
+
232
+ qc_axis.scatter(
233
+ index + normalizer_offsets,
234
+ normalizer_values,
235
+ s=point_size * 0.9,
236
+ color="#64748b",
237
+ marker="x",
238
+ linewidths=1.1,
239
+ zorder=3,
240
+ )
241
+ normalizer_center = _center(
242
+ normalizer_values,
243
+ statistic=data.observation_aggregation.across_unit_statistic,
244
+ )
245
+ qc_line = qc_axis.hlines(
246
+ normalizer_center,
247
+ index + 0.02,
248
+ index + 0.22,
249
+ color="#64748b",
250
+ linewidth=1.5,
251
+ linestyle="--",
252
+ zorder=4,
253
+ )
254
+ qc_line.set_gid("single-reporter-normalizer-center")
255
+
256
+ unit_label = (
257
+ "Declared replicate units" if data.unit_role == "declared_replicate" else "Observation units (not replicates)"
258
+ )
259
+ axis.set_title(f"{data.ratio_channel} by condition\n{unit_label}")
260
+ axis.set_xlabel(_column_label(data.condition_column))
261
+ value_space = data.selection.temporal_reduction.output_space
262
+ value_suffix = "" if value_space == "linear" else " (log2)"
263
+ axis.set_ylabel(f"Reduced {data.ratio_channel}{value_suffix}")
264
+ qc_axis.set_ylabel(f"Reduced {data.normalizer_channel}{value_suffix} (QC only)", color="#64748b")
265
+ axis.set_xticks(
266
+ range(len(data.condition_order)),
267
+ data.condition_order,
268
+ rotation=condition_tick_rotation,
269
+ ha="right" if condition_tick_rotation else "center",
270
+ )
271
+ axis.set_xlim(-0.45, max(0.55, len(data.condition_order) - 0.55))
272
+ return qc_axis
273
+
274
+
275
+ def _offsets(size: int, *, center: float) -> np.ndarray:
276
+ if size <= 1:
277
+ return np.asarray([center] if size else [], dtype=float)
278
+ return np.linspace(center - 0.06, center + 0.06, size)
279
+
280
+
281
+ def _column_label(column: str) -> str:
282
+ """Render configured semantic columns without storage-only alias suffixes."""
283
+
284
+ normalized = str(column).strip()
285
+ if normalized.endswith("_alias"):
286
+ normalized = normalized.removesuffix("_alias")
287
+ return normalized.replace("_", " ").capitalize()
288
+
289
+
290
+ def _center(values, *, statistic: str) -> float:
291
+ finite = np.asarray(values, dtype=float)
292
+ finite = finite[np.isfinite(finite)]
293
+ if finite.size == 0:
294
+ raise ValueError("single_reporter_diagnostic: reduction contains no finite values")
295
+ if statistic == "mean":
296
+ return float(finite.mean())
297
+ return float(np.median(finite))
298
+
299
+
300
+ __all__ = ["render_single_reporter_diagnostic"]
@@ -0,0 +1,315 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from contextlib import suppress
5
+ from typing import Literal
6
+
7
+ import matplotlib.pyplot as plt
8
+ import numpy as np
9
+ import pandas as pd
10
+
11
+ from reader_workbench.plotting.sinks import PlotFigure
12
+ from reader_workbench.plotting.style import PaletteBook, use_style
13
+
14
+ from ...ordering import order_levels
15
+ from ..common import alias_column, best_subplot_grid, colors_for, plot_figure, require_columns, warn_if_empty
16
+ from ..grouping import GroupMatch
17
+ from ..panels import draw_snapshot_panel, select_snapshot_rows, summarize_snapshot_values
18
+ from .planning import build_figure_groups, compute_shared_ylim, resolve_panel_configuration
19
+
20
+ _PanelBy = Literal["channel", "x", "group"]
21
+ _FileBy = Literal["auto", "group", "channel", "x"]
22
+
23
+
24
+ def _figure_stub(
25
+ *,
26
+ filename: str | None,
27
+ panel_by: _PanelBy,
28
+ group_col: str | None,
29
+ fig_label: str,
30
+ members: list[str | None],
31
+ selected_channel: str | None,
32
+ multiple_files: bool,
33
+ ) -> str:
34
+ if filename:
35
+ if not multiple_files:
36
+ return filename
37
+ suffix_parts: list[str] = []
38
+ if panel_by == "group" and selected_channel is not None:
39
+ suffix_parts.append(f"channel={selected_channel}")
40
+ elif panel_by == "channel":
41
+ key = members[0] if group_col and members and members[0] is not None else fig_label
42
+ suffix_parts.append(f"group={key}")
43
+ else:
44
+ suffix_parts.append(f"panel={fig_label}")
45
+ return f"{filename}__{'__'.join(suffix_parts)}"
46
+ if panel_by == "group":
47
+ return f"snap__grp__{selected_channel}"
48
+ if panel_by == "channel":
49
+ key = members[0] if group_col and members and members[0] is not None else fig_label
50
+ return f"snap__ch__{key}"
51
+ return f"snap__x__{selected_channel}__{fig_label}"
52
+
53
+
54
+ def plot_snapshot_barplot(
55
+ *,
56
+ df: pd.DataFrame,
57
+ x: str,
58
+ y: list[str] | str,
59
+ hue: str | None,
60
+ group_on: str | None,
61
+ pool_sets: list[dict[str, list[str]]] | None,
62
+ time: float,
63
+ pool_match: GroupMatch = "exact",
64
+ fig_kwargs: dict | None = None,
65
+ filename: str | None = None,
66
+ palette_book: PaletteBook | None = None,
67
+ agg: str = "mean",
68
+ dispersion: str = "sd",
69
+ time_tolerance: float = 0.51,
70
+ panel_by: _PanelBy = "channel",
71
+ channel_select: str | None = None,
72
+ file_by: _FileBy = "auto",
73
+ show_legend: bool = False,
74
+ legend_loc: str = "upper right",
75
+ ) -> list[PlotFigure]:
76
+ fig_kwargs = fig_kwargs or {}
77
+
78
+ if agg not in {"mean", "median"}:
79
+ raise ValueError("agg must be 'mean' or 'median'")
80
+ if dispersion not in {"sd", "iqr", "none"}:
81
+ raise ValueError("dispersion must be 'sd', 'iqr', or 'none'")
82
+ if file_by not in {"auto", "channel"}:
83
+ raise ValueError("snapshot_barplot: file_by supports only 'auto' or 'channel'")
84
+
85
+ x_col = alias_column(df, x)
86
+ hue_col = alias_column(df, hue) if hue else None
87
+ group_col = alias_column(df, group_on) if group_on else None
88
+ y_list = [y] if isinstance(y, str) else list(y)
89
+ if panel_by == "channel" and channel_select:
90
+ y_list = [channel_select]
91
+
92
+ required = ["time", "channel", "value", "position", x_col]
93
+ if hue_col:
94
+ required.append(hue_col)
95
+ if group_col:
96
+ required.append(group_col)
97
+ require_columns(df, required, where="snapshot_barplot")
98
+
99
+ work = df.copy()
100
+ work["value"] = pd.to_numeric(work["value"], errors="coerce")
101
+
102
+ if panel_by == "channel":
103
+ available = sorted(work["channel"].astype(str).unique().tolist())
104
+ missing = [str(channel) for channel in y_list if str(channel) not in available]
105
+ if missing:
106
+ raise ValueError(f"snapshot_barplot: requested channels not found: {missing}. Available: {available}")
107
+ work = work[work["channel"].astype(str).isin([str(channel) for channel in y_list])].copy()
108
+ if warn_if_empty(work, where="snapshot_barplot", detail="after channel filter"):
109
+ return []
110
+
111
+ if panel_by in {"x", "group"} and not channel_select:
112
+ raise ValueError("snapshot_barplot: channel_select is required when panel_by != 'channel'")
113
+ if channel_select and panel_by in {"x", "group"}:
114
+ available = sorted(work["channel"].astype(str).unique().tolist())
115
+ if str(channel_select) not in available:
116
+ raise ValueError(f"snapshot_barplot: channel_select {channel_select!r} not in data. Available: {available}")
117
+
118
+ selected_channel_default = None
119
+ if panel_by in {"x", "group"}:
120
+ selected_channel_default = str(
121
+ channel_select if channel_select else (y if isinstance(y, str) else (y_list[0] if y_list else None))
122
+ )
123
+ if not selected_channel_default:
124
+ raise ValueError(
125
+ f"snapshot_barplot: panel_by={panel_by!r} requires an explicit channel via channel_select or y."
126
+ )
127
+
128
+ key_cols = [column for column in [group_col, x_col, hue_col, "channel", "position"] if column]
129
+ target_channels = list(y_list) if panel_by == "channel" else [selected_channel_default]
130
+ selections = [
131
+ select_snapshot_rows(
132
+ df=work,
133
+ target_time=float(time),
134
+ keys=key_cols,
135
+ channel=str(channel),
136
+ tolerance=float(time_tolerance),
137
+ )
138
+ for channel in target_channels
139
+ ]
140
+ snapped_parts = [selection.rows for selection in selections if not selection.rows.empty]
141
+ if not snapped_parts:
142
+ logging.getLogger("reader").info(
143
+ "[warn]snapshot_barplot[/warn] • no rows available at any time — skipping figure"
144
+ )
145
+ return []
146
+ fallback_notes = [selection for selection in selections if selection.fell_back and not selection.rows.empty]
147
+ if fallback_notes:
148
+ preview = "; ".join(
149
+ f"{target_channels[idx]}: times={selection.fallback_times_preview} Δ≈{float(selection.fallback_delta or 0.0):.2f} h"
150
+ for idx, selection in enumerate(selections)
151
+ if selection.fell_back and not selection.rows.empty
152
+ )
153
+ logging.getLogger("reader").info(
154
+ "[warn]snapshot_barplot[/warn] • requested t=%.2f h; no rows within ±%.2f h — "
155
+ "using nearest available per key (%s)",
156
+ float(time),
157
+ float(time_tolerance),
158
+ preview,
159
+ )
160
+ snapped = pd.concat(snapped_parts, ignore_index=True)
161
+ snapped["value"] = pd.to_numeric(snapped["value"], errors="coerce")
162
+ times_used = pd.to_numeric(snapped["time"], errors="coerce").dropna()
163
+ t_used = float(times_used.median()) if not times_used.empty else float(time)
164
+
165
+ if group_col:
166
+ mask = snapped[group_col].notna() & (
167
+ ~snapped[group_col].astype(str).str.strip().str.lower().isin({"none", "nan", ""})
168
+ )
169
+ snapped = snapped.loc[mask].copy()
170
+ if snapped.empty:
171
+ return []
172
+
173
+ base_group_cols = [column for column in [group_col, x_col, hue_col, "channel"] if column]
174
+ seen_cols: set[str] = set()
175
+ base_group_cols = [column for column in base_group_cols if not (column in seen_cols or seen_cols.add(column))]
176
+ stats = summarize_snapshot_values(df=snapped, group_cols=base_group_cols, dispersion=dispersion)
177
+
178
+ fig_groups = build_figure_groups(
179
+ stats=stats,
180
+ group_col=group_col,
181
+ panel_by=panel_by,
182
+ pool_sets=pool_sets,
183
+ pool_match=pool_match,
184
+ )
185
+
186
+ iterate_channels = panel_by == "group" and file_by == "channel"
187
+ channels_for_files: list[str | None] = (
188
+ (y_list if iterate_channels else [None]) if y_list else ([None] if not iterate_channels else [])
189
+ )
190
+ if iterate_channels and not channels_for_files:
191
+ return []
192
+
193
+ figures: list[PlotFigure] = []
194
+ for fig_label, members in fig_groups:
195
+ for ch_for_file in channels_for_files:
196
+ panels, selected_channel = resolve_panel_configuration(
197
+ panel_by=panel_by,
198
+ members=members,
199
+ y_list=y_list,
200
+ group_col=group_col,
201
+ channel_select=channel_select,
202
+ selected_channel_default=selected_channel_default,
203
+ ch_for_file=ch_for_file,
204
+ stats=stats,
205
+ x_col=x_col,
206
+ )
207
+ rows, cols = best_subplot_grid(len(panels))
208
+ hue_levels_union = order_levels(stats[hue_col].astype(str).unique().tolist()) if hue_col else ["_single"]
209
+ colors = colors_for(max(1, len(hue_levels_union)), palette_book)
210
+ color_map = {level: colors[index % len(colors)] for index, level in enumerate(hue_levels_union)}
211
+
212
+ with use_style(rc=fig_kwargs.get("rc"), color_cycle=colors):
213
+ fig, axes = plt.subplots(rows, cols, figsize=(cols * 5, rows * 5), constrained_layout=True)
214
+ axes = np.atleast_1d(axes).ravel()
215
+ rasterize_zorder = fig_kwargs.get("rasterize_zorder", None)
216
+ if rasterize_zorder is not None:
217
+ for ax in axes:
218
+ ax.set_rasterization_zorder(float(rasterize_zorder))
219
+
220
+ if panel_by == "channel" and group_col and members and members[0] is not None:
221
+ fig.suptitle(f"{members[0]} • t={t_used:.2f} h", y=float(fig_kwargs.get("suptitle_y", 1.04)))
222
+ elif panel_by == "group":
223
+ fig.suptitle(f"{selected_channel} • t={t_used:.2f} h", y=float(fig_kwargs.get("suptitle_y", 1.04)))
224
+ else:
225
+ fig.suptitle(f"{fig_label} • t={t_used:.2f} h", y=float(fig_kwargs.get("suptitle_y", 1.04)))
226
+
227
+ y_lo_glob, y_hi_glob = (
228
+ compute_shared_ylim(
229
+ stats=stats,
230
+ snapped=snapped,
231
+ panels=panels,
232
+ panel_by=panel_by,
233
+ selected_channel=str(selected_channel),
234
+ group_col=group_col,
235
+ x_col=x_col,
236
+ agg=agg,
237
+ dispersion=dispersion,
238
+ )
239
+ if selected_channel is not None
240
+ else (None, None)
241
+ )
242
+
243
+ for ax_idx, panel in enumerate(panels):
244
+ ax = axes[ax_idx]
245
+ if panel_by == "channel":
246
+ gval = members[0] if (group_col and members and members[0] is not None) else None
247
+ sbar = stats[stats["channel"].astype(str) == str(panel)].copy()
248
+ srep = snapped[snapped["channel"].astype(str) == str(panel)].copy()
249
+ if gval is not None:
250
+ sbar = sbar[sbar[group_col].astype(str) == str(gval)]
251
+ srep = srep[srep[group_col].astype(str) == str(gval)]
252
+ channel_key = str(panel)
253
+ elif panel_by == "group":
254
+ sbar = stats[
255
+ (stats["channel"].astype(str) == str(selected_channel))
256
+ & (stats[group_col].astype(str) == str(panel))
257
+ ].copy()
258
+ srep = snapped[
259
+ (snapped["channel"].astype(str) == str(selected_channel))
260
+ & (snapped[group_col].astype(str) == str(panel))
261
+ ].copy()
262
+ channel_key = str(selected_channel)
263
+ else:
264
+ sbar = stats[
265
+ (stats["channel"].astype(str) == str(selected_channel))
266
+ & (stats[x_col].astype(str) == str(panel))
267
+ ].copy()
268
+ srep = snapped[
269
+ (snapped["channel"].astype(str) == str(selected_channel))
270
+ & (snapped[x_col].astype(str) == str(panel))
271
+ ].copy()
272
+ channel_key = str(selected_channel)
273
+
274
+ if sbar.empty:
275
+ ax.set_visible(False)
276
+ continue
277
+
278
+ title = str(panel) if panel_by == "group" else None
279
+ draw_snapshot_panel(
280
+ ax,
281
+ snapped=srep,
282
+ stats=sbar,
283
+ x_col=x_col,
284
+ hue_col=hue_col,
285
+ agg=agg,
286
+ dispersion=dispersion,
287
+ palette_book=palette_book,
288
+ show_legend=show_legend,
289
+ legend_loc=legend_loc,
290
+ title=title,
291
+ ylabel=str(channel_key if panel_by != "channel" else panel),
292
+ color_map=(color_map if hue_col else None),
293
+ )
294
+ with suppress(Exception):
295
+ ax.set_box_aspect(1.0)
296
+
297
+ for index in range(len(panels), len(axes)):
298
+ axes[index].set_visible(False)
299
+
300
+ if y_hi_glob is not None:
301
+ for axis in axes[: len(panels)]:
302
+ if axis.get_visible():
303
+ axis.set_ylim(y_lo_glob if y_lo_glob is not None else axis.get_ylim()[0], y_hi_glob)
304
+
305
+ stub = _figure_stub(
306
+ filename=filename,
307
+ panel_by=panel_by,
308
+ group_col=group_col,
309
+ fig_label=str(fig_label),
310
+ members=members,
311
+ selected_channel=str(selected_channel) if selected_channel is not None else None,
312
+ multiple_files=len(fig_groups) * len(channels_for_files) > 1,
313
+ )
314
+ figures.append(plot_figure(fig=fig, filename=stub, fig_kwargs=fig_kwargs))
315
+ return figures