Spaces:
Running
Running
File size: 21,282 Bytes
5d3f072 | 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 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 | """What two chains actually touch — measured from coordinates, not predicted.
THE DISTINCTION THIS MODULE EXISTS TO ENFORCE
---------------------------------------------
A docking result contains three completely different kinds of claim, and the
product's credibility depends on never letting them blur:
MEASURED geometry computed from the coordinates in front of us.
Buried surface area, atomic contacts, salt bridges, clashes.
Anyone with the same file gets the same answer. That is what
this module produces, and all it produces.
PREDICTED the docking method's own confidence in the pose (ipTM, pLDDT,
a docking score). Reported from the provider, never derived
here — the same rule `resolve.py` already applies to
AlphaFold's pLDDT.
INTERPRETED the sentence Turing writes about it. Downstream, grounded in
the first two, and never a substitute for either.
Everything here is the first kind. These numbers describe the pose; they say
nothing about whether the pose is right.
WHAT IS NOT COMPUTED, AND WILL NOT BE
-------------------------------------
Binding affinity. Kd, ΔG, kcal/mol. No protein-protein docking method in this
deployment estimates them, and a plausible-looking energy is the single most
damaging number this feature could display: it reads as a measurement, it is
quotable, and it would be fabricated. `stability.py` already made this call for
ΔΔG and said it in the same words. Interface area is not affinity, a hydrogen
bond count is not affinity, and no weighted sum of them becomes affinity.
HYDROGEN BONDS, HONESTLY
------------------------
Predicted structures and most crystal structures carry no hydrogen atoms, so a
true hydrogen bond — with its donor-H-acceptor angle — cannot be determined
from them. What can be measured is the heavy-atom geometry that a hydrogen bond
requires: a donor nitrogen or oxygen close enough to an acceptor. That is what
is counted here, and it is labelled `putative_hydrogen_bonds` everywhere it
appears, with the criterion attached. It is standard practice; calling the
result "hydrogen bonds" without the qualifier would not be.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Sequence, Tuple
from dee.core.structures import ChainInfo, NumberingMap, ResidueRef
logger = logging.getLogger(__name__)
# ---- criteria, each one a published convention rather than a tuned knob ----
# Any heavy atom of one chain within this of a heavy atom of the other. 5.0 A
# is the usual definition of an interface contact in the protein-protein
# literature and is what PDBePISA-style summaries use.
CONTACT_CUTOFF_A = 5.0
# Heavy-atom donor/acceptor separation consistent with a hydrogen bond. 3.5 A
# is the conventional heavy-atom cutoff used when hydrogens are absent.
HBOND_CUTOFF_A = 3.5
# Barlow & Thornton (1983): a salt bridge is a charged nitrogen and a charged
# oxygen within 4.0 A.
SALT_BRIDGE_CUTOFF_A = 4.0
# Below this, two non-bonded heavy atoms are interpenetrating. Generous on
# purpose: at 2.2 A a contact is unambiguous rather than arguable, so a
# reported clash is a real problem with the pose and not a rounding argument.
CLASH_CUTOFF_A = 2.2
# A residue counts as interface if it buries at least this much area. Small
# enough to catch a genuine rim residue, large enough to exclude a side chain
# that merely points in the right direction.
BURIAL_MIN_A2 = 1.0
# Shrake-Rupley sampling. 100 points is Biopython's default and is plenty for
# an area difference; raising it costs time on a shared CPU worker for digits
# nobody should be reading.
SASA_POINTS = 100
_CHARGED_DONORS = {
("ARG", "NE"), ("ARG", "NH1"), ("ARG", "NH2"),
("LYS", "NZ"),
("HIS", "ND1"), ("HIS", "NE2"),
}
_CHARGED_ACCEPTORS = {
("ASP", "OD1"), ("ASP", "OD2"),
("GLU", "OE1"), ("GLU", "OE2"),
}
_HYDROPHOBIC = {"ALA", "VAL", "LEU", "ILE", "MET", "PHE", "TRP", "PRO", "CYS",
"TYR"}
@dataclass(frozen=True)
class Contact:
"""One measured atom-atom contact across the interface."""
receptor: ResidueRef
ligand: ResidueRef
receptor_atom: str
ligand_atom: str
distance_a: float
kind: str # 'contact' | 'putative_hydrogen_bond' | 'salt_bridge' | 'clash'
def as_dict(self) -> Dict[str, Any]:
return {
"receptor": self.receptor.as_dict(),
"ligand": self.ligand.as_dict(),
"receptor_atom": self.receptor_atom,
"ligand_atom": self.ligand_atom,
"distance_a": round(self.distance_a, 2),
"kind": self.kind,
}
@dataclass
class InterfaceResidue:
"""A residue at the interface, in both numbering systems.
`user_position` is the 1-based position in the sequence the user gave us,
or None when the residue has no counterpart there. Mutation names are built
from this field and never from `seq_id`, because `seq_id` belongs to the
coordinate file, not to the scientist's protein.
"""
ref: ResidueRef
buried_a2: float
contacts: int
user_position: Optional[int] = None
@property
def mutation_prefix(self) -> Optional[str]:
"""'A147' — the anchor a substitution name is built on.
None when the residue is unmapped: without a position in the user's own
numbering there is no mutation to name, and guessing one is the
failure `structures.py` exists to prevent.
"""
letter = self.ref.one_letter
if letter is None or self.user_position is None:
return None
return f"{letter}{self.user_position}"
def as_dict(self) -> Dict[str, Any]:
out = dict(self.ref.as_dict())
out.update({
"buried_a2": round(self.buried_a2, 1),
"contacts": self.contacts,
"user_position": self.user_position,
})
prefix = self.mutation_prefix
if prefix:
out["mutation_prefix"] = prefix
return out
@dataclass
class InterfaceAnalysis:
"""Measured description of one pose's interface.
Every number here was computed from coordinates. `criteria` travels with
the result so a reader can check what "contact" meant, and so two results
are never compared under different definitions.
"""
receptor_chain: str
ligand_chain: str
buried_area_a2: float
receptor_buried_a2: float
ligand_buried_a2: float
receptor_residues: List[InterfaceResidue] = field(default_factory=list)
ligand_residues: List[InterfaceResidue] = field(default_factory=list)
contacts: List[Contact] = field(default_factory=list)
putative_hydrogen_bonds: int = 0
salt_bridges: int = 0
clashes: int = 0
hydrophobic_contacts: int = 0
notes: List[str] = field(default_factory=list)
@property
def contact_count(self) -> int:
return len([c for c in self.contacts if c.kind != "clash"])
def as_dict(self, *, max_contacts: int = 120) -> Dict[str, Any]:
return {
"receptor_chain": self.receptor_chain,
"ligand_chain": self.ligand_chain,
"measured": {
"buried_area_a2": round(self.buried_area_a2, 1),
"receptor_buried_a2": round(self.receptor_buried_a2, 1),
"ligand_buried_a2": round(self.ligand_buried_a2, 1),
"interface_residues_receptor": len(self.receptor_residues),
"interface_residues_ligand": len(self.ligand_residues),
"atomic_contacts": self.contact_count,
"putative_hydrogen_bonds": self.putative_hydrogen_bonds,
"salt_bridges": self.salt_bridges,
"hydrophobic_contacts": self.hydrophobic_contacts,
"clashes": self.clashes,
},
"criteria": {
"contact_cutoff_a": CONTACT_CUTOFF_A,
"putative_hydrogen_bond_cutoff_a": HBOND_CUTOFF_A,
"salt_bridge_cutoff_a": SALT_BRIDGE_CUTOFF_A,
"clash_cutoff_a": CLASH_CUTOFF_A,
"burial_min_a2": BURIAL_MIN_A2,
"hydrogens": "not modelled; hydrogen bonds are heavy-atom "
"geometry only",
"affinity": "not estimated by this method",
},
"receptor_residues": [r.as_dict() for r in self.receptor_residues],
"ligand_residues": [r.as_dict() for r in self.ligand_residues],
"contacts": [c.as_dict() for c in self.contacts[:max_contacts]],
"contacts_truncated": max(0, len(self.contacts) - max_contacts),
"notes": list(self.notes),
}
def summary_line(self) -> str:
"""One sentence of measured fact, safe to quote verbatim."""
return (
f"{self.buried_area_a2:.0f} A^2 buried across "
f"{len(self.receptor_residues)}+{len(self.ligand_residues)} "
f"residues, {self.contact_count} atomic contacts, "
f"{self.putative_hydrogen_bonds} putative hydrogen bond(s), "
f"{self.salt_bridges} salt bridge(s), {self.clashes} clash(es)."
)
def _is_heavy(atom) -> bool:
element = (getattr(atom, "element", "") or "").strip().upper()
if element:
return element not in ("H", "D")
return not (atom.get_id() or "").strip().upper().startswith(("H", "D"))
def _residue_key(residue) -> Tuple[Any, ...]:
return tuple(residue.id)
def analyse_interface(model, receptor_chain: str, ligand_chain: str, *,
chains: Optional[Dict[str, ChainInfo]] = None,
numbering: Optional[Dict[str, NumberingMap]] = None,
) -> InterfaceAnalysis:
"""Measure the interface between two chains of one Biopython model.
`model` is a parsed structure's first model holding BOTH chains — i.e. a
docked complex, not two separate files. `numbering` maps chain id to the
mapping established by :func:`structures.map_numbering`; when a chain is
absent from it, that chain's residues are reported without a user position
and a note says so.
"""
try:
from Bio.PDB import NeighborSearch
from Bio.PDB.SASA import ShrakeRupley
except Exception as exc: # noqa: BLE001
raise RuntimeError(
"The structural analysis library is not available on this "
"deployment."
) from exc
try:
rec = model[receptor_chain]
lig = model[ligand_chain]
except KeyError as exc:
raise ValueError(
f"The complex does not contain chains {receptor_chain} and "
f"{ligand_chain}."
) from exc
numbering = numbering or {}
chains = chains or {}
notes: List[str] = []
# ---- buried surface area: SASA of each chain alone, minus in complex ----
# Computed on copies so the input model is never mutated: the same model is
# re-measured for every pose and a detached-and-reattached chain would
# quietly change later answers.
sasa = ShrakeRupley(n_points=SASA_POINTS)
rec_alone = _chain_sasa(sasa, rec)
lig_alone = _chain_sasa(sasa, lig)
pair_sasa = _pair_sasa(sasa, rec, lig)
rec_buried_by_residue: Dict[Tuple[Any, ...], float] = {}
lig_buried_by_residue: Dict[Tuple[Any, ...], float] = {}
for key, alone in rec_alone.items():
rec_buried_by_residue[key] = max(
0.0, alone - pair_sasa.get((receptor_chain, key), alone))
for key, alone in lig_alone.items():
lig_buried_by_residue[key] = max(
0.0, alone - pair_sasa.get((ligand_chain, key), alone))
receptor_buried = sum(rec_buried_by_residue.values())
ligand_buried = sum(lig_buried_by_residue.values())
# ---- contacts ----
rec_atoms = [a for r in rec for a in r if _is_heavy(a)]
lig_atoms = [a for r in lig for a in r if _is_heavy(a)]
if not rec_atoms or not lig_atoms:
raise ValueError(
"One of the chains has no heavy atoms, so no interface can be "
"measured.")
contacts: List[Contact] = []
hbonds = salt_bridges = clashes = hydrophobic = 0
rec_contact_counts: Dict[Tuple[Any, ...], int] = {}
lig_contact_counts: Dict[Tuple[Any, ...], int] = {}
search = NeighborSearch(lig_atoms)
for atom in rec_atoms:
for partner in search.search(atom.coord, CONTACT_CUTOFF_A):
distance = float(atom - partner)
rres, lres = atom.get_parent(), partner.get_parent()
rkey, lkey = _residue_key(rres), _residue_key(lres)
rec_contact_counts[rkey] = rec_contact_counts.get(rkey, 0) + 1
lig_contact_counts[lkey] = lig_contact_counts.get(lkey, 0) + 1
rname = (rres.get_resname() or "").strip().upper()
lname = (lres.get_resname() or "").strip().upper()
raid = (atom.get_id() or "").strip().upper()
laid = (partner.get_id() or "").strip().upper()
relem = (getattr(atom, "element", "") or raid[:1]).strip().upper()
lelem = (getattr(partner, "element", "")
or laid[:1]).strip().upper()
kind = "contact"
if distance < CLASH_CUTOFF_A:
kind = "clash"
clashes += 1
elif (distance <= SALT_BRIDGE_CUTOFF_A
and (((rname, raid) in _CHARGED_DONORS
and (lname, laid) in _CHARGED_ACCEPTORS)
or ((lname, laid) in _CHARGED_DONORS
and (rname, raid) in _CHARGED_ACCEPTORS))):
kind = "salt_bridge"
salt_bridges += 1
elif (distance <= HBOND_CUTOFF_A
and relem in ("N", "O") and lelem in ("N", "O")):
kind = "putative_hydrogen_bond"
hbonds += 1
elif (relem == "C" and lelem == "C"
and rname in _HYDROPHOBIC and lname in _HYDROPHOBIC):
hydrophobic += 1
contacts.append(Contact(
receptor=_ref_for(rres, receptor_chain),
ligand=_ref_for(lres, ligand_chain),
receptor_atom=raid, ligand_atom=laid,
distance_a=distance, kind=kind,
))
# Shortest contact first: a reader scanning the list should meet the
# tightest interactions, and clashes, before the incidental ones.
contacts.sort(key=lambda c: c.distance_a)
receptor_residues = _interface_residues(
rec, receptor_chain, rec_buried_by_residue, rec_contact_counts,
numbering.get(receptor_chain))
ligand_residues = _interface_residues(
lig, ligand_chain, lig_buried_by_residue, lig_contact_counts,
numbering.get(ligand_chain))
for chain_id, residues in ((receptor_chain, receptor_residues),
(ligand_chain, ligand_residues)):
if chain_id not in numbering and residues:
notes.append(
f"Chain {chain_id} residues are given in the structure's own "
f"numbering; no sequence was supplied to map them onto.")
elif residues:
unmapped = [r for r in residues if r.user_position is None]
if unmapped:
notes.append(
f"{len(unmapped)} interface residue(s) on chain {chain_id} "
f"are not present in the sequence supplied for it, so they "
f"carry no sequence position.")
if clashes:
notes.append(
f"{clashes} atom pair(s) are closer than {CLASH_CUTOFF_A} A. A "
f"pose with steric clashes is physically questionable.")
return InterfaceAnalysis(
receptor_chain=receptor_chain,
ligand_chain=ligand_chain,
buried_area_a2=receptor_buried + ligand_buried,
receptor_buried_a2=receptor_buried,
ligand_buried_a2=ligand_buried,
receptor_residues=receptor_residues,
ligand_residues=ligand_residues,
contacts=contacts,
putative_hydrogen_bonds=hbonds,
salt_bridges=salt_bridges,
clashes=clashes,
hydrophobic_contacts=hydrophobic,
notes=notes,
)
def _ref_for(residue, chain_id: str) -> ResidueRef:
_, seq_id, icode = residue.id
return ResidueRef(chain=chain_id, seq_id=int(seq_id),
icode=str(icode or " "),
resname=(residue.get_resname() or "").strip().upper())
def _chain_sasa(sasa, chain) -> Dict[Tuple[Any, ...], float]:
"""Per-residue SASA for one chain in isolation.
Biopython writes `.sasa` onto the entity it is given, so this runs on a
detached copy — computing on the live chain would leave stale values behind
for whatever measures it next.
"""
import copy
clone = copy.deepcopy(chain)
clone.detach_parent()
sasa.compute(clone, level="R")
return {_residue_key(r): float(getattr(r, "sasa", 0.0) or 0.0)
for r in clone}
def _pair_sasa(sasa, rec, lig) -> Dict[Tuple[str, Tuple[Any, ...]], float]:
"""Per-residue SASA for both chains together, keyed by (chain, residue)."""
import copy
from Bio.PDB.Model import Model
holder = Model(0)
rec_copy, lig_copy = copy.deepcopy(rec), copy.deepcopy(lig)
rec_copy.detach_parent()
lig_copy.detach_parent()
holder.add(rec_copy)
holder.add(lig_copy)
sasa.compute(holder, level="R")
out: Dict[Tuple[str, Tuple[Any, ...]], float] = {}
for chain in holder:
for residue in chain:
out[(chain.id, _residue_key(residue))] = float(
getattr(residue, "sasa", 0.0) or 0.0)
return out
def _interface_residues(chain, chain_id: str,
buried: Dict[Tuple[Any, ...], float],
contact_counts: Dict[Tuple[Any, ...], int],
mapping: Optional[NumberingMap],
) -> List[InterfaceResidue]:
"""Residues that bury area or make contact, most buried first."""
out: List[InterfaceResidue] = []
for residue in chain:
key = _residue_key(residue)
area = buried.get(key, 0.0)
count = contact_counts.get(key, 0)
if area < BURIAL_MIN_A2 and count == 0:
continue
ref = _ref_for(residue, chain_id)
out.append(InterfaceResidue(
ref=ref,
buried_a2=area,
contacts=count,
user_position=(mapping.to_user_position(ref) if mapping else None),
))
out.sort(key=lambda r: (-r.buried_a2, r.ref.seq_id))
return out
# --------------------------------------------------------------------------
# Ranking
# --------------------------------------------------------------------------
def rank_poses(poses: Sequence[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Order candidate poses, defensibly, and say what the order rests on.
The provider's own confidence leads when it reports one. That is deliberate:
a co-folding model's interface confidence (ipTM) is trained to predict
whether an interface is real, which is the actual question, and no
combination of geometry measured here is a better answer to it. Measured
geometry is the tie-break and the sanity check, not the primary signal.
A pose with steric clashes is demoted regardless of its score: whatever the
model believed, atoms do not interpenetrate.
Each pose is annotated with `rank_basis` naming what decided its place, so
the ordering is never a black box. Returns a new list; the input is not
modified.
"""
ordered: List[Dict[str, Any]] = []
for pose in poses:
item = dict(pose)
measured = (item.get("interface") or {}).get("measured") or {}
confidence = item.get("confidence") or {}
primary = None
basis = []
for field_name in ("iptm", "protein_iptm", "interface_confidence"):
value = confidence.get(field_name)
if isinstance(value, (int, float)):
primary = float(value)
basis.append(f"{field_name}={primary:.3f} (reported by the "
f"method)")
break
if primary is None and isinstance(item.get("score"), (int, float)):
primary = float(item["score"])
basis.append(f"score={primary:.3f} (reported by the method)")
buried = float(measured.get("buried_area_a2") or 0.0)
clashes = int(measured.get("clashes") or 0)
if buried:
basis.append(f"{buried:.0f} A^2 buried (measured)")
if clashes:
basis.append(f"{clashes} clash(es) (measured) — demoted")
item["rank_basis"] = "; ".join(basis) or "no ranking signal available"
item["_sort"] = (
1 if clashes else 0,
-(primary if primary is not None else -1.0),
-buried,
)
ordered.append(item)
ordered.sort(key=lambda p: p["_sort"])
for position, item in enumerate(ordered, start=1):
item.pop("_sort", None)
item["rank"] = position
return ordered
|