File size: 12,657 Bytes
f4e8048
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Evaluate a single AF2-predicted PDB against a case's locked eval regions.

For a known-state case (KaiB, GA/GB):
- pLDDT overall and per-region (common_core, switch_region, switch_region_2A)
- Cα RMSD to state_A and state_B on common_core, switch_region, switch_region_2A
- TM-score to state_A and state_B on common_core
- Primary hit criterion (protocol §9.1, BINDING 3-condition): RMSD <= 3.0 Å on
  common_core AND mean pLDDT >= 70 overall AND switch_region pLDDT >= 70.
  NaN switch_region pLDDT → §9.1 NOT evaluable → hit_primary=False (issue #7).

For the discovery case (Mpt53):
- pLDDT overall
- RMSD and TM-score to state_A_reference (1LU4) on the full 134-aa common_core

CLI:
    python src/eval/evaluate_prediction.py PRED.pdb --case KaiB [--json OUT.json]
"""

from __future__ import annotations

import argparse
import csv
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Iterable

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

# ROOT holds data/, configs/ and results/ (all paths below are ROOT-relative).
# Default = repo layout (this file at <ROOT>/src/eval/); override with the
# SF_BENCH_ROOT env var to point at a relocated benchmark bundle root.
_ENV_ROOT = os.environ.get("SF_BENCH_ROOT")
ROOT = Path(_ENV_ROOT).resolve() if _ENV_ROOT else Path(__file__).resolve().parents[2]
MANIFEST_REGIONS = ROOT / "data" / "manifests" / "eval_regions.tsv"
STRUCT_ROOT = ROOT / "data" / "processed" / "structures"
CASES_YAML = ROOT / "configs" / "cases.yaml"


def parse_csv_list(s: str) -> list[int]:
    if not s or s in ("NA", ""):
        return []
    return [int(x) for x in s.split(",") if x.strip()]


def load_construct_start(case: str) -> int:
    """Return construct_start for the case (used to offset predicted PDB numbering).

    ColabFold emits predicted residues numbered 1..L; our eval_regions are in the
    state PDB's numbering (e.g. KaiB 5..95, Mpt53 38..173 mapped from UniProt).
    Predicted residue i maps to region residue (i + construct_start - 1).
    """
    cases = yaml.safe_load(CASES_YAML.read_text())["cases"]
    for c in cases:
        if c["case_name"] == case:
            return int(c["construct_start"])
    raise KeyError(f"no cases.yaml entry for {case}")


def load_regions(case: str) -> dict:
    """Return common_core / switch_region_3A / switch_region_2A residue lists.

    For GA/GB the stored row is `GA_GB_GA98_vs_GB98`.
    For Mpt53 the row is `Mpt53` with switch cols NA.
    """
    row_key = {
        "KaiB": "KaiB",
        "GA_GB": "GA_GB_GA98_vs_GB98",
        "Mpt53": "Mpt53",
    }.get(case)
    if row_key is None:
        raise ValueError(f"unknown case {case}")
    with MANIFEST_REGIONS.open() as f:
        reader = csv.DictReader(f, delimiter="\t")
        for row in reader:
            if row["case"] == row_key:
                return {
                    "case": row_key,
                    "common_core": parse_csv_list(row.get("common_core_residues", "")),
                    "switch_3A": parse_csv_list(row.get("switch_region_3A_residues", "")),
                    "switch_2A": parse_csv_list(row.get("switch_region_2A_residues", "")),
                }
    raise KeyError(f"no eval_regions row for {row_key}")


def load_state_paths(case: str) -> dict[str, Path]:
    """Map state name -> PDB path for the case."""
    if case == "KaiB":
        return {
            "state_A_2QKE": STRUCT_ROOT / "KaiB" / "state_A.pdb",
            "state_B_5JYT": STRUCT_ROOT / "KaiB" / "state_B.pdb",
        }
    if case == "GA_GB":
        # Two fold canonical references
        return {
            "GA98_2LHC": STRUCT_ROOT / "GA_GB" / "GA98_2LHC.pdb",
            "GB98_2LHD": STRUCT_ROOT / "GA_GB" / "GB98_2LHD.pdb",
        }
    if case == "Mpt53":
        return {
            "state_A_1LU4": STRUCT_ROOT / "Mpt53" / "state_A.pdb",
        }
    raise ValueError(f"unknown case {case}")


def read_ca_per_resid(pdb: Path, chain: str | None = None,
                      resid_offset: int = 0) -> dict[int, np.ndarray]:
    """Return {resid: Cα coord} for the first model, first (or named) chain.

    resid_offset is added to each residue id (used to map 1LU4 crystal 1001..
    into UniProt 38..171 frame when needed).
    """
    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] + resid_offset] = r["CA"].coord
        if out:
            return out
    raise RuntimeError(f"no Cα atoms found in {pdb} chain={chain}")


def read_ca_bfactors(pdb: Path, chain: str | None = None,
                     resid_offset: int = 0) -> dict[int, float]:
    """Return {resid: Cα B-factor} — for AF2 outputs, B-factor == pLDDT."""
    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] + resid_offset] = 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],
                     residues: Iterable[int]) -> tuple[float, int]:
    """Superpose pred onto ref using the given residue set; return RMSD + N."""
    residues = [r for r in residues if r in pred_ca and r in ref_ca]
    if len(residues) < 3:
        return float("nan"), len(residues)
    from Bio.PDB.Atom import Atom
    pred_atoms = [Atom("CA", pred_ca[r], 1.0, 1.0, " ", "CA", 1, "C") for r in residues]
    ref_atoms = [Atom("CA", ref_ca[r], 1.0, 1.0, " ", "CA", 1, "C") for r in residues]
    sup = Superimposer()
    sup.set_atoms(ref_atoms, pred_atoms)
    return float(sup.rms), len(residues)


def tmalign_score(pdb_a: Path, pdb_b: Path) -> tuple[float, float, int]:
    """Run TMalign, return (TM-score normalized by chain_1, chain_2, aligned_len).

    TMalign is OPTIONAL: it only fills the tmalign_* columns and is NOT part of
    the hit_primary criterion. If TMalign is not on PATH we skip it cleanly and
    return NaNs.
    """
    import shutil as _sh
    tm = _sh.which("TMalign")
    if tm is None:
        return float("nan"), float("nan"), 0
    try:
        proc = subprocess.run(
            [tm, str(pdb_a), str(pdb_b)],
            capture_output=True, text=True, timeout=120,
        )
    except Exception as e:
        print(f"WARN: TMalign failed for {pdb_a} vs {pdb_b}: {e}", file=sys.stderr)
        return float("nan"), float("nan"), 0
    tm1 = tm2 = float("nan")
    aln = 0
    for line in proc.stdout.splitlines():
        if line.startswith("TM-score=") and "normalized by length of Chain_1" in line:
            tm1 = float(line.split()[1])
        elif line.startswith("TM-score=") and "normalized by length of Chain_2" in line:
            tm2 = float(line.split()[1])
        elif line.startswith("Aligned length="):
            # "Aligned length= 66, RMSD= 3.86, Seq_id=n_identical/n_aligned= 0.000"
            parts = line.replace(",", " ").split()
            for i, p in enumerate(parts):
                if p == "length=":
                    aln = int(parts[i + 1])
    return tm1, tm2, aln


def evaluate(pdb: Path, case: str) -> dict:
    # §9.1 binding 3-condition hit criterion (CLAUDE.md §Evaluation Protocol):
    # ALL of (1) Cα RMSD ≤ 3.0 Å on common_core, (2) mean pLDDT ≥ 70 overall,
    # (3) mean pLDDT ≥ 70 in switch_region must hold. NaN switch_region pLDDT
    # means switch_region is undefined (or no overlap) → §9.1 is NOT evaluable
    # → hit_primary MUST be False (issue #7 fix).
    regions = load_regions(case)
    states = load_state_paths(case)

    # Predicted structure Cα + pLDDT (B-factors). Our predicted PDBs have chain A
    # and residues numbered 1..L. Shift to the state PDB / eval_region numbering.
    construct_start = load_construct_start(case)
    pred_offset = construct_start - 1
    pred_ca = read_ca_per_resid(pdb, chain="A", resid_offset=pred_offset)
    pred_plddt = read_ca_bfactors(pdb, chain="A", resid_offset=pred_offset)

    mean_plddt_overall = float(np.mean(list(pred_plddt.values())))
    mean_plddt_core = float(np.mean([pred_plddt[r] for r in regions["common_core"]
                                      if r in pred_plddt]))
    mean_plddt_switch3 = (float(np.mean([pred_plddt[r] for r in regions["switch_3A"]
                                          if r in pred_plddt]))
                          if regions["switch_3A"] else float("nan"))
    mean_plddt_switch2 = (float(np.mean([pred_plddt[r] for r in regions["switch_2A"]
                                          if r in pred_plddt]))
                          if regions["switch_2A"] else float("nan"))

    result = {
        "pdb": str(pdb.relative_to(ROOT)) if pdb.is_relative_to(ROOT) else str(pdb),
        "case": case,
        "pred_len": len(pred_ca),
        "mean_plddt_overall": mean_plddt_overall,
        "mean_plddt_core": mean_plddt_core,
        "mean_plddt_switch_3A": mean_plddt_switch3,
        "mean_plddt_switch_2A": mean_plddt_switch2,
        "states": {},
    }

    for state_name, state_pdb in states.items():
        # Determine state chain + offset
        if state_name.startswith("state_A_1LU4"):
            ref_chain = "A"
            ref_offset = -963  # crystal 1001..1134 -> UniProt 38..171
        elif state_name.startswith("state_A_2QKE"):
            ref_chain = "B"
            ref_offset = 0
        elif state_name.startswith("state_B_5JYT"):
            ref_chain = "A"
            ref_offset = 0
        else:
            # GA/GB canonical refs (GA98, GB98)
            ref_chain = "A"
            ref_offset = 0

        ref_ca = read_ca_per_resid(state_pdb, chain=ref_chain, resid_offset=ref_offset)

        rms_core, n_core = rmsd_on_residues(pred_ca, ref_ca, regions["common_core"])
        rms_s3, n_s3 = (rmsd_on_residues(pred_ca, ref_ca, regions["switch_3A"])
                        if regions["switch_3A"] else (float("nan"), 0))
        rms_s2, n_s2 = (rmsd_on_residues(pred_ca, ref_ca, regions["switch_2A"])
                        if regions["switch_2A"] else (float("nan"), 0))
        tm1, tm2, aln = tmalign_score(pdb, state_pdb)

        # §9.1 binding 3-condition: all three thresholds must hold. If
        # switch_region pLDDT is NaN, §9.1 is NOT evaluable → hit_primary=False.
        no_switch_region_defined = bool(np.isnan(mean_plddt_switch3))
        hit_primary = (
            not np.isnan(rms_core)
            and rms_core <= 3.0
            and mean_plddt_overall >= 70.0
            and (not no_switch_region_defined)
            and mean_plddt_switch3 >= 70.0
        )
        if no_switch_region_defined:
            print(
                f"WARN: switch_region pLDDT is NaN for {pdb} (case={case}, "
                f"state={state_name}); §9.1 not evaluable → hit_primary=False",
                file=sys.stderr,
            )

        result["states"][state_name] = {
            "rmsd_common_core_A": rms_core,
            "rmsd_switch_3A": rms_s3,
            "rmsd_switch_2A": rms_s2,
            "n_common_core_aligned": n_core,
            "n_switch_3A_aligned": n_s3,
            "n_switch_2A_aligned": n_s2,
            "tmalign_tm1": tm1,
            "tmalign_tm2": tm2,
            "tmalign_aligned_len": aln,
            "hit_primary": hit_primary,
            "no_switch_region_defined": no_switch_region_defined,
        }

    return result


def main(argv: list[str] | None = None) -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("pdb", type=Path, help="Path to AF2-predicted PDB")
    ap.add_argument("--case", required=True, choices=["KaiB", "GA_GB", "Mpt53"])
    ap.add_argument("--json", type=Path, default=None,
                    help="Optional path to dump JSON result")
    args = ap.parse_args(argv)

    result = evaluate(args.pdb, args.case)
    if args.json:
        args.json.parent.mkdir(parents=True, exist_ok=True)
        args.json.write_text(json.dumps(result, indent=2, default=float))
    print(json.dumps(result, indent=2, default=float))
    return 0


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