File size: 13,462 Bytes
bd082fe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
#!/usr/bin/env python3
"""main_benchmark §9.1 evaluator.

For each prediction PDB under <root>, computes:
- mean_plddt_overall, mean_plddt_switch, mean_plddt_core
- For each state (a, b): Cα RMSD common_core, TM-score, hit_primary (§9.1 binding 3-condition)

Regions come from data/main_benchmark/annotations/<case_id>/state_region_FINAL.tsv
Reference PDBs come from data/main_benchmark/structures/<case_id>/{state_a,state_b}.pdb

ID-pattern cases (state_a == state_b): single hit column hit_primary_state_a.
Per IDP_EVAL_OVERRIDE: treat the whole query region as the target region;
RMSD ≤3Å AND mean_pLDDT ≥70 → hit_primary=True. switch_region pLDDT check is
skipped because every residue is marked switch_region.

Usage:
    python scripts/main_benchmark_evaluate.py --case <case_id> --root <pred_dir>
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path

import numpy as np
import yaml
from Bio.PDB import PDBParser, Superimposer

# Benchmark data root: env override, else the bundled bench/main_benchmark that
# ships alongside this script (eval/ and bench/ are siblings at the repo root).
DATA_ROOT = Path(os.environ.get(
    "SF_MAIN_BENCH_DATA", Path(__file__).resolve().parents[1] / "bench" / "main_benchmark"))
# TMalign is optional: it only fills the TM-score columns and is NOT part of the
# hit_primary criterion. Resolved from PATH; None -> those columns are NaN.
TMALIGN = shutil.which("TMalign")

PRED_NAME_RE = re.compile(
    r"^(?P<subset>.+?)_unrelaxed_rank_(?P<rank>\d+)_alphafold2_ptm_model_(?P<model>\d+)_seed_(?P<seed>\d+)\.pdb$"
)

_CASES_CACHE: dict[str, dict] | None = None


def load_case(case_id: str) -> dict:
    global _CASES_CACHE
    if _CASES_CACHE is None:
        cases = yaml.safe_load((DATA_ROOT / "cases.yaml").read_text())["cases"]
        _CASES_CACHE = {c["case_id"]: c for c in cases}
    return _CASES_CACHE[case_id]


def load_regions(case_id: str) -> dict:
    path = DATA_ROOT / "annotations" / case_id / "state_region_FINAL.tsv"
    common_core: list[int] = []
    switch_3a: list[int] = []
    state_a_resnum: dict[int, int] = {}
    with path.open() as f:
        for line in f:
            if line.startswith("#"):
                continue
            line = line.rstrip("\n")
            if not line:
                continue
            parts = line.split("\t")
            if parts[0] == "residue_index_query":
                continue
            qi = int(parts[0])
            try:
                resnum_a = int(parts[1])
            except ValueError:
                resnum_a = qi
            cc = int(parts[3])
            sr = int(parts[4])
            if cc:
                common_core.append(qi)
            if sr:
                switch_3a.append(qi)
            state_a_resnum[qi] = resnum_a
    return {"common_core": common_core, "switch_3A": switch_3a,
            "state_a_resnum": state_a_resnum}


def is_id_case(case_id: str) -> bool:
    return case_id.startswith("SFB_ID_")


def read_ca_per_resid(pdb: Path, chain: str | None = None) -> dict[int, np.ndarray]:
    s = PDBParser(QUIET=True).get_structure("x", str(pdb))
    m = next(iter(s))
    for c in m:
        if chain is not None and c.id != chain:
            continue
        out: dict[int, np.ndarray] = {}
        for r in c:
            if r.id[0] != " ":
                continue
            if "CA" not in r:
                continue
            out[r.id[1]] = r["CA"].coord
        if out:
            return out
    raise RuntimeError(f"no Cα in {pdb} chain={chain}")


def read_ca_bfactors(pdb: Path, chain: str | None = None) -> dict[int, float]:
    s = PDBParser(QUIET=True).get_structure("x", str(pdb))
    m = next(iter(s))
    for c in m:
        if chain is not None and c.id != chain:
            continue
        out: dict[int, float] = {}
        for r in c:
            if r.id[0] != " ":
                continue
            if "CA" not in r:
                continue
            out[r.id[1]] = float(r["CA"].bfactor)
        if out:
            return out
    raise RuntimeError(f"no Cα B-factors in {pdb} chain={chain}")


def rmsd_on_residues(pred_ca: dict[int, np.ndarray],
                     ref_ca: dict[int, np.ndarray],
                     pred_to_ref: dict[int, int],
                     residues: list[int]) -> tuple[float, int]:
    pairs = [(qi, pred_to_ref[qi]) for qi in residues
             if qi in pred_ca and qi in pred_to_ref and pred_to_ref[qi] in ref_ca]
    if len(pairs) < 3:
        return float("nan"), len(pairs)
    from Bio.PDB.Atom import Atom
    pred_atoms = [Atom("CA", pred_ca[qi], 1.0, 1.0, " ", "CA", 1, "C") for qi, _ in pairs]
    ref_atoms = [Atom("CA", ref_ca[ri], 1.0, 1.0, " ", "CA", 1, "C") for _, ri in pairs]
    sup = Superimposer()
    sup.set_atoms(ref_atoms, pred_atoms)
    return float(sup.rms), len(pairs)


def tmalign(pdb_a: Path, pdb_b: Path) -> tuple[float, float, int]:
    if not TMALIGN:
        return float("nan"), float("nan"), 0
    try:
        p = subprocess.run([TMALIGN, str(pdb_a), str(pdb_b)],
                           capture_output=True, text=True, timeout=120)
    except Exception:
        return float("nan"), float("nan"), 0
    t1 = t2 = float("nan"); aln = 0
    for line in p.stdout.splitlines():
        if line.startswith("TM-score=") and "normalized by length of Chain_1" in line:
            try: t1 = float(line.split()[1])
            except: pass
        elif line.startswith("TM-score=") and "normalized by length of Chain_2" in line:
            try: t2 = float(line.split()[1])
            except: pass
        elif line.startswith("Aligned length="):
            parts = line.replace(",", " ").split()
            for i, q in enumerate(parts):
                if q == "length=":
                    try: aln = int(parts[i + 1])
                    except: pass
    return t1, t2, aln


def get_state_chain(case: dict, which: str) -> str:
    """Get the chain id within state PDB. The structures/ files use the curated chain
    from `case_definitions.py` build_v2 — they are usually chain A in the file.
    Try chain A first; fall back to the chain id from cases.yaml."""
    return case[f"state_{which}_chain"]


def state_chain_in_file(pdb: Path) -> str | None:
    """Return first chain id present in the PDB file (with Cα atoms)."""
    s = PDBParser(QUIET=True).get_structure("x", str(pdb))
    m = next(iter(s))
    for c in m:
        for r in c:
            if r.id[0] == " " and "CA" in r:
                return c.id
    return None


def evaluate_pdb(pdb: Path, case_id: str) -> dict:
    case = load_case(case_id)
    regions = load_regions(case_id)
    id_case = is_id_case(case_id)

    pred_ca = read_ca_per_resid(pdb, chain="A")
    pred_plddt = read_ca_bfactors(pdb, chain="A")
    # Pred residues are 1..L → match regions' residue_index_query directly (it's 1..L too)
    mean_plddt_overall = float(np.mean(list(pred_plddt.values())))
    # core / switch pLDDT (None if region empty)
    core_p = [pred_plddt[r] for r in regions["common_core"] if r in pred_plddt]
    sw_p = [pred_plddt[r] for r in regions["switch_3A"] if r in pred_plddt]
    mean_plddt_core = float(np.mean(core_p)) if core_p else float("nan")
    mean_plddt_switch = float(np.mean(sw_p)) if sw_p else float("nan")

    result = {
        "pdb": str(pdb),
        "case_id": case_id,
        "id_case": id_case,
        "pred_len": len(pred_ca),
        "mean_plddt_overall": mean_plddt_overall,
        "mean_plddt_core": mean_plddt_core,
        "mean_plddt_switch_3A": mean_plddt_switch,
        "states": {},
    }

    # Build pred-resi → state_resi mapping (state_a_resnum comes from FINAL.tsv).
    # state_b residue mapping = same query residue index (residue_index_query),
    # because the state_b PDBs were curated to use the query residue numbering too
    # (see build_v2 case definitions). We discover the actual chain id from the
    # state PDB file directly.
    pred_to_state_a = dict(regions["state_a_resnum"])

    for which in ["a", "b"]:
        pdb_state = DATA_ROOT / "structures" / case_id / f"state_{which}.pdb"
        if not pdb_state.exists():
            continue
        ch = state_chain_in_file(pdb_state)
        ref_ca = read_ca_per_resid(pdb_state, chain=ch)
        # Determine pred→ref residue map
        if which == "a":
            pred_to_ref = pred_to_state_a
        else:
            # state_b: curator built FINAL.tsv via residue-number intersection
            # (default) or positional pairing (MPT53). Try numbering-intersection
            # first: pred qi → state_a_resnum, expect those resnums to be present
            # in state_b PDB. If <50% overlap, fall back to POSITIONAL pairing:
            # qi=1 → state_b's 1st CA, qi=2 → 2nd, etc.
            ref_keys = set(ref_ca.keys())
            num_overlap = sum(1 for rn in pred_to_state_a.values() if rn in ref_keys)
            if pred_to_state_a and num_overlap >= 0.5 * len(pred_to_state_a):
                pred_to_ref = pred_to_state_a
            else:
                ref_sorted = sorted(ref_ca.keys())
                pred_sorted = sorted(pred_ca.keys())
                pred_to_ref = {p: r for p, r in zip(pred_sorted, ref_sorted)}

        rms_core, n_core = rmsd_on_residues(pred_ca, ref_ca, pred_to_ref,
                                            regions["common_core"])
        rms_sw, n_sw = rmsd_on_residues(pred_ca, ref_ca, pred_to_ref,
                                        regions["switch_3A"])
        t1, t2, aln = tmalign(pdb, pdb_state)

        if id_case:
            # IDP override: use the WHOLE query region as target (switch_3A = all residues)
            rms_target = rms_sw if not np.isnan(rms_sw) else rms_core
            hit = (not np.isnan(rms_target) and rms_target <= 3.0
                   and mean_plddt_overall >= 70.0)
        else:
            # Standard §9.1 binding 3-condition
            hit = (not np.isnan(rms_core) and rms_core <= 3.0
                   and mean_plddt_overall >= 70.0
                   and not np.isnan(mean_plddt_switch)
                   and mean_plddt_switch >= 70.0)

        result["states"][f"state_{which}"] = {
            "rmsd_common_core_A": rms_core,
            "rmsd_switch_3A": rms_sw,
            "n_common_core_aligned": n_core,
            "n_switch_3A_aligned": n_sw,
            "tmalign_tm1": t1,
            "tmalign_tm2": t2,
            "tmalign_aligned_len": aln,
            "hit_primary": hit,
        }

        if id_case:
            # ID cases: state_a == state_b, only emit state_a
            break

    return result


def main(argv: list[str] | None = None) -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--case", required=True)
    ap.add_argument("--root", required=True, type=Path)
    ap.add_argument("--out", type=Path, default=None)
    args = ap.parse_args(argv)

    pdbs = sorted(args.root.rglob("*_unrelaxed_rank_*.pdb"))
    if not pdbs:
        print(f"ERROR: no PDBs under {args.root}", file=sys.stderr)
        return 2
    out_tsv = args.out or args.root / "evals.tsv"
    sample = evaluate_pdb(pdbs[0], args.case)
    state_keys = list(sample["states"].keys())
    cols = ["subset_id", "model", "seed", "rank",
            "mean_plddt_overall", "mean_plddt_core", "mean_plddt_switch_3A"]
    for sk in state_keys:
        for m in ("rmsd_common_core_A", "rmsd_switch_3A",
                  "tmalign_tm1", "tmalign_tm2", "hit_primary"):
            cols.append(f"{sk}__{m}")
    cols.append("pdb")

    with out_tsv.open("w") as out:
        out.write("\t".join(cols) + "\n")
        for pdb in pdbs:
            m = PRED_NAME_RE.match(pdb.name)
            sid = m.group("subset") if m else pdb.stem
            rank = int(m.group("rank")) if m else -1
            model = int(m.group("model")) if m else -1
            seed = int(m.group("seed")) if m else -1
            try:
                r = evaluate_pdb(pdb, args.case)
            except Exception as e:
                print(f"WARN: eval failed {pdb}: {e}", file=sys.stderr)
                continue
            (pdb.with_name(pdb.stem + "_eval.json")).write_text(
                json.dumps(r, indent=2, default=float))
            row = [sid, model, seed, rank,
                   f"{r['mean_plddt_overall']:.2f}",
                   f"{r['mean_plddt_core']:.2f}" if r['mean_plddt_core'] == r['mean_plddt_core'] else "NA",
                   f"{r['mean_plddt_switch_3A']:.2f}" if r['mean_plddt_switch_3A'] == r['mean_plddt_switch_3A'] else "NA"]
            for sk in state_keys:
                sv = r["states"].get(sk, {})
                for k in ("rmsd_common_core_A", "rmsd_switch_3A",
                         "tmalign_tm1", "tmalign_tm2", "hit_primary"):
                    v = sv.get(k)
                    if v is None or (isinstance(v, float) and v != v):
                        row.append("NA")
                    elif isinstance(v, bool):
                        row.append("1" if v else "0")
                    elif isinstance(v, float):
                        row.append(f"{v:.4f}")
                    else:
                        row.append(str(v))
            row.append(str(pdb))
            out.write("\t".join(str(x) for x in row) + "\n")
    print(f"wrote {len(pdbs)} preds -> {out_tsv}")
    return 0


if __name__ == "__main__":
    sys.exit(main())