Spaces:
Running
Running
Download bioai-platform/backend/app/routers/docking.py from Samad14/bio-nexus-api: direct link, hf CLI and curl.
- Browser
- Download file 31.4 kB
-
https://huggingface.co/spaces/Samad14/bio-nexus-api/resolve/main/bioai-platform/backend/app/routers/docking.py
- Command line
-
hf download hf://spaces/Samad14/bio-nexus-api/bioai-platform/backend/app/routers/docking.py
-
curl -L -o docking.py https://huggingface.co/spaces/Samad14/bio-nexus-api/resolve/main/bioai-platform/backend/app/routers/docking.py
31.4 kB
| from __future__ import annotations | |
| import json | |
| import math | |
| import re | |
| from fastapi import APIRouter, HTTPException, Depends, Request | |
| from fastapi.responses import PlainTextResponse | |
| from pydantic import BaseModel, Field | |
| from typing import Any, Optional | |
| from app.services.supabase import get_client | |
| from app.services.auth import require_user_id | |
| from app.services.ssrf import validate_url | |
| router = APIRouter(prefix="/api/docking", tags=["Docking"]) | |
| _TABLE = "docking_jobs" | |
| # --------------------------------------------------------------------------- | |
| # Request / response schemas (match frontend DockingResult type) | |
| # --------------------------------------------------------------------------- | |
| class DockingJobCreate(BaseModel): | |
| pdb_id: str = "" | |
| smiles: str | |
| pdb_url: str = "" | |
| grid_center: Optional[list[float]] = None | |
| grid_size: list[float] = Field(default_factory=lambda: [20.0, 20.0, 20.0]) | |
| exhaustiveness: int = 8 | |
| num_modes: int = 9 | |
| class DockingJobResponse(BaseModel): | |
| job_id: str | |
| status: str | |
| result: Optional[dict[str, Any]] = None | |
| error: Optional[str] = None | |
| # --------------------------------------------------------------------------- | |
| # Helpers | |
| # --------------------------------------------------------------------------- | |
| def _prune_old(supabase, max_rows: int = 200): | |
| try: | |
| rows = ( | |
| supabase.table(_TABLE) | |
| .select("id") | |
| .order("created_at", desc=True) | |
| .range(max_rows, max_rows + 1000) | |
| .execute() | |
| .data | |
| ) | |
| if rows: | |
| supabase.table(_TABLE).delete().in_( | |
| "id", [r["id"] for r in rows] | |
| ).execute() | |
| except Exception: | |
| pass | |
| def _row_to_response(row: dict) -> dict: | |
| """Convert a Supabase row to the frontend DockingResult shape.""" | |
| result = None | |
| # Prefer Storage URL (Phase 0c) | |
| storage_url = row.get("storage_url") | |
| if storage_url: | |
| from app.services.artifact_storage import download_json | |
| result = download_json(storage_url) | |
| elif row.get("result_sdf"): | |
| try: | |
| result = json.loads(row["result_sdf"]) | |
| except Exception: | |
| pass | |
| return { | |
| "job_id": row["id"], | |
| "status": row["status"], | |
| "result": result, | |
| "error": row.get("error"), | |
| } | |
| def _row_to_list_response(row: dict) -> dict: | |
| """Lightweight row conversion for list views — skips Storage downloads.""" | |
| return { | |
| "job_id": row["id"], | |
| "status": row["status"], | |
| "result": None, | |
| "error": row.get("error"), | |
| } | |
| def _ligand_properties(smiles: str) -> dict: | |
| """Compute essential ligand properties from SMILES using RDKit. | |
| Uses the same descriptor conventions as the ADMET tool (HBA = N+O count). | |
| """ | |
| try: | |
| from rdkit import Chem | |
| from rdkit.Chem import Descriptors, Lipinski, rdMolDescriptors | |
| mol = Chem.MolFromSmiles(smiles) | |
| if mol is None: | |
| return {} | |
| hydrogen_count = sum(a.GetTotalNumHs() for a in mol.GetAtoms()) | |
| return { | |
| "molecular_formula": rdMolDescriptors.CalcMolFormula(mol), | |
| "molecular_weight": round(Descriptors.MolWt(mol), 2), | |
| "heavy_atoms": int(mol.GetNumHeavyAtoms()), | |
| "hydrogen_count": int(hydrogen_count), | |
| "total_atoms": int(mol.GetNumHeavyAtoms() + hydrogen_count), | |
| "rotatable_bonds": int(Lipinski.NumRotatableBonds(mol)), | |
| "tpsa": round(Descriptors.TPSA(mol, includeSandP=True), 2), | |
| "hbd": int(Lipinski.NumHDonors(mol)), | |
| "hba": int(rdMolDescriptors.CalcNumLipinskiHBA(mol)), | |
| "logp": round(Descriptors.MolLogP(mol), 2), | |
| } | |
| except Exception: | |
| return {} | |
| # --------------------------------------------------------------------------- | |
| # Background worker | |
| # --------------------------------------------------------------------------- | |
| def _run_docking_sync(job_id: str, payload: dict): | |
| """Run the full docking pipeline synchronously (in a thread).""" | |
| supabase = get_client() | |
| try: | |
| supabase.table(_TABLE).update({"status": "running"}).eq("id", job_id).execute() | |
| from app.tools.docking import ( | |
| fetch_pdb_from_rcsb, | |
| compute_grid_center, | |
| smiles_to_pdbqt, | |
| pdb_to_pdbqt_receptor, | |
| run_vina, | |
| ) | |
| import urllib.request | |
| from app.services.ssrf import validate_url | |
| pdb_id = payload.get("pdb_id", "").strip().upper() | |
| pdb_url = payload.get("pdb_url", "").strip() | |
| smiles = payload.get("ligand_smiles") or payload.get("smiles") | |
| if not smiles: | |
| raise ValueError("Missing ligand_smiles in job payload") | |
| # 1. Obtain PDB text | |
| pdb_text: str | None = None | |
| if pdb_url: | |
| validate_url(pdb_url) # SSRF guard even in worker | |
| try: | |
| pdb_text = urllib.request.urlopen(pdb_url, timeout=30).read().decode("utf-8", errors="replace") | |
| except Exception: | |
| pass | |
| if not pdb_text and pdb_id: | |
| pdb_text = fetch_pdb_from_rcsb(pdb_id) | |
| if not pdb_text: | |
| raise RuntimeError( | |
| "Could not obtain a PDB structure. " | |
| "Provide a valid pdb_id or pdb_url." | |
| ) | |
| # 2. Strip heteroatoms (keep protein backbone for receptor) | |
| protein_lines = [ | |
| l for l in pdb_text.splitlines() | |
| if l.startswith("ATOM") or l.startswith("TER") or l.startswith("END") | |
| ] | |
| protein_pdb = "\n".join(protein_lines) if protein_lines else pdb_text | |
| # 3. Compute grid center if not provided | |
| grid_center = payload.get("grid_center") | |
| if not grid_center or all(v == 0 for v in grid_center): | |
| grid_center = compute_grid_center(protein_pdb) | |
| # Add a small offset so the center isn't dead on a backbone atom | |
| grid_center = [round(c + 2.0, 3) for c in grid_center] | |
| grid_size = payload.get("grid_size", [20.0, 20.0, 20.0]) | |
| # 4. Prepare receptor | |
| protein_pdbqt = pdb_to_pdbqt_receptor(protein_pdb) | |
| # 5. Prepare ligand | |
| lig_pdbqt = smiles_to_pdbqt(smiles) | |
| # 6. Run AutoDock Vina | |
| vina_result = run_vina( | |
| protein_pdbqt=protein_pdbqt, | |
| ligand_pdbqt=lig_pdbqt, | |
| grid_center=grid_center, | |
| grid_size=grid_size, | |
| exhaustiveness=payload.get("exhaustiveness", 8), | |
| num_modes=payload.get("num_modes", 9), | |
| ) | |
| # 7. Compute interaction summary for best pose | |
| interactions = _compute_interactions( | |
| protein_pdb, vina_result["ligand_pdb"] | |
| ) | |
| pose_interactions = _summarize_pose_interactions( | |
| protein_pdb, vina_result.get("result_sdf", "") | |
| ) | |
| # 8. Ligand essential data (from SMILES, RDKit) | |
| ligand_properties = _ligand_properties(smiles) | |
| result_obj = { | |
| "pdb_id": pdb_id, | |
| "smiles": smiles, | |
| "poses": vina_result["poses"], | |
| "num_poses": vina_result["num_poses"], | |
| "ligand_properties": ligand_properties, | |
| "box_center": { | |
| "x": grid_center[0], | |
| "y": grid_center[1], | |
| "z": grid_center[2], | |
| }, | |
| "box_size": { | |
| "x": grid_size[0], | |
| "y": grid_size[1], | |
| "z": grid_size[2], | |
| }, | |
| "vina_log": vina_result.get("vina_log", ""), | |
| "vina_version": vina_result.get("vina_version", ""), | |
| "vina_seed": (vina_result.get("vina_meta") or {}).get("random_seed"), | |
| "vina_exhaustiveness": (vina_result.get("vina_meta") or {}).get("exhaustiveness"), | |
| "interactions": interactions, | |
| "pose_interactions": pose_interactions, | |
| "ligand_pdb": vina_result.get("ligand_pdb", ""), | |
| "result_sdf": vina_result.get("result_sdf", ""), | |
| "receptor_pdb": protein_pdb, | |
| } | |
| # Offload to Supabase Storage; DB keeps only the URL | |
| from app.services.artifact_storage import upload_json | |
| storage_url = upload_json(job_id, "result", result_obj) | |
| supabase.table(_TABLE).update({ | |
| "status": "complete", | |
| "storage_url": storage_url, | |
| "result_sdf": None, # cleared — data lives in Storage now | |
| }).eq("id", job_id).execute() | |
| except Exception as exc: | |
| import traceback | |
| tb = traceback.format_exc() | |
| supabase.table(_TABLE).update({ | |
| "status": "failed", | |
| "error": f"{exc}\n\n{tb}"[:4000], | |
| }).eq("id", job_id).execute() | |
| finally: | |
| _prune_old(supabase) | |
| # --------------------------------------------------------------------------- | |
| # Geometric interaction detector (H-bonds, hydrophobic, pi-stacking, salt bridges) | |
| # --------------------------------------------------------------------------- | |
| # Protein atom classification | |
| _HYDROPHOBIC_RES = {"ALA", "VAL", "LEU", "ILE", "MET", "PHE", "TRP", "PRO", "GLY"} | |
| _AROMATIC_RES = {"PHE", "TRP", "TYR", "HIS"} | |
| # Atoms in aromatic rings by residue (PDB atom names) | |
| _AROMATIC_RING_ATOMS = { | |
| "PHE": ["CG", "CD1", "CD2", "CE1", "CE2", "CZ"], | |
| "TYR": ["CG", "CD1", "CD2", "CE1", "CE2", "CZ"], | |
| "HIS": ["CG", "ND1", "CD2", "CE1", "NE2"], | |
| "TRP": ["CG", "CD1", "CD2", "NE1", "CE2", "CE3", "CZ2", "CZ3", "CH2"], | |
| } | |
| # Two-ring centroids for TRP (5-membered + 6-membered) | |
| _TRP_RING_ATOMS = { | |
| "five": ["CD1", "NE1", "CE2", "CG", "CD2"], | |
| "six": ["CE2", "CD2", "CZ2", "CH2", "CZ3", "CE3"], | |
| } | |
| # Polar atoms eligible for H-bonding | |
| _POLAR_ATOMS = {"N", "O", "S"} | |
| # Residue-level charge groups for salt bridges | |
| _ANIONIC_RES = {"ASP", "GLU"} | |
| _CATIONIC_RES = {"LYS", "ARG", "HIS"} | |
| # Atom names that define the charged group center | |
| _ANIONIC_CARBONS = {"ASP": "CG", "GLU": "CD"} | |
| _CATIONIC_NITROGENS = {"LYS": "NZ", "ARG": ["CZ", "NH1", "NH2"]} | |
| _PDB_COORD_RE = re.compile( | |
| r"^(ATOM|HETATM)\s+\d+\s+(\S+)\s+(\S{3})\s+(\S)\s+(\d+)\s+" | |
| r"([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)" | |
| ) | |
| def _parse_atom_coords(pdb_text: str) -> list[tuple[str, str, str, str, int, float, float, float]]: | |
| """Parse PDB into (record, atom_name, res_name, chain, res_seq, x, y, z).""" | |
| atoms = [] | |
| for line in pdb_text.splitlines(): | |
| m = _PDB_COORD_RE.match(line) | |
| if m: | |
| atoms.append(( | |
| m.group(1), m.group(2), m.group(3), m.group(4), | |
| int(m.group(5)), | |
| float(m.group(6)), float(m.group(7)), float(m.group(8)), | |
| )) | |
| return atoms | |
| def _distance(a: tuple[float, float, float], b: tuple[float, float, float]) -> float: | |
| return math.sqrt(sum((x - y) ** 2 for x, y in zip(a, b))) | |
| def _angle(a: tuple[float, float, float], b: tuple[float, float, float], | |
| c: tuple[float, float, float]) -> float: | |
| """Angle at vertex b between segments b→a and b→c, in degrees.""" | |
| ba = tuple(x - y for x, y in zip(a, b)) | |
| bc = tuple(x - y for x, y in zip(c, b)) | |
| dot = sum(x * y for x, y in zip(ba, bc)) | |
| mag_ba = math.sqrt(sum(x * x for x in ba)) | |
| mag_bc = math.sqrt(sum(x * x for x in bc)) | |
| if mag_ba < 1e-9 or mag_bc < 1e-9: | |
| return 0.0 | |
| cos_angle = max(-1.0, min(1.0, dot / (mag_ba * mag_bc))) | |
| return math.degrees(math.acos(cos_angle)) | |
| def _vec_sub(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]: | |
| return (a[0] - b[0], a[1] - b[1], a[2] - b[2]) | |
| def _vec_cross(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]: | |
| return ( | |
| a[1] * b[2] - a[2] * b[1], | |
| a[2] * b[0] - a[0] * b[2], | |
| a[0] * b[1] - a[1] * b[0], | |
| ) | |
| def _vec_norm(v: tuple[float, float, float]) -> float: | |
| return math.sqrt(sum(x * x for x in v)) | |
| def _ring_centroid(coords: list[tuple[float, float, float]]) -> tuple[float, float, float]: | |
| n = len(coords) | |
| if n == 0: | |
| return (0.0, 0.0, 0.0) | |
| return ( | |
| sum(c[0] for c in coords) / n, | |
| sum(c[1] for c in coords) / n, | |
| sum(c[2] for c in coords) / n, | |
| ) | |
| def _ring_normal(coords: list[tuple[float, float, float]]) -> tuple[float, float, float]: | |
| """Compute the normal vector of a planar ring via cross product of two edges.""" | |
| if len(coords) < 3: | |
| return (0.0, 0.0, 1.0) | |
| v1 = _vec_sub(coords[1], coords[0]) | |
| v2 = _vec_sub(coords[2], coords[0]) | |
| cross = _vec_cross(v1, v2) | |
| n = _vec_norm(cross) | |
| if n < 1e-9: | |
| return (0.0, 0.0, 1.0) | |
| return (cross[0] / n, cross[1] / n, cross[2] / n) | |
| def _build_residue_map(atoms: list[tuple]) -> dict[tuple[str, str, int], list[tuple]]: | |
| """Group atoms by (chain, res_name, res_seq).""" | |
| res_map: dict[tuple[str, str, int], list[tuple]] = {} | |
| for a in atoms: | |
| key = (a[3], a[2], a[4]) # chain, res_name, res_seq | |
| res_map.setdefault(key, []).append(a) | |
| return res_map | |
| def _find_hydrogens(atoms: list[tuple]) -> list[tuple]: | |
| """Return only hydrogen atoms from parsed PDB.""" | |
| return [a for a in atoms if a[1].startswith("H") or a[1] in ("1H", "2H", "3H")] | |
| def _compute_interactions(protein_pdb: str, ligand_pdb: str) -> dict: | |
| """ | |
| Compute protein-ligand interactions using proper geometry. | |
| H-bonds: donor-H···acceptor angle > 120°, distance < 3.5Å | |
| Hydrophobic: ligand carbon near protein carbon in hydrophobic residue, < 4.5Å | |
| Pi-stacking: aromatic ring centroids, distance < 5.5Å, inter-ring angle | |
| Salt bridges: charged group centroids, distance < 4.0Å | |
| """ | |
| if not ligand_pdb: | |
| return {"hbonds": [], "hydrophobic": [], "pi_stacking": [], "salt_bridges": []} | |
| prot_atoms = _parse_atom_coords(protein_pdb) | |
| lig_atoms = _parse_atom_coords(ligand_pdb) | |
| prot_h = _find_hydrogens(prot_atoms) | |
| lig_h = _find_hydrogens(lig_atoms) | |
| prot_heavy = [a for a in prot_atoms if not (a[1].startswith("H") or a[1] in ("1H", "2H", "3H"))] | |
| lig_heavy = [a for a in lig_atoms if not (a[1].startswith("H") or a[1] in ("1H", "2H", "3H"))] | |
| hbonds: list[dict] = [] | |
| hydrophobic: list[dict] = [] | |
| pi_stacking: list[dict] = [] | |
| salt_bridges: list[dict] = [] | |
| seen_hbonds: set[tuple] = set() | |
| seen_hydrophobic: set[tuple] = set() | |
| seen_salt: set[tuple] = set() | |
| # --- H-bonds with angle check --- | |
| for la in lig_heavy: | |
| l_elem = la[1][0] if la[1] else "" | |
| if l_elem not in _POLAR_ATOMS: | |
| continue | |
| lcoord = (la[5], la[6], la[7]) | |
| # Find nearest H on ligand for angle reference | |
| lig_h_near = None | |
| min_h_dist = 1.5 | |
| for h in lig_h: | |
| hd = _distance(lcoord, (h[5], h[6], h[7])) | |
| if hd < min_h_dist: | |
| min_h_dist = hd | |
| lig_h_near = (h[5], h[6], h[7]) | |
| for pa in prot_heavy: | |
| p_elem = pa[1][0] if pa[1] else "" | |
| if p_elem not in _POLAR_ATOMS: | |
| continue | |
| pcoord = (pa[5], pa[6], pa[7]) | |
| d = _distance(lcoord, pcoord) | |
| if d > 3.5 or d < 1.0: | |
| continue | |
| # Find nearest H on protein donor for angle check | |
| prot_h_near = None | |
| min_ph_dist = 1.5 | |
| for h in prot_h: | |
| hd = _distance(pcoord, (h[5], h[6], h[7])) | |
| if hd < min_ph_dist: | |
| min_ph_dist = hd | |
| prot_h_near = (h[5], h[6], h[7]) | |
| # Check angle if we have hydrogen positions | |
| angle_ok = True | |
| if lig_h_near and prot_h_near: | |
| # H-bond angle: ligand-H···protein or protein-H···ligand | |
| a1 = _angle(lig_h_near, lcoord, pcoord) | |
| a2 = _angle(prot_h_near, pcoord, lcoord) | |
| angle_ok = max(a1, a2) > 120.0 | |
| elif lig_h_near: | |
| a1 = _angle(lig_h_near, lcoord, pcoord) | |
| angle_ok = a1 > 120.0 | |
| elif prot_h_near: | |
| a1 = _angle(prot_h_near, pcoord, lcoord) | |
| angle_ok = a1 > 120.0 | |
| # If no H found at all, accept based on distance + element only | |
| if not angle_ok: | |
| continue | |
| key = (la[4], pa[4]) # (lig_res_seq, prot_res_seq) | |
| if key in seen_hbonds: | |
| continue | |
| seen_hbonds.add(key) | |
| hbonds.append({ | |
| "type": "hbond", | |
| "ligand_atom": la[1], | |
| "ligand_coords": [la[5], la[6], la[7]], | |
| "protein_residue": pa[2], | |
| "protein_residue_seq": pa[4], | |
| "protein_chain": pa[3], | |
| "protein_atom": pa[1], | |
| "protein_coords": [pa[5], pa[6], pa[7]], | |
| "distance": round(d, 2), | |
| "confidence": "high" if d < 3.0 else "medium", | |
| }) | |
| if len(hbonds) >= 20: | |
| break | |
| if len(hbonds) >= 20: | |
| break | |
| # --- Hydrophobic contacts --- | |
| for la in lig_heavy: | |
| if la[1][0] != "C": | |
| continue | |
| lcoord = (la[5], la[6], la[7]) | |
| for pa in prot_heavy: | |
| if pa[1][0] != "C": | |
| continue | |
| pres = pa[2] | |
| if pres not in _HYDROPHOBIC_RES: | |
| continue | |
| pcoord = (pa[5], pa[6], pa[7]) | |
| d = _distance(lcoord, pcoord) | |
| if d < 4.5: | |
| key = (la[4], pa[4]) | |
| if key in seen_hydrophobic: | |
| continue | |
| seen_hydrophobic.add(key) | |
| hydrophobic.append({ | |
| "type": "hydrophobic", | |
| "ligand_atom": la[1], | |
| "ligand_coords": [la[5], la[6], la[7]], | |
| "protein_residue": pres, | |
| "protein_residue_seq": pa[4], | |
| "protein_chain": pa[3], | |
| "protein_atom": pa[1], | |
| "protein_coords": [pa[5], pa[6], pa[7]], | |
| "distance": round(d, 2), | |
| }) | |
| if len(hydrophobic) >= 20: | |
| break | |
| if len(hydrophobic) >= 20: | |
| break | |
| # --- Pi-stacking (aromatic ring centroid geometry) --- | |
| prot_res_map = _build_residue_map(prot_heavy) | |
| for res_key, res_atoms in prot_res_map.items(): | |
| chain, res_name, res_seq = res_key | |
| if res_name not in _AROMATIC_RES: | |
| continue | |
| ring_atom_names = _AROMATIC_RING_ATOMS[res_name] | |
| ring_atoms_by_name = {a[1]: a for a in res_atoms} | |
| ring_coords = [] | |
| for rn in ring_atom_names: | |
| if rn in ring_atoms_by_name: | |
| a = ring_atoms_by_name[rn] | |
| ring_coords.append((a[5], a[6], a[7])) | |
| if len(ring_coords) < 3: | |
| continue | |
| centroid = _ring_centroid(ring_coords) | |
| normal = _ring_normal(ring_coords) | |
| # For TRP, also check the 5-membered ring | |
| rings_to_check = [(ring_coords, centroid, normal)] | |
| if res_name == "TRP": | |
| for ring_name in ("five", "six"): | |
| ring_atom_names_2 = _TRP_RING_ATOMS[ring_name] | |
| coords_2 = [] | |
| for rn in ring_atom_names_2: | |
| if rn in ring_atoms_by_name: | |
| a = ring_atoms_by_name[rn] | |
| coords_2.append((a[5], a[6], a[7])) | |
| if len(coords_2) >= 3: | |
| rings_to_check.append((coords_2, _ring_centroid(coords_2), _ring_normal(coords_2))) | |
| for ring_coords_r, centroid_r, normal_r in rings_to_check: | |
| # Find aromatic atoms in ligand (heuristic: C/N in a flat region) | |
| lig_aromatic_coords = [] | |
| for la in lig_heavy: | |
| if la[1][0] in ("C", "N"): | |
| lig_aromatic_coords.append((la[5], la[6], la[7])) | |
| if len(lig_aromatic_coords) < 3: | |
| continue | |
| # Use all ligand heavy atoms as a pseudo-centroid | |
| lig_centroid = _ring_centroid(lig_aromatic_coords) | |
| dist = _distance(centroid_r, lig_centroid) | |
| if dist > 6.5: | |
| continue | |
| # Compute angle between ring normal and vector to ligand centroid | |
| v_to_lig = _vec_sub(lig_centroid, centroid_r) | |
| v_norm = _vec_norm(v_to_lig) | |
| if v_norm < 1e-9: | |
| continue | |
| cos_angle = abs(sum(x * y for x, y in zip(normal_r, v_to_lig))) / ( | |
| _vec_norm(normal_r) * v_norm | |
| ) | |
| ring_angle = math.degrees(math.acos(max(0, min(1, cos_angle)))) | |
| # Parallel: ring normal ~parallel to centroid-centroid vector (angle < 30°) | |
| # T-shaped: ring normal ~perpendicular (angle 60-90°) | |
| stacking_type = "unknown" | |
| if ring_angle < 30 and dist < 5.5: | |
| stacking_type = "parallel" | |
| elif 60 < ring_angle < 90 and dist < 6.5: | |
| stacking_type = "perpendicular" | |
| if stacking_type == "unknown": | |
| continue | |
| pi_stacking.append({ | |
| "type": "pi_stacking", | |
| "protein_residue": res_name, | |
| "protein_residue_seq": res_seq, | |
| "protein_chain": chain, | |
| "ring_centroid": [round(c, 3) for c in centroid_r], | |
| "ring_normal": [round(c, 3) for c in normal_r], | |
| "ligand_centroid": [round(c, 3) for c in lig_centroid], | |
| "distance": round(dist, 2), | |
| "angle": round(ring_angle, 1), | |
| "stacking_type": stacking_type, | |
| "confidence": "high" if dist < 4.5 else "medium", | |
| }) | |
| if len(pi_stacking) >= 10: | |
| break | |
| if len(pi_stacking) >= 10: | |
| break | |
| # --- Salt bridges (charged group centroid distance) --- | |
| for la in lig_heavy: | |
| l_elem = la[1][0] if la[1] else "" | |
| if l_elem not in ("N", "O", "S", "C"): | |
| continue | |
| lcoord = (la[5], la[6], la[7]) | |
| for pa in prot_heavy: | |
| pres = pa[2] | |
| if pres in _ANIONIC_RES and pa[1] in ("OD1", "OD2", "OE1", "OE2"): | |
| d = _distance(lcoord, pa[1:8] if False else (pa[5], pa[6], pa[7])) | |
| if d < 4.0 and l_elem in ("N",): | |
| key = (la[4], pa[4]) | |
| if key not in seen_salt: | |
| seen_salt.add(key) | |
| salt_bridges.append({ | |
| "type": "salt_bridge", | |
| "ligand_atom": la[1], | |
| "ligand_coords": [la[5], la[6], la[7]], | |
| "protein_residue": pres, | |
| "protein_residue_seq": pa[4], | |
| "protein_chain": pa[3], | |
| "protein_atom": pa[1], | |
| "protein_coords": [pa[5], pa[6], pa[7]], | |
| "distance": round(d, 2), | |
| "charge_pair": "positive-negative", | |
| }) | |
| if pres in _CATIONIC_RES: | |
| cat_atoms = _CATIONIC_NITROGENS.get(pres, []) | |
| if isinstance(cat_atoms, str): | |
| cat_atoms = [cat_atoms] | |
| if pa[1] in cat_atoms: | |
| d = _distance(lcoord, (pa[5], pa[6], pa[7])) | |
| if d < 4.0 and l_elem in ("O",): | |
| key = (la[4], pa[4]) | |
| if key not in seen_salt: | |
| seen_salt.add(key) | |
| salt_bridges.append({ | |
| "type": "salt_bridge", | |
| "ligand_atom": la[1], | |
| "ligand_coords": [la[5], la[6], la[7]], | |
| "protein_residue": pres, | |
| "protein_residue_seq": pa[4], | |
| "protein_chain": pa[3], | |
| "protein_atom": pa[1], | |
| "protein_coords": [pa[5], pa[6], pa[7]], | |
| "distance": round(d, 2), | |
| "charge_pair": "negative-positive", | |
| }) | |
| return { | |
| "hbonds": hbonds[:20], | |
| "hydrophobic": hydrophobic[:20], | |
| "pi_stacking": pi_stacking[:10], | |
| "salt_bridges": salt_bridges[:10], | |
| } | |
| def _summarize_pose_interactions(protein_pdb: str, output_pdbqt: str) -> list[dict]: | |
| """Per-pose interaction summary.""" | |
| if not output_pdbqt: | |
| return [] | |
| models: dict[int, list[str]] = {} | |
| current: int | None = None | |
| for line in output_pdbqt.splitlines(): | |
| if line.startswith("MODEL"): | |
| parts = line.split() | |
| if len(parts) >= 2: | |
| current = int(parts[1]) | |
| models[current] = [] | |
| elif line.startswith("ENDMDL"): | |
| current = None | |
| elif current is not None: | |
| models.setdefault(current, []).append(line) | |
| summaries = [] | |
| for mid in sorted(models.keys()): | |
| lig_pdb = "\n".join(l for l in models[mid] if l.startswith("HETATM")) + "\nEND" | |
| inter = _compute_interactions(protein_pdb, lig_pdb) | |
| summaries.append({ | |
| "model": mid, | |
| "hbonds": len(inter.get("hbonds", [])), | |
| "hydrophobic": len(inter.get("hydrophobic", [])), | |
| "pi_stacking": len(inter.get("pi_stacking", [])), | |
| "salt_bridges": len(inter.get("salt_bridges", [])), | |
| }) | |
| return summaries | |
| # --------------------------------------------------------------------------- | |
| # API endpoints | |
| # --------------------------------------------------------------------------- | |
| async def create_docking_job(request: Request, body: DockingJobCreate, user_id: str = Depends(require_user_id)): | |
| supabase = get_client() | |
| _prune_old(supabase) | |
| # SSRF validation on user-supplied URL | |
| if body.pdb_url: | |
| validate_url(body.pdb_url) | |
| import uuid, datetime | |
| job_id = str(uuid.uuid4()) | |
| now = datetime.datetime.utcnow().isoformat() | |
| insert_row = { | |
| "id": job_id, | |
| "status": "queued", | |
| "ligand_smiles": body.smiles, | |
| "user_id": user_id, | |
| "payload": { | |
| "pdb_id": body.pdb_id, | |
| "pdb_url": body.pdb_url, | |
| "grid_center": body.grid_center or [0, 0, 0], | |
| "grid_size": body.grid_size, | |
| "exhaustiveness": body.exhaustiveness, | |
| "num_modes": body.num_modes, | |
| "smiles": body.smiles, | |
| "ligand_smiles": body.smiles, | |
| }, | |
| } | |
| try: | |
| supabase.table(_TABLE).insert(insert_row).execute() | |
| except Exception as e: | |
| if "ligand_smiles" in str(e): | |
| supabase.table(_TABLE).insert({ | |
| "id": job_id, "status": "queued", "user_id": user_id, | |
| "payload": insert_row["payload"], | |
| }).execute() | |
| else: | |
| raise | |
| return DockingJobResponse(job_id=job_id, status="queued", result=None) | |
| async def get_docking_job(job_id: str, user_id: str = Depends(require_user_id)): | |
| supabase = get_client() | |
| result = supabase.table(_TABLE).select("*").eq("id", job_id).eq("user_id", user_id).single().execute() | |
| if not result.data: | |
| raise HTTPException(status_code=404, detail="Docking job not found") | |
| return DockingJobResponse(**_row_to_response(result.data)) | |
| async def get_docking_pdb(job_id: str, user_id: str = Depends(require_user_id)): | |
| supabase = get_client() | |
| row = supabase.table(_TABLE).select("result_sdf,storage_url").eq("id", job_id).eq("user_id", user_id).single().execute() | |
| if not row.data: | |
| raise HTTPException(status_code=404, detail="Docking result not found") | |
| data = None | |
| if row.data.get("storage_url"): | |
| from app.services.artifact_storage import download_json | |
| data = download_json(row.data["storage_url"]) | |
| elif row.data.get("result_sdf"): | |
| try: | |
| data = json.loads(row.data["result_sdf"]) | |
| except Exception: | |
| pass | |
| if not data: | |
| raise HTTPException(status_code=404, detail="Docking result not found") | |
| ligand_pdb = data.get("ligand_pdb", "") | |
| if not ligand_pdb: | |
| raise HTTPException(status_code=404, detail="No ligand PDB available") | |
| from fastapi.responses import PlainTextResponse | |
| return PlainTextResponse(ligand_pdb, media_type="text/plain") | |
| def _load_docking_result(job_id: str, user_id: str) -> dict: | |
| supabase = get_client() | |
| row = supabase.table(_TABLE).select("result_sdf,storage_url").eq("id", job_id).eq("user_id", user_id).single().execute() | |
| if not row.data: | |
| raise HTTPException(status_code=404, detail="Docking result not found") | |
| data = None | |
| if row.data.get("storage_url"): | |
| from app.services.artifact_storage import download_json | |
| data = download_json(row.data["storage_url"]) | |
| elif row.data.get("result_sdf"): | |
| try: | |
| data = json.loads(row.data["result_sdf"]) | |
| except Exception: | |
| pass | |
| if not data: | |
| raise HTTPException(status_code=404, detail="Docking result not found") | |
| return data | |
| async def get_docking_ligand_sdf(job_id: str, user_id: str = Depends(require_user_id)): | |
| """Ligand-only SDF of the docked poses (techspec §2).""" | |
| data = _load_docking_result(job_id, user_id) | |
| sdf = data.get("result_sdf", "") | |
| if not sdf: | |
| raise HTTPException( | |
| status_code=404, | |
| detail="No SDF stored for this job (older runs predate SDF persistence — re-run the docking job)", | |
| ) | |
| return PlainTextResponse( | |
| sdf, | |
| media_type="chemical/x-mdl-molfile", | |
| headers={"Content-Disposition": f'attachment; filename="docked_{job_id[:8]}.sdf"'}, | |
| ) | |
| async def get_docking_complex_pdb(job_id: str, user_id: str = Depends(require_user_id)): | |
| """Receptor + docked ligand merged into a single PDB (techspec §2).""" | |
| data = _load_docking_result(job_id, user_id) | |
| receptor = data.get("receptor_pdb", "") | |
| ligand = data.get("ligand_pdb", "") | |
| if not receptor or not ligand: | |
| raise HTTPException( | |
| status_code=404, | |
| detail="Complex export needs both receptor and ligand structures (older runs may lack them — re-run the job)", | |
| ) | |
| complex_text = receptor.rstrip() + "\n" + ligand.rstrip() + "\nEND\n" | |
| return PlainTextResponse( | |
| complex_text, | |
| media_type="chemical/x-pdb", | |
| headers={"Content-Disposition": f'attachment; filename="complex_{job_id[:8]}.pdb"'}, | |
| ) | |
| async def list_docking_jobs(limit: int = 50, user_id: str = Depends(require_user_id)): | |
| supabase = get_client() | |
| rows = ( | |
| supabase.table(_TABLE) | |
| .select("*") | |
| .eq("user_id", user_id) | |
| .order("created_at", desc=True) | |
| .limit(limit) | |
| .execute() | |
| .data | |
| ) | |
| return {"jobs": [_row_to_list_response(r) for r in rows]} | |