import logging from typing import Optional from rdkit import Chem from protac_splitter.chemoinformatics import ( canonize, dummy2query, remove_attach_atom, remove_dummy_atoms, ) from protac_splitter.evaluation import ( split_prediction, check_reassembly, ) from protac_splitter.data.curation.substructure_extraction import get_attachment_bonds def fix_tetrahedral_centers_ligand( protac_mol: Chem.Mol, ligand_smiles: str, attachment_id: int = 1, ) -> Optional[str]: """ Fixes the tetrahedral centers of a ligand in a PROTAC molecule. Args: protac_mol (Chem.Mol): The RDKit molecule object of the PROTAC. ligand_smiles (str): The SMILES of the ligand to fix. attachment_id (int): The attachment point id of the ligand. Default is 1. Returns: A string containing the fixed ligand SMILES, or None if the fixing process failed. """ ligand_mol = Chem.MolFromSmiles(ligand_smiles) if ligand_mol is None: logging.error(f"Invalid ligand SMILES: {ligand_smiles}") return None ligand_mol = remove_dummy_atoms(ligand_mol) ligand_match = protac_mol.GetSubstructMatch(ligand_mol, useChirality=False) # useChirality=True # Get bonds to break to separate the ligand bonds_to_break = get_attachment_bonds(protac_mol, ligand_match) # Return if no bonds are found if len(bonds_to_break) != 1: logging.error('ERROR: Multiple attachment bonds') return None # Break the bonds to isolate the ligand frag_ligand_mol = Chem.FragmentOnBonds(protac_mol, bonds_to_break, addDummies=True, dummyLabels=[(attachment_id, attachment_id)]) # Get the fragments resulting from bond breaking try: frags = Chem.GetMolFrags(frag_ligand_mol, asMols=True, sanitizeFrags=True) except Exception as e: logging.error(e) return None # Identify the ligand fragment ligand_fragment = None for frag in frags: if frag.HasSubstructMatch(ligand_mol): ligand_fragment = frag break if ligand_fragment is None: logging.error('ERROR: POI fragment not found') ligand_fixed = Chem.MolToSmiles(ligand_fragment) ligand_fixed = canonize(ligand_fixed.replace(f'[{attachment_id}*]', f'[*:{attachment_id}]')) return ligand_fixed def fix_prediction( protac_smiles: str, pred_smiles: str, poi_attachment_id: int = 1, e3_attachment_id: int = 2, remove_stereochemistry: bool = False, verbose: int = 0, ) -> Optional[str]: """ Fixes a prediction by replacing the substructure that does not match the PROTAC with the rest of the PROTAC. Args: protac_smiles (str): The SMILES of the PROTAC. pred_smiles (str): The SMILES of the prediction. poi_attachment_id (int): The attachment point id of the POI. Default is 1. e3_attachment_id (int): The attachment point id of the E3 ligase. Default is 2. verbose (int): The verbosity level. Default is 0. Returns: A string containing the fixed predictions, or None if the fixing process failed. """ protac_mol = Chem.MolFromSmiles(protac_smiles) if protac_mol is None: logging.warning(f"Invalid PROTAC SMILES: {protac_smiles}") return None substructs = split_prediction(pred_smiles) # If there are at least two None values, there's nothing we can do to fix it if sum(v is None for v in substructs.values()) >= 2: logging.warning(f'Unable to continue, more than two substructures are not valid for given input: "{pred_smiles}"') return None # Get molecules of PROTAC and substructures substructs = {k: {'smiles': v, 'mol': Chem.MolFromSmiles(v) if v is not None else v} for k, v in substructs.items()} # Check if renaming the attachment points might already fix the prediction for sub in ['poi', 'e3', 'both']: if sub == 'e3': if substructs['e3']['smiles'] is None: continue e3_attempt = substructs['e3']['smiles'].replace(f'[*:{poi_attachment_id}]', f'[*:{e3_attachment_id}]') poi_attempt = substructs['poi']['smiles'] if sub == 'poi': if substructs['poi']['smiles'] is None: continue e3_attempt = substructs['e3']['smiles'] poi_attempt = substructs['poi']['smiles'].replace(f'[*:{e3_attachment_id}]', f'[*:{poi_attachment_id}]') else: if substructs['e3']['smiles'] is None or substructs['poi']['smiles'] is None: continue e3_attempt = substructs['e3']['smiles'].replace(f'[*:{e3_attachment_id}]', f'[*:{poi_attachment_id}]') poi_attempt = substructs['poi']['smiles'].replace(f'[*:{poi_attachment_id}]', f'[*:{e3_attachment_id}]') protac_attempt = f"{e3_attempt}.{substructs['linker']['smiles']}.{poi_attempt}" if check_reassembly(protac_smiles, protac_attempt): logging.info(f'Input works when renaming attachment points in {sub.title()} substruct. SMILES: "{protac_attempt}"') return protac_attempt # Check if swapping the POI and E3 attachments in the linker might already fix the prediction if substructs['linker']['smiles'] is None: continue linker_attempt = substructs['linker']['smiles'] linker_attempt = linker_attempt.replace(f'[*:{poi_attachment_id}]', f'[*:DUMMY]') linker_attempt = linker_attempt.replace(f'[*:{e3_attachment_id}]', f'[*:{poi_attachment_id}]') linker_attempt = linker_attempt.replace(f'[*:DUMMY]', f'[*:{e3_attachment_id}]') # Try with the original POI and E3 substructures protac_attempt = f"{substructs['e3']['smiles']}.{linker_attempt}.{substructs['poi']['smiles']}" if check_reassembly(protac_smiles, protac_attempt): logging.info(f'Input works when swapping POI and E3 attachment points in the linker. Fixed SMILES: "{protac_attempt}"') return protac_attempt # Try with the swapped POI and E3 substructures protac_attempt = f"{e3_attempt}.{linker_attempt}.{poi_attempt}" if check_reassembly(protac_smiles, protac_attempt): logging.info(f'Input works when swapping POI and E3 attachment points in the linker and in {sub.title()} substruct. Fixed SMILES: "{protac_attempt}"') return protac_attempt # Check if removing stereochemistry results in a valid prediction if remove_stereochemistry: Chem.RemoveStereochemistry(protac_mol) protac_smiles = Chem.MolToSmiles(protac_mol, canonical=True) for k, v in substructs.items(): if v['mol'] is not None: Chem.RemoveStereochemistry(v['mol']) substructs[k]['smiles'] = Chem.MolToSmiles(v['mol'], canonical=True) if all(v['mol'] is not None for v in substructs.values()): if check_reassembly( protac_smiles, '.'.join([v['smiles'] for v in substructs.values()]), ): logging.info(f'Input works when removing stereochemistry. SMILES: "{pred_smiles}"') return f"{substructs['e3']['smiles']}.{substructs['linker']['smiles']}.{substructs['poi']['smiles']}" # Check if any of the substructures is NOT a substructure of the PROTAC, if # so, we mark it as the wrong substructure to fix. num_matches = 0 wrong_substruct = None for sub in ['poi', 'linker', 'e3']: if substructs[sub]['mol'] is None: substructs[sub]['match'] = False wrong_substruct = sub elif protac_mol.HasSubstructMatch(dummy2query(substructs[sub]['mol'])): substructs[sub]['match'] = True num_matches += 1 else: substructs[sub]['match'] = False wrong_substruct = sub if num_matches < 2: logging.warning(f'Prediction does not contain at least two matching substructures of the PROTAC. Num matches: {num_matches}. Prediction SMILES: "{pred_smiles}"') return None # If the wrong substructure is still matching in the PROTAC, we need to a # more complex approach to fix the prediction (see below). def remove_substructure(mol, substructure, attachment_id, replaceDummies=False): if mol is None or substructure is None: return None smaller_mol = Chem.ReplaceCore( mol, substructure, labelByIndex=False, replaceDummies=replaceDummies, ) if smaller_mol is None: logging.warning(f'Failed to remove substructure from prediction SMILES: "{pred_smiles}"') return None smaller_smiles = Chem.MolToSmiles(smaller_mol, canonical=True) smaller_smiles = smaller_smiles.replace('[1*]', f'[*:{attachment_id}]') smaller_smiles = smaller_smiles.replace('[2*]', f'[*:{attachment_id}]') smaller_mol = canonize(Chem.MolFromSmiles(smaller_smiles)) return smaller_mol # If we still have 3 matches: for each substructure, we progressively remove # the other substructures, then we check if the resulting molecule is valid # and has only one fragment. if num_matches == 3: wrong_substruct = None for sub in ['poi', 'linker', 'e3']: removed_mol = Chem.MolFromSmiles(protac_smiles) # Put the current substructure at the end of the list [poi, e3, linker] sub_names = ['poi', 'e3', 'linker'] sub_names.remove(sub) sub_names.append(sub) # The linker often matches in many parts of the PROTAC, so we remove # it when checking the E3 and POI substructures. if sub != 'linker': sub_names.remove('linker') for s in sub_names: attachment_id = poi_attachment_id if s == 'poi' else e3_attachment_id removed_mol = remove_substructure( removed_mol, dummy2query(substructs[s]['mol']), attachment_id=attachment_id, ) # Check if resulting molecule is None, if so, it is the wrong one if removed_mol is None: substructs[sub]['match'] = False wrong_substruct = sub num_matches -= 1 break # Count the number of fragments in the removed molecule num_fragments = Chem.GetMolFrags(removed_mol, asMols=True, sanitizeFrags=False) if len(num_fragments) > 1: substructs[sub]['match'] = False wrong_substruct = sub num_matches -= 1 break if num_matches == 3: logging.warning(f'Prediction already contains all matching substructures of the PROTAC. Prediction SMILES: "{pred_smiles}"') return None # Get the order in which to remove the substructures and get the final one # as the fixed molecule. if wrong_substruct == 'linker': poi_atoms = substructs['poi']['mol'].GetNumAtoms() e3_atoms = substructs['e3']['mol'].GetNumAtoms() order = ['poi', 'e3'] if poi_atoms > e3_atoms else ['e3', 'poi'] else: if wrong_substruct == 'poi': order = ['e3', 'linker'] else: order = ['poi', 'linker'] logging.debug(f'Wrong substructure: {wrong_substruct.upper()}. Order: {order}') fixed_mol = protac_mol for sub in order: logging.debug(f'Removing substructure {sub.upper()} from PROTAC.') if 'linker' not in order: fixed_attach_id = poi_attachment_id if sub == 'poi' else e3_attachment_id else: fixed_attach_id = poi_attachment_id if 'e3' in order else e3_attachment_id if sub == 'linker': attach_id = poi_attachment_id if wrong_substruct == 'poi' else e3_attachment_id fixed_attach_id = poi_attachment_id if wrong_substruct == 'poi' else e3_attachment_id query_mol = remove_attach_atom(substructs[sub]['mol'], attach_id) replaceDummies = True else: query_mol = dummy2query(substructs[sub]['mol']) replaceDummies = False if verbose: # display(Draw.MolToImage(fixed_mol, legend=f"Starting molecule", size=(800, 300))) # display(Draw.MolToImage(query_mol, legend=f"Molecule {sub.upper()} to remove", size=(800, 300))) pass fixed_mol_tmp = remove_substructure( fixed_mol, query_mol, attachment_id=fixed_attach_id, replaceDummies=replaceDummies, ) if fixed_mol_tmp is None: logging.debug(f'Failed to replace substructure "{sub}" in prediction SMILES: "{pred_smiles}"') continue fixed_mol = fixed_mol_tmp # If there are multiple fragments, keep the biggest one fragments = Chem.GetMolFrags(fixed_mol, asMols=True) if len(fragments) > 1: logging.debug(f'Fixed molecule contains more than one fragment. Keeping the biggest one.') max_frag = max(fragments, key=lambda x: x.GetNumAtoms()) fixed_mol = max_frag # Get the SMILES of the fixed molecule fixed_smiles = Chem.MolToSmiles(canonize(fixed_mol), canonical=True) substructs[wrong_substruct]['smiles'] = fixed_smiles if verbose: # display(Draw.MolToImage(fixed_mol, legend=f"{wrong_substruct.upper()} fixed molecule: {fixed_smiles}", size=(800, 300))) pass # Concatenate the substructures check if the re-assembly is correct fixed_pred_smiles = f"{substructs['e3']['smiles']}.{substructs['linker']['smiles']}.{substructs['poi']['smiles']}" if not check_reassembly( protac_smiles, fixed_pred_smiles, ): # logging.warning(f"Failed to fix prediction, re-assembly check failed. Generated fixed prediction (failing): {fixed_pred_smiles}") # return None # Check if by flipping the tetrahedral centers of the ligands we can # still fix the prediction. protac_mol = canonize(Chem.MolFromSmiles(protac_smiles)) chiral_centers = Chem.FindMolChiralCenters( protac_mol, includeUnassigned=True, useLegacyImplementation=False, ) if not chiral_centers: logging.warning(f"Failed to fix prediction, re-assembly check failed. Generated fixed prediction (failing): {fixed_pred_smiles}") return None # Attempt to fix the tetrahedral centers of the ligands e3_fixed = fix_tetrahedral_centers_ligand(protac_mol, substructs['e3']['smiles'], attachment_id=e3_attachment_id) poi_fixed = fix_tetrahedral_centers_ligand(protac_mol, substructs['poi']['smiles'], attachment_id=poi_attachment_id) if e3_fixed is None or poi_fixed is None: logging.warning(f"Failed to fix prediction, re-assembly check failed. Generated fixed prediction (failing): {fixed_pred_smiles}") return None # Update the substructures with the fixed ligands and check re-assembly substructs['e3']['smiles'] = e3_fixed substructs['poi']['smiles'] = poi_fixed fixed_pred_smiles = f"{substructs['e3']['smiles']}.{substructs['linker']['smiles']}.{substructs['poi']['smiles']}" if not check_reassembly( protac_smiles, fixed_pred_smiles, ): logging.warning(f"Failed to fix prediction, re-assembly check failed. Generated fixed prediction (failing): {fixed_pred_smiles}") return None return fixed_pred_smiles