core-score 0.4.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.

Potentially problematic release.


This version of core-score might be problematic. Click here for more details.

core_score/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """CORE Score: agreement within an ensemble of predicted protein-ligand poses."""
2
+
3
+ from core_score.pipeline import run
4
+ from core_score.analysis.core import core_score as score
5
+
6
+ __version__ = "0.4.0"
7
+
8
+ __all__ = ["run", "score"]
core_score/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from core_score.cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """Check, compare and score the prepared poses."""
@@ -0,0 +1,61 @@
1
+ """Build each site ensemble's RMSD matrix and calculate CORE at each threshold."""
2
+
3
+ import itertools
4
+ import numpy as np
5
+ from core_score.outputs.files import diagnostic
6
+
7
+ NEIGHBOURS = 3 # each pose is scored against its three closest other poses
8
+
9
+
10
+ def score_groups(groups, rows, thresholds=(2.0,)):
11
+ """One score per site ensemble and threshold T. An ensemble with a missing or failed pair is not scored."""
12
+ scores = []
13
+ for group in groups:
14
+ ids = [pose["pose_id"] for pose in group["poses"]]
15
+ values, error = dict.fromkeys(thresholds), group.get("error")
16
+ if not error:
17
+ try:
18
+ key = (group["ensemble"], group["site"])
19
+ matrix = build_matrix(ids, [r for r in rows if (r["ensemble"], r["site"]) == key])
20
+ values = {threshold: core_score(matrix, threshold) for threshold in thresholds}
21
+ except ValueError as exc:
22
+ error = diagnostic("scoring", exc)
23
+ scores += [
24
+ dict(ensemble=group["ensemble"], site=group["site"], name=group["name"], pose_count=len(ids), threshold_angstrom=threshold,
25
+ core_score=value, error=error["error"] if error else None)
26
+ for threshold, value in values.items()
27
+ ]
28
+ return scores
29
+
30
+
31
+ def build_matrix(pose_ids, rows):
32
+ """Pairwise ligand-RMSD matrix. Raises unless every pair has exactly one usable RMSD;
33
+ a missing or failed comparison is never filled in."""
34
+ index = {pose_id: i for i, pose_id in enumerate(pose_ids)}
35
+ matrix = np.full((len(pose_ids), len(pose_ids)), np.nan)
36
+ np.fill_diagonal(matrix, 0.0)
37
+ for row in rows:
38
+ if row["n_assigned"] == 1 and not row["error"]:
39
+ i, j = index[row["pose_i"]], index[row["pose_j"]]
40
+ matrix[i, j] = matrix[j, i] = row["rmsd"]
41
+ missing = [(a, b) for (i, a), (j, b) in itertools.combinations(enumerate(pose_ids), 2)
42
+ if not np.isfinite(matrix[i, j])]
43
+ if missing:
44
+ raise ValueError(f"No usable ligand RMSD for pairs {missing}; CORE needs every pair")
45
+ return matrix
46
+
47
+
48
+ def core_score(matrix, threshold=2.0, neighbours=NEIGHBOURS):
49
+ """CORE Score of an ensemble from its complete pairwise ligand-RMSD matrix.
50
+
51
+ Each pose's RMSDs to its `neighbours` closest other poses are capped at `threshold`
52
+ and divided by it, and their mean is that pose's penalty: zero when those poses
53
+ reproduce it exactly, one when none of them lies within the threshold. CORE is one
54
+ minus the mean penalty over all poses, so it needs at least `neighbours` + 1 poses.
55
+ """
56
+ distances = np.array(matrix, dtype=float)
57
+ if len(distances) <= neighbours:
58
+ raise ValueError(f"CORE needs at least {neighbours + 1} poses; this ensemble has {len(distances)}")
59
+ np.fill_diagonal(distances, np.inf)
60
+ closest = np.sort(distances, axis=1)[:, :neighbours]
61
+ return float(1.0 - np.mean(np.minimum(closest, threshold) / threshold))
@@ -0,0 +1,79 @@
1
+ """Run OpenStructure's command-line actions. `ost` is found on PATH, and its chemical
2
+ library through OST's own discovery or OST_COMPOUNDS_CHEMLIB."""
3
+
4
+ import json
5
+ import os
6
+ import subprocess
7
+ import tempfile
8
+ from pathlib import Path
9
+ import numpy as np
10
+
11
+
12
+ class OSTError(RuntimeError):
13
+ def __init__(self, message, evidence):
14
+ super().__init__(message)
15
+ self.evidence = evidence
16
+
17
+
18
+ def execute(arguments, timeout=None):
19
+ """Run `ost <arguments> -o <file>` and return its JSON output with the execution record."""
20
+ # One numerical-library thread per process, so --workers alone sets the parallelism.
21
+ env = dict(os.environ, OMP_NUM_THREADS="1", OPENBLAS_NUM_THREADS="1", MKL_NUM_THREADS="1")
22
+ with tempfile.TemporaryDirectory() as directory:
23
+ output = Path(directory) / "ost.json"
24
+ command = ["ost", *map(str, arguments), "-o", str(output)]
25
+ process = subprocess.run(command, env=env, capture_output=True, text=True, timeout=timeout)
26
+ execution = dict(command=command, returncode=process.returncode,
27
+ stdout=process.stdout, stderr=process.stderr)
28
+ if not output.exists():
29
+ raise OSTError(process.stderr.strip() or f"OST exited with code {process.returncode}", execution)
30
+ return json.loads(output.read_text()), execution
31
+
32
+
33
+ def compare_ligands(model, reference, timeout=None, radius=4.0, lddt_pli=False):
34
+ """`ost compare-ligand-structures` result for one model/reference pair of prepared structures.
35
+ lDDT-PLI is only computed when asked for, since CORE uses RMSD alone."""
36
+ arguments = ["compare-ligand-structures", "-m", model["path"]]
37
+ if model["ligand_sdfs"]:
38
+ arguments += ["-ml", *model["ligand_sdfs"]]
39
+ arguments += ["-r", reference["path"]]
40
+ if reference["ligand_sdfs"]:
41
+ arguments += ["-rl", *reference["ligand_sdfs"]]
42
+ arguments += ["--fault-tolerant", "--rmsd", "--full-bs-search", "--full-results"]
43
+ if lddt_pli:
44
+ arguments += ["--lddt-pli", "--lddt-pli-add-mdl-contacts"]
45
+ if radius != 4.0:
46
+ arguments += ["--radius", radius]
47
+ result, execution = execute(arguments, timeout)
48
+ if execution["returncode"] or result.get("status") != "SUCCESS":
49
+ result["status"] = "FAILURE"
50
+ result.setdefault("exception", execution["stderr"][-2000:] or "OST did not report success")
51
+ result["_execution"] = execution
52
+ return result
53
+
54
+
55
+ def reads_ligand(prepared, timeout=None):
56
+ """Whether OST reads each ligand copy from the structure itself, connected the same way
57
+ as in the package's own SDFs (bonds from the coordinates). A ligand OST cannot find,
58
+ cannot read, or connects differently returns False."""
59
+ sdfs = [copy["ost_sdf"] for copy in prepared["copy_records"]]
60
+ try:
61
+ result = compare_ligands(prepared, dict(prepared, ligand_sdfs=sdfs), timeout)
62
+ except OSTError:
63
+ return False
64
+ assigned = [] if result["status"] == "FAILURE" else result.get("rmsd", {}).get("assigned_scores", [])
65
+ return len(assigned) == len(sdfs)
66
+
67
+
68
+ def superpose(model_path, reference_path, timeout=None):
69
+ """OST rigid superposition of matched protein C-alpha atoms, model onto reference."""
70
+ result, execution = execute(
71
+ ["compare-structures", "-m", model_path, "-r", reference_path, "--rigid-scores"], timeout
72
+ )
73
+ if execution["returncode"] or result.get("status") != "SUCCESS" or result.get("transform") is None:
74
+ raise OSTError(
75
+ f"OST rigid superposition failed (status {result.get('status')}); chains may not map",
76
+ dict(execution, ost_results=result),
77
+ )
78
+ return dict(transform=np.asarray(result["transform"], dtype=float),
79
+ rmsd=result.get("rmsd"), chain_mapping=result.get("chain_mapping") or {})
@@ -0,0 +1,119 @@
1
+ """Compare every pair of poses within each site ensemble with OST and save the measurements."""
2
+
3
+ import itertools
4
+ import math
5
+ from concurrent.futures import ProcessPoolExecutor
6
+ from functools import partial
7
+ from core_score.analysis import ost
8
+ from core_score.outputs.files import diagnostic, write_csv, write_jsonl
9
+
10
+
11
+ def compare_groups(groups, output, *, workers=1, timeout=None, radius_fallback=True):
12
+ results = run_pair_tasks(pair_tasks(groups), workers, timeout=timeout, radius_fallback=radius_fallback)
13
+ results.sort(key=lambda result: tuple(result[0][k] for k in ("ensemble", "site", "pose_i", "pose_j")))
14
+ rows = [row for row, _ in results]
15
+ write_csv(output / "pairs.csv", rows)
16
+ write_jsonl(output / "ligand_details.jsonl", [detail for _, detail in results])
17
+ return rows
18
+
19
+
20
+ def pair_tasks(groups):
21
+ """Every pair of poses within each site ensemble; earlier poses are the OST model."""
22
+ return [
23
+ (group, pose_i, pose_j)
24
+ for group in groups
25
+ if not group.get("error")
26
+ for pose_i, pose_j in itertools.combinations(group["poses"], 2)
27
+ ]
28
+
29
+
30
+ def run_pair_tasks(tasks, workers=1, **options):
31
+ """(row, detail) for each task, in task order, using up to `workers` processes."""
32
+ job = partial(pair_job, **options)
33
+ if workers == 1:
34
+ return [job(task) for task in tasks]
35
+ with ProcessPoolExecutor(max_workers=workers) as pool:
36
+ return list(pool.map(job, tasks))
37
+
38
+
39
+ def pair_job(task, *, timeout=None, radius_fallback=True, lddt_pli=False):
40
+ group, pose_i, pose_j = task
41
+ ids = dict(ensemble=group["ensemble"], site=group["site"],
42
+ pose_i=pose_i["pose_id"], pose_j=pose_j["pose_id"])
43
+ try:
44
+ row, detail = compare_pair(pose_i, pose_j, timeout=timeout, radius_fallback=radius_fallback, lddt_pli=lddt_pli)
45
+ except Exception as exc:
46
+ detail = diagnostic("comparison", exc, inputs=[pose_i["path"], pose_j["path"]])
47
+ row = dict(n_assigned=0, error=detail["error"])
48
+ return dict(ids, **row), dict(ids, **detail)
49
+
50
+
51
+ def compare_pair(pose_i, pose_j, *, timeout=None, radius_fallback=True, lddt_pli=False):
52
+ """OST scores for one pair, retried at 6 and then 8 A after a binding-site failure at 4 A.
53
+
54
+ The RMSD metrics come from the radius that scored. lDDT-PLI always stays from 4 A.
55
+ """
56
+ model, reference = pose_i["prepared"], pose_j["prepared"]
57
+ # Recovery needs one ligand copy per side. Ensembles are split by site before comparison.
58
+ retry = radius_fallback and model["copies"] == reference["copies"] == 1
59
+ results, attempts = [], []
60
+ for radius in (4.0, 6.0, 8.0) if retry else (4.0,):
61
+ result = ost.compare_ligands(model, reference, timeout, radius, lddt_pli)
62
+ failed = result["status"] == "FAILURE"
63
+ assigned = [] if failed else result.get("rmsd", {}).get("assigned_scores", [])
64
+ score = assigned[0].get("score") if len(assigned) == 1 else None
65
+ reason = result["exception"] if failed else result.get("rmsd", {})
66
+ results.append(result)
67
+ attempts.append(dict(radius=radius, score=score, reason=reason))
68
+ if usable(score) or not binding_site_failure(reason):
69
+ break
70
+ recovered = len(attempts) > 1 and usable(attempts[-1]["score"])
71
+ result = dict(results[0])
72
+ if recovered:
73
+ result["rmsd"] = results[-1]["rmsd"]
74
+ row = summarize(result)
75
+ if row["n_assigned"] == 0 and not row["error"]:
76
+ row["error"] = "OST assigned no ligand pair; see the saved OST results"
77
+ scored = attempts[-1] if recovered else attempts[0]
78
+ row.update(
79
+ standard_rmsd=attempts[0]["score"] if usable(attempts[0]["score"]) else None,
80
+ binding_site_radius_angstrom=scored["radius"] if usable(scored["score"]) else None,
81
+ radius_fallback_used=recovered,
82
+ )
83
+ detail = dict(error=row["error"], source_pose_i=pose_i["path"], source_pose_j=pose_j["path"],
84
+ radius_attempts=attempts, ost_results=results)
85
+ return row, detail
86
+
87
+
88
+ def summarize(result):
89
+ """OST's scores for the pair, blank unless exactly one ligand pair was assigned."""
90
+ failed = result["status"] == "FAILURE"
91
+ assigned = [] if failed else [
92
+ s for s in result.get("rmsd", {}).get("assigned_scores", []) if s.get("score") is not None
93
+ ]
94
+ contacts = [] if failed else [
95
+ s["score"] for s in result.get("lddt_pli", {}).get("assigned_scores", []) if s.get("score") is not None
96
+ ]
97
+ single = assigned[0] if len(assigned) == 1 else {}
98
+ return dict(
99
+ n_assigned=len(assigned),
100
+ rmsd=single.get("score"),
101
+ bb_rmsd=single.get("bb_rmsd"),
102
+ lddt_lp=single.get("lddt_lp"),
103
+ lddt_pli=contacts[0] if len(contacts) == 1 else None,
104
+ error=str(result.get("exception") or result.get("traceback") or "OST failed") if failed else "",
105
+ )
106
+
107
+
108
+ def usable(score):
109
+ """A finite, non-negative RMSD."""
110
+ return score is not None and math.isfinite(float(score)) and float(score) >= 0
111
+
112
+
113
+ def binding_site_failure(result):
114
+ """Whether OST's reasons name a binding-site failure (not a graph or general failure)."""
115
+ if isinstance(result, dict):
116
+ return any(binding_site_failure(v) for v in result.values())
117
+ if isinstance(result, (list, tuple)):
118
+ return any(binding_site_failure(v) for v in result)
119
+ return result in ("target_binding_site", "model_binding_site", "binding_site")
@@ -0,0 +1,65 @@
1
+ """PoseBusters dock checks for every pose. The results are saved for use downstream, such as
2
+ choosing the top-ranked valid pose. CORE uses every pose whatever its result. Unknown never
3
+ counts as a pass."""
4
+
5
+ from core_score.outputs.files import clean, diagnostic, write_json
6
+
7
+
8
+ def run_posebusters(groups, output):
9
+ from posebusters import PoseBusters
10
+
11
+ buster = PoseBusters(config="dock", max_workers=0)
12
+ checks = dock_checks(buster)
13
+ records = [check_pose(buster, checks, pose) for group in groups for pose in group["poses"]]
14
+ write_json(output / "posebusters.json", records)
15
+ return records
16
+
17
+
18
+ def dock_checks(buster):
19
+ """Names of the binary checks in PoseBusters' dock configuration."""
20
+ return [
21
+ normalized(module.get("rename_outputs", {}).get(name, name))
22
+ for module in buster.config["modules"]
23
+ for name in module.get("chosen_binary_test_output", [])
24
+ ]
25
+
26
+
27
+ def normalized(name):
28
+ return name.lower().replace(" ", "_")
29
+
30
+
31
+ def aggregate(values):
32
+ """False if any check failed, None if any is unknown (or there are none), otherwise True."""
33
+ values = list(values)
34
+ if any(v is False for v in values):
35
+ return False
36
+ if not values or any(v is None for v in values):
37
+ return None
38
+ return True
39
+
40
+
41
+ def check_pose(buster, checks, pose):
42
+ """A pose is valid only if every ligand copy passes every dock check. Each check's result
43
+ for the pose combines its copies the same way."""
44
+ record = dict(ensemble=pose["ensemble"], pose_id=pose["pose_id"], pb_valid=None,
45
+ checks=dict.fromkeys(checks))
46
+ if pose.get("error"):
47
+ return dict(record, error="Pose was not prepared; see details/prepared_manifest.json")
48
+ prepared = pose["prepared"]
49
+ copies = [check_copy(buster, checks, copy, prepared["receptor"]) for copy in prepared["copy_records"]]
50
+ combined = {name: aggregate(copy.get("checks", {}).get(name) for copy in copies) for name in checks}
51
+ return dict(record, pb_valid=aggregate(copy["pb_valid"] for copy in copies), checks=combined, copies=copies)
52
+
53
+
54
+ def check_copy(buster, checks, copy, receptor):
55
+ record = dict(copy_id=copy["copy_id"], preparation=copy["provenance"], pb_valid=None)
56
+ if copy["pb_error"]:
57
+ return dict(record, error=copy["pb_error"])
58
+ try:
59
+ report = buster.bust(copy["pb_sdf"], mol_cond=receptor, full_report=True)
60
+ except Exception as exc:
61
+ return dict(record, **diagnostic("posebusters", exc))
62
+ tests = {normalized(k): clean(v) for k, v in report.iloc[0].items()}
63
+ results = {name: tests.get(name) if isinstance(tests.get(name), bool) else None for name in checks}
64
+ return dict(record, pb_valid=aggregate(results.values()), checks=results, tests=tests)
65
+
@@ -0,0 +1,59 @@
1
+ """Optional comparison of every prediction against its ensemble's ground-truth structure.
2
+
3
+ A pose is correct at threshold T when its ligand RMSD to the reference is below T and its
4
+ lDDT-PLI is above 0.8, the definition used in the paper."""
5
+
6
+ import math
7
+ from core_score.outputs.files import fingerprint, write_json
8
+ from core_score.analysis.pairs import run_pair_tasks
9
+ from core_score.inputs.prepare import prepare
10
+
11
+ LDDT_PLI_CUTOFF = 0.8
12
+
13
+
14
+ def evaluate_groups(groups, output, thresholds=(2.0,), *, workers=1, timeout=None, radius_fallback=True):
15
+ """Ligand RMSD and lDDT-PLI of each prepared prediction to its reference, labelled correct
16
+ or not at each threshold. Ensembles without a reference are skipped. Reference results
17
+ never enter CORE."""
18
+ groups = [group for group in groups if "reference" in group]
19
+ tasks, records = [], []
20
+ for group in groups:
21
+ destination = output / "references" / fingerprint(group["ensemble"])[:20] / "reference.cif"
22
+ reference = prepare(group["reference"], destination, timeout=timeout)
23
+ for pose in group["poses"]:
24
+ error = reference.get("error") or pose.get("error")
25
+ if error:
26
+ records.append(dict(ensemble=group["ensemble"], pose_id=pose["pose_id"], rmsd=None, error=error))
27
+ else:
28
+ tasks.append((dict(group, site="reference"), pose, reference))
29
+ for row, detail in run_pair_tasks(tasks, workers, timeout=timeout, radius_fallback=radius_fallback, lddt_pli=True):
30
+ records.append(dict(row, pose_id=row["pose_i"], detail=detail))
31
+ write_json(output / "reference_details.json", records)
32
+ keys = ((group["ensemble"], pose["pose_id"]) for group in groups for pose in group["poses"])
33
+ order = {key: i for i, key in enumerate(keys)}
34
+ labels = label_reference_results(records, thresholds)
35
+ return sorted(labels, key=lambda label: order[label["ensemble"], label["pose_id"]])
36
+
37
+
38
+ def correct_column(threshold):
39
+ """The column saying whether a pose is correct at `threshold`."""
40
+ return f"correct_{threshold:g}A"
41
+
42
+
43
+ def label_reference_results(records, thresholds):
44
+ """One row per pose. A pose is correct at T when RMSD < T and lDDT-PLI > 0.8. A failed or
45
+ ambiguous comparison, or one missing either score, is unknown (blank scores and labels),
46
+ not incorrect."""
47
+ labels = []
48
+ for record in records:
49
+ rmsd, lddt_pli = record.get("rmsd"), record.get("lddt_pli")
50
+ known = not record.get("error") and all(v is not None and math.isfinite(v) for v in (rmsd, lddt_pli))
51
+ label = dict(ensemble=record["ensemble"], pose_id=record["pose_id"],
52
+ rmsd=rmsd if known else None, lddt_pli=lddt_pli if known else None)
53
+ for threshold in thresholds:
54
+ label[correct_column(threshold)] = bool(rmsd < threshold and lddt_pli > LDDT_PLI_CUTOFF) if known else None
55
+ label["radius_fallback_used"] = record.get("radius_fallback_used", False)
56
+ error = record.get("error")
57
+ label["error"] = (error["error"] if isinstance(error, dict) else error) or None # message only; details in JSON
58
+ labels.append(label)
59
+ return labels
core_score/cli.py ADDED
@@ -0,0 +1,90 @@
1
+ """One command: load inputs and run the analysis."""
2
+
3
+ import argparse
4
+ import math
5
+ import sys
6
+ from pathlib import Path
7
+ from core_score.pipeline import run
8
+
9
+
10
+ def positive(value):
11
+ number = float(value)
12
+ if not math.isfinite(number) or number <= 0:
13
+ raise argparse.ArgumentTypeError("must be finite and positive")
14
+ return number
15
+
16
+
17
+ def fraction(value):
18
+ number = float(value)
19
+ if not 0 <= number <= 1:
20
+ raise argparse.ArgumentTypeError("must be between 0 and 1")
21
+ return number
22
+
23
+
24
+ def worker_count(value):
25
+ number = int(value)
26
+ if number < 1:
27
+ raise argparse.ArgumentTypeError("must be at least 1")
28
+ return number
29
+
30
+
31
+ def parser():
32
+ p = argparse.ArgumentParser(
33
+ prog="core-score",
34
+ description="Prepare structures, run PoseBusters, compare poses and calculate CORE.",
35
+ epilog="Example: core-score inputs.csv --out results/ --workers 4. Make inputs.csv with core-score-csv.",
36
+ )
37
+ p.add_argument("inputs", metavar="INPUT", nargs="+", type=Path,
38
+ help="CSV of ensembles, or structure files forming one ensemble")
39
+ p.add_argument("--out", type=Path, required=True, help="Results directory")
40
+ p.add_argument("--smiles", help="Ligand SMILES for structure files; a CSV sets its own")
41
+ p.add_argument("--ligand-chain", dest="ligand_chains", action="append",
42
+ help="Ligand chain for structure files; repeat for identical copies")
43
+ p.add_argument("--workers", type=worker_count, default=1,
44
+ help="Simultaneous OST comparisons (default: 1)")
45
+ p.add_argument("--timeout", type=positive, default=None,
46
+ help="Seconds per OST call (default: no timeout)")
47
+ p.add_argument("--no-radius-fallback", action="store_true",
48
+ help="Disable eligible binding-site retries at 6 and 8 angstroms")
49
+ p.add_argument("--skip-posebusters", action="store_true", help="Do not run PoseBusters checks")
50
+ p.add_argument("--reference", type=Path,
51
+ help="Ground-truth structure for structure files; a CSV sets its own")
52
+ p.add_argument("--rmsd-thresholds", type=positive, nargs="+", default=[2.0],
53
+ help="Thresholds T in angstroms: CORE is calculated at each, and a pose is "
54
+ "correct when its RMSD to the reference is below T and its lDDT-PLI is "
55
+ "above 0.8 (default: 2)")
56
+ p.add_argument("--plot", action="store_true",
57
+ help="Also save core_scores.png: a dot per complex (needs matplotlib)")
58
+ p.add_argument("--core-cutoffs", type=fraction, nargs="+", default=[0.5, 0.6, 0.7, 0.8, 0.9],
59
+ help="CORE cutoffs: each complex is reported as above (CORE >= cutoff) or below "
60
+ "each one (default: 0.5 to 0.9)")
61
+ return p
62
+
63
+
64
+ def main(argv=None):
65
+ words = sys.argv[1:] if argv is None else argv
66
+ args = parser().parse_args(words or ["--help"])
67
+ try:
68
+ summary = run(
69
+ args.inputs,
70
+ args.out,
71
+ smiles=args.smiles,
72
+ ligand_chains=args.ligand_chains,
73
+ reference=args.reference,
74
+ workers=args.workers,
75
+ timeout=args.timeout,
76
+ radius_fallback=not args.no_radius_fallback,
77
+ skip_posebusters=args.skip_posebusters,
78
+ thresholds=args.rmsd_thresholds,
79
+ cutoffs=args.core_cutoffs,
80
+ plot=args.plot,
81
+ )
82
+ except (ValueError, OSError, RuntimeError) as exc:
83
+ print(f"core-score: {exc}", file=sys.stderr)
84
+ return 2
85
+ print(
86
+ f"Completed {summary['completed_comparisons']}/{summary['comparison_count']} "
87
+ f"pair comparisons; calculated {summary['score_count']} CORE scores."
88
+ )
89
+ print(f"Results saved to {args.out}")
90
+ return int(any(summary["status"].values()))
@@ -0,0 +1 @@
1
+ """Read the inputs and prepare every pose."""
@@ -0,0 +1,113 @@
1
+ """Load prediction ensembles from a CSV file, or from structure files given directly.
2
+
3
+ The CSV needs three columns. `poses` is one structure file, or a wildcard matching many:
4
+
5
+ ensemble,smiles,poses
6
+ 7ly6,CC(=O)Nc1ccc(O)cc1,predictions/7ly6/*.cif
7
+
8
+ Optional columns are confidence (one pose per row), ligand_chain (space separated when the
9
+ ligand has several copies), reference (the ground-truth structure) and ligand_sdf (the
10
+ ligand in the structure's frame, for docking outputs). Paths are relative to the CSV.
11
+ """
12
+
13
+ import csv
14
+ import glob
15
+ import os
16
+ from collections import defaultdict
17
+ from pathlib import Path
18
+ from core_score.analysis.core import NEIGHBOURS
19
+ from core_score.inputs.structures import template_mol
20
+
21
+ REQUIRED = ("ensemble", "smiles", "poses")
22
+ OPTIONAL = ("confidence", "ligand_chain", "reference", "ligand_sdf")
23
+ MIN_POSES = NEIGHBOURS + 1
24
+
25
+
26
+ def load_inputs(inputs, *, smiles=None, ligand_chains=None, reference=None):
27
+ """Ensemble records from one CSV, or from structure files forming one ensemble.
28
+
29
+ Poses are sorted by path, never kept in listing order. That order fixes the
30
+ site-assignment anchor and which pose of each pair OST treats as the model, so
31
+ reordering the input never changes a score. Pose IDs are paths relative to the CSV.
32
+ """
33
+ inputs = [Path(path) for path in inputs]
34
+ if len(inputs) == 1 and inputs[0].suffix.lower() == ".csv":
35
+ base, rows = inputs[0].resolve().parent, read_csv(inputs[0])
36
+ else:
37
+ base = Path.cwd()
38
+ rows = [dict(ensemble="ensemble", smiles=smiles or "", poses=str(path),
39
+ ligand_chain=" ".join(ligand_chains or []), reference=str(reference or ""))
40
+ for path in inputs]
41
+ ensembles = defaultdict(list)
42
+ for row in rows:
43
+ ensembles[row["ensemble"]].append({k: v.strip() for k, v in row.items() if v and v.strip()})
44
+ groups = [make_group(name, members, base) for name, members in ensembles.items()]
45
+ small = [f"{group['ensemble']} ({len(group['poses'])})" for group in groups if len(group["poses"]) < MIN_POSES]
46
+ if small:
47
+ raise ValueError(f"CORE needs at least {MIN_POSES} poses per ensemble, since each pose is scored "
48
+ f"against its {NEIGHBOURS} closest others. Too few: {', '.join(small)}")
49
+ return groups
50
+
51
+
52
+ def read_csv(path):
53
+ with Path(path).open(newline="") as handle:
54
+ reader = csv.DictReader(handle)
55
+ columns = reader.fieldnames or []
56
+ missing = [c for c in REQUIRED if c not in columns]
57
+ unknown = [c for c in columns if c not in REQUIRED + OPTIONAL]
58
+ if missing or unknown:
59
+ raise ValueError(f"{path}: missing columns {missing}, unknown columns {unknown}. "
60
+ f"The CSV needs {', '.join(REQUIRED)} and may add {', '.join(OPTIONAL)}.")
61
+ rows = list(reader)
62
+ blank = [i + 2 for i, row in enumerate(rows) if not all((row[c] or "").strip() for c in REQUIRED)]
63
+ if blank:
64
+ raise ValueError(f"{path}: lines {blank} leave ensemble, smiles or poses empty")
65
+ return rows
66
+
67
+
68
+ def make_group(name, rows, base):
69
+ """One ensemble from its CSV rows. Every row must give the same SMILES and reference."""
70
+ for column in ("smiles", "reference"):
71
+ if len({row.get(column) for row in rows}) > 1:
72
+ raise ValueError(f"{name}: every row of an ensemble needs the same {column}")
73
+ try:
74
+ template_mol(rows[0].get("smiles", ""))
75
+ except ValueError as exc:
76
+ raise ValueError(f"{name}: {exc}. Give the ligand SMILES in the CSV, or --smiles with structure files.") from exc
77
+ poses = [pose for row in rows for pose in make_poses(name, row, base)]
78
+ paths = [pose["path"] for pose in poses]
79
+ if len(set(paths)) != len(paths):
80
+ raise ValueError(f"{name}: a structure is listed more than once")
81
+ if len({"confidence" in pose for pose in poses}) != 1:
82
+ raise ValueError(f"{name}: give a confidence for every pose or for none")
83
+ group = dict(ensemble=name, poses=sorted(poses, key=lambda pose: pose["path"]))
84
+ if rows[0].get("reference"):
85
+ path = resolve(base, rows[0]["reference"])
86
+ group["reference"] = dict(ensemble=name, pose_id="reference", path=path, smiles=rows[0]["smiles"])
87
+ return group
88
+
89
+
90
+ def make_poses(name, row, base):
91
+ """The poses one CSV row names: one file, or every file its wildcard matches."""
92
+ paths = sorted(glob.glob(str(base / row["poses"]), recursive=True))
93
+ if not paths:
94
+ raise FileNotFoundError(f"{name}: no structure file matches {row['poses']}")
95
+ single = {k: row[k] for k in ("confidence", "ligand_sdf") if k in row}
96
+ if single and len(paths) > 1:
97
+ raise ValueError(f"{name}: {row['poses']} matches {len(paths)} files, so give "
98
+ f"{' and '.join(single)} on a row of its own for each pose")
99
+ poses = []
100
+ for path in paths:
101
+ path = str(Path(path).resolve())
102
+ pose = dict(ensemble=name, pose_id=Path(os.path.relpath(path, base)).as_posix(), path=path,
103
+ smiles=row["smiles"], ligand_chains=row.get("ligand_chain", "").split())
104
+ if "confidence" in row:
105
+ pose["confidence"] = float(row["confidence"])
106
+ if "ligand_sdf" in row:
107
+ pose["ligand_sdfs"] = [resolve(base, row["ligand_sdf"])]
108
+ poses.append(pose)
109
+ return poses
110
+
111
+
112
+ def resolve(base, path):
113
+ return str((base / path).resolve(strict=True))