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,191 @@
1
+ """Aggregate four-state event-window well summaries into design-level records."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+
8
+ from .contracts import FourStateEventWindowAnalysisSpec, ReductionSpec
9
+ from .event_sensitivity import symmetric_event_sensitivity_half_width
10
+ from .observation_resampling import joint_state_descriptive_resampling_draws
11
+ from .reduction import combine_bound_kinds, invert_bound_kind
12
+ from .seeds import stable_seed
13
+ from .sources import STATE_ORDER, ExperimentSource
14
+
15
+
16
+ def build_design_records(
17
+ midpoint: pd.DataFrame,
18
+ *,
19
+ lower: pd.DataFrame,
20
+ upper: pd.DataFrame,
21
+ source: ExperimentSource,
22
+ request: FourStateEventWindowAnalysisSpec,
23
+ reduction: ReductionSpec,
24
+ ) -> pd.DataFrame:
25
+ """Aggregate one reduction and its event-bound sensitivity records."""
26
+
27
+ midpoint_values = _aggregate_state_values(midpoint, request=request)
28
+ lower_values = _aggregate_state_values(lower, request=request)
29
+ upper_values = _aggregate_state_values(upper, request=request)
30
+ records: list[dict[str, object]] = []
31
+ for design_id, design in midpoint_values.groupby("design_id", sort=True):
32
+ if set(design["state"].astype(str)) != set(STATE_ORDER):
33
+ raise ValueError(f"{source.experiment_id}:{design_id}:{reduction.id} lacks complete four-state support.")
34
+ lower_design = lower_values.loc[lower_values["design_id"].astype(str).eq(str(design_id))].set_index("state")
35
+ upper_design = upper_values.loc[upper_values["design_id"].astype(str).eq(str(design_id))].set_index("state")
36
+ design_by_state = design.set_index("state")
37
+ record: dict[str, object] = {
38
+ "experiment_id": source.experiment_id,
39
+ "design_id": str(design_id),
40
+ "reference_design_id": request.source.reference_design_id,
41
+ "reduction_id": reduction.id,
42
+ "reduction_method": reduction.method,
43
+ "response_basis": reduction.response_basis,
44
+ "reduction_role": reduction.role,
45
+ "observation_stat": request.aggregation.observation_stat,
46
+ "descriptive_resampling_draws": request.aggregation.descriptive_resampling_draws,
47
+ "descriptive_interval_mass": request.aggregation.descriptive_interval_mass,
48
+ "event_id": source.event.event_id,
49
+ "event_time_estimate_assay_h": source.event.estimate_assay_h,
50
+ "event_time_uncertainty_h": source.event.uncertainty_h,
51
+ "window_start_event_h": reduction.window_start_event_h,
52
+ "window_end_event_h": reduction.window_end_event_h,
53
+ "is_reference": str(design_id) == request.source.reference_design_id,
54
+ "positive_floor": request.quality.positive_floor,
55
+ "allowed_max_interior_gap_h": request.quality.max_interior_gap_h,
56
+ "required_min_observations_per_state": request.quality.min_observations_per_state,
57
+ "min_observation_count_per_state": int(design["observation_count"].min()),
58
+ "min_observed_points_per_trace": int(design["min_observed_points"].min()),
59
+ "max_observed_interior_gap_h": float(design["max_interior_gap_h"].max()),
60
+ "min_pre_observed_points_per_trace": int(design["min_pre_observed_points"].min()),
61
+ "max_pre_observed_interior_gap_h": float(design["max_pre_interior_gap_h"].max()),
62
+ }
63
+ for state in STATE_ORDER:
64
+ row = design_by_state.loc[state]
65
+ lower_row = lower_design.loc[state]
66
+ upper_row = upper_design.loc[state]
67
+ record[f"r{state}"] = float(row["response"])
68
+ record[f"b{state}"] = float(row["anchored_magnitude"])
69
+ record[f"r{state}_descriptive_resampling_sd"] = float(row["response_descriptive_resampling_sd"])
70
+ record[f"b{state}_descriptive_resampling_sd"] = float(row["anchored_magnitude_descriptive_resampling_sd"])
71
+ record[f"r{state}_descriptive_interval_low"] = float(row["response_descriptive_interval_low"])
72
+ record[f"r{state}_descriptive_interval_high"] = float(row["response_descriptive_interval_high"])
73
+ record[f"b{state}_descriptive_interval_low"] = float(row["anchored_magnitude_descriptive_interval_low"])
74
+ record[f"b{state}_descriptive_interval_high"] = float(row["anchored_magnitude_descriptive_interval_high"])
75
+ record[f"r{state}_event_half_range"] = symmetric_event_sensitivity_half_width(
76
+ float(row["response"]),
77
+ float(lower_row["response"]),
78
+ float(upper_row["response"]),
79
+ )
80
+ record[f"b{state}_event_half_range"] = symmetric_event_sensitivity_half_width(
81
+ float(row["anchored_magnitude"]),
82
+ float(lower_row["anchored_magnitude"]),
83
+ float(upper_row["anchored_magnitude"]),
84
+ )
85
+ for prefix, field in (("r", "response"), ("b", "anchored_magnitude")):
86
+ record[f"{prefix}{state}_event_sensitivity_has_policy_clipping"] = any(
87
+ bool(candidate[f"{field}_has_policy_clipping"]) for candidate in (row, lower_row, upper_row)
88
+ )
89
+ record[f"{prefix}{state}_event_sensitivity_has_instrument_overflow"] = any(
90
+ bool(candidate[f"{field}_has_instrument_overflow"]) for candidate in (row, lower_row, upper_row)
91
+ )
92
+ record[f"r{state}_has_policy_clipping"] = bool(row["response_has_policy_clipping"])
93
+ record[f"r{state}_has_instrument_overflow"] = bool(row["response_has_instrument_overflow"])
94
+ record[f"r{state}_bound_kind"] = str(row["response_bound_kind"])
95
+ record[f"b{state}_has_policy_clipping"] = bool(row["anchored_magnitude_has_policy_clipping"])
96
+ record[f"b{state}_has_instrument_overflow"] = bool(row["anchored_magnitude_has_instrument_overflow"])
97
+ record[f"b{state}_bound_kind"] = str(row["anchored_magnitude_bound_kind"])
98
+ record[f"n{state}"] = int(row["observation_count"])
99
+ records.append(record)
100
+ return pd.DataFrame.from_records(records)
101
+
102
+
103
+ def _aggregate_state_values(wells: pd.DataFrame, *, request: FourStateEventWindowAnalysisSpec) -> pd.DataFrame:
104
+ stat = request.aggregation.observation_stat
105
+ aggregate = np.median if stat == "median" else np.mean
106
+ anchor_id = request.source.reference_design_id
107
+ anchor = wells.loc[wells["design_id"].astype(str).eq(anchor_id)]
108
+ if set(anchor["state"].astype(str)) != set(STATE_ORDER):
109
+ raise ValueError(f"reference design {anchor_id!r} lacks complete four-state support.")
110
+ rows: list[dict[str, object]] = []
111
+ alpha = (1.0 - request.aggregation.descriptive_interval_mass) / 2.0
112
+ for (design_id, state), frame in wells.groupby(["design_id", "state"], sort=True):
113
+ response_values = frame["response_well"].to_numpy(dtype=float)
114
+ magnitude_values = frame["magnitude_well"].to_numpy(dtype=float)
115
+ state_anchor = anchor.loc[anchor["state"].astype(str).eq(str(state))]
116
+ anchor_values = state_anchor["magnitude_well"].to_numpy(dtype=float)
117
+ minimum = request.quality.min_observations_per_state
118
+ if min(len(response_values), len(magnitude_values), len(anchor_values)) < minimum:
119
+ raise ValueError(
120
+ f"{design_id}:{state} requires at least {minimum} within-experiment design and reference observations."
121
+ )
122
+ rng = np.random.default_rng(
123
+ stable_seed(
124
+ request.aggregation.random_seed,
125
+ str(frame["experiment_id"].iloc[0]),
126
+ str(design_id),
127
+ str(state),
128
+ str(frame["reduction_id"].iloc[0]),
129
+ )
130
+ )
131
+ response_draws, anchored_magnitude_draws = joint_state_descriptive_resampling_draws(
132
+ response_values,
133
+ magnitude_values,
134
+ anchor_values,
135
+ samples=request.aggregation.descriptive_resampling_draws,
136
+ stat=stat,
137
+ rng=rng,
138
+ paired_anchor=str(design_id) == anchor_id,
139
+ )
140
+ response_bound = combine_bound_kinds(*frame["response_bound_kind"])
141
+ design_magnitude_bound = combine_bound_kinds(*frame["magnitude_bound_kind"])
142
+ anchor_magnitude_bound = combine_bound_kinds(*state_anchor["magnitude_bound_kind"])
143
+ anchored_bound = (
144
+ "exact"
145
+ if str(design_id) == anchor_id
146
+ else combine_bound_kinds(
147
+ design_magnitude_bound,
148
+ invert_bound_kind(anchor_magnitude_bound),
149
+ )
150
+ )
151
+ rows.append(
152
+ {
153
+ "design_id": str(design_id),
154
+ "state": str(state),
155
+ "response": float(aggregate(response_values)),
156
+ "anchored_magnitude": float(aggregate(magnitude_values) - aggregate(anchor_values)),
157
+ "response_descriptive_resampling_sd": float(np.std(response_draws, ddof=1)),
158
+ "anchored_magnitude_descriptive_resampling_sd": float(np.std(anchored_magnitude_draws, ddof=1)),
159
+ "response_descriptive_interval_low": float(np.quantile(response_draws, alpha)),
160
+ "response_descriptive_interval_high": float(np.quantile(response_draws, 1.0 - alpha)),
161
+ "anchored_magnitude_descriptive_interval_low": float(np.quantile(anchored_magnitude_draws, alpha)),
162
+ "anchored_magnitude_descriptive_interval_high": float(
163
+ np.quantile(anchored_magnitude_draws, 1.0 - alpha)
164
+ ),
165
+ "observation_count": int(min(len(response_values), len(magnitude_values))),
166
+ "min_observed_points": int(
167
+ frame[["response_observed_point_count", "magnitude_observed_point_count"]].min().min()
168
+ ),
169
+ "max_interior_gap_h": float(
170
+ frame[["response_max_interior_gap_h", "magnitude_max_interior_gap_h"]].max().max()
171
+ ),
172
+ "min_pre_observed_points": int(frame["response_pre_observed_point_count"].min()),
173
+ "max_pre_interior_gap_h": float(frame["response_pre_max_interior_gap_h"].max()),
174
+ "response_has_policy_clipping": bool(frame["response_policy_clipped_point_count"].gt(0).any()),
175
+ "response_has_instrument_overflow": bool(frame["response_instrument_overflow_point_count"].gt(0).any()),
176
+ "response_bound_kind": response_bound,
177
+ "anchored_magnitude_has_policy_clipping": bool(
178
+ frame["magnitude_policy_clipped_point_count"].gt(0).any()
179
+ or state_anchor["magnitude_policy_clipped_point_count"].gt(0).any()
180
+ ),
181
+ "anchored_magnitude_has_instrument_overflow": bool(
182
+ frame["magnitude_instrument_overflow_point_count"].gt(0).any()
183
+ or state_anchor["magnitude_instrument_overflow_point_count"].gt(0).any()
184
+ ),
185
+ "anchored_magnitude_bound_kind": anchored_bound,
186
+ }
187
+ )
188
+ return pd.DataFrame.from_records(rows)
189
+
190
+
191
+ __all__ = ["build_design_records"]
@@ -0,0 +1,50 @@
1
+ """Validate scalar and mapping fields used by four-state event-window contracts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+
10
+
11
+ def mapping(value: object, *, context: str) -> dict[str, Any]:
12
+ if not isinstance(value, Mapping):
13
+ raise ValueError(f"{context} must be a mapping.")
14
+ return {str(key): item for key, item in value.items()}
15
+
16
+
17
+ def exact_fields(
18
+ value: object,
19
+ *,
20
+ context: str,
21
+ required: set[str],
22
+ optional: set[str] | None = None,
23
+ ) -> dict[str, Any]:
24
+ payload = mapping(value, context=context)
25
+ allowed = required | (optional or set())
26
+ unknown = sorted(set(payload) - allowed)
27
+ if unknown:
28
+ raise ValueError(f"{context} has unknown fields: {unknown}.")
29
+ missing = sorted(required - set(payload))
30
+ if missing:
31
+ raise ValueError(f"{context} is missing required fields: {missing}.")
32
+ return payload
33
+
34
+
35
+ def nonempty(value: object, *, context: str) -> str:
36
+ if not isinstance(value, str) or not value.strip():
37
+ raise ValueError(f"{context} must be a non-empty string.")
38
+ return value.strip()
39
+
40
+
41
+ def finite(value: object, *, context: str) -> float:
42
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
43
+ raise ValueError(f"{context} must be a finite number.")
44
+ result = float(value)
45
+ if not np.isfinite(result):
46
+ raise ValueError(f"{context} must be a finite number.")
47
+ return result
48
+
49
+
50
+ __all__ = ["exact_fields", "finite", "mapping", "nonempty"]
@@ -0,0 +1,320 @@
1
+ """Strict contracts for event-relative plate-reader response summaries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from dataclasses import dataclass
7
+ from typing import Literal
8
+
9
+ from .contract_fields import exact_fields as _exact_fields
10
+ from .contract_fields import finite as _finite
11
+ from .contract_fields import mapping as _mapping
12
+ from .contract_fields import nonempty as _nonempty
13
+ from .design_dispositions import DesignDisposition, parse_design_dispositions
14
+ from .well_exclusions import WellExclusion, parse_well_exclusions
15
+
16
+ ReductionMethod = Literal["geometric_time_mean", "integrated_linear_mean"]
17
+ ResponseBasis = Literal["post_window", "post_minus_pre"]
18
+ ReductionRole = Literal["primary", "sensitivity"]
19
+ ObservationStat = Literal["mean", "median"]
20
+ EventEstimateMethod = Literal["segment_gap_midpoint"]
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class FourStateEventWindowSourceSpec:
25
+ response_channel: str
26
+ magnitude_channel: str
27
+ growth_channel: str
28
+ reference_design_id: str
29
+ state_column: str
30
+ state_values: Mapping[str, str]
31
+ state_labels: Mapping[str, str]
32
+ state_values_case_sensitive: bool = True
33
+ well_exclusions: tuple[WellExclusion, ...] = ()
34
+ design_dispositions: tuple[DesignDisposition, ...] = ()
35
+
36
+ @classmethod
37
+ def from_mapping(cls, value: object) -> FourStateEventWindowSourceSpec:
38
+ fields = {
39
+ "response_channel",
40
+ "magnitude_channel",
41
+ "growth_channel",
42
+ "reference_design_id",
43
+ "state_column",
44
+ "state_values",
45
+ }
46
+ payload = _exact_fields(
47
+ value,
48
+ context="source",
49
+ required=fields,
50
+ optional={
51
+ "design_dispositions",
52
+ "state_labels",
53
+ "state_values_case_sensitive",
54
+ "well_exclusions",
55
+ },
56
+ )
57
+ state_values = _mapping(payload["state_values"], context="source.state_values")
58
+ expected_states = {"00", "10", "01", "11"}
59
+ if set(state_values) != expected_states:
60
+ raise ValueError("source.state_values must define exactly 00, 10, 01, and 11.")
61
+ normalized_values = {
62
+ state: _nonempty(state_values[state], context=f"source.state_values.{state}")
63
+ for state in sorted(expected_states)
64
+ }
65
+ if len(set(normalized_values.values())) != 4:
66
+ raise ValueError("source.state_values must map to four distinct source values.")
67
+ state_labels = _mapping(payload.get("state_labels", normalized_values), context="source.state_labels")
68
+ if set(state_labels) != expected_states:
69
+ raise ValueError("source.state_labels must define exactly 00, 10, 01, and 11.")
70
+ normalized_labels = {
71
+ state: _nonempty(state_labels[state], context=f"source.state_labels.{state}")
72
+ for state in sorted(expected_states)
73
+ }
74
+ case_sensitive = payload.get("state_values_case_sensitive", True)
75
+ if not isinstance(case_sensitive, bool):
76
+ raise ValueError("source.state_values_case_sensitive must be true or false.")
77
+ reference_design_id = _nonempty(payload["reference_design_id"], context="source.reference_design_id")
78
+ return cls(
79
+ response_channel=_nonempty(payload["response_channel"], context="source.response_channel"),
80
+ magnitude_channel=_nonempty(payload["magnitude_channel"], context="source.magnitude_channel"),
81
+ growth_channel=_nonempty(payload["growth_channel"], context="source.growth_channel"),
82
+ reference_design_id=reference_design_id,
83
+ state_column=_nonempty(payload["state_column"], context="source.state_column"),
84
+ state_values=normalized_values,
85
+ state_labels=normalized_labels,
86
+ state_values_case_sensitive=case_sensitive,
87
+ well_exclusions=parse_well_exclusions(payload.get("well_exclusions", [])),
88
+ design_dispositions=parse_design_dispositions(payload.get("design_dispositions", [])),
89
+ )
90
+
91
+ def require_known_experiment_ids(self, experiment_ids: Sequence[str]) -> None:
92
+ """Reject design selections that do not name a resolved source experiment."""
93
+
94
+ known = {str(value) for value in experiment_ids}
95
+ referenced = {item.experiment_id for item in (*self.well_exclusions, *self.design_dispositions)}
96
+ unknown = sorted(referenced - known)
97
+ if unknown:
98
+ raise ValueError(f"source experiment selections name unknown experiments: {unknown}.")
99
+
100
+ def require_known_reduction_ids(self, reduction_ids: Sequence[str]) -> None:
101
+ """Reject design dispositions that do not name a declared reduction."""
102
+
103
+ known = {str(value) for value in reduction_ids}
104
+ for disposition in self.design_dispositions:
105
+ if disposition.reduction_id not in known:
106
+ raise ValueError(f"design disposition names unknown reduction {disposition.reduction_id!r}.")
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class EventSpec:
111
+ event_id: str
112
+ event_kind: str
113
+ segment_column: str
114
+ pre_segment_index: int
115
+ post_segment_index: int
116
+ estimate_method: EventEstimateMethod
117
+ declaration: str
118
+
119
+ @classmethod
120
+ def from_mapping(cls, value: object) -> EventSpec:
121
+ fields = {
122
+ "event_id",
123
+ "event_kind",
124
+ "segment_column",
125
+ "pre_segment_index",
126
+ "post_segment_index",
127
+ "estimate_method",
128
+ "declaration",
129
+ }
130
+ payload = _exact_fields(value, context="event", required=fields)
131
+ pre = payload["pre_segment_index"]
132
+ post = payload["post_segment_index"]
133
+ if isinstance(pre, bool) or not isinstance(pre, int):
134
+ raise ValueError("event.pre_segment_index must be an integer.")
135
+ if isinstance(post, bool) or not isinstance(post, int):
136
+ raise ValueError("event.post_segment_index must be an integer.")
137
+ if pre == post:
138
+ raise ValueError("event segment indexes must differ.")
139
+ estimate = _nonempty(payload["estimate_method"], context="event.estimate_method")
140
+ if estimate != "segment_gap_midpoint":
141
+ raise ValueError("event.estimate_method must be 'segment_gap_midpoint' for symmetric event uncertainty.")
142
+ return cls(
143
+ event_id=_nonempty(payload["event_id"], context="event.event_id"),
144
+ event_kind=_nonempty(payload["event_kind"], context="event.event_kind"),
145
+ segment_column=_nonempty(payload["segment_column"], context="event.segment_column"),
146
+ pre_segment_index=pre,
147
+ post_segment_index=post,
148
+ estimate_method=estimate,
149
+ declaration=_nonempty(payload["declaration"], context="event.declaration"),
150
+ )
151
+
152
+
153
+ @dataclass(frozen=True)
154
+ class ReductionSpec:
155
+ id: str
156
+ window_start_event_h: float
157
+ window_end_event_h: float
158
+ method: ReductionMethod
159
+ response_basis: ResponseBasis
160
+ role: ReductionRole
161
+ pre_window_duration_h: float | None = None
162
+
163
+ @classmethod
164
+ def from_mapping(cls, value: object, *, index: int) -> ReductionSpec:
165
+ required = {
166
+ "id",
167
+ "window_start_event_h",
168
+ "window_end_event_h",
169
+ "method",
170
+ "response_basis",
171
+ "role",
172
+ }
173
+ payload = _exact_fields(
174
+ value,
175
+ context=f"reductions[{index}]",
176
+ required=required,
177
+ optional={"pre_window_duration_h"},
178
+ )
179
+ start = _finite(payload["window_start_event_h"], context=f"reductions[{index}].window_start_event_h")
180
+ end = _finite(payload["window_end_event_h"], context=f"reductions[{index}].window_end_event_h")
181
+ if start < 0.0 or end <= start:
182
+ raise ValueError(f"reductions[{index}] requires 0 <= window start < window end.")
183
+ method = _nonempty(payload["method"], context=f"reductions[{index}].method")
184
+ if method not in {"geometric_time_mean", "integrated_linear_mean"}:
185
+ raise ValueError(f"reductions[{index}].method is unsupported: {method!r}.")
186
+ basis = _nonempty(payload["response_basis"], context=f"reductions[{index}].response_basis")
187
+ if basis not in {"post_window", "post_minus_pre"}:
188
+ raise ValueError(f"reductions[{index}].response_basis is unsupported: {basis!r}.")
189
+ role = _nonempty(payload["role"], context=f"reductions[{index}].role")
190
+ if role not in {"primary", "sensitivity"}:
191
+ raise ValueError(f"reductions[{index}].role is unsupported: {role!r}.")
192
+ raw_pre = payload.get("pre_window_duration_h")
193
+ pre = None if raw_pre is None else _finite(raw_pre, context=f"reductions[{index}].pre_window_duration_h")
194
+ if basis == "post_minus_pre" and (pre is None or pre <= 0.0):
195
+ raise ValueError(f"reductions[{index}] post_minus_pre requires a positive pre_window_duration_h.")
196
+ if basis == "post_window" and pre is not None:
197
+ raise ValueError(f"reductions[{index}] pre_window_duration_h is only valid for post_minus_pre.")
198
+ return cls(
199
+ id=_nonempty(payload["id"], context=f"reductions[{index}].id"),
200
+ window_start_event_h=start,
201
+ window_end_event_h=end,
202
+ method=method,
203
+ response_basis=basis,
204
+ role=role,
205
+ pre_window_duration_h=pre,
206
+ )
207
+
208
+
209
+ @dataclass(frozen=True)
210
+ class AggregationSpec:
211
+ observation_stat: ObservationStat
212
+ descriptive_resampling_draws: int
213
+ descriptive_interval_mass: float
214
+ random_seed: int
215
+
216
+ @classmethod
217
+ def from_mapping(cls, value: object) -> AggregationSpec:
218
+ fields = {
219
+ "observation_stat",
220
+ "descriptive_resampling_draws",
221
+ "descriptive_interval_mass",
222
+ "random_seed",
223
+ }
224
+ payload = _exact_fields(value, context="aggregation", required=fields)
225
+ stat = _nonempty(payload["observation_stat"], context="aggregation.observation_stat")
226
+ if stat not in {"mean", "median"}:
227
+ raise ValueError(f"aggregation.observation_stat is unsupported: {stat!r}.")
228
+ samples = payload["descriptive_resampling_draws"]
229
+ seed = payload["random_seed"]
230
+ if isinstance(samples, bool) or not isinstance(samples, int) or samples < 100:
231
+ raise ValueError("aggregation.descriptive_resampling_draws must be an integer of at least 100.")
232
+ if isinstance(seed, bool) or not isinstance(seed, int) or seed < 0:
233
+ raise ValueError("aggregation.random_seed must be a non-negative integer.")
234
+ level = _finite(payload["descriptive_interval_mass"], context="aggregation.descriptive_interval_mass")
235
+ if not 0.5 < level < 1.0:
236
+ raise ValueError("aggregation.descriptive_interval_mass must be between 0.5 and 1.0.")
237
+ return cls(
238
+ observation_stat=stat,
239
+ descriptive_resampling_draws=samples,
240
+ descriptive_interval_mass=level,
241
+ random_seed=seed,
242
+ )
243
+
244
+
245
+ @dataclass(frozen=True)
246
+ class QualitySpec:
247
+ positive_floor: float
248
+ max_interior_gap_h: float
249
+ min_observations_per_state: int
250
+
251
+ @classmethod
252
+ def from_mapping(cls, value: object) -> QualitySpec:
253
+ fields = {"positive_floor", "max_interior_gap_h", "min_observations_per_state"}
254
+ payload = _exact_fields(value, context="quality", required=fields)
255
+ floor = _finite(payload["positive_floor"], context="quality.positive_floor")
256
+ gap = _finite(payload["max_interior_gap_h"], context="quality.max_interior_gap_h")
257
+ observations = payload["min_observations_per_state"]
258
+ if floor <= 0.0:
259
+ raise ValueError("quality.positive_floor must be positive.")
260
+ if gap <= 0.0:
261
+ raise ValueError("quality.max_interior_gap_h must be positive.")
262
+ if isinstance(observations, bool) or not isinstance(observations, int) or observations < 2:
263
+ raise ValueError("quality.min_observations_per_state must be an integer of at least 2.")
264
+ return cls(
265
+ positive_floor=floor,
266
+ max_interior_gap_h=gap,
267
+ min_observations_per_state=observations,
268
+ )
269
+
270
+
271
+ @dataclass(frozen=True)
272
+ class FourStateEventWindowAnalysisSpec:
273
+ source: FourStateEventWindowSourceSpec
274
+ event: EventSpec
275
+ reductions: tuple[ReductionSpec, ...]
276
+ aggregation: AggregationSpec
277
+ quality: QualitySpec
278
+
279
+ @property
280
+ def primary_reduction(self) -> ReductionSpec:
281
+ return next(spec for spec in self.reductions if spec.role == "primary")
282
+
283
+ @classmethod
284
+ def from_mapping(cls, value: object) -> FourStateEventWindowAnalysisSpec:
285
+ fields = {
286
+ "source",
287
+ "event",
288
+ "reductions",
289
+ "aggregation",
290
+ "quality",
291
+ }
292
+ payload = _exact_fields(value, context="analysis", required=fields)
293
+ raw_reductions = payload["reductions"]
294
+ if isinstance(raw_reductions, (str, bytes)) or not isinstance(raw_reductions, Sequence):
295
+ raise ValueError("reductions must be a non-empty sequence.")
296
+ reductions = tuple(ReductionSpec.from_mapping(item, index=index) for index, item in enumerate(raw_reductions))
297
+ ids = [spec.id for spec in reductions]
298
+ if not reductions or len(ids) != len(set(ids)):
299
+ raise ValueError("reduction ids must be non-empty and unique.")
300
+ if sum(spec.role == "primary" for spec in reductions) != 1:
301
+ raise ValueError("analysis must declare exactly one primary reduction.")
302
+ source = FourStateEventWindowSourceSpec.from_mapping(payload["source"])
303
+ source.require_known_reduction_ids(ids)
304
+ return cls(
305
+ source=source,
306
+ event=EventSpec.from_mapping(payload["event"]),
307
+ reductions=reductions,
308
+ aggregation=AggregationSpec.from_mapping(payload["aggregation"]),
309
+ quality=QualitySpec.from_mapping(payload["quality"]),
310
+ )
311
+
312
+
313
+ __all__ = [
314
+ "AggregationSpec",
315
+ "EventSpec",
316
+ "QualitySpec",
317
+ "ReductionSpec",
318
+ "FourStateEventWindowAnalysisSpec",
319
+ "FourStateEventWindowSourceSpec",
320
+ ]
@@ -0,0 +1,54 @@
1
+ """Define plate- and reduction-scoped design eligibility dispositions."""
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
+ _OBSERVATION_REASON = "insufficient_state_observations"
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class DesignDisposition:
15
+ experiment_id: str
16
+ design_id: str
17
+ state: str
18
+ reduction_id: str
19
+ reason: str
20
+
21
+
22
+ def parse_design_dispositions(value: object) -> tuple[DesignDisposition, ...]:
23
+ if isinstance(value, (str, bytes)) or not isinstance(value, Sequence):
24
+ raise ValueError("source.design_dispositions must be a sequence.")
25
+ dispositions: list[DesignDisposition] = []
26
+ for index, item in enumerate(value):
27
+ context = f"source.design_dispositions[{index}]"
28
+ payload = exact_fields(
29
+ item,
30
+ context=context,
31
+ required={"experiment_id", "design_id", "state", "reduction_id", "reason"},
32
+ )
33
+ state = nonempty(payload["state"], context=f"{context}.state")
34
+ if state not in {"00", "10", "01", "11"}:
35
+ raise ValueError(f"{context}.state must be 00, 10, 01, or 11.")
36
+ reason = nonempty(payload["reason"], context=f"{context}.reason")
37
+ if reason != _OBSERVATION_REASON:
38
+ raise ValueError(f"{context}.reason must be {_OBSERVATION_REASON!r}.")
39
+ dispositions.append(
40
+ DesignDisposition(
41
+ experiment_id=nonempty(payload["experiment_id"], context=f"{context}.experiment_id"),
42
+ design_id=nonempty(payload["design_id"], context=f"{context}.design_id"),
43
+ state=state,
44
+ reduction_id=nonempty(payload["reduction_id"], context=f"{context}.reduction_id"),
45
+ reason=reason,
46
+ )
47
+ )
48
+ keys = [(item.experiment_id, item.design_id, item.reduction_id) for item in dispositions]
49
+ if len(keys) != len(set(keys)):
50
+ raise ValueError("source.design_dispositions must not repeat an experiment, design, and reduction.")
51
+ return tuple(dispositions)
52
+
53
+
54
+ __all__ = ["DesignDisposition", "parse_design_dispositions"]