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

from ..slap.sequential import GraphMatcher
from ..exact.certificate import certify_result, certify_results_exact
from .smiles import (
    HAS_RDKIT,
    smiles2lgp,
    get_numbered_rxn_smiles,
    expand_reaction_center_hydrogens,
    reaction_center_atom_maps_from_signature,
    reaction_center_signature_from_mapped_smiles,
)
from .its import (
    dedup_mapped_rxns,
    mapped_rxn_is_electron_balanced,
)


def _hydrogen_modes(add_Hs):
    if add_Hs is True:
        return True, True, False
    if add_Hs is False:
        return False, False, False
    if isinstance(add_Hs, str) and add_Hs in {"reaction_center", "center"}:
        return False, True, True
    raise ValueError("add_Hs must be True, False, or 'reaction_center'")


def _result_hydrogen_mapped_smiles(rxn_smiles, map_nums_pair, selected_maps, binary):
    """Map reaction-center hydrogens in a constrained second pass."""
    expanded_rxn = expand_reaction_center_hydrogens(
        rxn_smiles,
        map_nums_pair,
        selected_maps,
    )
    mapper = AAMapper(binary=binary)
    try:
        mapper.map_smiles(
            expanded_rxn,
            add_Hs=False,
            break_sym="all",
            unique=True,
            certify=False,
            electron_balance=False,
            enumerate_exact=False,
        )
        return [r["smiles"] for r in mapper.results] or [expanded_rxn]
    except Exception:
        return [expanded_rxn]


def _effective_hcount_weight(rxn_smiles, hcount_weight, hcount_mode):
    """Return active H-count weight for a reaction."""
    if hcount_weight <= 0:
        return 0.0
    if hcount_mode in (None, "always"):
        return hcount_weight
    if hcount_mode in {"product_acid", "product-acid"}:
        from rdkit import Chem

        product = Chem.MolFromSmiles(rxn_smiles.split(">>", 1)[1])
        acid = Chem.MolFromSmarts("[CX3](=O)[OX2H1]")
        return (
            hcount_weight
            if product is not None and product.HasSubstructMatch(acid)
            else 0.0
        )
    raise ValueError("hcount_mode must be 'always' or 'product_acid'")


