Skip to content

Commit d7abd9a

Browse files
committed
Add per-run provenance manifests and a dataset-level QC/completion report
Both use data that already existed in scattered form (requirements.txt, environment_lock.txt, per-run output files) -- nothing here is invented. - New Automation_code/provenance.py: build_manifest()/write_run_manifest() record receptor, ligand, seeds, docking parameters, actual runtime tool versions (vina --version / obabel -V queried live, not just the static lock file), Python version, a UTC timestamp, and SHA-256 hashes of the input/output files involved. Hashing a missing file returns None rather than raising, so a manifest can still be written for a run that failed partway through. - dataset/code.py's main() now writes run_manifest.json into each docking_N/ run directory alongside the existing log.txt/pose_analysis.csv, for every run it actually executes (the existing analysis_csv-exists skip for already-completed runs is unchanged, so resuming a partial batch does not retroactively manifest earlier runs from a prior invocation). - New collect_qc_data()/write_qc_report() in dataset/code.py aggregate the existing per-run completion state (same on-disk layout main() already writes) into one checkable statistic, e.g. '480/480 pairs completed (1440/1440 individual docking runs)', plus a per-pair list of anything incomplete. Written to OUTPUT_ROOT/qc_report.json. This is a pure, post-hoc, read-only aggregation over existing output files -- it does not change main()'s control flow or its existing resumability behaviour. - cli.py's 'run' subcommand writes an equivalent run-level manifest (receptor/ligands/seeds/params/tool versions) after docking completes; 'batch' now calls write_qc_report() after master_organizer(). 11 new tests (7 for provenance.py, 4 for the QC report, all against synthetic fixtures/tmp_path layouts -- no network, no Vina, no Open Babel required). Full suite: 60 passed, 1 skipped (obabel not installed in this environment, pre-existing/expected skip).
1 parent 50d6b64 commit d7abd9a

5 files changed

Lines changed: 456 additions & 0 deletions

File tree

