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