[docs] class AAMapper(GraphMatcher): """Reaction-SMILES AAM mapper.""" def __init__( self, binary=True, max_lap_fingerprints=10000, cache_label_blocks=False, deterministic_labels=False, ): super().__init__( binary, max_lap_fingerprints=max_lap_fingerprints, cache_label_blocks=cache_label_blocks, deterministic_labels=deterministic_labels, ) self._valfactor = 2
[docs] def get_maps(self, lgp, break_sym_targets=None, interactive=False, base=None): """Graph mappings plus chemical distance.""" super().get_maps(lgp, break_sym_targets, interactive, base) for r in self.results: if r["val"] % self._valfactor == 0: r["cd"] = int(r["val"] // self._valfactor) else: r["cd"] = r["val"] / self._valfactor
[docs] def map_smiles( # noqa: C901 self, rxn_smiles, add_Hs=True, break_sym="heavy", interactive=False, unique=True, certify=False, electron_balance=False, enumerate_exact=False, hcount_weight=0.0, hcount_mode="always", repair_depth=0, repair_cap=128, repair_slack=0.0, repair_min_cd=4.0, repair_final=False, ): """Map reaction SMILES; results contain mapped SMILES and cd.""" if not HAS_RDKIT: raise ImportError("RDKit is required for processing SMILES") if not self.binary: self._valfactor = 4 graph_add_hs, display_hs, reaction_center_hs = _hydrogen_modes(add_Hs) active_hcount_weight = _effective_hcount_weight( rxn_smiles, hcount_weight, hcount_mode, ) lgp = smiles2lgp(rxn_smiles, add_Hs=graph_add_hs) targets = self._get_targets(break_sym, lgp[0].props["atomic numbers"]) if interactive: natoms = len(lgp[0].labels) idxs_1based = list(range(1, natoms + 1)) smis = get_numbered_rxn_smiles( rxn_smiles, [idxs_1based, idxs_1based], explicit_hs=graph_add_hs, ).split(">>") print("Reaction SMILES with 1-based indexes") print(smis[0]) print(">>") print(smis[1]) print() self.get_maps(lgp, break_sym_targets=targets, interactive=interactive, base=1) if enumerate_exact: from ..exact.kernel import extract_kernel from ..exact.enumerate import ( annotate_hcount_scores, complete_mapping, enumerate_kernel_optima, expand_results_by_local_swaps, improve_results_by_pair_swaps, improve_results_by_hcount_permutations, ) from ..exact.certificate import Certificate from ..slap.lap import recover_mapping self.results = improve_results_by_pair_swaps( lgp, self.results, binary=self.binary, valfactor=self._valfactor, ) kernel_seed_results = list(self.results) self.results = expand_results_by_local_swaps( lgp, self.results, binary=self.binary, valfactor=self._valfactor, depth=repair_depth, cap=repair_cap, slack=repair_slack, min_cd=repair_min_cd, ) repair_applied = any(r.get("repair") == "local-swap" for r in self.results) repair_candidates = ( list(self.results) if repair_final and repair_applied else [] ) self.results = improve_results_by_hcount_permutations( lgp, self.results, binary=self.binary, valfactor=self._valfactor, hcount_weight=active_hcount_weight, ) if active_hcount_weight: self._annotate_smiles_results( rxn_smiles, graph_add_hs, display_hs, reaction_center_hs, lgp, ) if electron_balance: balanced = [] rejected = [] for r in self.results: ok = mapped_rxn_is_electron_balanced(r["its_smiles"]) r["electron_balanced"] = ok if ok is not False: balanced.append(r) else: rejected.append(r) self.results = balanced or rejected if unique and not reaction_center_hs and len(self.results) > 1: self.results = dedup_mapped_rxns( self.results, smiles_key="its_smiles" ) if certify: method = "hcount-biased" if active_hcount_weight else "local-repair" for r in self.results: r["certificate"] = Certificate( upper_bound=float(r["cd"]), lower_bound=float("nan"), method=method, ) return kernel = extract_kernel(kernel_seed_results, lgp, binary=self.binary) enumerated = enumerate_kernel_optima( kernel, rxn_smiles=rxn_smiles, unique=unique, electron_balance=electron_balance, explicit_hs=display_hs, reaction_center_hs=reaction_center_hs, ) self.results = enumerated.results if repair_candidates: seen_mappings = { tuple(r.get("mapping") or recover_mapping(r["lgp"])) for r in self.results } repair_append = [] for result in repair_candidates: mapping = complete_mapping( lgp, result.get("mapping") or recover_mapping(result["lgp"]), binary=self.binary, ) key = tuple(mapping) if key in seen_mappings: continue seen_mappings.add(key) updated = dict(result) updated["mapping"] = mapping repair_append.append(updated) if repair_append: exact_results = self.results self.results = repair_append self._annotate_smiles_results( rxn_smiles, graph_add_hs, display_hs, reaction_center_hs, lgp, ) self.results = exact_results + self.results if electron_balance: balanced = [] rejected = [] for r in self.results: ok = mapped_rxn_is_electron_balanced(r["its_smiles"]) r["electron_balanced"] = ok if ok is not False: balanced.append(r) else: rejected.append(r) self.results = balanced or rejected if unique and not reaction_center_hs and len(self.results) > 1: if len(self.results) <= 64: self.results = dedup_mapped_rxns( self.results, smiles_key="its_smiles", ) else: seen_rxns = set() deduped = [] for r in self.results: key = r.get("its_smiles") or r.get("smiles") if key in seen_rxns: continue seen_rxns.add(key) deduped.append(r) self.results = deduped annotate_hcount_scores( lgp, self.results, binary=self.binary, hcount_weight=active_hcount_weight, ) if certify: if active_hcount_weight: method = "hcount-biased" elif repair_final and repair_applied: method = "enum+repair" elif enumerated.proven_optimal and enumerated.enumeration_complete: method = "enum-exact" elif enumerated.proven_optimal: method = "single-exact" else: method = "enum" for r in self.results: lower_bound = ( float("nan") if active_hcount_weight else enumerated.cost ) r["certificate"] = Certificate( upper_bound=float(r["cd"]), lower_bound=lower_bound, method=method, ) else: self._annotate_smiles_results( rxn_smiles, graph_add_hs, display_hs, reaction_center_hs, lgp, ) if not enumerate_exact and electron_balance: balanced = [] rejected = [] for r in self.results: ok = mapped_rxn_is_electron_balanced(r["its_smiles"]) r["electron_balanced"] = ok if ok is not False: balanced.append(r) else: rejected.append(r) self.results = balanced or rejected if ( not enumerate_exact and unique and not reaction_center_hs and len(self.results) > 1 ): self.results = dedup_mapped_rxns(self.results, smiles_key="its_smiles") if not enumerate_exact: if certify == "exact": certify_results_exact(self.results, self.binary) elif certify: for r in self.results: certify_result(r, self.binary)
def _annotate_smiles_results( self, rxn_smiles, graph_add_hs, display_hs, reaction_center_hs, lgp, ): mapped_results = [] for r in self.results: r["its_smiles"] = get_numbered_rxn_smiles( rxn_smiles, [r["lgp"][0].labels, r["lgp"][1].labels], explicit_hs=False, ) if reaction_center_hs: seen_signatures = set() unique_center_results = [] for r in self.results: signature = reaction_center_signature_from_mapped_smiles( r["its_smiles"] ) r["_reaction_center_signature"] = signature if not signature: unique_center_results.append(r) continue if signature in seen_signatures: continue seen_signatures.add(signature) unique_center_results.append(r) self.results = unique_center_results for r in self.results: if graph_add_hs: from ..exact.enumerate import complete_mapping from ..slap.lap import recover_mapping mapping = complete_mapping( lgp, recover_mapping(r["lgp"]), binary=self.binary, ) react_nums = list(range(1, len(mapping) + 1)) prod_nums = [0] * len(mapping) for i, p in enumerate(mapping): prod_nums[p] = i + 1 map_nums_pair = [react_nums, prod_nums] elif reaction_center_hs: selected_maps = reaction_center_atom_maps_from_signature( r.get("_reaction_center_signature") or reaction_center_signature_from_mapped_smiles(r["its_smiles"]) ) map_nums_pair = [r["lgp"][0].labels, r["lgp"][1].labels] smiles_list = _result_hydrogen_mapped_smiles( rxn_smiles, map_nums_pair, selected_maps, binary=self.binary, ) for mapped_smiles in smiles_list: rr = dict(r) rr["smiles"] = mapped_smiles mapped_results.append(rr) continue else: map_nums_pair = [r["lgp"][0].labels, r["lgp"][1].labels] r["smiles"] = get_numbered_rxn_smiles( rxn_smiles, map_nums_pair, explicit_hs=display_hs, ) mapped_results.append(r) self.results = mapped_results def _get_targets(self, break_sym, atomic_nums): if break_sym == "heavy": return [i for i in range(len(atomic_nums)) if atomic_nums[i] > 1] elif break_sym == "all": return list(range(len(atomic_nums))) else: return break_sym