"""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