syntheogenesis / dee /core /interface.py
github-actions[bot]
Deploy 0d241f9
5d3f072
Raw History Blame Contribute Delete
21.3 kB
"""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