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,214 @@
1
+ """Strict configuration model for the four-state vector transform."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Literal
8
+
9
+ _CONFIG_KEYS = frozenset(
10
+ {
11
+ "carry_metadata",
12
+ "design_by",
13
+ "eps_abs",
14
+ "eps_range",
15
+ "eps_ratio",
16
+ "eps_ref",
17
+ "exclude_reference_from_output",
18
+ "log2_offset_delta",
19
+ "ref_add_alpha",
20
+ "reference",
21
+ "require_all_corners_per_design",
22
+ "response",
23
+ "target_time_h",
24
+ "time_column",
25
+ "time_mode",
26
+ "time_tolerance_h",
27
+ "treatment_case_sensitive",
28
+ "treatment_column",
29
+ "treatment_map",
30
+ }
31
+ )
32
+
33
+
34
+ def _sub(obj: Mapping[str, Any], key: str, *, allowed: frozenset[str]) -> dict[str, Any]:
35
+ value = obj.get(key)
36
+ if value is None:
37
+ return {}
38
+ if isinstance(value, Mapping):
39
+ payload = dict(value)
40
+ else:
41
+ raise ValueError(f"four_state_vector.{key} must be a mapping.")
42
+ unsupported = sorted(str(item) for item in payload if item not in allowed)
43
+ if unsupported:
44
+ noun = "setting" if len(unsupported) == 1 else "settings"
45
+ raise ValueError(f"four_state_vector.{key} has unsupported {noun}: {', '.join(unsupported)}.")
46
+ return payload
47
+
48
+
49
+ def _nonempty_text(value: object, *, field_name: str) -> str:
50
+ if not isinstance(value, str) or not value.strip():
51
+ raise ValueError(f"four_state_vector.{field_name} must be a non-empty string.")
52
+ return value.strip()
53
+
54
+
55
+ def _string_list(value: object, *, field_name: str) -> list[str]:
56
+ if not isinstance(value, list) or any(not isinstance(item, str) or not item.strip() for item in value):
57
+ raise ValueError(f"four_state_vector.{field_name} must be a list of non-empty strings.")
58
+ return [item.strip() for item in value]
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class FourStateVectorResponseCfg:
63
+ """Channels used for logic shape and reference-anchored intensity."""
64
+
65
+ logic_channel: str
66
+ intensity_channel: str
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class FourStateVectorReferenceCfg:
71
+ design_id: str
72
+ observation_stat: Literal["mean", "median"] = "mean"
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class FourStateVectorConfig:
77
+ # experiment structure
78
+ design_by: list[str]
79
+ time_column: str
80
+
81
+ # channels
82
+ response: FourStateVectorResponseCfg
83
+
84
+ # mapping
85
+ treatment_map: dict[str, str] # keys = {"00","10","01","11"}
86
+ reference: FourStateVectorReferenceCfg
87
+ treatment_case_sensitive: bool = True
88
+ treatment_column: str | None = None
89
+
90
+ # snapshot/time picking
91
+ target_time_h: float | None = None
92
+ time_mode: Literal["nearest", "last_before", "first_after", "exact"] = "nearest"
93
+ time_tolerance_h: float | None = 0.5
94
+ # misc rules
95
+ require_all_corners_per_design: bool = True
96
+
97
+ # intensity math knobs (spec §1.1b)
98
+ ref_add_alpha: float = 0.0 # α: additive to A_i in denominator
99
+ log2_offset_delta: float = 0.0 # δ: additive inside log2(y_linear + δ)
100
+
101
+ # numerical guards
102
+ eps_ratio: float = 1e-9 # ratio/log guard for logic & intensity
103
+ eps_range: float = 1e-12 # min range for min-max (logic)
104
+ eps_ref: float = 1e-9 # reference denom guard
105
+ eps_abs: float = 0.0 # tiny add in numerator for intensity
106
+
107
+ # output table policy
108
+ exclude_reference_from_output: bool = True
109
+ carry_metadata: list[str] = field(default_factory=lambda: ["sequence", "id"])
110
+
111
+
112
+ def load_four_state_vector_config(xform_cfg: Mapping[str, Any]) -> FourStateVectorConfig:
113
+ """
114
+ Validate an explicit transform mapping and build an FourStateVectorConfig.
115
+ Requires explicit 'response.logic_channel' and 'response.intensity_channel'.
116
+ """
117
+ if not isinstance(xform_cfg, Mapping):
118
+ raise ValueError("four-state vector settings must be a mapping.")
119
+ unsupported = sorted(str(key) for key in xform_cfg if key not in _CONFIG_KEYS)
120
+ if unsupported:
121
+ raise ValueError(f"Unsupported four-state vector settings: {', '.join(unsupported)}.")
122
+
123
+ design_by = _string_list(xform_cfg.get("design_by", ["design_id"]), field_name="design_by")
124
+ if not design_by or design_by[0] != "design_id":
125
+ raise ValueError(
126
+ "four_state_vector.design_by must start with 'design_id' to align with the four-state vector spec."
127
+ )
128
+ time_column = _nonempty_text(xform_cfg.get("time_column", "time"), field_name="time_column")
129
+
130
+ resp_in = _sub(xform_cfg, "response", allowed=frozenset({"logic_channel", "intensity_channel"}))
131
+ logic_channel = resp_in.get("logic_channel", None)
132
+ intensity_channel = resp_in.get("intensity_channel", None)
133
+ response = FourStateVectorResponseCfg(
134
+ logic_channel=_nonempty_text(logic_channel, field_name="response.logic_channel"),
135
+ intensity_channel=_nonempty_text(intensity_channel, field_name="response.intensity_channel"),
136
+ )
137
+
138
+ # mapping
139
+ raw_treatment_map = xform_cfg.get("treatment_map", {})
140
+ if not isinstance(raw_treatment_map, Mapping):
141
+ raise ValueError("four_state_vector.treatment_map must be a mapping.")
142
+ tmap = dict(raw_treatment_map)
143
+ if set(tmap.keys()) != {"00", "10", "01", "11"}:
144
+ raise ValueError("four_state_vector.treatment_map must have exactly the keys {'00','10','01','11'}.")
145
+ if any(not isinstance(value, str) or not value.strip() for value in tmap.values()):
146
+ raise ValueError("four_state_vector.treatment_map values must be non-empty strings.")
147
+ tmap = {key: value.strip() for key, value in tmap.items()}
148
+ tcase = bool(xform_cfg.get("treatment_case_sensitive", True))
149
+ treatment_column_raw = xform_cfg.get("treatment_column")
150
+ if treatment_column_raw is not None and (
151
+ not isinstance(treatment_column_raw, str) or not treatment_column_raw.strip()
152
+ ):
153
+ raise ValueError("four_state_vector.treatment_column must be a non-empty string when provided.")
154
+ treatment_column = str(treatment_column_raw).strip() if isinstance(treatment_column_raw, str) else None
155
+
156
+ # time picking
157
+ target_time_h = xform_cfg.get("target_time_h")
158
+ time_mode = str(xform_cfg.get("time_mode", "nearest")).lower()
159
+ tol = xform_cfg.get("time_tolerance_h", 0.5)
160
+ if time_mode not in {"nearest", "last_before", "first_after", "exact"}:
161
+ raise ValueError(f"Invalid four_state_vector.time_mode='{time_mode}'")
162
+ require_all = bool(xform_cfg.get("require_all_corners_per_design", True))
163
+
164
+ ref = _sub(xform_cfg, "reference", allowed=frozenset({"design_id", "observation_stat"}))
165
+ ref_label = _nonempty_text(ref.get("design_id"), field_name="reference.design_id")
166
+ reference = FourStateVectorReferenceCfg(
167
+ design_id=ref_label,
168
+ observation_stat=(str(ref.get("observation_stat", "mean")).lower() if ref.get("observation_stat") else "mean"),
169
+ )
170
+ if reference.observation_stat not in {"mean", "median"}:
171
+ raise ValueError(f"Invalid four_state_vector.reference.observation_stat='{reference.observation_stat}'")
172
+ # eps / table policy
173
+ eps_ratio = float(xform_cfg.get("eps_ratio", 1e-9))
174
+ eps_range = float(xform_cfg.get("eps_range", 1e-12))
175
+ eps_ref = float(xform_cfg.get("eps_ref", 1e-9))
176
+ eps_abs = float(xform_cfg.get("eps_abs", 0.0))
177
+
178
+ ref_add_alpha = float(xform_cfg.get("ref_add_alpha", 0.0))
179
+ log2_offset_delta = float(xform_cfg.get("log2_offset_delta", 0.0))
180
+ exclude_ref = bool(xform_cfg.get("exclude_reference_from_output", True))
181
+ carry_metadata = _string_list(
182
+ xform_cfg.get("carry_metadata", ["sequence", "id"]),
183
+ field_name="carry_metadata",
184
+ )
185
+ cfg = FourStateVectorConfig(
186
+ design_by=design_by,
187
+ time_column=time_column,
188
+ response=response,
189
+ treatment_map=tmap,
190
+ treatment_case_sensitive=tcase,
191
+ treatment_column=treatment_column,
192
+ target_time_h=(float(target_time_h) if target_time_h is not None else None),
193
+ time_mode=time_mode,
194
+ time_tolerance_h=(float(tol) if tol is not None else None),
195
+ require_all_corners_per_design=require_all,
196
+ reference=reference,
197
+ eps_ratio=eps_ratio,
198
+ eps_range=eps_range,
199
+ eps_ref=eps_ref,
200
+ eps_abs=eps_abs,
201
+ ref_add_alpha=ref_add_alpha,
202
+ log2_offset_delta=log2_offset_delta,
203
+ exclude_reference_from_output=exclude_ref,
204
+ carry_metadata=carry_metadata,
205
+ )
206
+ return cfg
207
+
208
+
209
+ __all__ = [
210
+ "FourStateVectorConfig",
211
+ "FourStateVectorResponseCfg",
212
+ "FourStateVectorReferenceCfg",
213
+ "load_four_state_vector_config",
214
+ ]
@@ -0,0 +1,361 @@
1
+ """Pure preparation and rendering for persisted four-state vector diagnostics.
2
+
3
+ The diagnostic joins an annotated measurement trace to the already-persisted
4
+ vector summary. It does not select a new acquisition time or recompute vector.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ from collections.abc import Mapping, Sequence
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+
14
+ import numpy as np
15
+ import pandas as pd
16
+
17
+ from reader_workbench.domains.plate_reader.plots.dual_reporter_triptych import (
18
+ DualReporterTriptychData,
19
+ build_triptych_data,
20
+ )
21
+
22
+ STATE_ORDER = ("00", "10", "01", "11")
23
+ _LOGIC_COLUMNS = {state: f"v{state}" for state in STATE_ORDER}
24
+ _INTENSITY_COLUMNS = {state: f"y{state}_star" for state in STATE_ORDER}
25
+ _STATE_COLORS = {
26
+ "00": "#364152",
27
+ "10": "#237A70",
28
+ "01": "#2F65D9",
29
+ "11": "#B82E3F",
30
+ }
31
+ _STATE_MARKERS = {"00": "o", "10": "s", "01": "^", "11": "D"}
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class FourStateVectorDiagnosticData:
36
+ design_id: str
37
+ selected_time_h: float
38
+ reference_design_id: str
39
+ time_column: str
40
+ state_column: str
41
+ triptych: DualReporterTriptychData
42
+ logic_components: dict[str, float]
43
+ intensity_components: dict[str, float]
44
+
45
+
46
+ def prepare_four_state_vector_diagnostics(
47
+ annotated: pd.DataFrame,
48
+ vector: pd.DataFrame,
49
+ *,
50
+ treatment_column: str,
51
+ treatment_map: Mapping[str, str],
52
+ treatment_case_sensitive: bool,
53
+ time_column: str,
54
+ growth_channel: str,
55
+ response_channel: str,
56
+ design_ids: Sequence[str] | None = None,
57
+ time_atol: float = 1e-9,
58
+ trajectory_interval_mass: float = 0.95,
59
+ trajectory_resamples: int = 300,
60
+ ) -> tuple[FourStateVectorDiagnosticData, ...]:
61
+ """Prepare one diagnostic per persisted vector design.
62
+
63
+ Four-state vector row order is the default output order. An explicit ``design_ids``
64
+ selection preserves caller order and fails when any id is unavailable.
65
+ """
66
+
67
+ _require_columns(
68
+ annotated,
69
+ ("design_id", treatment_column, time_column, "channel", "value", "position"),
70
+ where="four-state vector diagnostic annotated data",
71
+ )
72
+ _require_columns(
73
+ vector,
74
+ (
75
+ "design_id",
76
+ "time_selected_h",
77
+ "reference_design_id",
78
+ *_LOGIC_COLUMNS.values(),
79
+ *_INTENSITY_COLUMNS.values(),
80
+ ),
81
+ where="four-state vector diagnostic vector data",
82
+ )
83
+ if annotated.empty:
84
+ raise ValueError("four-state vector diagnostic annotated data must not be empty")
85
+ if vector.empty:
86
+ raise ValueError("four-state vector diagnostic vector data must not be empty")
87
+ if not math.isfinite(float(time_atol)) or float(time_atol) < 0.0:
88
+ raise ValueError("four-state vector diagnostic time_atol must be finite and non-negative")
89
+
90
+ source_to_state = _source_to_state(
91
+ treatment_map,
92
+ case_sensitive=treatment_case_sensitive,
93
+ )
94
+ vector_work = vector.copy().reset_index(drop=True)
95
+ vector_work["design_id"] = _non_empty_text_series(vector_work["design_id"], field="vector.design_id")
96
+ duplicate_designs = vector_work.loc[vector_work["design_id"].duplicated(keep=False), "design_id"].unique().tolist()
97
+ if duplicate_designs:
98
+ raise ValueError(f"four-state vector diagnostic vector has duplicate design_id values: {duplicate_designs}")
99
+
100
+ selected_designs = _selected_design_ids(vector_work, design_ids=design_ids)
101
+ annotated_work = annotated.copy()
102
+ annotated_work["design_id"] = _non_empty_text_series(
103
+ annotated_work["design_id"],
104
+ field="annotated.design_id",
105
+ )
106
+ source_values = annotated_work[treatment_column].astype(str)
107
+ if not treatment_case_sensitive:
108
+ source_values = source_values.str.strip().str.casefold()
109
+ annotated_work["__four_state_vector_state"] = source_values.map(source_to_state)
110
+ annotated_work = annotated_work[annotated_work["__four_state_vector_state"].isin(STATE_ORDER)].copy()
111
+
112
+ prepared: list[FourStateVectorDiagnosticData] = []
113
+ by_design = vector_work.set_index("design_id", drop=False)
114
+ for design_id in selected_designs:
115
+ vector_row = by_design.loc[design_id]
116
+ selected_time_h = _finite_scalar(vector_row["time_selected_h"], field=f"{design_id}.time_selected_h")
117
+ reference_design_id = _non_empty_text(
118
+ vector_row["reference_design_id"],
119
+ field=f"{design_id}.reference_design_id",
120
+ )
121
+ design_traces = annotated_work[annotated_work["design_id"].eq(design_id)].copy()
122
+ if design_traces.empty:
123
+ raise ValueError(f"four-state vector diagnostic has no annotated rows for vector design_id {design_id!r}")
124
+ _require_state_channel_coverage(
125
+ design_traces,
126
+ design_id=design_id,
127
+ channels=(growth_channel, response_channel),
128
+ )
129
+ _require_persisted_time_in_traces(
130
+ design_traces,
131
+ design_id=design_id,
132
+ selected_time_h=selected_time_h,
133
+ time_column=time_column,
134
+ channels=(growth_channel, response_channel),
135
+ time_atol=float(time_atol),
136
+ )
137
+ triptych = build_triptych_data(
138
+ design_traces,
139
+ time_col=time_column,
140
+ treatment_col="__four_state_vector_state",
141
+ growth_channel=growth_channel,
142
+ ratio_channel=response_channel,
143
+ snapshot_channel=response_channel,
144
+ snapshot_time=selected_time_h,
145
+ treatment_order=STATE_ORDER,
146
+ time_atol=float(time_atol),
147
+ trajectory_interval_mass=float(trajectory_interval_mass),
148
+ trajectory_resamples=int(trajectory_resamples),
149
+ )
150
+ logic_components = {
151
+ state: _finite_scalar(vector_row[column], field=f"{design_id}.{column}")
152
+ for state, column in _LOGIC_COLUMNS.items()
153
+ }
154
+ invalid_logic = {state: value for state, value in logic_components.items() if value < 0.0 or value > 1.0}
155
+ if invalid_logic:
156
+ raise ValueError(
157
+ f"four-state vector diagnostic logic-shape components must lie in [0, 1] for {design_id!r}: {invalid_logic}"
158
+ )
159
+ intensity_components = {
160
+ state: _finite_scalar(vector_row[column], field=f"{design_id}.{column}")
161
+ for state, column in _INTENSITY_COLUMNS.items()
162
+ }
163
+ prepared.append(
164
+ FourStateVectorDiagnosticData(
165
+ design_id=design_id,
166
+ selected_time_h=selected_time_h,
167
+ reference_design_id=reference_design_id,
168
+ time_column=time_column,
169
+ state_column="__four_state_vector_state",
170
+ triptych=triptych,
171
+ logic_components=logic_components,
172
+ intensity_components=intensity_components,
173
+ )
174
+ )
175
+ return tuple(prepared)
176
+
177
+
178
+ def render_four_state_vector_diagnostic(
179
+ data: FourStateVectorDiagnosticData,
180
+ *,
181
+ title: str | None = None,
182
+ figsize: tuple[float, float] = (15.0, 5.5),
183
+ dpi: int = 300,
184
+ ) -> Any:
185
+ """Render trajectories beside the persisted vector components."""
186
+
187
+ if len(figsize) != 2 or any(not math.isfinite(float(value)) or float(value) <= 0.0 for value in figsize):
188
+ raise ValueError("four-state vector diagnostic figsize must contain two positive finite values")
189
+ if int(dpi) < 1:
190
+ raise ValueError("four-state vector diagnostic dpi must be positive")
191
+
192
+ from reader_workbench.domains.plate_reader.plots.dual_reporter_triptych_render import ( # noqa: PLC0415
193
+ render_dual_reporter_triptych,
194
+ )
195
+
196
+ figure = render_dual_reporter_triptych(
197
+ data.triptych,
198
+ time_col=data.time_column,
199
+ treatment_col=data.state_column,
200
+ title=None,
201
+ figsize=figsize,
202
+ )
203
+ figure.set_dpi(int(dpi))
204
+ figure.axes[0].set_title("Growth trajectory")
205
+ figure.axes[1].set_title("Response trajectory")
206
+ snapshot_spec = figure.axes[2].get_subplotspec()
207
+ figure.delaxes(figure.axes[2])
208
+ component_grid = snapshot_spec.subgridspec(2, 1, hspace=0.7)
209
+ logic_axis = figure.add_subplot(component_grid[0, 0])
210
+ intensity_axis = figure.add_subplot(component_grid[1, 0])
211
+ _render_components(
212
+ logic_axis,
213
+ data.logic_components,
214
+ title="Logic shape",
215
+ x_label="Persisted value (unit interval)",
216
+ x_limits=(-0.05, 1.05),
217
+ )
218
+ _render_components(
219
+ intensity_axis,
220
+ data.intensity_components,
221
+ title="Relative intensity",
222
+ x_label="Persisted value (log2)",
223
+ draw_zero=True,
224
+ )
225
+
226
+ heading = title.strip() if isinstance(title, str) and title.strip() else "four-state vector diagnostic"
227
+ figure.suptitle(
228
+ f"{heading} · {data.design_id}\n"
229
+ f"selected time {data.selected_time_h:g} h · reference {data.reference_design_id}",
230
+ fontsize=13,
231
+ )
232
+ return figure
233
+
234
+
235
+ def _render_components(
236
+ axis: Any,
237
+ values: Mapping[str, float],
238
+ *,
239
+ title: str,
240
+ x_label: str,
241
+ x_limits: tuple[float, float] | None = None,
242
+ draw_zero: bool = False,
243
+ ) -> None:
244
+ positions = np.arange(len(STATE_ORDER), dtype=float)
245
+ component_values = np.array([values[state] for state in STATE_ORDER], dtype=float)
246
+ if draw_zero:
247
+ axis.axvline(0.0, color="#8A94A6", linewidth=0.9)
248
+ for position, state, value in zip(positions, STATE_ORDER, component_values, strict=True):
249
+ axis.hlines(position, min(0.0, value), max(0.0, value), color=_STATE_COLORS[state], linewidth=1.5)
250
+ axis.scatter(
251
+ value,
252
+ position,
253
+ color=_STATE_COLORS[state],
254
+ marker=_STATE_MARKERS[state],
255
+ s=34,
256
+ zorder=3,
257
+ )
258
+ axis.set_yticks(positions, labels=STATE_ORDER)
259
+ axis.invert_yaxis()
260
+ axis.set_title(title)
261
+ axis.set_xlabel(x_label)
262
+ axis.set_ylabel("State")
263
+ if x_limits is not None:
264
+ axis.set_xlim(*x_limits)
265
+ axis.grid(axis="x", color="#D8DEE8", linewidth=0.6, alpha=0.7)
266
+
267
+
268
+ def _selected_design_ids(vector: pd.DataFrame, *, design_ids: Sequence[str] | None) -> tuple[str, ...]:
269
+ available = tuple(vector["design_id"].astype(str).tolist())
270
+ if design_ids is None:
271
+ return available
272
+ selected = tuple(_non_empty_text(value, field="design_ids") for value in design_ids)
273
+ if not selected:
274
+ raise ValueError("four-state vector diagnostic design_ids must not be empty when provided")
275
+ if len(set(selected)) != len(selected):
276
+ raise ValueError("four-state vector diagnostic design_ids must be unique")
277
+ missing = [design_id for design_id in selected if design_id not in set(available)]
278
+ if missing:
279
+ raise ValueError(f"four-state vector diagnostic design_ids are absent from vector: {missing}")
280
+ return selected
281
+
282
+
283
+ def _source_to_state(treatment_map: Mapping[str, str], *, case_sensitive: bool) -> dict[str, str]:
284
+ if set(treatment_map) != set(STATE_ORDER):
285
+ raise ValueError("four-state vector diagnostic treatment_map must contain exactly 00, 10, 01, and 11")
286
+ reverse: dict[str, str] = {}
287
+ for state in STATE_ORDER:
288
+ source_value = _non_empty_text(treatment_map[state], field=f"treatment_map.{state}")
289
+ key = source_value if case_sensitive else source_value.strip().casefold()
290
+ if key in reverse:
291
+ raise ValueError("four-state vector diagnostic treatment_map source values must be unique")
292
+ reverse[key] = state
293
+ return reverse
294
+
295
+
296
+ def _require_state_channel_coverage(frame: pd.DataFrame, *, design_id: str, channels: Sequence[str]) -> None:
297
+ for channel in channels:
298
+ observed = set(
299
+ frame.loc[frame["channel"].astype(str).eq(str(channel)), "__four_state_vector_state"].astype(str)
300
+ )
301
+ missing = [state for state in STATE_ORDER if state not in observed]
302
+ if missing:
303
+ raise ValueError(
304
+ f"four-state vector diagnostic design {design_id!r} channel {channel!r} is missing state traces: {missing}"
305
+ )
306
+
307
+
308
+ def _require_persisted_time_in_traces(
309
+ frame: pd.DataFrame,
310
+ *,
311
+ design_id: str,
312
+ selected_time_h: float,
313
+ time_column: str,
314
+ channels: Sequence[str],
315
+ time_atol: float,
316
+ ) -> None:
317
+ times = pd.to_numeric(frame[time_column], errors="coerce")
318
+ for channel in channels:
319
+ channel_times = times[frame["channel"].astype(str).eq(str(channel))].dropna().to_numpy(dtype=float)
320
+ if not np.isclose(channel_times, selected_time_h, rtol=0.0, atol=time_atol).any():
321
+ raise ValueError(
322
+ f"four-state vector diagnostic persisted selection time {selected_time_h:g} h for {design_id!r} "
323
+ f"is absent from channel {channel!r} traces"
324
+ )
325
+
326
+
327
+ def _require_columns(frame: pd.DataFrame, columns: Sequence[str], *, where: str) -> None:
328
+ missing = [column for column in columns if column not in frame.columns]
329
+ if missing:
330
+ raise ValueError(f"{where} is missing columns: {missing}")
331
+
332
+
333
+ def _finite_scalar(value: object, *, field: str) -> float:
334
+ try:
335
+ number = float(value)
336
+ except (TypeError, ValueError):
337
+ raise ValueError(f"four-state vector diagnostic {field} must be a finite number") from None
338
+ if not math.isfinite(number):
339
+ raise ValueError(f"four-state vector diagnostic {field} must be a finite number")
340
+ return number
341
+
342
+
343
+ def _non_empty_text_series(series: pd.Series, *, field: str) -> pd.Series:
344
+ values = series.astype(str).str.strip()
345
+ if values.eq("").any() or values.str.casefold().eq("nan").any():
346
+ raise ValueError(f"four-state vector diagnostic {field} must contain non-empty values")
347
+ return values
348
+
349
+
350
+ def _non_empty_text(value: object, *, field: str) -> str:
351
+ text = str(value).strip()
352
+ if not text or text.casefold() in {"nan", "<na>"}:
353
+ raise ValueError(f"four-state vector diagnostic {field} must be a non-empty string")
354
+ return text
355
+
356
+
357
+ __all__ = [
358
+ "FourStateVectorDiagnosticData",
359
+ "prepare_four_state_vector_diagnostics",
360
+ "render_four_state_vector_diagnostic",
361
+ ]
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from collections.abc import Sequence
5
+
6
+ import pandas as pd
7
+
8
+ from reader_workbench.errors import FourStateVectorError
9
+
10
+ from .collection.checks import require_vector_columns
11
+ from .collection.constants import VECTOR_CHANNELS
12
+ from .collection.render import render_four_state_vector_collection_heatmap
13
+
14
+ _NUMERIC_COLUMNS = (
15
+ "intensity_log2_offset_delta",
16
+ "r_logic",
17
+ *VECTOR_CHANNELS,
18
+ )
19
+
20
+
21
+ def normalize_experiment_heatmap_frame(vector: pd.DataFrame, *, experiment_id: str) -> pd.DataFrame:
22
+ """Adapt one experiment's logic.four_state_vector.v1 table to the heatmap renderer contract."""
23
+
24
+ require_vector_columns(vector)
25
+ if vector.empty:
26
+ raise FourStateVectorError("four-state vector heatmap has no rows to plot.")
27
+ normalized_experiment_id = _non_empty_text(experiment_id, field="experiment_id")
28
+
29
+ frame = vector.copy().reset_index(drop=True)
30
+ _require_non_empty_text_columns(frame, columns=("design_id", "reference_design_id"))
31
+ for column in _NUMERIC_COLUMNS:
32
+ frame[column] = _finite_numeric(frame[column], column=column)
33
+ if "time_selected_h" in frame.columns:
34
+ frame["time_selected_h"] = _finite_numeric(
35
+ frame["time_selected_h"],
36
+ column="time_selected_h",
37
+ allow_nan=True,
38
+ )
39
+ frame["flat_logic"] = frame["flat_logic"].astype(bool)
40
+
41
+ frame["source_index"] = 0
42
+ frame["source_experiment_id"] = normalized_experiment_id
43
+ frame["source_row_index"] = range(len(frame))
44
+ frame["row_label"] = _row_labels(frame, experiment_id=normalized_experiment_id)
45
+ return frame
46
+
47
+
48
+ def render_experiment_four_state_vector_heatmap(
49
+ vector: pd.DataFrame,
50
+ *,
51
+ experiment_id: str,
52
+ title: str | None = None,
53
+ max_y_tick_labels: int = 80,
54
+ ):
55
+ frame = normalize_experiment_heatmap_frame(vector, experiment_id=experiment_id)
56
+ return render_four_state_vector_collection_heatmap(frame, title=title, max_y_tick_labels=max_y_tick_labels)
57
+
58
+
59
+ def _row_labels(frame: pd.DataFrame, *, experiment_id: str) -> list[str]:
60
+ return [f"{experiment_id}::{design_id}" for design_id in frame["design_id"].astype(str).tolist()]
61
+
62
+
63
+ def _finite_numeric(series: pd.Series, *, column: str, allow_nan: bool = False) -> pd.Series:
64
+ values = pd.to_numeric(series, errors="coerce")
65
+ coerced_to_nan = series.notna() & values.isna()
66
+ nonfinite = values.notna() & ~values.fillna(0.0).map(lambda value: math.isfinite(float(value)))
67
+ invalid = coerced_to_nan | nonfinite
68
+ if not allow_nan:
69
+ invalid |= values.isna()
70
+ if invalid.any():
71
+ raise FourStateVectorError(f"four-state vector heatmap column {column!r} must contain finite numeric values.")
72
+ return values.astype(float)
73
+
74
+
75
+ def _require_non_empty_text_columns(frame: pd.DataFrame, *, columns: Sequence[str]) -> None:
76
+ for column in columns:
77
+ values = frame[column].astype(str).str.strip()
78
+ if values.eq("").any():
79
+ raise FourStateVectorError(f"four-state vector heatmap column {column!r} must contain non-empty values.")
80
+
81
+
82
+ def _non_empty_text(value: str, *, field: str) -> str:
83
+ text = str(value).strip()
84
+ if not text:
85
+ raise FourStateVectorError(f"four-state vector heatmap {field} must be a non-empty string.")
86
+ return text