code/Automation_code/provenance.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
"""Per-run provenance manifest generation.
2+
3+
Makes "reproducible" a demonstrated, per-run, machine-readable fact rather
4+
than only an assertion in the manuscript: for a given docking run, this
5+
records exactly what produced it -- tool versions actually present on the
6+
machine that ran it (not just the static, project-level requirements.txt /
7+
environment_lock.txt), the seeds and parameters used, timestamps, and file
8+
paths/hashes of the inputs and outputs involved.
9+
10+
Every field here is either already known to the caller (receptor/ligand
11+
names, seeds, docking parameters, file paths) or trivially queryable from the
12+
running environment (tool --version output, the Python interpreter version,
13+
a file's own SHA-256) -- nothing here is invented or inferred.
14+
"""
15+
import hashlib
16+
import json
17+
import os
18+
import subprocess
19+
import sys
20+
from datetime import datetime, timezone
21+
22+
23+
def _tool_version(cmd_args, timeout=10):
24+
"""Run e.g. ['vina', '--version'] and return the first non-empty line of
25+
its combined stdout+stderr, or None if the executable isn't found or
26+
doesn't respond in time. Never raises."""
27+
try:
28+
result = subprocess.run(
29+
cmd_args, capture_output=True, text=True, timeout=timeout
30+
)
31+
except (OSError, subprocess.TimeoutExpired):
32+
return None
33+
combined = (result.stdout or "") + (result.stderr or "")
34+
for line in combined.splitlines():
35+
if line.strip():
36+
return line.strip()
37+
return None
38+
39+
40+
def _sha256_of_file(path, chunk_size=1 << 20):
41+
"""SHA-256 of a file's contents, or None if the file doesn't exist or
42+
can't be read (e.g. a run that failed before writing that output)."""
43+
if not path or not os.path.isfile(path):
44+
return None
45+
digest = hashlib.sha256()
46+
try:
47+
with open(path, "rb") as f:
48+
for chunk in iter(lambda: f.read(chunk_size), b""):
49+
digest.update(chunk)
50+
except OSError:
51+
return None
52+
return digest.hexdigest()
53+
54+
55+
def build_manifest(
56+
receptor,
57+
ligand,
58+
seeds,
59+
input_files=None,
60+
output_files=None,
61+
docking_params=None,
62+
vina_exe="vina",
63+
obabel_exe="obabel",
64+
extra=None,
65+
):
66+
"""Build a provenance manifest dict for one receptor-ligand docking run.
67+
68+
Args:
69+
receptor: receptor identifier (e.g. "OR7D4").
70+
ligand: ligand identifier (e.g. "Androstenone").
71+
seeds: the seed(s) actually used for this run (list of ints, or a
72+
single int for a one-seed run).
73+
input_files: optional {label: path} of input files to hash
74+
(e.g. {"receptor_pdbqt": ..., "ligand_pdbqt": ..., "config": ...}).
75+
output_files: optional {label: path} of output files to hash
76+
(e.g. {"docked_pdbqt": ..., "log": ...}).
77+
docking_params: optional dict of docking parameters actually used
78+
(exhaustiveness, num_modes, energy_range, etc.) -- recorded
79+
verbatim, not recomputed or validated here.
80+
vina_exe: the Vina executable/path actually configured for this run
81+
(so the manifest reflects what would really execute, matching
82+
config.py's ODORSIG_VINA_EXE convention).
83+
obabel_exe: same, for Open Babel.
84+
extra: optional dict of any additional caller-supplied fields.
85+
86+
Returns:
87+
A JSON-serialisable dict. Does not write anything to disk --
88+
see write_run_manifest() for that.
89+
"""
90+
manifest = {
91+
"receptor": receptor,
92+
"ligand": ligand,
93+
"seeds": seeds,
94+
"timestamp_utc": datetime.now(timezone.utc).isoformat(timespec="seconds"),
95+
"python_version": sys.version.split()[0],
96+
"vina_version": _tool_version([vina_exe, "--version"]),
97+
"obabel_version": _tool_version([obabel_exe, "-V"]),
98+
"docking_params": docking_params or {},
99+
"input_files": {
100+
label: {"path": path, "sha256": _sha256_of_file(path)}
101+
for label, path in (input_files or {}).items()
102+
},
103+
"output_files": {
104+
label: {"path": path, "sha256": _sha256_of_file(path)}
105+
for label, path in (output_files or {}).items()
106+
},
107+
}
108+
if extra:
109+
manifest["extra"] = extra
110+
return manifest
111+
112+
113+
def write_run_manifest(manifest_path, **kwargs):
114+
"""Build a manifest (see build_manifest() for kwargs) and write it as
115+
indented JSON to manifest_path. Returns the manifest dict."""
116+
manifest = build_manifest(**kwargs)
117+
with open(manifest_path, "w") as f:
118+
json.dump(manifest, f, indent=2)
119+
f.write("\n")
120+
return manifest

