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,474 @@
1
+ """Fold-change table construction for tidy plate-reader traces."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import math
7
+ from collections.abc import Mapping, Sequence
8
+ from dataclasses import dataclass
9
+ from typing import Any, Literal
10
+
11
+ import numpy as np
12
+ import pandas as pd
13
+
14
+ from reader_workbench.domains.plate_reader.analysis.timepoints import nearest_time_per_key
15
+ from reader_workbench.domains.plate_reader.ordering import smart_string_numeric_key
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class FoldChangeAnalysisSpec:
20
+ """Explicit domain inputs for one fold-change analysis."""
21
+
22
+ target: str
23
+ report_times: tuple[float, ...]
24
+ time_tolerance: float = 0.51
25
+ observation_stat: Literal["median", "mean"] = "median"
26
+ treatment_column: str = "treatment"
27
+ group_by: tuple[str, ...] = ("design_id",)
28
+ expected_treatments: tuple[str, ...] = ()
29
+ use_global_baseline: bool = False
30
+ global_baseline_value: str | None = None
31
+ overrides: tuple[Mapping[str, Any], ...] = ()
32
+ fc_column: str = "FC"
33
+ log2fc_column: str = "log2FC"
34
+ attach_metadata: tuple[str, ...] = ("batch",)
35
+
36
+
37
+ def synonyms_for(col: str) -> list[str]:
38
+ names = [str(col)]
39
+ if str(col).endswith("_alias"):
40
+ names.append(str(col)[:-6])
41
+ else:
42
+ names.append(str(col) + "_alias")
43
+ return names
44
+
45
+
46
+ def pick_alias_column(df: pd.DataFrame, base: str | None) -> str | None:
47
+ if not base:
48
+ return None
49
+ alias = f"{base}_alias"
50
+ if base in df.columns:
51
+ return base
52
+ if alias in df.columns:
53
+ return alias
54
+ return None
55
+
56
+
57
+ def resolve_baseline_label(
58
+ row_like: Mapping[str, Any],
59
+ *,
60
+ use_global: bool,
61
+ global_value: str | None,
62
+ overrides: Sequence[Mapping[str, Any]],
63
+ group_by_cols: list[str],
64
+ logger: logging.Logger | None = None,
65
+ ) -> str | None:
66
+ syn_view: dict[str, Any] = {}
67
+ for group_col in group_by_cols:
68
+ value = row_like.get(group_col, None)
69
+ for key in synonyms_for(group_col):
70
+ if key not in syn_view:
71
+ syn_view[key] = value
72
+ for rule in overrides or []:
73
+ if "baseline_value" not in rule:
74
+ continue
75
+ ok = True
76
+ for key, value in rule.items():
77
+ if key == "baseline_value":
78
+ continue
79
+ if str(syn_view.get(key, "")) != str(value):
80
+ ok = False
81
+ break
82
+ if ok:
83
+ return str(rule["baseline_value"])
84
+ if use_global and global_value is not None:
85
+ return str(global_value)
86
+ if logger is not None and overrides:
87
+ keys = ", ".join(group_by_cols)
88
+ logger.debug("fold_change: no override matched for {%s}=%s", keys, [row_like.get(k) for k in group_by_cols])
89
+ return None
90
+
91
+
92
+ def log_fold_change_summary(
93
+ logger: logging.Logger | None,
94
+ *,
95
+ target: str,
96
+ timepoint: float,
97
+ stats: pd.DataFrame,
98
+ group_cols: list[str],
99
+ treatment_col: str,
100
+ rows_emitted: int,
101
+ missing_baseline_groups: int,
102
+ ) -> None:
103
+ if logger is None:
104
+ return
105
+ n_groups = int(stats[group_cols].drop_duplicates().shape[0])
106
+ n_treat = int(stats[treatment_col].nunique())
107
+ logger.info(
108
+ "fold_change • target=[accent]%s[/accent] • t≈%.2f h • groups=%d • treatments=%d • rows=%d • missing_baseline=%d",
109
+ target,
110
+ float(timepoint),
111
+ n_groups,
112
+ n_treat,
113
+ rows_emitted,
114
+ missing_baseline_groups,
115
+ )
116
+
117
+
118
+ def compute_fold_change_table(
119
+ df: pd.DataFrame,
120
+ *,
121
+ spec: FoldChangeAnalysisSpec,
122
+ logger: logging.Logger | None = None,
123
+ ) -> pd.DataFrame:
124
+ df = df.copy()
125
+ df["value"] = pd.to_numeric(df["value"], errors="coerce")
126
+
127
+ treatment_col = pick_alias_column(df, spec.treatment_column)
128
+ group_cols = [col for col in (pick_alias_column(df, group) for group in spec.group_by) if col]
129
+
130
+ if treatment_col is None:
131
+ raise ValueError(f"fold_change: treatment column '{spec.treatment_column}' (or its alias) is missing")
132
+ if not group_cols:
133
+ raise ValueError("fold_change: none of the group_by columns are present in the dataframe")
134
+
135
+ target = str(spec.target)
136
+ expected_treatments = tuple(str(value).strip() for value in spec.expected_treatments)
137
+ if any(not value for value in expected_treatments) or len(set(expected_treatments)) != len(expected_treatments):
138
+ raise ValueError("fold_change: expected_treatments must contain unique non-empty labels")
139
+ expected_treatment_set = set(expected_treatments)
140
+ base = df[df["channel"].astype(str) == target].copy()
141
+ if base.empty:
142
+ if expected_treatment_set:
143
+ raise ValueError(
144
+ f"fold_change: target channel {target!r} has no rows while expected_treatments is declared"
145
+ )
146
+ if logger is not None:
147
+ logger.warning("fold_change: target channel %r has no rows; emitting typed empty table", target)
148
+ return build_empty_fold_change_table(group_cols=group_cols, spec=spec)
149
+
150
+ identity_mask = pd.Series(True, index=base.index)
151
+ for column in [*group_cols, treatment_col]:
152
+ values = base[column]
153
+ identity_mask &= values.notna() & ~values.astype(str).str.strip().str.casefold().isin({"", "nan", "none"})
154
+ incomplete_identity_rows = int((~identity_mask).sum())
155
+ if incomplete_identity_rows:
156
+ raise ValueError(
157
+ "fold_change: requires complete analytical identity for every target observation; "
158
+ f"found {incomplete_identity_rows} row(s) with missing group or treatment labels"
159
+ )
160
+
161
+ out_rows: list[dict[str, Any]] = []
162
+ nearest_keys: list[str] = [col for col in (group_cols + [treatment_col, "position"]) if col in base.columns]
163
+ group_roster = list(base[group_cols].drop_duplicates().itertuples(index=False, name=None))
164
+
165
+ for timepoint in [float(item) for item in spec.report_times]:
166
+ snapped = nearest_time_per_key(
167
+ base, target_time=float(timepoint), keys=nearest_keys, tol=float(spec.time_tolerance)
168
+ )
169
+ _require_exact_finite_values(snapped)
170
+ if expected_treatment_set:
171
+ snapped_groups = set(snapped[group_cols].drop_duplicates().itertuples(index=False, name=None))
172
+ for group_values in group_roster:
173
+ if group_values in snapped_groups:
174
+ continue
175
+ group_desc = " | ".join(f"{column}={group_values[index]}" for index, column in enumerate(group_cols))
176
+ missing = sorted(expected_treatment_set, key=smart_string_numeric_key)
177
+ raise ValueError(
178
+ f"fold_change: incomplete treatment cohort at t≈{timepoint:g} h for {group_desc}; "
179
+ f"missing={missing}, unexpected=[]"
180
+ )
181
+ if snapped.empty:
182
+ if logger is not None:
183
+ logger.warning(
184
+ "fold_change: t≈%.2f h: no rows within ±%.3g h for target=%s",
185
+ timepoint,
186
+ spec.time_tolerance,
187
+ target,
188
+ )
189
+ continue
190
+
191
+ if expected_treatment_set:
192
+ for group_values, cohort in snapped.groupby(group_cols, dropna=False):
193
+ if not isinstance(group_values, tuple):
194
+ group_values = (group_values,)
195
+ group_desc = " | ".join(f"{column}={group_values[index]}" for index, column in enumerate(group_cols))
196
+ observed_treatments = set(cohort[treatment_col].astype(str))
197
+ missing = sorted(expected_treatment_set - observed_treatments, key=smart_string_numeric_key)
198
+ unexpected = sorted(observed_treatments - expected_treatment_set, key=smart_string_numeric_key)
199
+ if missing or unexpected:
200
+ raise ValueError(
201
+ f"fold_change: incomplete treatment cohort at t≈{timepoint:g} h for {group_desc}; "
202
+ f"missing={missing}, unexpected={unexpected}"
203
+ )
204
+
205
+ agg_extras = {}
206
+ if "treatment" in snapped.columns:
207
+ agg_extras["__treatment_raw"] = ("treatment", "first")
208
+ if "treatment_alias" in snapped.columns:
209
+ agg_extras["__treatment_alias"] = ("treatment_alias", "first")
210
+
211
+ grouped = (
212
+ snapped.assign(time_used=pd.to_numeric(snapped["time"], errors="coerce"))
213
+ .groupby([*group_cols, treatment_col], dropna=False)
214
+ .agg(
215
+ val=("value", spec.observation_stat),
216
+ n=("value", "count"),
217
+ time_used=("time_used", "median"),
218
+ **agg_extras,
219
+ )
220
+ .reset_index()
221
+ )
222
+
223
+ rows_before = len(out_rows)
224
+ fallbacks_used = 0
225
+ missing_baseline_groups = 0
226
+
227
+ for group_values, sub in grouped.groupby(group_cols, dropna=False):
228
+ if not isinstance(group_values, tuple):
229
+ group_values = (group_values,)
230
+ group_map = {group_cols[idx]: group_values[idx] for idx in range(len(group_cols))}
231
+
232
+ baseline_label = resolve_baseline_label(
233
+ group_map,
234
+ use_global=spec.use_global_baseline,
235
+ global_value=spec.global_baseline_value,
236
+ overrides=spec.overrides,
237
+ group_by_cols=group_cols,
238
+ logger=logger,
239
+ )
240
+
241
+ baseline_rows = (
242
+ sub[sub[treatment_col].astype(str) == str(baseline_label)]
243
+ if baseline_label is not None
244
+ else pd.DataFrame()
245
+ )
246
+ if baseline_rows.empty and baseline_label is not None:
247
+ if "__treatment_raw" in sub.columns:
248
+ baseline_rows = sub[sub["__treatment_raw"].astype(str) == str(baseline_label)]
249
+ if baseline_rows.empty and "__treatment_alias" in sub.columns:
250
+ baseline_rows = sub[sub["__treatment_alias"].astype(str) == str(baseline_label)]
251
+
252
+ if (
253
+ baseline_rows.empty
254
+ and spec.use_global_baseline
255
+ and spec.global_baseline_value is not None
256
+ and baseline_label is not None
257
+ and str(baseline_label) != str(spec.global_baseline_value)
258
+ ):
259
+ global_baseline_rows = (
260
+ sub[sub[treatment_col].astype(str) == str(spec.global_baseline_value)]
261
+ if not sub.empty
262
+ else pd.DataFrame()
263
+ )
264
+ if not global_baseline_rows.empty:
265
+ if logger is not None:
266
+ group_desc = " | ".join(f"{col}={group_map.get(col)}" for col in group_cols)
267
+ logger.info(
268
+ "fold_change • t≈%.2f h • %s: override baseline %r not found → using global %r",
269
+ float(timepoint),
270
+ group_desc,
271
+ str(baseline_label),
272
+ str(spec.global_baseline_value),
273
+ )
274
+ baseline_label = str(spec.global_baseline_value)
275
+ baseline_rows = global_baseline_rows
276
+ fallbacks_used += 1
277
+
278
+ if baseline_rows.empty:
279
+ missing_baseline_groups += 1
280
+ if logger is not None and spec.use_global_baseline and spec.global_baseline_value is not None:
281
+ group_desc = " | ".join(f"{col}={group_map.get(col)}" for col in group_cols)
282
+ logger.warning(
283
+ "[warn]fold_change[/warn] • t≈%.2f h • %s: baseline not present "
284
+ "(override=%r, global=%r) — FC set to NaN",
285
+ float(timepoint),
286
+ group_desc,
287
+ str(baseline_label),
288
+ str(spec.global_baseline_value),
289
+ )
290
+ baseline_value = math.nan
291
+ baseline_n = 0
292
+ baseline_time = math.nan
293
+ else:
294
+ baseline_value = float(baseline_rows["val"].iloc[0])
295
+ baseline_n = int(baseline_rows["n"].iloc[0])
296
+ baseline_time = (
297
+ float(baseline_rows["time_used"].iloc[0])
298
+ if pd.notna(baseline_rows["time_used"].iloc[0])
299
+ else float("nan")
300
+ )
301
+
302
+ for _, row in sub.iterrows():
303
+ value = float(row["val"])
304
+ fc = (
305
+ float("nan")
306
+ if not (np.isfinite(baseline_value) and baseline_value != 0)
307
+ else value / baseline_value
308
+ )
309
+ log2fc = float(np.log2(fc)) if (np.isfinite(fc) and fc > 0) else float("nan")
310
+
311
+ treatment_out = str(row.get("__treatment_raw", row[treatment_col]))
312
+ baseline_out = (
313
+ str(baseline_rows["__treatment_raw"].iloc[0])
314
+ if (
315
+ "__treatment_raw" in baseline_rows.columns
316
+ and not baseline_rows.empty
317
+ and pd.notna(baseline_rows["__treatment_raw"].iloc[0])
318
+ )
319
+ else (str(baseline_label) if baseline_label is not None else "")
320
+ )
321
+ emitted = {
322
+ "target": target,
323
+ "time": float(timepoint),
324
+ "treatment": treatment_out,
325
+ spec.fc_column: fc,
326
+ spec.log2fc_column: log2fc,
327
+ "n": int(row["n"]),
328
+ "baseline_value": baseline_out,
329
+ "baseline_n": int(baseline_n),
330
+ "baseline_time": baseline_time,
331
+ }
332
+ for group_col in group_cols:
333
+ emitted[group_col] = row[group_col]
334
+ out_rows.append(emitted)
335
+
336
+ log_fold_change_summary(
337
+ logger,
338
+ target=target,
339
+ timepoint=timepoint,
340
+ stats=grouped,
341
+ group_cols=group_cols,
342
+ treatment_col=treatment_col,
343
+ rows_emitted=(len(out_rows) - rows_before),
344
+ missing_baseline_groups=missing_baseline_groups,
345
+ )
346
+ if logger is not None and fallbacks_used:
347
+ logger.info(
348
+ "fold_change • t≈%.2f h • override→global fallbacks: %d",
349
+ float(timepoint),
350
+ int(fallbacks_used),
351
+ )
352
+
353
+ out = build_empty_fold_change_table(group_cols=group_cols, spec=spec) if not out_rows else pd.DataFrame(out_rows)
354
+
355
+ metadata_frame = base.copy()
356
+ metadata_frame["__output_treatment"] = metadata_frame[
357
+ "treatment" if "treatment" in metadata_frame.columns else treatment_col
358
+ ].astype(str)
359
+ metadata_keys = [*group_cols, "__output_treatment"]
360
+ output_metadata_keys = [*group_cols, "treatment"]
361
+ for metadata_col in spec.attach_metadata:
362
+ if metadata_col in df.columns and metadata_col not in out.columns:
363
+ base_meta = (
364
+ metadata_frame.groupby(metadata_keys, dropna=False)[metadata_col]
365
+ .agg(lambda series: series.dropna().iloc[0] if series.dropna().nunique() == 1 else np.nan)
366
+ .reset_index()
367
+ .rename(columns={"__output_treatment": "treatment"})
368
+ )
369
+ out = out.merge(base_meta, on=output_metadata_keys, how="left")
370
+
371
+ for column in ["time", "baseline_time"]:
372
+ if column in out.columns:
373
+ out[column] = pd.to_numeric(out[column], errors="coerce")
374
+ for column in [spec.fc_column, spec.log2fc_column]:
375
+ if column in out.columns:
376
+ out[column] = pd.to_numeric(out[column], errors="coerce")
377
+ for column in ["n", "baseline_n"]:
378
+ if column in out.columns:
379
+ out[column] = pd.to_numeric(out[column], errors="coerce").astype("Int64")
380
+
381
+ if logger is not None:
382
+ treatment_levels = sorted(out["treatment"].astype(str).unique().tolist(), key=smart_string_numeric_key)
383
+ group_preview = ", ".join(group_cols)
384
+ logger.info(
385
+ "fold_change • done • target=[accent]%s[/accent] • times=[%s] • group_by=[%s] • treatments=[%s]",
386
+ target,
387
+ ", ".join(f"{float(item):.2f}" for item in spec.report_times),
388
+ group_preview or "—",
389
+ ", ".join(treatment_levels[:10]) + (" …" if len(treatment_levels) > 10 else ""),
390
+ )
391
+
392
+ if logger is not None and not out.empty and spec.log2fc_column in out.columns:
393
+ good = out[pd.to_numeric(out[spec.log2fc_column], errors="coerce").notna()].copy()
394
+ if not good.empty:
395
+ good[spec.log2fc_column] = pd.to_numeric(good[spec.log2fc_column], errors="coerce")
396
+ good["__abs_l2fc"] = good[spec.log2fc_column].abs()
397
+ times = sorted(pd.to_numeric(good["time"], errors="coerce").dropna().unique())
398
+ for timepoint in times:
399
+ sub = good[pd.to_numeric(good["time"], errors="coerce") == float(timepoint)].copy()
400
+ if sub.empty:
401
+ continue
402
+ top = sub.sort_values("__abs_l2fc", ascending=False).head(5)
403
+
404
+ def _desc(row) -> str:
405
+ group_desc = " | ".join(f"{col}={row[col]}" for col in group_cols if col in row.index)
406
+ base_label = str(row.get("baseline_value", "")) or "—"
407
+ return (
408
+ f" • {(group_desc + ' • ' if group_desc else '')}treatment={row['treatment']} "
409
+ f"→ {row[spec.fc_column]:.3g}x "
410
+ f"(log2FC={row[spec.log2fc_column]:.2f}; baseline={base_label})"
411
+ )
412
+
413
+ lines = "\n".join(_desc(row) for _, row in top.iterrows())
414
+ logger.info("fold_change • t≈%.2f h • strongest changes:\n%s", float(timepoint), lines or " —")
415
+ if group_cols:
416
+ primary_group = group_cols[0]
417
+ for timepoint in times:
418
+ sub = good[pd.to_numeric(good["time"], errors="coerce") == float(timepoint)].copy()
419
+ if sub.empty:
420
+ continue
421
+ logger.info("fold_change • t=%.2f h • per-%s top changes:", float(timepoint), primary_group)
422
+ for group_value, subset in sub.groupby(primary_group, dropna=False):
423
+ top_subset = subset.sort_values("__abs_l2fc", ascending=False).head(3)
424
+ items = (
425
+ ", ".join(
426
+ f"treatment={row['treatment']}: {row[spec.fc_column]:.3g}x "
427
+ f"(l2FC={row[spec.log2fc_column]:.2f})"
428
+ for _, row in top_subset.iterrows()
429
+ )
430
+ or "—"
431
+ )
432
+ logger.info(" • %s → %s", str(group_value), items)
433
+
434
+ return out
435
+
436
+
437
+ def _require_exact_finite_values(frame: pd.DataFrame) -> None:
438
+ exact_finite = pd.Series(np.isfinite(frame["value"]), index=frame.index)
439
+ if "value_bound_kind" in frame.columns:
440
+ exact_finite &= frame["value_bound_kind"].eq("exact")
441
+ for flag_column in ("value_policy_clipped", "value_instrument_overflow"):
442
+ if flag_column in frame.columns:
443
+ exact_finite &= frame[flag_column].eq(False)
444
+ if not exact_finite.all():
445
+ raise ValueError(
446
+ "fold_change: requires exact finite values; "
447
+ f"found {int((~exact_finite).sum())} censored, bounded, or non-finite selected target observation(s)"
448
+ )
449
+
450
+
451
+ def build_empty_fold_change_table(*, group_cols: list[str], spec: FoldChangeAnalysisSpec) -> pd.DataFrame:
452
+ columns = [
453
+ "target",
454
+ "time",
455
+ "treatment",
456
+ spec.fc_column,
457
+ spec.log2fc_column,
458
+ "n",
459
+ "baseline_value",
460
+ "baseline_n",
461
+ "baseline_time",
462
+ *group_cols,
463
+ ]
464
+ return pd.DataFrame(columns=columns)
465
+
466
+
467
+ __all__ = [
468
+ "FoldChangeAnalysisSpec",
469
+ "build_empty_fold_change_table",
470
+ "compute_fold_change_table",
471
+ "pick_alias_column",
472
+ "resolve_baseline_label",
473
+ "synonyms_for",
474
+ ]
@@ -0,0 +1,21 @@
1
+ """Plate-reader four-state event-window contracts and pure analysis primitives."""
2
+
3
+ from .contracts import (
4
+ AggregationSpec,
5
+ EventSpec,
6
+ FourStateEventWindowAnalysisSpec,
7
+ FourStateEventWindowSourceSpec,
8
+ QualitySpec,
9
+ ReductionSpec,
10
+ )
11
+ from .reduction import four_state_event_window_temporal_spec
12
+
13
+ __all__ = [
14
+ "AggregationSpec",
15
+ "EventSpec",
16
+ "QualitySpec",
17
+ "ReductionSpec",
18
+ "FourStateEventWindowAnalysisSpec",
19
+ "FourStateEventWindowSourceSpec",
20
+ "four_state_event_window_temporal_spec",
21
+ ]