Source code for synkit.Chem.Reaction.Mapper.chem.smiles

try:
    from rdkit import Chem

    HAS_RDKIT = True
except ImportError:
    HAS_RDKIT = False
from collections import Counter, defaultdict
from ..graph.labeled_graph import LabeledGraph


def _mol_from_smiles(smi, remove_hs=True):
    params = Chem.SmilesParserParams()
    params.removeHs = remove_hs
    return Chem.MolFromSmiles(smi, params)


[docs] def count_elements_by_atomic_num(mol): element_count = Counter() for atom in mol.GetAtoms(): element_count[atom.GetAtomicNum()] += 1 return element_count
[docs] def balance_elements(mol1, mol2): diff = count_elements_by_atomic_num(mol2) diff.subtract(count_elements_by_atomic_num(mol1)) # `any(diff.values())` is True for any non-zero (positive or negative) count. if not any(diff.values()): return Chem.Mol(mol1), Chem.Mol(mol2) rwmol1 = Chem.RWMol(mol1) rwmol2 = Chem.RWMol(mol2) for atomic_num, count in diff.items(): if count > 0: for _ in range(abs(count)): rwmol1.AddAtom(Chem.Atom(atomic_num)) elif count < 0: for _ in range(abs(count)): rwmol2.AddAtom(Chem.Atom(atomic_num)) return rwmol1.GetMol(), rwmol2.GetMol()
[docs] def get_labeled_graph_from_mol(mol): labels = [atom.GetAtomicNum() for atom in mol.GetAtoms()] graph = {} for atom in mol.GetAtoms(): idx = atom.GetIdx() others = {} for bond in atom.GetBonds(): other_idx = bond.GetOtherAtomIdx(idx) bond_order = int(2 * bond.GetBondTypeAsDouble() + 0.1) others[other_idx] = bond_order graph[idx] = others return LabeledGraph(graph, labels)
[docs] def smiles2lgp(rxn_smiles, add_Hs=True): natoms_pair = [[], []] sources_pair = [[], []] ss = rxn_smiles.split(">") r = _mol_from_smiles(ss[0], remove_hs=False) p = _mol_from_smiles(ss[2], remove_hs=False) if r is None or p is None: raise ValueError(f"Could not parse reaction SMILES: {rxn_smiles!r}") sources_pair[0].append(r) sources_pair[1].append(p) natoms_pair[0].append(r.GetNumAtoms()) natoms_pair[1].append(p.GetNumAtoms()) if add_Hs: r = Chem.AddHs(r) p = Chem.AddHs(p) sources_pair[0].append(r) sources_pair[1].append(p) natoms_pair[0].append(r.GetNumAtoms()) natoms_pair[1].append(p.GetNumAtoms()) r, p = balance_elements(r, p) sources_pair[0].append(r) sources_pair[1].append(p) natoms_pair[0].append(r.GetNumAtoms()) natoms_pair[1].append(p.GetNumAtoms()) atomic_nums_pair = [ [atom.GetAtomicNum() for atom in mol.GetAtoms()] for mol in [r, p] ] lgp = [] for mol in [r, p]: lgp.append(get_labeled_graph_from_mol(mol)) ini_l2i_pair = [defaultdict(list), defaultdict(list)] for mol, ini_l2i in zip([r, p], ini_l2i_pair): for atom in mol.GetAtoms(): label = atom.GetAtomMapNum() if label > 0: ini_l2i[label * 1000].append(atom.GetIdx()) for label, idxs0 in ini_l2i_pair[0].items(): if label in ini_l2i_pair[1]: idxs1 = ini_l2i_pair[1][label] if len(idxs0) != len(idxs1): import warnings warnings.warn( f"Ignoring unbalanced atom-map constraint: " f"{len(idxs0)} reactant atom(s) vs {len(idxs1)} product atom(s) " f"share map number {label // 1000}", stacklevel=4, ) continue for lg, idxs in zip(lgp, [idxs0, idxs1]): for idx in idxs: lg.labels[idx] = label lg.build_label2idxs() for lg, atomic_nums, natoms_slices, sources in zip( lgp, atomic_nums_pair, natoms_pair, sources_pair ): lg.set_prop("atomic numbers", atomic_nums) lg.set_prop("natoms slices", natoms_slices) lg.set_prop("sources", sources) return lgp
# elg = extended labeled graph
[docs] def smiles2elg(rxn_smiles, add_Hs=True, binarize=True, weight=1000): natoms = [] sources = [] ss = rxn_smiles.split(">") s = ss[0] + "." + ss[2] _r = _mol_from_smiles(ss[0], remove_hs=False) _p = _mol_from_smiles(ss[2], remove_hs=False) mol = _mol_from_smiles(s, remove_hs=False) if _r is None or _p is None or mol is None: raise ValueError(f"Could not parse reaction SMILES: {rxn_smiles!r}") natoms_r = _r.GetNumAtoms() natoms_p = _p.GetNumAtoms() sources.append(mol) natoms.append(mol.GetNumAtoms()) if add_Hs: mol = Chem.AddHs(mol) sources.append(mol) natoms.append(mol.GetNumAtoms()) atomic_nums = [atom.GetAtomicNum() for atom in mol.GetAtoms()] atom_map_nums = [atom.GetAtomMapNum() for atom in mol.GetAtoms()] elg = get_labeled_graph_from_mol(mol) if binarize: elg.binarize_graph() amn2i_r = defaultdict(list) for i in range(natoms_r): if atom_map_nums[i] > 0 and atomic_nums[i] > 1: amn2i_r[atom_map_nums[i]].append(i) amn2i_p = defaultdict(list) for i in range(natoms_r, natoms_p + natoms_r): if atom_map_nums[i] > 0 and atomic_nums[i] > 1: amn2i_p[atom_map_nums[i]].append(i) amns = set(amn for amn in amn2i_r.keys() if len(amn2i_r[amn]) == 1) & set( amn for amn in amn2i_p.keys() if len(amn2i_p[amn]) == 1 ) for amn in amns: i = amn2i_r[amn][0] j = amn2i_p[amn][0] elg.graph[i][j] = weight elg.graph[j][i] = weight elg.set_prop("atomic numbers", atomic_nums) elg.set_prop("natoms slices", natoms) elg.set_prop("sources", sources) return elg
def _normalise_explicit_h_atoms(explicit_h_atoms_pair): if explicit_h_atoms_pair is None: return [None, None] return [ None if atoms is None else sorted(set(atoms)) for atoms in explicit_h_atoms_pair ] def _assign_selected_hydrogen_maps(mols, base_map_nums_pair): """Locally match selected explicit hydrogens by mapped heavy-atom parent.""" next_map = 1 + max((m for nums in base_map_nums_pair for m in nums), default=0) grouped_pair = [] for mol, base_map_nums in zip(mols, base_map_nums_pair): grouped = defaultdict(list) for atom in mol.GetAtoms(): if atom.GetAtomicNum() != 1: continue nbrs = atom.GetNeighbors() if not nbrs: continue parent_idx = nbrs[0].GetIdx() if parent_idx >= len(base_map_nums): continue parent_map = base_map_nums[parent_idx] if parent_map: grouped[parent_map].append(atom.GetIdx()) grouped_pair.append(grouped) h_maps_pair = [{}, {}] parent_maps = set(grouped_pair[0]) | set(grouped_pair[1]) for parent_map in sorted(parent_maps): left = grouped_pair[0].get(parent_map, []) right = grouped_pair[1].get(parent_map, []) n_shared = min(len(left), len(right)) for k in range(n_shared): h_maps_pair[0][left[k]] = next_map h_maps_pair[1][right[k]] = next_map next_map += 1 for atom_idx in left[n_shared:]: h_maps_pair[0][atom_idx] = next_map next_map += 1 for atom_idx in right[n_shared:]: h_maps_pair[1][atom_idx] = next_map next_map += 1 return h_maps_pair def _hydrogen_counts_by_parent(mol): grouped = defaultdict(list) for atom in mol.GetAtoms(): if atom.GetAtomicNum() != 1: continue nbrs = atom.GetNeighbors() if nbrs: grouped[nbrs[0].GetIdx()].append(atom.GetIdx()) return grouped def _add_exact_parent_hydrogens(mol, h_counts): if not h_counts: return mol parents = sorted(h_counts) mol = Chem.RWMol(Chem.AddHs(mol, onlyOnAtoms=parents)) grouped = _hydrogen_counts_by_parent(mol) remove = [] for parent_idx, desired_count in h_counts.items(): h_idxs = grouped.get(parent_idx, []) remove.extend(h_idxs[desired_count:]) for atom_idx in sorted(remove, reverse=True): mol.RemoveAtom(atom_idx) return mol
[docs] def get_numbered_rxn_smiles( rxn_smiles, map_nums_pair, explicit_hs=False, explicit_h_atoms_pair=None, explicit_h_counts_pair=None, map_selected_hs=True, all_hs_explicit=None, ): new_smiles_pair = [] explicit_h_atoms_pair = _normalise_explicit_h_atoms(explicit_h_atoms_pair) if explicit_h_counts_pair is None: explicit_h_counts_pair = [None, None] mols = [] adjusted_pair = [] for smi, map_nums, h_atoms, h_counts in zip( rxn_smiles.split(">>"), map_nums_pair, explicit_h_atoms_pair, explicit_h_counts_pair, ): mol = _mol_from_smiles(smi, remove_hs=False) base_natoms = mol.GetNumAtoms() if h_atoms is not None: h_atoms = [i for i in h_atoms if i < base_natoms] if explicit_hs and h_counts is not None: h_counts = { parent_idx: count for parent_idx, count in h_counts.items() if parent_idx < base_natoms and count > 0 } mol = _add_exact_parent_hydrogens(mol, h_counts) elif explicit_hs and h_atoms is None: mol = Chem.AddHs(mol) elif explicit_hs and h_atoms: mol = Chem.AddHs(mol, onlyOnAtoms=h_atoms) natoms = mol.GetNumAtoms() nnums = len(map_nums) if nnums < natoms: adjusted_map_nums = map_nums + [0] * (natoms - nnums) else: adjusted_map_nums = map_nums[:natoms] mols.append(Chem.RWMol(mol)) adjusted_pair.append(adjusted_map_nums) h_maps_pair = ( _assign_selected_hydrogen_maps(mols, adjusted_pair) if ( explicit_hs and map_selected_hs and any(atoms is not None for atoms in explicit_h_atoms_pair) ) else [{}, {}] ) if all_hs_explicit is None: all_hs_explicit = explicit_hs for side, (mol, adjusted_map_nums) in enumerate(zip(mols, adjusted_pair)): for atom, map_num in zip(mol.GetAtoms(), adjusted_map_nums): atom.SetAtomMapNum(map_num) for atom_idx, map_num in h_maps_pair[side].items(): mol.GetAtomWithIdx(atom_idx).SetAtomMapNum(map_num) new_smiles_pair.append( Chem.MolToSmiles( mol, canonical=False, allHsExplicit=all_hs_explicit, ) ) return ">>".join(new_smiles_pair)
[docs] def selected_atom_indices_from_maps(map_nums_pair, selected_maps): return [ [i for i, map_num in enumerate(map_nums) if map_num in selected_maps] for map_nums in map_nums_pair ]
[docs] def reaction_center_signature_from_mapped_smiles(mapped_rxn_smiles): """Cheap reaction-center signature from mapped heavy-atom SMILES. The signature captures changed heavy-heavy bonds and mapped-heavy-atom H-count deltas. It is enough to decide which hydrogens should be expanded for reaction-center display, without constructing a full SynKit ITS graph. """ mols = [ _mol_from_smiles(smi, remove_hs=False) for smi in mapped_rxn_smiles.split(">>") ] edge_pair = [] hcount_pair = [] for mol in mols: edges = {} hcounts = {} for atom in mol.GetAtoms(): atom_map = atom.GetAtomMapNum() if atom_map and atom.GetAtomicNum() != 1: hcounts[int(atom_map)] = atom.GetTotalNumHs(includeNeighbors=True) for bond in mol.GetBonds(): a0 = bond.GetBeginAtom() a1 = bond.GetEndAtom() if a0.GetAtomicNum() == 1 or a1.GetAtomicNum() == 1: continue m0 = a0.GetAtomMapNum() m1 = a1.GetAtomMapNum() if not m0 or not m1: continue key = tuple(sorted((int(m0), int(m1)))) edges[key] = int(2 * bond.GetBondTypeAsDouble() + 0.1) edge_pair.append(edges) hcount_pair.append(hcounts) changed_edges = [] for key in sorted(set(edge_pair[0]) | set(edge_pair[1])): left = edge_pair[0].get(key, 0) right = edge_pair[1].get(key, 0) if left != right: changed_edges.append((key[0], key[1], left, right)) hcount_deltas = [] for atom_map in sorted(set(hcount_pair[0]) | set(hcount_pair[1])): left = hcount_pair[0].get(atom_map, 0) right = hcount_pair[1].get(atom_map, 0) if left != right: hcount_deltas.append((atom_map, left, right)) return (tuple(changed_edges), tuple(hcount_deltas))
[docs] def reaction_center_atom_maps_from_signature(signature): maps = set() changed_edges, hcount_deltas = signature for a, b, _, _ in changed_edges: maps.add(a) maps.add(b) for atom_map, _, _ in hcount_deltas: maps.add(atom_map) return maps
[docs] def selected_hydrogen_counts_from_hcount_deltas( rxn_smiles, map_nums_pair, selected_maps ): mols = [_mol_from_smiles(smi, remove_hs=False) for smi in rxn_smiles.split(">>")] atom_by_map_pair = [] hcount_by_map_pair = [] for mol, map_nums in zip(mols, map_nums_pair): atom_by_map = {} hcount_by_map = {} for atom in mol.GetAtoms(): idx = atom.GetIdx() if idx >= len(map_nums) or atom.GetAtomicNum() == 1: continue map_num = map_nums[idx] if map_num in selected_maps: atom_by_map[map_num] = idx hcount_by_map[map_num] = atom.GetTotalNumHs(includeNeighbors=True) atom_by_map_pair.append(atom_by_map) hcount_by_map_pair.append(hcount_by_map) counts_pair = [{}, {}] for map_num in sorted(set(atom_by_map_pair[0]) & set(atom_by_map_pair[1])): left = hcount_by_map_pair[0].get(map_num, 0) right = hcount_by_map_pair[1].get(map_num, 0) if left > right: counts_pair[0][atom_by_map_pair[0][map_num]] = left - right elif right > left: counts_pair[1][atom_by_map_pair[1][map_num]] = right - left if sum(counts_pair[0].values()) != sum(counts_pair[1].values()): return [{}, {}] if not counts_pair[0] and not counts_pair[1]: return None return counts_pair
[docs] def expand_reaction_center_hydrogens( rxn_smiles, map_nums_pair, selected_maps, ): """Expand, but do not map, hydrogens attached to selected heavy maps.""" explicit_h_counts_pair = selected_hydrogen_counts_from_hcount_deltas( rxn_smiles, map_nums_pair, selected_maps, ) explicit_h_atoms_pair = ( selected_atom_indices_from_maps( map_nums_pair, selected_maps, ) if explicit_h_counts_pair is None else [None, None] ) return get_numbered_rxn_smiles( rxn_smiles, map_nums_pair, explicit_hs=True, explicit_h_atoms_pair=explicit_h_atoms_pair, explicit_h_counts_pair=explicit_h_counts_pair, map_selected_hs=False, all_hs_explicit=True, )
[docs] def remap_reaction_center_hydrogens( rxn_smiles, map_nums_pair, selected_maps, binary=True, ): """Backward-compatible local expansion helper. The mapper now performs the real second-pass hydrogen remapping itself so the newly explicit H atoms are optimized, not assigned locally. """ expanded_rxn = expand_reaction_center_hydrogens( rxn_smiles, map_nums_pair, selected_maps, ) return expanded_rxn
[docs] def canonicalize_rxn_smiles(rxn_smiles): components_cano = [] ss = rxn_smiles.split(">") ss.pop(1) for s in ss: mol = Chem.MolFromSmiles(s) mol_cano = Chem.RWMol(mol) mapnums = [] for atom in mol_cano.GetAtoms(): mapnums.append(atom.GetAtomMapNum()) atom.SetAtomMapNum(0) mol_cano = Chem.RWMol(Chem.MolFromSmiles(Chem.MolToSmiles(mol_cano))) matches = mol.GetSubstructMatches(mol_cano) if matches: for atom, idx in zip(mol_cano.GetAtoms(), matches[0]): atom.SetAtomMapNum(mapnums[idx]) s_cano = Chem.MolToSmiles(mol_cano, canonical=False, allHsExplicit=True) else: s_cano = Chem.MolToSmiles(mol, canonical=False, allHsExplicit=True) components_cano.append(s_cano) return ">>".join(components_cano)