"""Helpers used by ``translator.py`` for E-SMILES caption refactoring.""" from __future__ import annotations import ast import csv import re from pathlib import Path from typing import Dict, List, Tuple from rdkit import Chem _ABBREV_CSV = Path(__file__).parent / "abbrevs_example.csv" _IONIC_MONOVALENT_METALS = frozenset({"Li", "Na", "K"}) def parse_smiles(smiles: str, *, sanitize: bool = True) -> Chem.rdchem.Mol | None: """Keep explicit atoms so E-SMILES indices survive every parse boundary.""" params = Chem.SmilesParserParams() params.removeHs = False params.parseName = False params.sanitize = sanitize return Chem.MolFromSmiles(str(smiles), params) def normalize_ionic_smiles(smiles: str) -> str: """Charge neutral O-alkali bonds as an ionic salt when unambiguous.""" mol = parse_smiles(smiles) if mol is None: return str(smiles) editable = Chem.RWMol(mol) changed = False for bond in editable.GetBonds(): if bond.GetBondType() != Chem.BondType.SINGLE: continue begin, end = bond.GetBeginAtom(), bond.GetEndAtom() if begin.GetSymbol() not in _IONIC_MONOVALENT_METALS and end.GetSymbol() not in _IONIC_MONOVALENT_METALS: continue oxygen = begin if begin.GetSymbol() == "O" else end if end.GetSymbol() == "O" else None metal = begin if begin.GetSymbol() in _IONIC_MONOVALENT_METALS else end if end.GetSymbol() in _IONIC_MONOVALENT_METALS else None if oxygen is None or metal is None: continue if ( metal.GetDegree() != 1 or oxygen.GetDegree() > 2 or oxygen.GetNumExplicitHs() > 0 or any( atom.GetSymbol() in _IONIC_MONOVALENT_METALS and atom.GetIdx() != metal.GetIdx() for atom in oxygen.GetNeighbors() ) ): continue if oxygen.GetFormalCharge() == 0 and metal.GetFormalCharge() == 0: oxygen.SetFormalCharge(-1) metal.SetFormalCharge(1) changed = True if not changed: return str(smiles) try: Chem.SanitizeMol(editable) # Atom 0 is the implicit attachment site. Canonical output can move it # to oxygen or lithium; serialization can also pin its H as in [SH]. rewritten = Chem.MolToSmiles(editable, canonical=False, isomericSmiles=True) round_tripped = parse_smiles(rewritten) if round_tripped is None or _attachment_slot(mol) != _attachment_slot(round_tripped): return str(smiles) return rewritten except Exception: return str(smiles) def _attachment_slot(mol: Chem.rdchem.Mol) -> Tuple[str, int]: """Track the implicit attachment atom and any pinned explicit hydrogens.""" if mol.GetNumAtoms() == 0: return ("", 0) atom = mol.GetAtomWithIdx(0) return (atom.GetSymbol(), atom.GetNumExplicitHs() if atom.GetNoImplicit() else 0) class UnmappableAnnotationError(ValueError): """A retained annotation references an atom removed by substitution.""" def _load_abbrev_smi() -> Dict[str, str]: mapping: Dict[str, str] = {} with _ABBREV_CSV.open(encoding="utf-8") as f: reader = csv.DictReader(f) for row in reader: symbol = row.get("symbol") smiles = row.get("smiles") if symbol is None or smiles is None: continue mapping[symbol] = normalize_ionic_smiles(smiles) return mapping _abbrev_smi: Dict[str, str] = _load_abbrev_smi() def get_abbrev_smi() -> Dict[str, str]: return _abbrev_smi def get_mol(smi: str) -> Chem.rdchem.Mol: mol = parse_smiles(smi) if mol is None: raise ValueError(f"Invalid SMILES: {smi}") return mol _PRESERVED_RECORD_PATTERN = re.compile( r".*?|.*?|\d+:.+?|.*?|" r".*?|.*?", re.DOTALL, ) _PORT_PAIR_PATTERN = re.compile(r"\[(?P\d+):(?P\d+)\]") _ATOM_RECORD_INDEX_PATTERN = re.compile(r"(?<=)(?P\d+)(?=:)") def _preserved_records( groups: str, atom_index_map: Dict[int, int] | None = None, ) -> str: """Return pre-compatible records, remapping top-level graph indices. Nested ```` records own a separate molecule namespace and are therefore kept byte-for-byte. Top-level local-SRU ports and virtual-arc endpoints, however, refer to the outer graph and must follow canonical atom ordering. """ def remap_pair(match: re.Match, ordered: bool = False) -> str: if atom_index_map is None: return match.group(0) left, right = int(match.group("left")), int(match.group("right")) if left not in atom_index_map or right not in atom_index_map: raise UnmappableAnnotationError( "Unable to remap a preserved E-SMILES atom index" ) left, right = atom_index_map[left], atom_index_map[right] if ordered: left, right = sorted((left, right)) return f"[{left}:{right}]" records: List[str] = [] for match in _PRESERVED_RECORD_PATTERN.finditer(groups): record = match.group(0) if record.startswith(("", "")): record = _PORT_PAIR_PATTERN.sub(lambda pair: remap_pair(pair, ordered=True), record) elif record.startswith(""): record = _PORT_PAIR_PATTERN.sub(remap_pair, record) elif record.startswith("") and atom_index_map is not None: indexed = _ATOM_RECORD_INDEX_PATTERN.search(record) old_index = int(indexed.group("index")) if indexed is not None else -1 if old_index not in atom_index_map: raise UnmappableAnnotationError( "Unable to remap a preserved E-SMILES atom index" ) record = _ATOM_RECORD_INDEX_PATTERN.sub( str(atom_index_map[old_index]), record, count=1, ) records.append(record) return "".join(records) def _strip_preserved_records(groups: str) -> str: return _PRESERVED_RECORD_PATTERN.sub("", groups) def split_groups(groups: str) -> Tuple[Dict[int, Dict[str, str]], Dict[int, List[str]]]: pattern = r"<(?Pr|a|d)>(?P.*?)" matches = re.finditer(pattern, _strip_preserved_records(groups)) a_groups: Dict[int, Dict[str, str]] = {} r_groups: Dict[int, List[str]] = {} for m in matches: type_ = m.group("type") text = m.group("content") if ":" in text: ind, content = text.split(":", 1) else: ind, content = "", text if not ind.isdigit(): continue if type_ == "r": r_groups.setdefault(int(ind), []).append(content) else: a_groups[int(ind)] = {"content": content, "type": type_} return a_groups, r_groups def get_groups_str(a_groups: Dict[int, Dict], r_groups: Dict[int, Dict] | None = None) -> str: groups_str = "" for k, v in sorted(a_groups.items(), key=lambda x: x[0]): tag = v.get("type", "a") groups_str += f"<{tag}>{k}:{v['content']}" if r_groups is not None: for k, v in sorted(r_groups.items(), key=lambda x: x[0]): contents = v["content"] if isinstance(v["content"], list) else [v["content"]] for content in contents: groups_str += f"{k}:{content}" return groups_str def _canonical_output_order(mol: Chem.rdchem.Mol) -> Tuple[Dict[int, int], Chem.rdchem.Mol | None]: output_mol = Chem.Mol(mol) for atom in output_mol.GetAtoms(): atom.SetAtomMapNum(0) smi = Chem.MolToSmiles(output_mol, canonical=True, isomericSmiles=True) final_mol = parse_smiles(smi) try: order = ast.literal_eval(output_mol.GetProp("_smilesAtomOutputOrder")) except Exception: order = list(range(output_mol.GetNumAtoms())) return {internal_idx: output_idx for output_idx, internal_idx in enumerate(order)}, final_mol def is_single_bond(bond: Chem.rdchem.Bond) -> bool: return bond.GetBondType() == Chem.BondType.SINGLE def alter_atom( atom: Chem.rdchem.Atom, smiles: None | str, element: None | str = None, ) -> None: if smiles: src_mol_check = parse_smiles(smiles) rep_atom = src_mol_check.GetAtomWithIdx(0) atom.SetAtomicNum(rep_atom.GetAtomicNum()) atom.SetFormalCharge(rep_atom.GetFormalCharge()) atom.SetIsotope(rep_atom.GetIsotope()) elif element: atom.SetAtomicNum(Chem.GetPeriodicTable().GetAtomicNumber(element)) atom.SetFormalCharge(0) atom.SetIsotope(0) else: raise ValueError("Either smiles or element must be provided") atom.SetNumExplicitHs(0) atom.SetNoImplicit(False) if atom.GetDegree() == 1: atom.SetIsAromatic(False) def merge_group(tgt_mol: Chem.rdchem.RWMol, src: str, attach_idx: int) -> None: dummy_atom = tgt_mol.GetAtomWithIdx(attach_idx) assert dummy_atom.GetSymbol() == "*", "Non-dummy attachment point" src_mol = get_mol(src) neighbors = dummy_atom.GetNeighbors() assert len(neighbors) == 1, "Attachment point must have exactly one neighbor" nb_idx = neighbors[0].GetIdx() conn_bond = tgt_mol.GetBondBetweenAtoms(attach_idx, nb_idx) assert conn_bond.GetBondType() == Chem.BondType.SINGLE, ( "Attachment point must link to single bond" ) idx_map: Dict[int, int] = {} for atom in src_mol.GetAtoms(): i = tgt_mol.AddAtom(atom) idx_map[atom.GetIdx()] = i tgt_atom = tgt_mol.GetAtomWithIdx(i) tgt_atom.SetChiralTag(atom.GetChiralTag()) for bond in src_mol.GetBonds(): j = idx_map[bond.GetBeginAtomIdx()] k = idx_map[bond.GetEndAtomIdx()] tgt_mol.AddBond(j, k, bond.GetBondType()) tgt_mol.RemoveBond(attach_idx, nb_idx) tgt_mol.AddBond(nb_idx, idx_map[0], Chem.BondType.SINGLE) def remap_groups(mol: Chem.rdchem.Mol, groups: str, ring_info: tuple) -> str: old_ind2agroup, old_ind2rgroup = split_groups(groups) new_ind2agroup: Dict[int, Dict] = {} internal_ind_map: Dict[int, int] = {} old_to_output: Dict[int, int] = {} internal_to_output, output_mol = _canonical_output_order(mol) for atom in mol.GetAtoms(): s = atom.GetSmarts() atom.SetAtomMapNum(0) if ":" not in s: continue try: old_ind = int(s.split(":")[1].replace("]", "")) - 1 except Exception: continue internal_ind = atom.GetIdx() internal_ind_map[old_ind] = internal_ind new_ind = internal_to_output.get(internal_ind, internal_ind) old_to_output[old_ind] = new_ind group = old_ind2agroup.get(old_ind) if group is not None: new_ind2agroup[new_ind] = group new_ind2rgroup: Dict[int, Dict] = {} if old_ind2rgroup: new_ring_info = output_mol.GetRingInfo().AtomRings() if output_mol is not None else () new_ring_sets = [set(ring) for ring in new_ring_info] ring_map: Dict[int, int | None] = {} for orig_idx, orig_ring in enumerate(ring_info): mapped = { internal_to_output.get(internal_ind_map.get(atom, atom), internal_ind_map.get(atom, atom)) for atom in orig_ring } best_match = None best_overlap = 0 for new_idx, new_ring in enumerate(new_ring_sets): overlap = len(mapped.intersection(new_ring)) if overlap > best_overlap: best_overlap = overlap best_match = new_idx ring_map[orig_idx] = best_match if best_overlap > 0 else None for old_ind, group_list in old_ind2rgroup.items(): new_ring_idx = ring_map.get(old_ind) if new_ring_idx is None: continue entry = new_ind2rgroup.setdefault( new_ring_idx, {"content": [], "type": "r"} ) entry["content"].extend(group_list) return ( get_groups_str(new_ind2agroup, new_ind2rgroup) + _preserved_records(groups, old_to_output) ) def carbon_chain_repetition_process(mol, atom_id, desc, is_markush, error_msg=False): atom = mol.GetAtomWithIdx(atom_id) count = 1 if desc.multiple and desc.multiple.isdigit(): count = int(desc.multiple) try: atom.SetAtomicNum(6) atom.SetFormalCharge(0) atom.SetNumExplicitHs(0) atom.SetNoImplicit(False) atom.SetIsAromatic(False) atom.SetIsotope(0) atom.SetChiralTag(Chem.ChiralType.CHI_UNSPECIFIED) for bond in atom.GetBonds(): bond.SetBondType(Chem.BondType.SINGLE) bond.SetIsAromatic(False) if count > 1: nbrs = atom.GetNeighbors() degree = len(nbrs) if degree == 2: nb_right = nbrs[1] right_idx = nb_right.GetIdx() mol.RemoveBond(atom_id, right_idx) current_end_idx = atom_id for _ in range(count - 1): new_atom = Chem.Atom(6) new_atom.SetIsAromatic(False) new_idx = mol.AddAtom(new_atom) mol.AddBond(current_end_idx, new_idx, Chem.BondType.SINGLE) current_end_idx = new_idx mol.AddBond(current_end_idx, right_idx, Chem.BondType.SINGLE) elif degree == 1: current_end_idx = atom_id for _ in range(count - 1): new_atom = Chem.Atom(6) new_atom.SetIsAromatic(False) new_idx = mol.AddAtom(new_atom) mol.AddBond(current_end_idx, new_idx, Chem.BondType.SINGLE) current_end_idx = new_idx else: is_markush = True except Exception: is_markush = True return is_markush __all__ = [ "alter_atom", "carbon_chain_repetition_process", "get_abbrev_smi", "normalize_ionic_smiles", "get_groups_str", "get_mol", "is_single_bond", "merge_group", "remap_groups", "split_groups", ]