File size: 6,850 Bytes
f12d3b3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 | """
PROTAC Builder — Abstract Base Class
Three-component assembly: warhead + linker + E3 ligand.
All methods accept Chem.Mol objects. The DeFoG graph → Chem.Mol conversion
happens once upstream; this class never parses SMILES on method inputs.
Assembly output (assembled PROTAC) is returned as a SMILES string — this is
RDKit's native output from reaction SMARTS and is fine as an output format.
"""
from abc import ABC, abstractmethod
from typing import Optional
from rdkit import Chem
from rdkit.Chem import AllChem, Descriptors
class PROTAC_Builder(ABC):
"""
Abstract base class for PROTAC builders.
Subclasses encode one specific warhead + E3 ligand + linker chemistry
combination (e.g. erlotinib-COOH + VH032-COOH + diamine linker).
Parameters
----------
warhead : Chem.Mol
Fixed warhead molecule.
e3 : Chem.Mol
Fixed E3 ligand molecule.
warhead_name / e3_name : str
Display names for logging.
"""
def __init__(self, warhead: Chem.Mol, e3: Chem.Mol,
warhead_name: str = "Warhead", e3_name: str = "E3 Ligand"):
if warhead is None:
raise ValueError("warhead must be a Chem.Mol object")
if e3 is None:
raise ValueError("e3 must be a Chem.Mol object")
self.warhead_mol = warhead
self.e3_mol = e3
self.warhead_name = warhead_name
self.e3_name = e3_name
# Canonical SMILES stored for display / serialization only
self.warhead_smiles = Chem.MolToSmiles(warhead)
self.e3_smiles = Chem.MolToSmiles(e3)
self._verify_warhead_reactive_group()
self._verify_e3_reactive_group()
# ------------------------------------------------------------------
# Abstract interface
# ------------------------------------------------------------------
@abstractmethod
def _verify_warhead_reactive_group(self):
"""Confirm warhead has the expected reactive group for linker attachment."""
pass
@abstractmethod
def _verify_e3_reactive_group(self):
"""Confirm E3 ligand has the expected reactive group for linker attachment."""
pass
@abstractmethod
def find_linker_attachment_sites(self, linker_mol: Chem.Mol) -> list:
"""
Find attachment sites on the linker for both warhead and E3 ends.
Returns list of dicts:
[{'warhead_site': atom_idx, 'e3_site': atom_idx, ...}]
"""
pass
@abstractmethod
def _assemble_protac(self, linker_mol: Chem.Mol, warhead_site_idx: int,
e3_site_idx: int) -> Optional[str]:
"""
Perform the two bond-forming reactions.
Returns SMILES of the assembled PROTAC, or None on failure.
"""
pass
@abstractmethod
def check_attachment_site_compatibility(self, protac_mol: Chem.Mol) -> bool:
"""Verify assembled PROTAC has expected bond signatures at both ends."""
pass
@abstractmethod
def _basic_validation(self, protac_mol: Chem.Mol) -> dict:
"""
Run PROTAC-specific validation checks.
Must include '_passed': bool in the returned dict.
"""
pass
# ------------------------------------------------------------------
# Shared public interface
# ------------------------------------------------------------------
def assemble(self, linker_mol: Chem.Mol, return_all: bool = True,
amide_only: bool = False):
"""
Assemble warhead + linker + E3 into a full PROTAC.
Parameters
----------
linker_mol : Chem.Mol
Generated linker as a molecular graph.
return_all : bool
True → return all unique assembled PROTACs (list[str]).
False → return first valid PROTAC (str).
amide_only : bool
If True, only attempt the amide (COOH/CHO) assembly path and
skip the CH3 alkylation path. Use for strict final validation.
Returns
-------
list[str] if return_all else str | None
"""
sites = self.find_linker_attachment_sites(linker_mol, amide_only=amide_only)
if not sites:
raise ValueError("No attachment sites found on linker")
protacs = []
seen = set()
for site in sites:
try:
smiles = self._assemble_protac(
linker_mol, site["warhead_site"], site["e3_site"],
warhead_type=site.get("warhead_type", "nh2"),
e3_type=site.get("e3_type", "nh2"),
warhead_restored=site.get("warhead_restored", False),
e3_restored=site.get("e3_restored", False),
)
if smiles and smiles not in seen:
seen.add(smiles)
protacs.append(smiles)
except Exception:
continue
if not protacs:
raise ValueError("Assembly failed at all linker sites")
return protacs if return_all else protacs[0]
def validate_protac(self, protac_mol: Chem.Mol):
"""
Returns (passed: bool, checks: dict) for an assembled PROTAC Mol.
"""
checks = self._basic_validation(protac_mol)
passed = checks.pop("_passed", False)
return passed, checks
def get_protac_properties(self, protac_mol: Chem.Mol) -> dict:
"""Compute molecular properties for an assembled PROTAC."""
return {
"mw": round(Descriptors.MolWt(protac_mol), 2),
"logp": round(Descriptors.MolLogP(protac_mol), 2),
"tpsa": round(Descriptors.TPSA(protac_mol), 2),
"heavy_atoms": protac_mol.GetNumHeavyAtoms(),
"hbd": Descriptors.NumHDonors(protac_mol),
"hba": Descriptors.NumHAcceptors(protac_mol),
"rotbonds": Descriptors.NumRotatableBonds(protac_mol),
}
def batch_assemble(self, linker_mols: list[Chem.Mol],
return_all: bool = True) -> dict:
"""Assemble PROTACs for a list of linker Mol objects."""
results = {"protacs": [], "successful": [], "failed": [], "total": 0}
for i, mol in enumerate(linker_mols):
try:
p = self.assemble(mol, return_all=return_all)
results["protacs"].append(p)
results["successful"].append(i)
results["total"] += len(p) if return_all else 1
except Exception as e:
results["protacs"].append(None)
results["failed"].append((i, str(e)))
print(f"Assembled {results['total']} PROTAC(s) from "
f"{len(results['successful'])}/{len(linker_mols)} linkers")
return results |