Spaces:
Running
Running
Upload folder using huggingface_hub
Browse files- app/.source-revision +1 -1
- app/services/rdkit_interactions.py +106 -3
app/.source-revision
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
|
|
|
|
| 1 |
+
44a903c1d800aedc4401f98894addaa88d1212c6
|
app/services/rdkit_interactions.py
CHANGED
|
@@ -31,15 +31,118 @@ def _feature_factory():
|
|
| 31 |
return ChemicalFeatures.BuildFeatureFactory(path)
|
| 32 |
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
def _pdb_mol(block: str, label: str):
|
| 35 |
if not block or not any(line.startswith(("ATOM ", "HETATM")) for line in block.splitlines()):
|
| 36 |
raise ValueError(f"{label} structure contains no PDB atom records")
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
if mol is None or mol.GetNumHeavyAtoms() == 0:
|
| 39 |
raise ValueError(f"RDKit could not construct a chemically usable {label} molecule")
|
| 40 |
return mol
|
| 41 |
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
def _ligand_with_template(ligand_pdb: str, smiles: str):
|
| 44 |
template = Chem.MolFromSmiles(smiles)
|
| 45 |
if template is None:
|
|
@@ -172,7 +275,7 @@ def _ring_interactions(protein, ligand, protein_features, ligand_features):
|
|
| 172 |
def calculate_interactions(protein_pdb: str, ligand_pdb: str, ligand_smiles: str) -> dict[str, Any]:
|
| 173 |
"""Return RDKit feature-based interaction evidence for one docked pose."""
|
| 174 |
factory = _feature_factory()
|
| 175 |
-
|
| 176 |
ligand = _with_hydrogens(_ligand_with_template(ligand_pdb, ligand_smiles))
|
| 177 |
pf = _atom_features(protein, factory)
|
| 178 |
lf = _atom_features(ligand, factory)
|
|
@@ -252,7 +355,7 @@ def calculate_interactions(protein_pdb: str, ligand_pdb: str, ligand_smiles: str
|
|
| 252 |
"limitations": [
|
| 253 |
"Interaction classifications depend on the supplied protonation/tautomer state and prepared coordinates.",
|
| 254 |
"RDKit feature perception is a computational model, not experimental evidence of binding.",
|
| 255 |
-
"Only the provided receptor atoms and docked pose are evaluated; omitted waters, ions, cofactors, and alternate conformations are not inferred.",
|
| 256 |
"Distances and angles are geometric screens, not interaction energies or binding-affinity measurements.",
|
| 257 |
],
|
| 258 |
}
|
|
|
|
| 31 |
return ChemicalFeatures.BuildFeatureFactory(path)
|
| 32 |
|
| 33 |
|
| 34 |
+
def _normalize_pose_pdb(block: str) -> str:
|
| 35 |
+
"""Rebuild fixed-column PDB records from PDBQT while preserving atom order/coordinates."""
|
| 36 |
+
rows = []
|
| 37 |
+
for line in block.splitlines():
|
| 38 |
+
if line[:6].strip() not in ("ATOM", "HETATM"):
|
| 39 |
+
continue
|
| 40 |
+
fields = line.split()
|
| 41 |
+
if len(fields) < 9:
|
| 42 |
+
continue
|
| 43 |
+
try:
|
| 44 |
+
serial = int(fields[1])
|
| 45 |
+
atom_name = fields[2]
|
| 46 |
+
x, y, z = (float(fields[i]) for i in (5, 6, 7))
|
| 47 |
+
element = fields[-1].strip().upper()
|
| 48 |
+
except (ValueError, IndexError):
|
| 49 |
+
continue
|
| 50 |
+
element = "".join(ch for ch in element if ch.isalpha())[:2].title()
|
| 51 |
+
if not element:
|
| 52 |
+
element = "".join(ch for ch in atom_name if ch.isalpha())[:1].title()
|
| 53 |
+
atom_field = f" {atom_name:<3s}"[:4] if len(atom_name) < 4 else atom_name[:4]
|
| 54 |
+
rows.append(
|
| 55 |
+
f"ATOM {serial:5d} {atom_field:4s} UNL A 1 "
|
| 56 |
+
f"{x:8.3f}{y:8.3f}{z:8.3f}{1.0:6.2f}{0.0:6.2f} {element:>2s} "
|
| 57 |
+
)
|
| 58 |
+
if not rows:
|
| 59 |
+
raise ValueError("Could not recover ligand atom coordinates from PDB/PDBQT records")
|
| 60 |
+
return "\\n".join(rows) + "\\nEND\\n"
|
| 61 |
+
|
| 62 |
+
|
| 63 |
def _pdb_mol(block: str, label: str):
|
| 64 |
if not block or not any(line.startswith(("ATOM ", "HETATM")) for line in block.splitlines()):
|
| 65 |
raise ValueError(f"{label} structure contains no PDB atom records")
|
| 66 |
+
try:
|
| 67 |
+
mol = Chem.MolFromPDBBlock(block, sanitize=True, removeHs=False, proximityBonding=True)
|
| 68 |
+
except Exception:
|
| 69 |
+
mol = None
|
| 70 |
+
if mol is None and label == "ligand":
|
| 71 |
+
normalized = _normalize_pose_pdb(block)
|
| 72 |
+
mol = Chem.MolFromPDBBlock(normalized, sanitize=True, removeHs=False, proximityBonding=True)
|
| 73 |
if mol is None or mol.GetNumHeavyAtoms() == 0:
|
| 74 |
raise ValueError(f"RDKit could not construct a chemically usable {label} molecule")
|
| 75 |
return mol
|
| 76 |
|
| 77 |
|
| 78 |
+
def _protein_mol(block: str):
|
| 79 |
+
"""Parse protein residues independently to prevent false inter-residue proximity bonds.
|
| 80 |
+
|
| 81 |
+
Reconnects sequential peptide C-N bonds after residue-level chemical perception.
|
| 82 |
+
Any residue that cannot be perceived is explicitly recorded as excluded.
|
| 83 |
+
"""
|
| 84 |
+
from collections import OrderedDict
|
| 85 |
+
residues = OrderedDict()
|
| 86 |
+
for line in block.splitlines():
|
| 87 |
+
if line.startswith("ATOM "):
|
| 88 |
+
if len(line) > 16 and line[16] not in (" ", "A"):
|
| 89 |
+
continue
|
| 90 |
+
key = (line[21:22], line[22:26], line[26:27], line[17:20])
|
| 91 |
+
residues.setdefault(key, []).append(line)
|
| 92 |
+
parsed = []
|
| 93 |
+
skipped = []
|
| 94 |
+
for key, lines in residues.items():
|
| 95 |
+
residue_block = "\\n".join(lines) + "\\nEND\\n"
|
| 96 |
+
try:
|
| 97 |
+
residue_mol = Chem.MolFromPDBBlock(
|
| 98 |
+
residue_block, sanitize=True, removeHs=False, proximityBonding=True
|
| 99 |
+
)
|
| 100 |
+
except Exception:
|
| 101 |
+
residue_mol = None
|
| 102 |
+
if residue_mol is None or residue_mol.GetNumHeavyAtoms() == 0:
|
| 103 |
+
skipped.append({"chain": key[0].strip(), "residue_number": key[1].strip(),
|
| 104 |
+
"residue_name": key[3].strip(), "reason": "RDKit residue sanitization failed"})
|
| 105 |
+
continue
|
| 106 |
+
parsed.append((key, residue_mol))
|
| 107 |
+
if not parsed:
|
| 108 |
+
raise ValueError("RDKit could not chemically perceive any protein residues")
|
| 109 |
+
combined = parsed[0][1]
|
| 110 |
+
residue_atom_indices = []
|
| 111 |
+
for key, mol in parsed:
|
| 112 |
+
if mol is not parsed[0][1]:
|
| 113 |
+
offset = combined.GetNumAtoms()
|
| 114 |
+
combined = Chem.CombineMols(combined, mol)
|
| 115 |
+
else:
|
| 116 |
+
offset = 0
|
| 117 |
+
names = {}
|
| 118 |
+
for atom in mol.GetAtoms():
|
| 119 |
+
info = atom.GetPDBResidueInfo()
|
| 120 |
+
if info:
|
| 121 |
+
names[info.GetName().strip()] = atom.GetIdx() + offset
|
| 122 |
+
residue_atom_indices.append((key, names))
|
| 123 |
+
editable = Chem.RWMol(combined)
|
| 124 |
+
peptide_bonds_added = 0
|
| 125 |
+
for (left_key, left_atoms), (right_key, right_atoms) in zip(residue_atom_indices, residue_atom_indices[1:]):
|
| 126 |
+
same_chain = left_key[0] == right_key[0]
|
| 127 |
+
try:
|
| 128 |
+
sequential = int(right_key[1]) == int(left_key[1]) + 1
|
| 129 |
+
except ValueError:
|
| 130 |
+
sequential = False
|
| 131 |
+
if same_chain and sequential and "C" in left_atoms and "N" in right_atoms:
|
| 132 |
+
c_idx, n_idx = left_atoms["C"], right_atoms["N"]
|
| 133 |
+
p1 = combined.GetConformer().GetAtomPosition(c_idx)
|
| 134 |
+
p2 = combined.GetConformer().GetAtomPosition(n_idx)
|
| 135 |
+
distance = p1.Distance(p2)
|
| 136 |
+
if 1.1 <= distance <= 1.8:
|
| 137 |
+
editable.AddBond(c_idx, n_idx, Chem.BondType.SINGLE)
|
| 138 |
+
peptide_bonds_added += 1
|
| 139 |
+
protein = editable.GetMol()
|
| 140 |
+
Chem.SanitizeMol(protein)
|
| 141 |
+
protein.SetProp("_omvetra_skipped_residues", str(skipped))
|
| 142 |
+
protein.SetProp("_omvetra_peptide_bonds_added", str(peptide_bonds_added))
|
| 143 |
+
return protein
|
| 144 |
+
|
| 145 |
+
|
| 146 |
def _ligand_with_template(ligand_pdb: str, smiles: str):
|
| 147 |
template = Chem.MolFromSmiles(smiles)
|
| 148 |
if template is None:
|
|
|
|
| 275 |
def calculate_interactions(protein_pdb: str, ligand_pdb: str, ligand_smiles: str) -> dict[str, Any]:
|
| 276 |
"""Return RDKit feature-based interaction evidence for one docked pose."""
|
| 277 |
factory = _feature_factory()
|
| 278 |
+
protein_raw = _protein_mol(protein_pdb)\n protein_parse = {\n "parser": "RDKit residue-wise perception with sequential peptide-bond reconstruction",\n "residues_skipped": eval(protein_raw.GetProp("_omvetra_skipped_residues")),\n "peptide_bonds_reconstructed": int(protein_raw.GetProp("_omvetra_peptide_bonds_added")),\n }\n protein = _with_hydrogens(protein_raw)
|
| 279 |
ligand = _with_hydrogens(_ligand_with_template(ligand_pdb, ligand_smiles))
|
| 280 |
pf = _atom_features(protein, factory)
|
| 281 |
lf = _atom_features(ligand, factory)
|
|
|
|
| 355 |
"limitations": [
|
| 356 |
"Interaction classifications depend on the supplied protonation/tautomer state and prepared coordinates.",
|
| 357 |
"RDKit feature perception is a computational model, not experimental evidence of binding.",
|
| 358 |
+
"Only the provided receptor atoms and docked pose are evaluated; omitted waters, ions, cofactors, and alternate conformations are not inferred.",\n "Residues listed in protein_parse.residues_skipped were excluded from chemical feature perception; a DEGRADED status must not be interpreted as complete receptor coverage.",
|
| 359 |
"Distances and angles are geometric screens, not interaction energies or binding-affinity measurements.",
|
| 360 |
],
|
| 361 |
}
|