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,308 @@
1
+ """Manifest-backed source loading for four-state event-window analysis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+
11
+ from .contracts import EventSpec, FourStateEventWindowSourceSpec
12
+ from .well_exclusions import WellExclusion
13
+
14
+ STATE_ORDER = ("00", "10", "01", "11")
15
+ ANNOTATED_CONTRACT = "plate_reader.annotated.v1"
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class EventInterval:
20
+ experiment_id: str
21
+ event_id: str
22
+ event_kind: str
23
+ interval_start_assay_h: float
24
+ interval_end_assay_h: float
25
+ estimate_assay_h: float
26
+ estimate_method: str
27
+ uncertainty_h: float
28
+ post_event_coverage_h: float
29
+ declaration: str
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class ExperimentSource:
34
+ experiment_id: str
35
+ response: pd.DataFrame
36
+ magnitude: pd.DataFrame
37
+ trajectory: pd.DataFrame
38
+ event: EventInterval
39
+ well_exclusions: tuple[WellExclusion, ...] = ()
40
+
41
+
42
+ def event_record(event: EventInterval) -> pd.DataFrame:
43
+ return pd.DataFrame.from_records(
44
+ [
45
+ {
46
+ "experiment_id": event.experiment_id,
47
+ "event_id": event.event_id,
48
+ "event_kind": event.event_kind,
49
+ "event_interval_start_assay_h": event.interval_start_assay_h,
50
+ "event_interval_end_assay_h": event.interval_end_assay_h,
51
+ "event_time_estimate_assay_h": event.estimate_assay_h,
52
+ "event_time_estimate_method": event.estimate_method,
53
+ "event_time_uncertainty_h": event.uncertainty_h,
54
+ "post_event_coverage_h": event.post_event_coverage_h,
55
+ "declaration": event.declaration,
56
+ }
57
+ ]
58
+ )
59
+
60
+
61
+ def build_experiment_source(
62
+ *,
63
+ experiment_id: str,
64
+ response_frame: pd.DataFrame,
65
+ magnitude_frame: pd.DataFrame,
66
+ trajectory_frame: pd.DataFrame,
67
+ source_spec: FourStateEventWindowSourceSpec,
68
+ event_spec: EventSpec,
69
+ ) -> ExperimentSource:
70
+ """Normalize three already-resolved records into one analysis source."""
71
+
72
+ state_column = source_spec.state_column
73
+ treatment_map = source_spec.state_values
74
+ case_sensitive = source_spec.state_values_case_sensitive
75
+ if set(treatment_map) != set(STATE_ORDER) or len(set(treatment_map.values())) != len(STATE_ORDER):
76
+ raise ValueError(f"{experiment_id}: resolved state map must define four distinct 00, 10, 01, and 11 values.")
77
+ response = _load_signal(
78
+ response_frame,
79
+ channel=source_spec.response_channel,
80
+ state_column=state_column,
81
+ treatment_map=treatment_map,
82
+ case_sensitive=case_sensitive,
83
+ event_spec=event_spec,
84
+ context=f"{experiment_id}:response",
85
+ )
86
+ magnitude = _load_signal(
87
+ magnitude_frame,
88
+ channel=source_spec.magnitude_channel,
89
+ state_column=state_column,
90
+ treatment_map=treatment_map,
91
+ case_sensitive=case_sensitive,
92
+ event_spec=event_spec,
93
+ context=f"{experiment_id}:magnitude",
94
+ )
95
+ trajectory = _load_signal(
96
+ trajectory_frame,
97
+ channel=source_spec.growth_channel,
98
+ state_column=state_column,
99
+ treatment_map=treatment_map,
100
+ case_sensitive=case_sensitive,
101
+ event_spec=event_spec,
102
+ context=f"{experiment_id}:growth",
103
+ require_positive=False,
104
+ )
105
+ well_exclusions = tuple(item for item in source_spec.well_exclusions if item.experiment_id == experiment_id)
106
+ _validate_well_exclusions(
107
+ well_exclusions,
108
+ response=response,
109
+ magnitude=magnitude,
110
+ trajectory=trajectory,
111
+ experiment_id=experiment_id,
112
+ )
113
+ event = resolve_event_interval(response, experiment_id=experiment_id, event_spec=event_spec)
114
+ magnitude_event = resolve_event_interval(magnitude, experiment_id=experiment_id, event_spec=event_spec)
115
+ growth_event = resolve_event_interval(trajectory, experiment_id=experiment_id, event_spec=event_spec)
116
+ _require_event_parity(event, magnitude_event, context=f"{experiment_id}:response/magnitude")
117
+ _require_event_parity(event, growth_event, context=f"{experiment_id}:response/growth")
118
+
119
+ for frame in (response, magnitude, trajectory):
120
+ frame["experiment_id"] = experiment_id
121
+ frame["time_from_event_h"] = frame["time"].to_numpy(dtype=float) - event.estimate_assay_h
122
+ reference_id = source_spec.reference_design_id
123
+ if reference_id not in set(magnitude["design_id"].astype(str)):
124
+ raise ValueError(f"{experiment_id}: reference design {reference_id!r} is absent from magnitude records.")
125
+
126
+ return ExperimentSource(
127
+ experiment_id=experiment_id,
128
+ response=response,
129
+ magnitude=magnitude,
130
+ trajectory=trajectory,
131
+ event=event,
132
+ well_exclusions=well_exclusions,
133
+ )
134
+
135
+
136
+ def _validate_well_exclusions(
137
+ exclusions: tuple[WellExclusion, ...],
138
+ *,
139
+ response: pd.DataFrame,
140
+ magnitude: pd.DataFrame,
141
+ trajectory: pd.DataFrame,
142
+ experiment_id: str,
143
+ ) -> None:
144
+ for exclusion in exclusions:
145
+ expected = {(exclusion.design_id, exclusion.state)}
146
+ for signal_kind, frame in (
147
+ ("response", response),
148
+ ("magnitude", magnitude),
149
+ ("growth", trajectory),
150
+ ):
151
+ selected = frame.loc[frame["position"].astype(str).eq(exclusion.position)]
152
+ observed = set(zip(selected["design_id"].astype(str), selected["state"].astype(str), strict=True))
153
+ if observed != expected:
154
+ raise ValueError(
155
+ f"{experiment_id}:{signal_kind} well exclusion {exclusion.position!r} "
156
+ f"does not match design/state {exclusion.design_id!r}/{exclusion.state!r}."
157
+ )
158
+
159
+
160
+ def resolve_event_interval(
161
+ frame: pd.DataFrame,
162
+ *,
163
+ experiment_id: str,
164
+ event_spec: EventSpec,
165
+ ) -> EventInterval:
166
+ segments = pd.to_numeric(frame[event_spec.segment_column], errors="coerce")
167
+ segment_values = segments.to_numpy(dtype=float, na_value=np.nan)
168
+ if not np.isfinite(segment_values).all() or not np.equal(segment_values, np.trunc(segment_values)).all():
169
+ raise ValueError(f"{experiment_id}: event segment indexes must be finite integers.")
170
+ segment_indexes = segments.astype(int)
171
+ indexes = set(segment_indexes)
172
+ expected = {event_spec.pre_segment_index, event_spec.post_segment_index}
173
+ if indexes != expected:
174
+ raise ValueError(
175
+ f"{experiment_id}: event requires segment indexes {sorted(expected)}; found {sorted(indexes)}."
176
+ )
177
+ times = pd.to_numeric(frame["time"], errors="coerce")
178
+ if not np.isfinite(times.to_numpy(dtype=float)).all():
179
+ raise ValueError(f"{experiment_id}: event alignment requires finite acquisition times.")
180
+ pre = times.loc[segment_indexes.eq(event_spec.pre_segment_index)]
181
+ post = times.loc[segment_indexes.eq(event_spec.post_segment_index)]
182
+ last_pre = float(pre.max())
183
+ first_post = float(post.min())
184
+ assay_end = float(post.max())
185
+ if not last_pre < first_post <= assay_end:
186
+ raise ValueError(
187
+ f"{experiment_id}: event segments are not chronological: "
188
+ f"last_pre={last_pre}, first_post={first_post}, assay_end={assay_end}."
189
+ )
190
+ estimate = (last_pre + first_post) / 2.0
191
+ return EventInterval(
192
+ experiment_id=experiment_id,
193
+ event_id=event_spec.event_id,
194
+ event_kind=event_spec.event_kind,
195
+ interval_start_assay_h=last_pre,
196
+ interval_end_assay_h=first_post,
197
+ estimate_assay_h=estimate,
198
+ estimate_method=event_spec.estimate_method,
199
+ uncertainty_h=(first_post - last_pre) / 2.0,
200
+ post_event_coverage_h=assay_end - first_post,
201
+ declaration=event_spec.declaration,
202
+ )
203
+
204
+
205
+ def _load_signal(
206
+ frame: pd.DataFrame,
207
+ *,
208
+ channel: str,
209
+ state_column: str,
210
+ treatment_map: Mapping[str, str],
211
+ case_sensitive: bool,
212
+ event_spec: EventSpec,
213
+ context: str,
214
+ require_positive: bool = True,
215
+ ) -> pd.DataFrame:
216
+ frame = frame.copy()
217
+ required = {"design_id", "position", "time", "channel", "value", state_column, event_spec.segment_column}
218
+ missing = sorted(required - set(frame.columns))
219
+ if missing:
220
+ raise ValueError(f"{context} record is missing columns: {missing}.")
221
+ work = frame.loc[frame["channel"].astype(str).eq(channel)].copy()
222
+ if work.empty:
223
+ raise ValueError(f"{context} record has no rows for channel {channel!r}.")
224
+ reverse = {(value if case_sensitive else value.strip().casefold()): state for state, value in treatment_map.items()}
225
+ values = work[state_column].astype(str)
226
+ if not case_sensitive:
227
+ values = values.str.strip().str.casefold()
228
+ work["state"] = values.map(reverse)
229
+ unknown = sorted(work.loc[work["state"].isna(), state_column].astype(str).unique().tolist())
230
+ if unknown:
231
+ raise ValueError(f"{context} record contains unmapped state values: {unknown}.")
232
+ work["design_id"] = work["design_id"].astype(str)
233
+ work["position"] = work["position"].astype(str)
234
+ work["state"] = work["state"].astype(str)
235
+ work["time"] = pd.to_numeric(work["time"], errors="coerce")
236
+ work["value"] = pd.to_numeric(work["value"], errors="coerce")
237
+ if not np.isfinite(work[["time", "value"]].to_numpy(dtype=float)).all():
238
+ raise ValueError(f"{context} record contains non-finite time or values.")
239
+ if require_positive and (work["value"].to_numpy(dtype=float) <= 0.0).any():
240
+ raise ValueError(
241
+ f"{context} record contains non-positive values; "
242
+ "four-state event-window log-space reduction requires strictly positive source values."
243
+ )
244
+ _normalize_value_provenance(work, context=context)
245
+ return work.loc[
246
+ :,
247
+ [
248
+ "design_id",
249
+ "position",
250
+ "state",
251
+ "time",
252
+ "channel",
253
+ "value",
254
+ "value_policy_clipped",
255
+ "value_instrument_overflow",
256
+ "value_bound_kind",
257
+ event_spec.segment_column,
258
+ ],
259
+ ].reset_index(drop=True)
260
+
261
+
262
+ def _normalize_value_provenance(frame: pd.DataFrame, *, context: str) -> None:
263
+ required = {"value_policy_clipped", "value_instrument_overflow", "value_bound_kind"}
264
+ missing = sorted(required - set(frame.columns))
265
+ if missing:
266
+ raise ValueError(f"{context} record is missing required value provenance columns: {missing}.")
267
+ policy = _boolean_provenance(frame["value_policy_clipped"], context=f"{context}:value_policy_clipped")
268
+ overflow = _boolean_provenance(frame["value_instrument_overflow"], context=f"{context}:value_instrument_overflow")
269
+ frame["value_policy_clipped"] = policy
270
+ frame["value_instrument_overflow"] = overflow
271
+ bounds = frame["value_bound_kind"].astype(str)
272
+ allowed = {"exact", "lower", "upper", "indeterminate"}
273
+ unknown = sorted(set(bounds) - allowed)
274
+ if unknown:
275
+ raise ValueError(f"{context} record contains unsupported value_bound_kind values: {unknown}.")
276
+ if ((policy | overflow) & bounds.eq("exact")).any():
277
+ raise ValueError(f"{context} record marks clipped or overflowed values as exact.")
278
+ if (bounds.ne("exact") & ~(policy | overflow)).any():
279
+ raise ValueError(f"{context} record contains a value bound without clipping or overflow provenance.")
280
+ frame["value_bound_kind"] = bounds
281
+
282
+
283
+ def _boolean_provenance(values: pd.Series, *, context: str) -> pd.Series:
284
+ if values.isna().any() or not values.map(lambda value: isinstance(value, (bool, np.bool_))).all():
285
+ raise ValueError(f"{context} must contain booleans without missing values.")
286
+ return values.astype(bool)
287
+
288
+
289
+ def _require_event_parity(left: EventInterval, right: EventInterval, *, context: str) -> None:
290
+ fields = (
291
+ "interval_start_assay_h",
292
+ "interval_end_assay_h",
293
+ "estimate_assay_h",
294
+ "post_event_coverage_h",
295
+ )
296
+ if any(not np.isclose(getattr(left, field), getattr(right, field), rtol=0.0, atol=1.0e-12) for field in fields):
297
+ raise ValueError(f"{context} event bounds disagree across source records.")
298
+
299
+
300
+ __all__ = [
301
+ "ANNOTATED_CONTRACT",
302
+ "STATE_ORDER",
303
+ "EventInterval",
304
+ "ExperimentSource",
305
+ "build_experiment_source",
306
+ "event_record",
307
+ "resolve_event_interval",
308
+ ]
@@ -0,0 +1,94 @@
1
+ """Verify that declared well exclusions correspond to missing temporal support."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+
7
+ import pandas as pd
8
+
9
+ from .contracts import QualitySpec, ReductionSpec
10
+ from .reduction import four_state_event_window_temporal_spec, reduce_temporal_trace
11
+ from .well_exclusions import WellExclusion
12
+
13
+
14
+ def require_well_exclusion_support_failure(
15
+ exclusions: Sequence[WellExclusion],
16
+ *,
17
+ reductions: Mapping[str, ReductionSpec],
18
+ quality: QualitySpec,
19
+ response: pd.DataFrame,
20
+ magnitude: pd.DataFrame,
21
+ event_estimates_h: Sequence[float],
22
+ pre_window_end_h: float,
23
+ experiment_id: str,
24
+ ) -> None:
25
+ for exclusion in exclusions:
26
+ try:
27
+ reduction = reductions[exclusion.reduction_id]
28
+ except KeyError as exc:
29
+ raise ValueError(
30
+ f"{experiment_id}: well exclusion names unknown reduction {exclusion.reduction_id!r}."
31
+ ) from exc
32
+ support_failures: list[str] = []
33
+ for frame in (response, magnitude):
34
+ trace = frame.loc[frame["position"].astype(str).eq(exclusion.position)]
35
+ for event_estimate_h in event_estimates_h:
36
+ try:
37
+ reduce_temporal_trace(
38
+ trace["time"].to_numpy(dtype=float),
39
+ trace["value"].to_numpy(dtype=float),
40
+ spec=four_state_event_window_temporal_spec(reduction, quality),
41
+ trace_id=f"{experiment_id}:{exclusion.position}:{reduction.id}:support",
42
+ origin_h=event_estimate_h,
43
+ policy_clipped=trace["value_policy_clipped"].to_numpy(dtype=bool),
44
+ instrument_overflow=trace["value_instrument_overflow"].to_numpy(dtype=bool),
45
+ bound_kinds=trace["value_bound_kind"].to_numpy(dtype=object),
46
+ )
47
+ except ValueError as exc:
48
+ if _is_support_failure(str(exc)):
49
+ support_failures.append(str(exc))
50
+ if reduction.response_basis == "post_minus_pre":
51
+ if reduction.pre_window_duration_h is None:
52
+ raise ValueError(f"{experiment_id}:{reduction.id}: delta response lacks an explicit pre-event window.")
53
+ trace = response.loc[response["position"].astype(str).eq(exclusion.position)]
54
+ try:
55
+ reduce_temporal_trace(
56
+ trace["time"].to_numpy(dtype=float),
57
+ trace["value"].to_numpy(dtype=float),
58
+ spec=four_state_event_window_temporal_spec(
59
+ reduction,
60
+ quality,
61
+ absolute_window_h=(
62
+ pre_window_end_h - reduction.pre_window_duration_h,
63
+ pre_window_end_h,
64
+ ),
65
+ ),
66
+ trace_id=f"{experiment_id}:{exclusion.position}:{reduction.id}:pre-support",
67
+ policy_clipped=trace["value_policy_clipped"].to_numpy(dtype=bool),
68
+ instrument_overflow=trace["value_instrument_overflow"].to_numpy(dtype=bool),
69
+ bound_kinds=trace["value_bound_kind"].to_numpy(dtype=object),
70
+ )
71
+ except ValueError as exc:
72
+ if _is_support_failure(str(exc)):
73
+ support_failures.append(str(exc))
74
+ if not support_failures:
75
+ raise ValueError(
76
+ f"{experiment_id}:{exclusion.position}:{exclusion.reduction_id} "
77
+ "does not have insufficient event-window coverage."
78
+ )
79
+
80
+
81
+ def _is_support_failure(message: str) -> bool:
82
+ return any(
83
+ token in message
84
+ for token in (
85
+ "does not cover",
86
+ "interior gap",
87
+ "observations in the selected interval",
88
+ "requires observed interval boundaries",
89
+ "selected interval contains no observations",
90
+ )
91
+ )
92
+
93
+
94
+ __all__ = ["require_well_exclusion_support_failure"]
@@ -0,0 +1,54 @@
1
+ """Define explicit, reduction-bound exclusions for unsupported assay wells."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from dataclasses import dataclass
7
+
8
+ from .contract_fields import exact_fields, nonempty
9
+
10
+ _COVERAGE_REASON = "insufficient_event_window_coverage"
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class WellExclusion:
15
+ experiment_id: str
16
+ design_id: str
17
+ state: str
18
+ position: str
19
+ reduction_id: str
20
+ reason: str
21
+
22
+
23
+ def parse_well_exclusions(value: object) -> tuple[WellExclusion, ...]:
24
+ if isinstance(value, (str, bytes)) or not isinstance(value, Sequence):
25
+ raise ValueError("source.well_exclusions must be a sequence.")
26
+ exclusions: list[WellExclusion] = []
27
+ for index, item in enumerate(value):
28
+ fields = {"experiment_id", "design_id", "state", "position", "reduction_id", "reason"}
29
+ payload = exact_fields(item, context=f"source.well_exclusions[{index}]", required=fields)
30
+ state = nonempty(payload["state"], context=f"source.well_exclusions[{index}].state")
31
+ if state not in {"00", "10", "01", "11"}:
32
+ raise ValueError(f"source.well_exclusions[{index}].state must be 00, 10, 01, or 11.")
33
+ reason = nonempty(payload["reason"], context=f"source.well_exclusions[{index}].reason")
34
+ if reason != _COVERAGE_REASON:
35
+ raise ValueError(f"source.well_exclusions[{index}].reason must be {_COVERAGE_REASON!r}.")
36
+ exclusions.append(
37
+ WellExclusion(
38
+ experiment_id=nonempty(
39
+ payload["experiment_id"], context=f"source.well_exclusions[{index}].experiment_id"
40
+ ),
41
+ design_id=nonempty(payload["design_id"], context=f"source.well_exclusions[{index}].design_id"),
42
+ state=state,
43
+ position=nonempty(payload["position"], context=f"source.well_exclusions[{index}].position"),
44
+ reduction_id=nonempty(payload["reduction_id"], context=f"source.well_exclusions[{index}].reduction_id"),
45
+ reason=reason,
46
+ )
47
+ )
48
+ keys = [(item.experiment_id, item.position, item.reduction_id) for item in exclusions]
49
+ if len(keys) != len(set(keys)):
50
+ raise ValueError("source.well_exclusions must not repeat an experiment, position, and reduction.")
51
+ return tuple(exclusions)
52
+
53
+
54
+ __all__ = ["WellExclusion", "parse_well_exclusions"]
@@ -0,0 +1,76 @@
1
+ """Plate-reader timepoint selection and nearest-snapshot helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from collections.abc import Sequence
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+ import polars as pl
11
+
12
+
13
+ def nearest_time_per_key(df: pd.DataFrame, *, target_time: float, keys: Sequence[str], tol: float) -> pd.DataFrame:
14
+ work = pl.from_pandas(df.reset_index(drop=True)).with_row_index("__row__")
15
+ time_col = "time"
16
+ time_expr = pl.col(time_col).cast(pl.Float64, strict=False)
17
+ time_expr = pl.when(time_expr.is_nan()).then(None).otherwise(time_expr)
18
+ work = work.with_columns(time_expr.alias(time_col))
19
+ work = work.filter(pl.col(time_col).is_not_null())
20
+ work = work.with_columns((pl.col(time_col) - float(target_time)).abs().alias("__dt__"))
21
+ work = work.with_columns(pl.col("__dt__").min().over(list(keys)).alias("__dt_min__"))
22
+ work = work.filter(pl.col("__dt__") == pl.col("__dt_min__"))
23
+ work = work.sort("__row__").unique(subset=list(keys), keep="first")
24
+ work = work.filter(pl.col("__dt__") <= float(tol))
25
+ work = work.drop(["__dt__", "__dt_min__", "__row__"])
26
+ return work.to_pandas(use_pyarrow_extension_array=False)
27
+
28
+
29
+ def choose_nearest_time(
30
+ times: Sequence[object] | np.ndarray,
31
+ *,
32
+ target_time: float,
33
+ tol: float | None,
34
+ where: str,
35
+ logger: logging.Logger | None = None,
36
+ ) -> float:
37
+ cleaned = pd.to_numeric(pd.Series(times), errors="coerce").dropna().to_numpy(dtype=float)
38
+ if cleaned.size == 0:
39
+ raise ValueError(f"{where}: no valid time values")
40
+ unique_times = np.asarray(sorted(np.unique(cleaned)), dtype=float)
41
+ diffs = np.abs(unique_times - float(target_time))
42
+ chosen_index = int(np.argmin(diffs))
43
+ chosen_time = float(unique_times[chosen_index])
44
+ chosen_delta = float(diffs[chosen_index])
45
+ if tol is not None and chosen_delta > float(tol) and logger is not None:
46
+ logger.info(
47
+ "[warn]%s[/warn] • requested t=%.2f h; nearest available t=%.2f h (Δ=%.2f h) — using nearest",
48
+ where,
49
+ float(target_time),
50
+ chosen_time,
51
+ chosen_delta,
52
+ )
53
+ return chosen_time
54
+
55
+
56
+ def infer_acquisition_transition_time_h(df: pd.DataFrame, *, time_col: str) -> float | None:
57
+ """Return the first time in a later workbook acquisition segment.
58
+
59
+ A sheet transition is acquisition provenance. It does not identify a
60
+ biological intervention unless a separate, explicit event contract says
61
+ that it does.
62
+ """
63
+ if "sheet_index" not in df.columns:
64
+ return None
65
+ sheet_values = pd.to_numeric(df["sheet_index"], errors="coerce").dropna()
66
+ if sheet_values.empty:
67
+ return None
68
+ min_sheet = float(sheet_values.min())
69
+ sheet_series = pd.to_numeric(df["sheet_index"], errors="coerce")
70
+ times = pd.to_numeric(df.loc[sheet_series > min_sheet, time_col], errors="coerce").dropna()
71
+ if times.empty:
72
+ return None
73
+ return float(times.min())
74
+
75
+
76
+ __all__ = ["choose_nearest_time", "infer_acquisition_transition_time_h", "nearest_time_per_key"]
@@ -0,0 +1,6 @@
1
+ """Plate-reader instrument parsers."""
2
+
3
+ from .sample_map import parse_sample_map
4
+ from .synergy_h1 import parse_kinetic_only, parse_snapshot_and_timeseries
5
+
6
+ __all__ = ["parse_kinetic_only", "parse_sample_map", "parse_snapshot_and_timeseries"]
@@ -0,0 +1,65 @@
1
+ """Plate-reader sample-map parsing."""
2
+
3
+ from collections import Counter
4
+ from pathlib import Path
5
+
6
+ import pandas as pd
7
+
8
+
9
+ def parse_sample_map(path: str | Path) -> pd.DataFrame:
10
+ """
11
+ Load a sample metadata map with a 'position' key.
12
+
13
+ Accepted inputs:
14
+ 1) A table with an explicit 'position' column (preferred).
15
+ 2) A table with 'row' and 'col' columns (will be combined into 'position').
16
+
17
+ This parser is plate-reader oriented: the join key is a well position.
18
+ """
19
+ p = Path(path)
20
+ if not p.exists():
21
+ raise ValueError(f"Sample map does not exist: {p}")
22
+ if not p.is_file():
23
+ raise ValueError(f"Sample map must be a regular file: {p}")
24
+ suffix = p.suffix.lower()
25
+ if suffix == ".xlsx":
26
+ df = pd.read_excel(p)
27
+ elif suffix == ".csv":
28
+ df = pd.read_csv(p)
29
+ else:
30
+ raise ValueError(f"Unsupported sample-map format {suffix or '<none>'!r}; expected .csv or .xlsx")
31
+
32
+ normalized_columns = [str(column).strip().lower() for column in df.columns]
33
+ duplicate_columns = sorted(name for name, count in Counter(normalized_columns).items() if count > 1)
34
+ if duplicate_columns:
35
+ raise ValueError(f"Sample map has duplicate column names after normalization: {duplicate_columns}")
36
+ cols = dict(zip(normalized_columns, df.columns, strict=True))
37
+ if "position" in cols:
38
+ if cols["position"] != "position":
39
+ df = df.rename(columns={cols["position"]: "position"})
40
+ elif {"row", "col"}.issubset(cols):
41
+ row_col = cols["row"]
42
+ col_col = cols["col"]
43
+ out = df.copy()
44
+ row_values = out[row_col].astype("string").str.strip()
45
+ col_values = out[col_col].astype("string").str.strip()
46
+ invalid_parts = row_values.isna() | row_values.eq("") | col_values.isna() | col_values.eq("")
47
+ if invalid_parts.any():
48
+ rows = [int(index) + 2 for index in out.index[invalid_parts].tolist()]
49
+ raise ValueError(f"Sample map has blank row/col values at file rows: {rows}")
50
+ out["position"] = row_values + col_values
51
+ df = out.drop(columns=[row_col, col_col])
52
+ else:
53
+ raise ValueError("Sample map must contain either a 'position' column or a ('row','col') pair.")
54
+
55
+ positions = df["position"].astype("string").str.strip().str.upper()
56
+ invalid = positions.isna() | positions.eq("") | positions.eq("NAN") | positions.eq("<NA>")
57
+ if invalid.any():
58
+ rows = [int(index) + 2 for index in df.index[invalid].tolist()]
59
+ raise ValueError(f"Sample map has blank position values at file rows: {rows}")
60
+ duplicates = sorted(positions[positions.duplicated(keep=False)].unique().tolist())
61
+ if duplicates:
62
+ raise ValueError(f"Sample map positions must be unique; duplicates: {duplicates}")
63
+ df = df.copy()
64
+ df["position"] = positions
65
+ return df
@@ -0,0 +1,6 @@
1
+ """Public parser surface for BioTek Synergy H1 workbooks."""
2
+
3
+ from ._parser import parse_kinetic_only, parse_snapshot_and_timeseries
4
+ from ._shared import probe_synergy_workbook
5
+
6
+ __all__ = ["parse_kinetic_only", "parse_snapshot_and_timeseries", "probe_synergy_workbook"]