Spaces:
Running
Running
Download dee/core/interface.py from WINTER4000/syntheogenesis: direct link, hf CLI and curl.
- Browser
- Download file 21.3 kB
-
https://huggingface.co/spaces/WINTER4000/syntheogenesis/resolve/main/dee/core/interface.py
- Command line
-
hf download hf://spaces/WINTER4000/syntheogenesis/dee/core/interface.py
-
curl -L -o interface.py https://huggingface.co/spaces/WINTER4000/syntheogenesis/resolve/main/dee/core/interface.py
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"} | |
| 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, | |
| } | |
| 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 | |
| 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 | |
| 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) | |
| 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 | |