code/cli.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
This CLI does not introduce a second configuration mechanism.
2323
"""
2424
import argparse
25+
import os
2526
import subprocess
2627
import sys
2728
from pathlib import Path
@@ -44,6 +45,7 @@ def _cmd_run(args):
4445
from Automation_code.Step_03_prepare_receptor_ligand import prepare_receptor_and_ligands
4546
from Automation_code.Step_04_config_file import create_all_configs_for_receptor
4647
from Automation_code.Step_05_docking import run_all_dockings_for_receptor
48+
from Automation_code.provenance import write_run_manifest
4749

4850
ligands = [l.strip() for l in args.ligands.split(",") if l.strip()]
4951
seed_list = args.seed if args.seed else None # None -> random-seed mode, matching app.py
@@ -79,6 +81,29 @@ def _cmd_run(args):
7981
args.receptor, config.PREPARED_MODELS_DIR, seed_list
8082
)
8183
print(result)
84+
85+
# Run-level provenance manifest. run_all_dockings_for_receptor() writes
86+
# its own per-seed log/output files directly (not returned as a
87+
# structured list), so this records the run as a whole -- receptor,
88+
# ligands, seeds/params actually used, and tool versions on this
89+
# machine -- rather than per-individual-file hashes as dataset/code.py's
90+
# batch path does; see Automation_code/provenance.py.
91+
manifest_dir = os.path.join(config.DOCKING_OUTPUT_DIR, f"{args.receptor}_folder")
92+
os.makedirs(manifest_dir, exist_ok=True)
93+
write_run_manifest(
94+
os.path.join(manifest_dir, "run_manifest.json"),
95+
receptor=args.receptor,
96+
ligand=",".join(ligands),
97+
seeds=seed_list if seed_list is not None else "random",
98+
docking_params={
99+
"exhaustiveness": args.exhaustiveness,
100+
"num_modes": args.num_modes,
101+
"energy_range": args.energy_range,
102+
},
103+
vina_exe=os.environ.get("ODORSIG_VINA_EXE", "vina"),
104+
obabel_exe=os.environ.get("ODORSIG_OBABEL_PATH", "obabel"),
105+
extra={"result_summary": result},
106+
)
82107
return 0
83108

84109

@@ -108,6 +133,7 @@ def _cmd_batch(args):
108133

109134
dataset_module.main()
110135
dataset_module.master_organizer()
136+
dataset_module.write_qc_report()
111137
return 0
112138

113139

dataset/code.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import os
22
import time
3+
import json
34
import requests
45
import subprocess
56
import glob
@@ -15,6 +16,7 @@
1516
from Automation_code.reproducibility import reproducibility_stats
1617
from Automation_code.receptor_ligand_prep import extract_chain_a, convert_to_pdbqt
1718
from Automation_code.Step_04_config_file import generate_blind_config
19+
from Automation_code.provenance import write_run_manifest
1820
from Bio import Entrez
1921
from bs4 import BeautifulSoup
2022
from selenium import webdriver
@@ -439,6 +441,36 @@ def main():
439441
aff = affinities[i] if i < len(affinities) else "N/A"
440442
f.write(f"{i+1},{aff},{res['h_bonds']},{res['hydrophobic']}\n")
441443

444+
# Per-run provenance manifest -- what actually produced this
445+
# run's output, machine-readable and checkable, not just
446+
# asserted in the manuscript. Written whenever a run
447+
# attempted docking (out_pdbqt path is known either way);
448+
# file hashes come back None for anything that didn't get
449+
# produced, which is itself informative rather than an error.
450+
write_run_manifest(
451+
os.path.join(run_dir, "run_manifest.json"),
452+
receptor=rec_name,
453+
ligand=lig_name,
454+
seeds=seed,
455+
docking_params={
456+
"exhaustiveness": EXHAUSTIVENESS,
457+
"num_modes": NUM_POSES,
458+
"energy_range": ENERGY_RANGE,
459+
},
460+
input_files={
461+
"receptor_pdbqt": rec_pdbqt,
462+
"ligand_pdbqt": lig_pdbqt,
463+
"config": config_file,
464+
},
465+
output_files={
466+
"docked_pdbqt": out_pdbqt,
467+
"log": log_file,
468+
"analysis_csv": analysis_csv,
469+
},
470+
vina_exe=VINA_PATH,
471+
obabel_exe=OBABEL_PATH,
472+
)
473+
442474
print(f"✅ Row {index+1} Finished.")
443475
else:
444476
print(f"🛑 Error preparing inputs for Row {index+1}.")
@@ -585,6 +617,77 @@ def master_organizer():
585617
print(f"✅ Master Excel created: {os.path.abspath(final_excel_name)}")
586618

587619

620+
def collect_qc_data():
621+
"""Aggregate per-pair, per-run completion status from OUTPUT_ROOT into a
622+
single, checkable record -- e.g. "480/480 pairs completed (1440/1440
623+
individual docking runs)" -- rather than requiring a reviewer (or the
624+
authors) to manually count output folders. Reads the same on-disk layout
625+
main() already writes (pair_folder/docking_N/pose_analysis.csv); does not
626+
change what main() writes or how it decides to skip already-completed
627+
runs.
628+
629+
Returns a dict; use write_qc_report() to also persist it to disk.
630+
"""
631+
qc = {
632+
"requested_pairs": 0,
633+
"expected_runs_per_pair": len(DOCKING_SEEDS),
634+
"completed_pairs": 0,
635+
"completed_runs": 0,
636+
"requested_runs": 0,
637+
"incomplete_pairs": [],
638+
}
639+
if not os.path.exists(EXCEL_FILE):
640+
qc["completion_summary"] = f"Pair list not found at {EXCEL_FILE}; nothing to report."
641+
return qc
642+
643+
df = pd.read_excel(EXCEL_FILE)
644+
qc["requested_pairs"] = len(df)
645+
646+
for _, row in df.iterrows():
647+
rec_name = str(row["Receptor"]).strip()
648+
lig_name = str(row["Ligand"]).strip()
649+
pair_folder = os.path.join(OUTPUT_ROOT, f"{rec_name}_{lig_name}")
650+
651+
completed_runs_this_pair = 0
652+
for run_num in range(1, len(DOCKING_SEEDS) + 1):
653+
qc["requested_runs"] += 1
654+
analysis_csv = os.path.join(pair_folder, f"docking_{run_num}", "pose_analysis.csv")
655+
if os.path.exists(analysis_csv):
656+
completed_runs_this_pair += 1
657+
qc["completed_runs"] += 1
658+
659+
if completed_runs_this_pair == len(DOCKING_SEEDS):
660+
qc["completed_pairs"] += 1
661+
else:
662+
qc["incomplete_pairs"].append({
663+
"receptor": rec_name,
664+
"ligand": lig_name,
665+
"completed_runs": completed_runs_this_pair,
666+
"expected_runs": len(DOCKING_SEEDS),
667+
})
668+
669+
qc["completion_summary"] = (
670+
f"{qc['completed_pairs']}/{qc['requested_pairs']} pairs completed "
671+
f"({qc['completed_runs']}/{qc['requested_runs']} individual docking runs)"
672+
)
673+
return qc
674+
675+
676+
def write_qc_report(path=None):
677+
"""Write collect_qc_data()'s result to OUTPUT_ROOT/qc_report.json (or a
678+
caller-supplied path) as indented JSON, and print the one-line summary."""
679+
qc = collect_qc_data()
680+
report_path = path or os.path.join(OUTPUT_ROOT, "qc_report.json")
681+
os.makedirs(os.path.dirname(report_path) or ".", exist_ok=True)
682+
with open(report_path, "w") as f:
683+
json.dump(qc, f, indent=2)
684+
f.write("\n")
685+
print(f"📋 {qc['completion_summary']}")
686+
print(f" QC report saved to: {report_path}")
687+
return qc
688+
689+
588690
if __name__ == "__main__":
589691
main()
590692
master_organizer()
693+
write_qc_report()

tests/test_provenance.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"""Unit tests for Automation_code.provenance -- per-run manifest generation.
2+
3+
Pure Python, no network. Tool-version lookups use the real `python`/a
4+
guaranteed-missing executable rather than mocking subprocess, so the
5+
"tool not found" path is exercised for real, not just asserted.
6+
"""
7+
import hashlib
8+
import json
9+
import os
10+
import sys
11+
12+
from Automation_code.provenance import build_manifest, write_run_manifest, _sha256_of_file, _tool_version
13+
14+
15+
def test_tool_version_returns_first_nonempty_line_for_a_real_executable():
16+
version = _tool_version([sys.executable, "--version"])
17+
assert version is not None
18+
assert "Python" in version
19+
20+
21+
def test_tool_version_returns_none_for_missing_executable():
22+
assert _tool_version(["definitely-not-a-real-executable-xyz"]) is None
23+
24+
25+
def test_sha256_of_file_matches_hashlib_reference(tmp_path):
26+
f = tmp_path / "sample.txt"
27+
f.write_bytes(b"OdorSig provenance test content")
28+
expected = hashlib.sha256(b"OdorSig provenance test content").hexdigest()
29+
assert _sha256_of_file(str(f)) == expected
30+
31+
32+
def test_sha256_of_file_returns_none_for_missing_file():
33+
assert _sha256_of_file("/nonexistent/path/does-not-exist.pdbqt") is None
34+
assert _sha256_of_file(None) is None
35+
36+
37+
def test_build_manifest_records_receptor_ligand_seeds_and_params():
38+
manifest = build_manifest(
39+
receptor="OR7D4",
40+
ligand="Androstenone",
41+
seeds=[101, 102, 103],
42+
docking_params={"exhaustiveness": 8, "num_modes": 10, "energy_range": 3},
43+
vina_exe="definitely-not-installed",
44+
obabel_exe="definitely-not-installed",
45+
)
46+
assert manifest["receptor"] == "OR7D4"
47+
assert manifest["ligand"] == "Androstenone"
48+
assert manifest["seeds"] == [101, 102, 103]
49+
assert manifest["docking_params"] == {"exhaustiveness": 8, "num_modes": 10, "energy_range": 3}
50+
assert manifest["vina_version"] is None # executable genuinely not found
51+
assert "timestamp_utc" in manifest
52+
assert manifest["python_version"] == sys.version.split()[0]
53+
54+
55+
def test_build_manifest_hashes_input_and_output_files(tmp_path):
56+
receptor_file = tmp_path / "receptor.pdbqt"
57+
receptor_file.write_text("dummy receptor content")
58+
59+
manifest = build_manifest(
60+
receptor="OR7D4", ligand="Androstenone", seeds=[101],
61+
input_files={"receptor_pdbqt": str(receptor_file)},
62+
output_files={"docked_pdbqt": str(tmp_path / "does_not_exist.pdbqt")},
63+
)
64+
assert manifest["input_files"]["receptor_pdbqt"]["path"] == str(receptor_file)
65+
assert manifest["input_files"]["receptor_pdbqt"]["sha256"] == hashlib.sha256(
66+
b"dummy receptor content"
67+
).hexdigest()
68+
# Output file doesn't exist yet (e.g. a run that hasn't completed) -- no hash, no crash.
69+
assert manifest["output_files"]["docked_pdbqt"]["sha256"] is None
70+
71+
72+
def test_write_run_manifest_writes_valid_json(tmp_path):
73+
manifest_path = tmp_path / "run_manifest.json"
74+
returned = write_run_manifest(
75+
str(manifest_path), receptor="OR1A1", ligand="Citral", seeds=[100, 200, 300],
76+
)
77+
assert manifest_path.is_file()
78+
on_disk = json.loads(manifest_path.read_text())
79+
assert on_disk == returned
80+
assert on_disk["receptor"] == "OR1A1"
81+
82+
83+
def test_manifest_is_json_serialisable_end_to_end(tmp_path):
84+
"""Guards against accidentally putting a non-JSON-serialisable value
85+
(e.g. a raw datetime object) into the manifest."""
86+
manifest_path = tmp_path / "run_manifest.json"
87+
write_run_manifest(
88+
str(manifest_path), receptor="OR3A3", ligand="Vanillin", seeds=100,
89+
extra={"note": "single random-seed run"},
90+
)
91+
with open(manifest_path) as f:
92+
json.load(f) # raises if the file isn't valid JSON

0 commit comments

Comments
 (0)