diff --git a/AutoREACTER/__init__.py b/AutoREACTER/__init__.py index 7c18550..0a0bf2c 100644 --- a/AutoREACTER/__init__.py +++ b/AutoREACTER/__init__.py @@ -3,7 +3,7 @@ AutoREACTER is a tool for automated reaction-based molecular system generation. """ -__version__ = "0.2.3" +__version__ = "0.3" __title__ = "AutoREACTER" __author__ = "Janitha Mahanthe, Jacob Gissinger" diff --git a/AutoREACTER/_compat.py b/AutoREACTER/_compat.py deleted file mode 100644 index 28bf209..0000000 --- a/AutoREACTER/_compat.py +++ /dev/null @@ -1,60 +0,0 @@ -import sys -import collections -import collections.abc -import numpy as np - -def apply_legacy_patches(): - """ - Injects removed aliases back into numpy and collections at runtime, - and bridges the OpenMM simtk namespace for older versions of Foyer/mBuild. - """ - # 1. Restore removed Collections aliases - _missing_classes = [ - "MutableSet", "MutableMapping", "Mapping", "MutableSequence", - "Sequence", "Set", "Iterable", "Iterator", "Callable", - "Container", "Hashable", "ItemsView", "KeysView", "ValuesView" - ] - for _name in _missing_classes: - if not hasattr(collections, _name) and hasattr(collections.abc, _name): - setattr(collections, _name, getattr(collections.abc, _name)) - - # 2. Restore removed NumPy aliases - if not hasattr(np, "float"): np.float = float - if not hasattr(np, "int"): np.int = int - if not hasattr(np, "complex"): np.complex = complex - if not hasattr(np, "bool"): np.bool = np.bool_ - if not hasattr(np, "object"): np.object = np.object_ - if not hasattr(np, "str"): np.str = np.str_ - - # 3. Robust OpenMM 'simtk' shim for Python 3.12+ - try: - import sys - import types - import openmm - import openmm.app - import openmm.app.element - import openmm.unit - - # Create pure, fake modules to bypass strict filesystem import checks - simtk = types.ModuleType("simtk") - simtk_openmm = types.ModuleType("simtk.openmm") - simtk_openmm_app = types.ModuleType("simtk.openmm.app") - - # Copy the contents of the real modules into our fake ones - simtk_openmm.__dict__.update(openmm.__dict__) - simtk_openmm_app.__dict__.update(openmm.app.__dict__) - - # Manually wire the internal tree together - simtk.openmm = simtk_openmm - simtk.unit = openmm.unit - simtk_openmm.app = simtk_openmm_app - simtk_openmm_app.element = openmm.app.element - - # Register them in sys.modules so the 'import' statements find them instantly - sys.modules["simtk"] = simtk - sys.modules["simtk.openmm"] = simtk_openmm - sys.modules["simtk.openmm.app"] = simtk_openmm_app - sys.modules["simtk.openmm.app.element"] = openmm.app.element - sys.modules["simtk.unit"] = openmm.unit - except ImportError: - pass \ No newline at end of file diff --git a/AutoREACTER/arx_cli.py b/AutoREACTER/arx_cli.py index c2683c4..14a2efc 100644 --- a/AutoREACTER/arx_cli.py +++ b/AutoREACTER/arx_cli.py @@ -14,6 +14,7 @@ from contextlib import contextmanager import os from pathlib import Path +import shutil import sys import threading from PIL import Image @@ -30,6 +31,9 @@ from AutoREACTER.reaction_preparation.ff_wrapper.REACTER_files_builder import REACTERFilesBuilder from AutoREACTER.sim_setup.simulation_setup import SimulationSetupManager +class NoReactionGenerated(Exception): + """Custom exception raised when no reaction is generated in the pipeline.""" + pass class ErrorHandler: """ @@ -98,7 +102,8 @@ def __init__(self, input: Path) -> None: self.img_dir = self.session.images_dir # with open(self.session.output_dir / "AutoREACTER.log", 'w') as f: # f.write("--- Starting AutoREACTER Session ---\n") - + # Save a copy of the input JSON to the output directory + self._save_input_json(abs_path) # Save an initial grid image of all monomers self._save_rdkit_img( InputParser().initial_molecules_image_grid(self.session), @@ -314,6 +319,10 @@ def process(self): # ------------------------------------------------------------------ # Internal helpers – lazy detection & image saving # ------------------------------------------------------------------ + def _save_input_json(self, abs_path: Path): + destination_file = "input.json" + destination_path = self.session.output_dir / destination_file + shutil.copy(abs_path, destination_path) def _ensure_fg_detected(self): """ @@ -384,7 +393,10 @@ def _save_rdkit_img(self, img, path: Path, is_non_reactant: bool = False): if img is None: if is_non_reactant: return - raise ValueError("No image was generated. Cannot save molecule image.") + raise NoReactionGenerated( + "No reaction was generated. This is an error from AutoREACTER. " + "Please file an issue on https://github.com/NanoCIPHER-Lab/AutoREACTER/issues to improve the software." + ) # Case 1: PIL image if hasattr(img, "save"): diff --git a/AutoREACTER/detectors/functional_groups_detector.py b/AutoREACTER/detectors/functional_groups_detector.py index c9bed73..bff0448 100644 --- a/AutoREACTER/detectors/functional_groups_detector.py +++ b/AutoREACTER/detectors/functional_groups_detector.py @@ -1,5 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, List + """ * Monomer Functionality Detection Module -------------------------------------- @@ -111,12 +112,14 @@ from AutoREACTER.input_parser import MonomerEntry # Conditional import for FunctionalGroupsLibrary to support both installed and local usage. -from .functional_groups_library import FunctionalGroupsLibrary +from AutoREACTER.detectors.functional_groups_library.registry import FunctionalGroupsLibrary logger = logging.getLogger(__name__) # Module-level logger for future diagnostics. if TYPE_CHECKING: from AutoREACTER.session import Session - + from AutoREACTER.reaction_preparation.reaction_processor.reaction_progression import ( + MonomerRoleforIndexBasedFGDetection, + ) @dataclass(slots=True) class FunctionalGroupInfo: @@ -127,19 +130,23 @@ class FunctionalGroupInfo: functionality_type (str): Type of functionality (e.g., 'vinyl', 'mono', 'di_identical', 'di_different'). fg_name (str): Name of the functional group (e.g., 'acrylate'). fg_smarts_1 (str): Primary SMARTS pattern for matching. + fg_1_indexes (Optional[Tuple[int, ...]]): Atom indices for matches of fg_smarts_1. fg_count_1 (int): Number of matches for fg_smarts_1. fg_smarts_2 (Optional[str]): Secondary SMARTS pattern (for 'di_different' types). + fg_2_indexes (Optional[Tuple[int, ...]]): Atom indices for matches of fg_smarts_2. fg_count_2 (Optional[int]): Number of matches for fg_smarts_2. """ functionality_type: str fg_name: str fg_smarts_1: str fg_count_1: int + fg_1_indexes: Optional[Tuple[Tuple[int, ...], ...]] = None fg_smarts_2: Optional[str] = None fg_count_2: Optional[int] = None + fg_2_indexes: Optional[Tuple[Tuple[int, ...], ...]] = None -@dataclass(slots=True, frozen=True) +@dataclass(slots=True) class MonomerRole: """ Immutable dataclass representing a monomer with its detected functional groups. @@ -152,6 +159,10 @@ class MonomerRole: smiles: str name: str functionalities: Tuple[FunctionalGroupInfo, ...] # Tuple of detected functionalities for the monomer + rdkit_mol: Optional[rdchem.Mol] = None # Optional RDKit molecule object for the monomer + indexes_in_template: List[int] = None # Optional list of atom indices in the template + is_monomer: bool = False # Flag indicating if the monomer is eligible for polymerization + is_looped: bool = False @dataclass(slots=True) class FunctionalGroupVisualization: @@ -349,6 +360,7 @@ def functional_groups_detector( smiles=smiles, name=monomer.name, functionalities=tuple(detected_functionalities), + is_monomer=True ) ) @@ -401,6 +413,140 @@ def _functional_groups_detector_for_visualization( ) ) return monomer_roles_visualization + + def _detect_functional_groups_by_index( + self, + mol: Chem.Mol, + smarts: str, + atom_indices: list[int], + ) -> bool: + """Return True when any SMARTS match overlaps the supplied atom indices.""" + target_indices = set(atom_indices) + + patt = Chem.MolFromSmarts(smarts) + if patt is None: + logger.warning("Invalid SMARTS pattern: %s", smarts) + return False + + matches = mol.GetSubstructMatches(patt, uniquify=True) + return any(target_indices.intersection(match) for match in matches) + + def index_based_functional_groups_detector( + self, + monomer_roles_in: list[MonomerRoleforIndexBasedFGDetection], + ) -> list[MonomerRole] | bool: + """ + Detect functional groups across a list of monomers and categorize them into roles, + restricted to a given set of atom indices per monomer. + + Iterates over predefined monomer_types, matches each against the monomer's + rdkit_mol, and keeps only matches that overlap with the monomer's + `indexes_in_template`. Prints matches for debugging/user feedback. + + Args: + monomer_roles_in (list[MonomerRoleforIndexBasedFGDetection]): List of monomer + roles to process, each carrying the atom indices of interest. + + Returns: + list[MonomerRole] | bool: List of MonomerRole objects with index-filtered + functionalities, or False if none detected. + + Notes: + - Index-based rule: at least ONE match overlapping the given indices is + enough to qualify, regardless of functionality_type. This intentionally + breaks the whole-molecule 'di_identical' (>=2 matches) rule, since here + we only care whether the given index sits inside a valid functional group, + not how many total sites exist on the monomer. + """ + + monomer_roles_out = [] + + for monomer in monomer_roles_in: + if monomer.is_looped: + continue # Skip already processed monomers + + mol = monomer.rdkit_mol + + target_indices = set(monomer.indexes_in_template or []) + detected_functionalities = [] + all_matches = [] + + # Check against each predefined functional group type. + for functional_group in self.monomer_types.values(): + ftype = functional_group["functionality_type"] + smarts_1 = functional_group["smarts_1"] + smarts_2 = functional_group.get("smarts_2") + + patt1 = Chem.MolFromSmarts(smarts_1) + if patt1 is None: + logger.warning(f"Invalid primary SMARTS: {smarts_1}") + continue + + matches1 = mol.GetSubstructMatches(patt1, uniquify=True) + # Index-based filter: keep only matches touching at least one target index. + matches1_hit = [m for m in matches1 if target_indices.intersection(m)] + count_1 = len(matches1_hit) + + count_2 = None + matches2_hit = [] + + if smarts_2: + patt2 = Chem.MolFromSmarts(smarts_2) + if patt2 is None: + logger.warning(f"Invalid secondary SMARTS: {smarts_2}") + continue + + matches2 = mol.GetSubstructMatches(patt2, uniquify=True) + matches2_hit = [m for m in matches2 if target_indices.intersection(m)] + count_2 = len(matches2_hit) + + # di_different: still need one overlapping hit on EACH pattern. + functionality_count = 2 if (count_1 >= 1 and count_2 >= 1) else 0 + else: + # vinyl / mono / di_identical: ONE overlapping match is enough. + # (Breaks the normal di_identical >=2 rule on purpose for index-based detection.) + functionality_count = 1 if count_1 >= 1 else 0 + + if functionality_count > 0: + functional_matches = tuple(matches1_hit) + tuple(matches2_hit) + all_matches.extend(functional_matches) + + # Log detected functionality for debugging/user feedback. + # print(f"{monomer.smiles} has functionality: {functional_group['group_name']}") + + detected_functionalities.append( + FunctionalGroupInfo( + functionality_type=ftype, + fg_name=functional_group["group_name"], + fg_smarts_1=smarts_1, + fg_count_1=count_1, + fg_1_indexes=tuple(matches1_hit) if matches1_hit else None, + fg_smarts_2=smarts_2, + fg_count_2=count_2, + fg_2_indexes=tuple(matches2_hit) if matches2_hit else None, + ) + ) + + # Add to roles if any functionalities detected. + if detected_functionalities: + monomer_roles_out.append( + MonomerRole( + smiles=monomer.smiles, + name=monomer.name, + rdkit_mol=monomer.rdkit_mol, + functionalities=tuple(detected_functionalities), + is_monomer=False, # This is a product, not an input monomer + is_looped=False, # Yet to be processed in the loop + indexes_in_template=monomer.indexes_in_template, + ) + ) + + # Store results for potential downstream use. + if not monomer_roles_out: + return False # No functional groups detected; handle as needed + # first break condition: if no monomer roles are detected, return False to indicate no further processing is needed. + + return monomer_roles_out # Return list of MonomerRole; visualization not considered here. def functional_group_highlighted_molecules_image_grid(self, session: Session) -> Image: """Convert monomer roles with detected functionalities into visualizations. diff --git a/AutoREACTER/detectors/functional_groups_library.py b/AutoREACTER/detectors/functional_groups_library.py deleted file mode 100644 index 1408d62..0000000 --- a/AutoREACTER/detectors/functional_groups_library.py +++ /dev/null @@ -1,213 +0,0 @@ -""" -This module defines a library of functional groups relevant to polymer chemistry, -particularly for the detection of monomers in reaction simulations. Each functional group is characterized by -its functionality type (e.g., 'vinyl', 'mono', 'di_different', 'di_identical'), -SMARTS patterns for substructure matching, and group names for identification. This library serves as a reference -for the FunctionalGroupsDetector to identify and classify monomers based on their chemical structure. -""" - - -class FunctionalGroupsLibrary: - def __init__(self): - self.monomer_types = { - - # ============================================================ - # Hydroxy / Carboxylic Acid AB-Type Monomers - # ============================================================ - - "hydroxy_carboxylic_acid_monomer": { - "functionality_type": "di_different", - "smarts_1": "[OX2H1;!$([O][C,S]=*):1]", - "smarts_2": "[CX3:2](=[O])[OX2H1]", - "group_name": "hydroxy_carboxylic_acid", - "comments": None, - }, - - "hydroxy_acid_halides_monomer": { - "functionality_type": "di_different", - "smarts_1": "[OX2H1;!$([O][C,S]=*):1]", - "smarts_2": "[CX3:2](=[O])[Cl,Br,I]", - "group_name": "hydroxy_acid_halide", - "comments": "Hydroxy acid halides are highly reactive and less commonly used monomers for polyesterification compared to hydroxy carboxylic acids." - }, - - # ============================================================ - # Alcohol / Thiol Functional Monomers - # ============================================================ - - "diol_monomer": { - "functionality_type": "di_identical", - "smarts_1": "[OX2H1;!$([O][C,S]=*):1]", - "group_name": "diol", - "comments": None, - }, - - "dithiol_monomer": { - "functionality_type": "di_identical", - "smarts_1": "[SX2H1;!$([S][C,S]=*):1]", - "group_name": "dithiol", - "comments": None, - }, - - "hydroxy_thiol_monomer": { - "functionality_type": "di_different", - "smarts_1": "[OX2H1;!$([O][C,S]=*):1]", - "smarts_2": "[SX2H1;!$([S][C,S]=*):2]", - "group_name": "hydroxy_thiol", - "comments": None, - }, - - # ============================================================ - # Amine / Amino Acid Monomers - # ============================================================ - - "amino_acid_monomer": { - "functionality_type": "di_different", - "smarts_1": "[NX3;H2,H1;!$([N][C,S]=*):1]", - "smarts_2": "[CX3:2](=[O])[OX2H1]", - "group_name": "amino_acid", - "comments": None, - }, - - "di_amine_monomer": { - "functionality_type": "di_identical", - "smarts_1": "[NX3;H2,H1;!$([N][C,S]=*):1]", - "group_name": "di_amine", - "comments": None, - }, - - # ============================================================ - # Carboxylic Acid / Acid Halide / Ester Monomers - # ============================================================ - - "di_carboxylic_acid_monomer": { - "functionality_type": "di_identical", - "smarts_1": "[CX3:1](=[O])[OX2H1]", - "group_name": "di_carboxylic_acid", - "comments": None, - }, - - "di_carboxylic_acid_halide_monomer": { - "functionality_type": "di_identical", - "smarts_1": "[CX3:1](=[O])[Cl,Br,I]", - "group_name": "di_carboxylic_acid_halide", - "comments": None, - }, - - "carboxylic_acid_acid_halide_monomer": { - "functionality_type": "di_different", - "smarts_1": "[CX3:1](=[O])[OX2H1]", - "smarts_2": "[CX3:2](=[O])[Cl,Br,I]", - "group_name": "carboxylic_acid_acid_halide", - "comments": "Mixed COOH/acid-halide AB monomer. Edge case; forms polyanhydride-type linkage, not polyester.", - }, - - "di_carboxylic_ester_monomer": { - "functionality_type": "di_identical", - "smarts_1": "[CX3:1](=[O])[OX2H0][#6]", - "group_name": "di_carboxylic_ester", - "comments": None, - }, - - # ============================================================ - # Isocyanate Monomers - # ============================================================ - - "di_isocyanate_monomer": { - "functionality_type": "di_identical", - "smarts_1": "[NX2]=[CX2:1]=[OX1]", - "group_name": "di_isocyanate", - "comments": None, - }, - - # ============================================================ - # Commented functional groups - # ============================================================ - - # ------------------------------------------------------------ - # Cyclic Anhydride / Epoxide Functional Groups - # ------------------------------------------------------------ - - # "di_cyclic_anhydride_monomer": { - # "functionality_type": "di_identical", - # "smarts_1": "[CX3,c;R:1](=[OX1])[OX2,o;R][CX3,c;R:2](=[OX1])", - # "group_name": "di_cyclic_anhydride" - # }, - - # "di_epoxide_monomer": { - # "functionality_type": "di_identical", - # "smarts_1": "[CX4;H2,H1,H0;R:1]1[OX2;R:2][CX4;H1,H0;R:3]1", - # "group_name": "di_epoxide" - # }, - - # ------------------------------------------------------------ - # Vinyl / Olefin Functional Groups - # ------------------------------------------------------------ - - # "vinyl_monomer": { - # "functionality_type": "vinyl", - # "smarts_1": "[C]=[C;D1]", - # "group_name": "vinyl" - # }, - - # "cyclic_olefin_monomer": { - # "functionality_type": "vinyl", - # "smarts_1": "[CX3;R:1]=[CX3;R:2]", - # "group_name": "cyclic_olefin" - # }, - - # ------------------------------------------------------------ - # Ring-Opening Functional Groups - # ------------------------------------------------------------ - - # "lactone_monomer": { - # "functionality_type": "mono", - # "smarts_1": "[CX3;R:1](=[OX1])[OX2;R:2]", - # "group_name": "lactone" - # }, - - # "cyclic_anhydride_monomer": { - # "functionality_type": "mono", - # "smarts_1": "[C,c;R:1][CX3,c;R](=[OX1])[OX2,o;R][CX3,c;R](=[OX1])[C,c;R:2]", - # "group_name": "cyclic_anhydride" - # }, - - # "epoxide_monomer": { - # "functionality_type": "mono", - # "smarts_1": "[CX4;R:3]1[OX2;R:4][CX4;R:5]1", - # "group_name": "epoxide" - # }, - - # "lactam_monomer": { - # "functionality_type": "mono", - } # "smarts_1": "[CX3;R:1](=[OX1])[NX3;R:2]", - # "group_name": "lactam" - # }, - # "di_amine_monomer": { - # "functionality_type": "di_identical", - # "smarts_1": "[N&X3;H2,H1;!$(NC=*):3]", - - # "group_name": "di_amine" - # }, - # "primery_di_amine_monomer": { - # "functionality_type": "di_identical", - # "smarts_1": "[C,c:6][NX3;H2;!$(N[C,S]=*)]", - # "group_name": "di_primery_amine" - # }, - # "di_cyclic_anhydride_monomer": { - # "functionality_type": "di_identical", - # "smarts_1": "[CX3,c;R:1](=[OX1])[OX2,o;R][CX3,c;R:2](=[OX1])", - # "group_name": "di_cyclic_anhydride" - # }, - # "di_isocyanate_monomer": { - # "functionality_type": "di_identical", - # "smarts_1": "[NX2:1]=[CX2]=[OX1,SX1:2]", - # "group_name": "di_isocyanate" - # }, - # "di_epoxide_monomer": { - # "functionality_type": "di_identical", - # "smarts_1": "[CX4;H2,H1,H0;R:1]1[OX2;R:2][CX4;H1,H0;R:3]1", - # "group_name": "di_epoxide" - # } - # need to add more functional groups here from "J. Chem. Inf. Model. 2023, 63, 5539−5548" - # is there monomers with both COCl and COOH groups? diff --git a/AutoREACTER/detectors/functional_groups_library/__init__.py b/AutoREACTER/detectors/functional_groups_library/__init__.py new file mode 100644 index 0000000..133353b --- /dev/null +++ b/AutoREACTER/detectors/functional_groups_library/__init__.py @@ -0,0 +1,5 @@ +"""Functional groups organized by reactive motif.""" + +from .registry import FUNCTIONAL_GROUPS, FunctionalGroupsLibrary, load_functional_groups + +__all__ = ["FUNCTIONAL_GROUPS", "FunctionalGroupsLibrary", "load_functional_groups"] diff --git a/AutoREACTER/detectors/functional_groups_library/active_centers.py b/AutoREACTER/detectors/functional_groups_library/active_centers.py new file mode 100644 index 0000000..033e706 --- /dev/null +++ b/AutoREACTER/detectors/functional_groups_library/active_centers.py @@ -0,0 +1,14 @@ +FUNCTIONAL_GROUPS = { + 'vinyl_chain_end_radical': { + 'functionality_type': 'vinyl', + 'smarts_1': '[C;!R;D3;v3]', + 'group_name': 'vinyl_chain_end_radical', + 'comments': None + }, + # 'romp_alkylidene_motif': { + # 'functionality_type': 'mono', + # 'smarts_1': '[Ru]=[C]', + # 'group_name': 'romp_alkylidene', + # 'comments': None + # } +} \ No newline at end of file diff --git a/AutoREACTER/detectors/functional_groups_library/aromatic_groups.py b/AutoREACTER/detectors/functional_groups_library/aromatic_groups.py new file mode 100644 index 0000000..5bcf472 --- /dev/null +++ b/AutoREACTER/detectors/functional_groups_library/aromatic_groups.py @@ -0,0 +1,32 @@ +# FUNCTIONAL_GROUPS = { +# 'bis_p_halogenatedaryl_sulfone_monomer': { +# 'functionality_type': 'di_identical', +# 'smarts_1': '[c]([F,Cl,Br,I])[c][SX4](=[OX1])(=[OX1])', +# 'group_name': 'bis(p-halogenatedaryl)sulfone', +# 'comments': None +# }, +# 'bis_p_fluoroaryl_ketone_monomer': { +# 'functionality_type': 'di_identical', +# 'smarts_1': '[c]([F])[c][CX3](=[OX1])', +# 'group_name': 'bis(p-fluoroaryl)ketone_monomer', +# 'comments': None +# }, +# 'phenol_monomer': { +# 'functionality_type': 'di_identical', +# 'smarts_1': '[cH1][c][OX2H1]', +# 'group_name': 'phenol', +# 'comments': None +# }, +# 'hydroxymethyl_phenol_monomer': { +# 'functionality_type': 'mono', +# 'smarts_1': '[c][CH2][OX2H1]', +# 'group_name': 'hydroxymethyl_phenol', +# 'comments': None +# }, +# 'hindered_phenol_monomer': { +# 'functionality_type': 'di_identical', +# 'smarts_1': '[c]1([OX2H1])[c]([C])[c][cH1][c][c]1([C])', +# 'group_name': 'hindered_phenol', +# 'comments': None +# } +# } \ No newline at end of file diff --git a/AutoREACTER/detectors/functional_groups_library/carboxyl_and_carbonyl_groups.py b/AutoREACTER/detectors/functional_groups_library/carboxyl_and_carbonyl_groups.py new file mode 100644 index 0000000..99f5eb2 --- /dev/null +++ b/AutoREACTER/detectors/functional_groups_library/carboxyl_and_carbonyl_groups.py @@ -0,0 +1,39 @@ +FUNCTIONAL_GROUPS = { + 'di_carboxylic_acid_monomer': { + 'functionality_type': 'di_identical', + 'smarts_1': '[CX3:1](=[O])[OX2H1]', + 'group_name': 'di_carboxylic_acid', + 'comments': None + }, + 'di_carboxylic_acid_halide_monomer': { + 'functionality_type': 'di_identical', + 'smarts_1': '[CX3:1](=[O])[Cl,Br,I]', + 'group_name': 'di_carboxylic_acid_halide', + 'comments': None + }, + 'di_carboxylic_ester_monomer': { + 'functionality_type': 'di_identical', + 'smarts_1': '[CX3:1](=[O])[OX2H0][#6]', + 'group_name': 'di_carboxylic_ester', + 'comments': None + }, + 'phosgene_monomer': { + 'functionality_type': 'mono', + # Elaborated SMARTS strictly requires a carbonyl carbon bonded to exactly two chlorines + 'smarts_1': '[Cl:1]-[CX3:2](=[OX1:3])-[Cl:4]', + 'group_name': 'phosgene', + 'comments': None + }, + 'diphenyl_carbonate_monomer': { + 'functionality_type': 'di_identical', + 'smarts_1': '[CX3](=[OX1])[OX2][c]', + 'group_name': 'diphenyl_carbonate', + 'comments': None + } + # 'formaldehyde_monomer': { + # 'functionality_type': 'mono', + # 'smarts_1': '[CH2]=[OX1]', + # 'group_name': 'formaldehyde', + # 'comments': None + # } +} \ No newline at end of file diff --git a/AutoREACTER/detectors/functional_groups_library/halide_groups.py b/AutoREACTER/detectors/functional_groups_library/halide_groups.py new file mode 100644 index 0000000..a9bcad5 --- /dev/null +++ b/AutoREACTER/detectors/functional_groups_library/halide_groups.py @@ -0,0 +1,8 @@ +# FUNCTIONAL_GROUPS = { +# 'organic_dihalide_monomer': { +# 'functionality_type': 'di_identical', +# 'smarts_1': '[CX4][Cl,Br,I]', +# 'group_name': 'organic_dihalide', +# 'comments': None +# } +# } diff --git a/AutoREACTER/detectors/functional_groups_library/heterocumulene_groups.py b/AutoREACTER/detectors/functional_groups_library/heterocumulene_groups.py new file mode 100644 index 0000000..1730c03 --- /dev/null +++ b/AutoREACTER/detectors/functional_groups_library/heterocumulene_groups.py @@ -0,0 +1,11 @@ + + +FUNCTIONAL_GROUPS = { + 'di_isocyanate_monomer': + { + 'functionality_type': 'di_identical', + 'smarts_1': '[NX2]=[CX2:1]=[OX1]', + 'group_name': 'di_isocyanate', + 'comments': None + } + } diff --git a/AutoREACTER/detectors/functional_groups_library/mixed_ab_groups.py b/AutoREACTER/detectors/functional_groups_library/mixed_ab_groups.py new file mode 100644 index 0000000..8679d04 --- /dev/null +++ b/AutoREACTER/detectors/functional_groups_library/mixed_ab_groups.py @@ -0,0 +1,37 @@ +FUNCTIONAL_GROUPS = { + 'hydroxy_carboxylic_acid_monomer': { + 'functionality_type': 'di_different', + 'smarts_1': '[OX2H1;!$([O][C,S]=*):1]', + 'smarts_2': '[CX3:2](=[O])[OX2H1]', + 'group_name': 'hydroxy_carboxylic_acid', + 'comments': None + }, + 'hydroxy_acid_halides_monomer': { + 'functionality_type': 'di_different', + 'smarts_1': '[OX2H1;!$([O][C,S]=*):1]', + 'smarts_2': '[CX3:2](=[O])[Cl,Br,I]', + 'group_name': 'hydroxy_acid_halide', + 'comments': None + }, + 'amino_acid_monomer': { + 'functionality_type': 'di_different', + 'smarts_1': '[NX3;H2,H1;!$([N][C,S]=*):1]', + 'smarts_2': '[CX3:2](=[O])[OX2H1]', + 'group_name': 'amino_acid', + 'comments': None + }, + 'carboxylic_acid_acid_halide_monomer': { + 'functionality_type': 'di_different', + 'smarts_1': '[CX3:1](=[O])[OX2H1]', + 'smarts_2': '[CX3:2](=[O])[Cl,Br,I]', + 'group_name': 'carboxylic_acid_acid_halide', + 'comments': None + }, + 'hydroxy_thiol_monomer': { + 'functionality_type': 'di_different', + 'smarts_1': '[OX2H1;!$([O][C,S]=*):1]', + 'smarts_2': '[SX2H1;!$([S][C,S]=*):2]', + 'group_name': 'hydroxy_thiol', + 'comments': None + } +} \ No newline at end of file diff --git a/AutoREACTER/detectors/functional_groups_library/nitrogen_groups.py b/AutoREACTER/detectors/functional_groups_library/nitrogen_groups.py new file mode 100644 index 0000000..36aa22b --- /dev/null +++ b/AutoREACTER/detectors/functional_groups_library/nitrogen_groups.py @@ -0,0 +1,32 @@ +FUNCTIONAL_GROUPS = { + 'primary_amine_monomer': { + 'functionality_type': 'mono', + 'smarts_1': '[NX3H2;!$(NC=O);!$(NC=[N,O,S])]', + 'group_name': 'primary_amine', + 'comments': None + }, + 'secondary_amine_monomer': { + 'functionality_type': 'mono', + 'smarts_1': '[NX3H1;!$(NC=O);!$(NC=[N,O,S])]', + 'group_name': 'secondary_amine', + 'comments': None + }, + 'di_amine_monomer': { + 'functionality_type': 'di_identical', + 'smarts_1': '[NX3;H2,H1;!$([N][C,S]=*):1]', + 'group_name': 'di_amine', + 'comments': None + }, + 'di_primary_amine_monomer': { + 'functionality_type': 'di_identical', + 'smarts_1': '[NX3H2;!$([N][C,S]=*)]', + 'group_name': 'di_primary_amine', + 'comments': None + }, + # 'tetra_amine_monomer': { + # 'functionality_type': 'di_identical', + # 'smarts_1': '[c]([NX3H2;!$([N][C,S]=*)])[c][NX3H2;!$([N][C,S]=*)]', + # 'group_name': 'tetra_amine', + # 'comments': None + # } +} \ No newline at end of file diff --git a/AutoREACTER/detectors/functional_groups_library/oxygen_groups.py b/AutoREACTER/detectors/functional_groups_library/oxygen_groups.py new file mode 100644 index 0000000..d87891e --- /dev/null +++ b/AutoREACTER/detectors/functional_groups_library/oxygen_groups.py @@ -0,0 +1,20 @@ +FUNCTIONAL_GROUPS = { + 'diol_monomer': { + 'functionality_type': 'di_identical', + 'smarts_1': '[OX2H1;!$([O][C,S]=*):1]', + 'group_name': 'diol', + 'comments': None + }, + # 'initiator_monomer': { + # 'functionality_type': 'mono', + # 'smarts_1': '[OX2H1;!$([O][C,S]=*)]', + # 'group_name': 'lactone_initiator', + # 'comments': None + # }, + 'water_monomer': { + 'functionality_type': 'mono', + 'smarts_1': '[OH2:1]', + 'group_name': 'water', + 'comments': None + } +} \ No newline at end of file diff --git a/AutoREACTER/detectors/functional_groups_library/registry.py b/AutoREACTER/detectors/functional_groups_library/registry.py new file mode 100644 index 0000000..98c1391 --- /dev/null +++ b/AutoREACTER/detectors/functional_groups_library/registry.py @@ -0,0 +1,62 @@ +"""Aggregate and validate all motif-based functional-group modules.""" + +from .oxygen_groups import FUNCTIONAL_GROUPS as OXYGEN_GROUPS +from .sulfur_groups import FUNCTIONAL_GROUPS as SULFUR_GROUPS +from .nitrogen_groups import FUNCTIONAL_GROUPS as NITROGEN_GROUPS +from .carboxyl_and_carbonyl_groups import FUNCTIONAL_GROUPS as CARBOXYL_AND_CARBONYL_GROUPS +from .mixed_ab_groups import FUNCTIONAL_GROUPS as MIXED_AB_GROUPS +from .ring_groups import FUNCTIONAL_GROUPS as RING_GROUPS +from .vinyl_and_alkene_groups import FUNCTIONAL_GROUPS as VINYL_AND_ALKENE_GROUPS +# from .aromatic_groups import FUNCTIONAL_GROUPS as AROMATIC_GROUPS +from .silicon_groups import FUNCTIONAL_GROUPS as SILICON_GROUPS +# from .halide_groups import FUNCTIONAL_GROUPS as HALIDE_GROUPS +from .heterocumulene_groups import FUNCTIONAL_GROUPS as HETEROCUMULENE_GROUPS +from .active_centers import FUNCTIONAL_GROUPS as ACTIVE_CENTERS + +_FUNCTIONAL_GROUP_MODULES = [ + OXYGEN_GROUPS, + SULFUR_GROUPS, + NITROGEN_GROUPS, + CARBOXYL_AND_CARBONYL_GROUPS, + MIXED_AB_GROUPS, + RING_GROUPS, + VINYL_AND_ALKENE_GROUPS, + # AROMATIC_GROUPS, + SILICON_GROUPS, + # HALIDE_GROUPS, + HETEROCUMULENE_GROUPS, + ACTIVE_CENTERS, +] + + +def load_functional_groups() -> dict: + """Return one flat functional-group dictionary with duplicate protection.""" + merged = {} + group_name_to_key = {} + + for module in _FUNCTIONAL_GROUP_MODULES: + for entry_key, entry in module.items(): + if entry_key in merged: + raise ValueError(f"Duplicate functional-group key: {entry_key}") + + group_name = entry["group_name"] + if group_name in group_name_to_key: + previous = group_name_to_key[group_name] + raise ValueError( + f"Duplicate group_name {group_name!r} in {previous!r} and {entry_key!r}" + ) + + merged[entry_key] = entry + group_name_to_key[group_name] = entry_key + + return merged + + +FUNCTIONAL_GROUPS = load_functional_groups() + + +class FunctionalGroupsLibrary: + """Backward-compatible class exposing ``self.monomer_types``.""" + + def __init__(self): + self.monomer_types = load_functional_groups() diff --git a/AutoREACTER/detectors/functional_groups_library/ring_groups.py b/AutoREACTER/detectors/functional_groups_library/ring_groups.py new file mode 100644 index 0000000..8aee8f8 --- /dev/null +++ b/AutoREACTER/detectors/functional_groups_library/ring_groups.py @@ -0,0 +1,44 @@ +FUNCTIONAL_GROUPS = { + # 'epoxide_monomer': { + # 'functionality_type': 'mono', + # 'smarts_1': '[CX4;R:3]1[OX2;R:4][CX4;R:5]1', + # 'group_name': 'epoxide', + # 'comments': None + # }, + 'di_epoxy_monomer': { + 'functionality_type': 'di_identical', + 'smarts_1': '[CX4;R1]1[OX2;R1][CX4;R1]1', + 'group_name': 'di_epoxide', + 'comments': None + }, + # 'lactone_monomer': { + # 'functionality_type': 'mono', + # 'smarts_1': '[CX3;R:1](=[OX1])[OX2;R:2]', + # 'group_name': 'lactone', + # 'comments': None + # }, + # 'lactam_monomer': { + # 'functionality_type': 'mono', + # 'smarts_1': '[CX3;R:1](=[OX1])[NX3H1;R:2]', + # 'group_name': 'lactam', + # 'comments': None + # }, + # 'cyclic_anhydride_monomer': { + # 'functionality_type': 'mono', + # 'smarts_1': '[CX3;R:1](=[OX1])[OX2;R][CX3;R:2](=[OX1])', + # 'group_name': 'cyclic_anhydride', + # 'comments': None + # }, + # 'di_cyclic_anhydride_monomer': { + # 'functionality_type': 'di_identical', + # 'smarts_1': '[CX3,c;R:1](=[OX1])[OX2,o;R][CX3,c;R:2](=[OX1])', + # 'group_name': 'di_cyclic_anhydride', + # 'comments': None + # }, + # 'cyclic_olefin_monomer': { + # 'functionality_type': 'vinyl', + # 'smarts_1': '[CX3;R:1]=[CX3;R:2]', + # 'group_name': 'cyclic_olefin', + # 'comments': None + # } +} \ No newline at end of file diff --git a/AutoREACTER/detectors/functional_groups_library/silicon_groups.py b/AutoREACTER/detectors/functional_groups_library/silicon_groups.py new file mode 100644 index 0000000..117ec72 --- /dev/null +++ b/AutoREACTER/detectors/functional_groups_library/silicon_groups.py @@ -0,0 +1,14 @@ +FUNCTIONAL_GROUPS = { + 'dichlorosilane_monomer': { + 'functionality_type': 'di_identical', + 'smarts_1': '[Si][Cl]', + 'group_name': 'dichlorosilane', + 'comments': None + }, + 'silanediol_monomer': { + 'functionality_type': 'di_identical', + 'smarts_1': '[Si][OX2H1]', + 'group_name': 'silanediol', + 'comments': None + } +} \ No newline at end of file diff --git a/AutoREACTER/detectors/functional_groups_library/sulfur_groups.py b/AutoREACTER/detectors/functional_groups_library/sulfur_groups.py new file mode 100644 index 0000000..fbc6b95 --- /dev/null +++ b/AutoREACTER/detectors/functional_groups_library/sulfur_groups.py @@ -0,0 +1,14 @@ +FUNCTIONAL_GROUPS = { + 'dithiol_monomer': { + 'functionality_type': 'di_identical', + 'smarts_1': '[SX2H1;!$([S][C,S]=*):1]', + 'group_name': 'dithiol', + 'comments': None + }, + # 'sodium_sulfide_monomer': { + # 'functionality_type': 'mono', + # 'smarts_1': '[S-2]', + # 'group_name': 'sodium_sulfide', + # 'comments': None + # } +} \ No newline at end of file diff --git a/AutoREACTER/detectors/functional_groups_library/vinyl_and_alkene_groups.py b/AutoREACTER/detectors/functional_groups_library/vinyl_and_alkene_groups.py new file mode 100644 index 0000000..569a003 --- /dev/null +++ b/AutoREACTER/detectors/functional_groups_library/vinyl_and_alkene_groups.py @@ -0,0 +1,26 @@ +FUNCTIONAL_GROUPS = { + 'vinyl_monomer': { + 'functionality_type': 'vinyl', + 'smarts_1': '[CH2]=[C;!R]', + 'group_name': 'vinyl', + 'comments': None + }, + 'diene_monomer': { + 'functionality_type': 'di_identical', + 'smarts_1': '[CH2]=[C;!R]', + 'group_name': 'diene', + 'comments': None + }, + # 'bis_alkene_monomer': { + # 'functionality_type': 'di_identical', + # 'smarts_1': '[C]=[C]', + # 'group_name': 'bis_alkene', + # 'comments': None + # } + 'tetrafluoroethylene_monomer': { + 'functionality_type': 'vinyl', + 'smarts_1': '[CX3](-[F])(-[F])=[CX3](-[F])(-[F])', + 'group_name': 'tetrafluoroethylene', + 'comments': None + } +} \ No newline at end of file diff --git a/AutoREACTER/detectors/reaction_detector.py b/AutoREACTER/detectors/reaction_detector.py index 5d7b68e..6b7ad8c 100644 --- a/AutoREACTER/detectors/reaction_detector.py +++ b/AutoREACTER/detectors/reaction_detector.py @@ -42,7 +42,7 @@ # Attempt to import internal library components try: - from reactions_library import ReactionLibrary + from AutoREACTER.detectors.reactions_library.registry import ReactionLibrary except (ImportError, ModuleNotFoundError): from .reactions_library import ReactionLibrary @@ -63,7 +63,6 @@ class EmptyReactionListError(Exception): This should be prevented by the reaction_selection method, but this error serves as a safeguard.""" pass - @dataclass(slots=True) class ReactionInstance: """ @@ -239,7 +238,150 @@ def reaction_detector(self, session: "Session") -> None: functional_group_2=fg_2 ) ) - session.reaction_instances = reaction_instances + if not reaction_instances: + raise EmptyReactionListError( + "\nNo reaction instances found for the specified monomer combination. " + "Please verify that your input monomer combinations are correct. " + "If you believe this represents a valid and standard reaction that AutoREACTER should support, " + "please submit an issue at https://github.com/NanoCIPHER-Lab/AutoREACTER/issues for consideration.\n" + "Thank you for helping improve AutoREACTER." + ) + else: + session.reaction_instances = reaction_instances + + + def index_based_reaction_detector( + self, monomer_roles: List[MonomerRole] + ) -> List[ReactionInstance]: + """ + Scans a list of index-based monomer roles to find all possible polymerization + reactions, same logic as reaction_detector but operating on a direct list of + MonomerRole objects (as produced by index_based_functional_groups_detector) + instead of session.monomer_roles. + + Looping rule: + - Homo-polymerization (single monomer role): skip if that monomer role + is already looped (is_looped=True). + - Co-polymerization / same-reactant-two-FG (two monomer roles involved): + skip ONLY if BOTH monomer roles are already looped. If either one is + still fresh (is_looped=False), the pair is still looked up/processed. + + Args: + monomer_roles: List of MonomerRole objects to analyze. + + Returns: + List[ReactionInstance]: All detected reaction instances for this pass. + """ + reaction_instances = [] + seen_pairs: Set[Tuple] = set() + + for reaction_name, reaction_info in self.reactions.items(): + reactant_1_name = reaction_info.get("reactant_1") + reactant_2_name = reaction_info.get("reactant_2") + same_reactants = reaction_info.get("same_reactants", False) + + # CASE 1: HOMO-POLYMERIZATION (e.g., A + A) + if same_reactants and reactant_2_name is None: + for monomer_role in monomer_roles: + # Single-monomer case: skip only if this monomer is already looped. + if monomer_role.is_looped: + continue + + fg_hits = self._matching_fgs(monomer_role, reactant_1_name) + for fg in fg_hits: + pair_key = self._seen_pair_key(reaction_name, monomer_role, fg) + if pair_key not in seen_pairs: + seen_pairs.add(pair_key) + reaction_instances.append( + ReactionInstance( + reaction_name=reaction_name, + reaction_smarts=reaction_info["reaction"], + delete_atom=reaction_info["delete_atom"], + references=reaction_info["reference"], + same_reactants=same_reactants, + monomer_1=monomer_role, + functional_group_1=fg + ) + ) + + # CASE 2: CO-POLYMERIZATION (e.g., A + B) + else: + for monomer_role_i in monomer_roles: + fg_hits_i = self._matching_fgs(monomer_role_i, reactant_1_name) + if not fg_hits_i: + continue + + for fg_i in fg_hits_i: + for monomer_role_j in monomer_roles: + # Prevent a monomer reacting with itself in a co-monomer definition + if monomer_role_i == monomer_role_j: + continue + + # Pairwise rule: skip only if BOTH are already looped. + if monomer_role_i.is_looped and monomer_role_j.is_looped: + continue + + fg_hits_j = self._matching_fgs(monomer_role_j, reactant_2_name) + for fg_j in fg_hits_j: + pair_key = self._seen_pair_key( + reaction_name, monomer_role_i, fg_i, monomer_role_j, fg_j + ) + if pair_key not in seen_pairs: + seen_pairs.add(pair_key) + reaction_instances.append( + ReactionInstance( + reaction_name=reaction_name, + reaction_smarts=reaction_info["reaction"], + delete_atom=reaction_info["delete_atom"], + references=reaction_info["reference"], + same_reactants=same_reactants, + monomer_1=monomer_role_i, + functional_group_1=fg_i, + monomer_2=monomer_role_j, + functional_group_2=fg_j + ) + ) + + # CASE 1.1: Same reactant has two functional groups (e.g., A + A with FG1 and FG2) + if not same_reactants and reactant_2_name is not None: + for monomer_role in monomer_roles: + # Both "slots" are the same monomer role here, so the pairwise + # both-looped rule collapses to a single-monomer check. + if monomer_role.is_looped: + continue + + fg_hits_1 = self._matching_fgs(monomer_role, reactant_1_name) + fg_hits_2 = self._matching_fgs(monomer_role, reactant_2_name) + + for fg_1 in fg_hits_1: + for fg_2 in fg_hits_2: + + # Skip identical FG objects + if fg_1 == fg_2: + continue + + pair_key = self._seen_pair_key( + reaction_name, monomer_role, fg_1, monomer_role, fg_2 + ) + + if pair_key not in seen_pairs: + seen_pairs.add(pair_key) + + reaction_instances.append( + ReactionInstance( + reaction_name=reaction_name, + reaction_smarts=reaction_info["reaction"], + delete_atom=reaction_info["delete_atom"], + references=reaction_info["reference"], + same_reactants=same_reactants, + monomer_1=monomer_role, + functional_group_1=fg_1, + monomer_2=monomer_role, + functional_group_2=fg_2 + ) + ) + + return reaction_instances def create_reaction_image(self, reactant_a_smiles: str, reactant_b_smiles: str, reaction_smarts: str, reaction_name: str) -> Image.Image: diff --git a/AutoREACTER/detectors/reactions_library.py b/AutoREACTER/detectors/reactions_library.py deleted file mode 100644 index c4ed848..0000000 --- a/AutoREACTER/detectors/reactions_library.py +++ /dev/null @@ -1,476 +0,0 @@ -import json -from typing import Dict, Any - - -""" -TODO: Missing Polymerization Mechanisms - -Step-Growth Condensation / Addition -- Polycarbonates: Diols + Phosgene or Diphenyl Carbonate -- Polyureas: Diamines + Diisocyanates -- Aromatic Polyimides: Dianhydrides + Diamines -- Polybenzimidazoles (PBI): Tetraamines + Dicarboxylates -- Phenol-Formaldehyde (Bakelite): Phenol + Formaldehyde - -Aromatic / High-Performance Polymers -- Aromatic Polyethers (PEEK/Sulfones): Activated dihalides + Bisphenols -- Spiro Polymers - -Sulfur / Silicon-Based Polymers -- Polysiloxanes (Silicones): Hydrolysis/Condensation of Dichlorosilanes -- Polysulfides: Dihalides + Sodium Sulfide - -Click / Ring-Opening / Metathesis Polymerizations -- Thiol-Ene Click Polymerizations -- Ring-Opening Metathesis Polymerization (ROMP) -- Cycloaddition (Four-Center) Reactions - -Architectural / Supramolecular Polymers -- Dendritic Polymers: Random Hyperbranched and Dendrimers -- Pseudopolyrotaxanes and Polyrotaxanes - -Special Polymerization Environments -- Enzymatic Polymerizations: In Vivo / In Vitro biocatalysis -- Polymerization in Supercritical Carbon Dioxide -- Thiophene Polymerizations: Oxidative Polymerization of Thiophenes -""" - - -class ReactionLibrary: - def __init__(self): - self.reactions = { - - # ============================================================ - # Polyesterification: Hydroxy Acids / Acid Halides - # ============================================================ - - "Hydroxy Carboxylic Acid Polycondensation(Polyesterification)": { - "same_reactants": True, - "reactant_1": "hydroxy_carboxylic_acid", - "product": "polyester_chain", - "delete_atom": True, - "reaction": "[OX2H1;!$([O][C,S]=*):1]-[H:3].[CX3:2](=[O:5])[OX2H1:4]>>[OX2:1]-[CX3:2](=[O:5]).[O:4]-[H:3]", - "reference": { - "smarts": "https://pubs.acs.org/doi/10.1021/acs.jcim.3c00329", - "reaction_and_mechanism": [ - "https://pubs.acs.org/doi/10.1021/ed048pA734.1", - "https://pubs.acs.org/doi/10.1021/ed073pA312" - ] - }, - "comments": None - }, - - "Hydroxy Carboxylic and Hydroxy Carboxylic Polycondensation(Polyesterification)": { - "same_reactants": False, - "reactant_1": "hydroxy_carboxylic_acid", - "reactant_2": "hydroxy_carboxylic_acid", - "product": "polyester_chain", - "delete_atom": True, - "reaction": "[OX2H1;!$([O][C,S]=*):1]-[H:3].[CX3:2](=[O:5])[OX2H1:4]>>[OX2:1]-[CX3:2](=[O:5]).[O:4]-[H:3]", - "reference": { - "smarts": "https://pubs.acs.org/doi/10.1021/acs.jcim.3c00329", - "reaction_and_mechanism": [ - "https://pubs.acs.org/doi/10.1021/ed048pA734.1", - "https://pubs.acs.org/doi/10.1021/ed073pA312" - ] - }, - "comments": None - }, - - "Hydroxy Acid Halides Polycondensation(Polyesterification)": { - "same_reactants": True, - "reactant_1": "hydroxy_acid_halide", - "product": "polyester_chain", - "delete_atom": True, - "reaction": "[OX2H1;!$([O][C,S]=*):1]-[H:3].[CX3:2](=[O:5])[Cl,Br,I:4]>>[OX2:1]-[CX3:2](=[O:5]).[Cl,Br,I:4]-[H:3]", - "reference": { - "smarts": None, - "reaction_and_mechanism": [ - "https://pubs.acs.org/doi/10.1021/ed073pA312" - ] - }, - "comments": None - }, - - "Hydroxy Acid Halides Hydroxy Acid Halides Polycondensation(Polyesterification)": { - "same_reactants": False, - "reactant_1": "hydroxy_acid_halide", - "reactant_2": "hydroxy_acid_halide", - "product": "polyester_chain", - "delete_atom": True, - "reaction": "[OX2H1;!$([O][C,S]=*):1]-[H:3].[CX3:2](=[O:5])[Cl,Br,I:4]>>[OX2:1]-[CX3:2](=[O:5]).[Cl,Br,I:4]-[H:3]", - "reference": { - "smarts": None, - "reaction_and_mechanism": [ - "https://pubs.acs.org/doi/10.1021/ed073pA312" - ] - }, - "comments": None - }, - - # ============================================================ - # Polyesterification: Diols + Diacids / Diacid Halides / Esters - # ============================================================ - - "Diol and Di-Carboxylic Acid Polycondensation(Polyesterification)": { - "same_reactants": False, - "reactant_1": "diol", - "reactant_2": "di_carboxylic_acid", - "product": "polyester_chain", - "delete_atom": True, - "reaction": "[CX3:1](=[O:3])[OX2H1:4].[OX2H1;!$([O][C,S]=*):2]-[H:5]>>[CX3:1](=[O:3])-[OX2:2].[O:4]-[H:5]", - "reference": { - "smarts": [ - "https://pubs.acs.org/doi/10.1021/acs.jcim.3c00329" - ], - "reaction_and_mechanism": [ - "https://pubs.acs.org/doi/10.1021/ed048pA734.1", - "https://pubs.acs.org/doi/10.1021/ed073pA312" - ] - }, - "comments": None - }, - - "Diol and Di-Acid Halide Polycondensation(Polyesterification)": { - "same_reactants": False, - "reactant_1": "diol", - "reactant_2": "di_carboxylic_acid_halide", - "product": "polyester_chain", - "delete_atom": True, - "reaction": "[CX3:1](=[O:3])[Cl,Br,I:4].[OX2H1;!$([O][C,S]=*):2]-[H:5]>>[CX3:1](=[O:3])-[OX2:2].[Cl,Br,I:4]-[H:5]", - "reference": { - "smarts": [ - "https://pubs.acs.org/doi/10.1021/acs.jcim.3c00329" - ], - "reaction_and_mechanism": [ - "https://pubs.acs.org/doi/10.1021/ed048pA734.1", - "https://pubs.acs.org/doi/10.1021/ed073pA312" - ] - }, - "comments": None - }, - - "Diol and Di-Carboxylic Ester Polycondensation(Transesterification)": { - "same_reactants": False, - "reactant_1": "diol", - "reactant_2": "di_carboxylic_ester", - "product": "polyester_chain", - "delete_atom": True, - "reaction": "[OX2H1;!$([O][C,S]=*):1]-[H:3].[CX3:2](=[O:5])[OX2H0:4][#6:6]>>[OX2:1]-[CX3:2](=[O:5]).[OX2:4](-[H:3])-[#6:6]", - "reference": { - "smarts": None, - "reaction_and_mechanism": None - }, - "comments": None - }, - - # ============================================================ - # Polyanhydride Formation - # ============================================================ - - "Carboxylic Acid and Acid Halide Polycondensation(Polyanhydride Formation)": { - "same_reactants": True, - "reactant_1": "carboxylic_acid_acid_halide", - "product": "polyanhydride_chain", - "delete_atom": True, - "reaction": "[CX3:1](=[O:3])[Cl,Br,I:4].[CX3:2](=[O:5])[OX2H1:6]-[H:7]>>[CX3:1](=[O:3])-[OX2:6]-[CX3:2](=[O:5]).[Cl,Br,I:4]-[H:7]", - "reference": { - "smarts": None, - "reaction_and_mechanism": None - }, - "comments": None - }, - - # ============================================================ - # Polythioesterification - # ============================================================ - - "Dithiol and Di-Carboxylic Acid Halide Polycondensation(Polythioesterification)": { - "same_reactants": False, - "reactant_1": "dithiol", - "reactant_2": "di_carboxylic_acid_halide", - "product": "polythioester_chain", - "delete_atom": True, - "reaction": "[CX3:1](=[O:3])[Cl,Br,I:4].[SX2H1;!$([S][C,S]=*):2]-[H:5]>>[CX3:1](=[O:3])-[SX2:2].[Cl,Br,I:4]-[H:5]", - "reference": { - "smarts": None, - "reaction_and_mechanism": None - }, - "comments": None - }, - - "Dithiol and Di-Carboxylic Acid Polycondensation(Polythioesterification)": { - "same_reactants": False, - "reactant_1": "dithiol", - "reactant_2": "di_carboxylic_acid", - "product": "polythioester_chain", - "delete_atom": True, - "reaction": "[CX3:1](=[O:3])[OX2H1:4].[SX2H1;!$([S][C,S]=*):2]-[H:5]>>[CX3:1](=[O:3])-[SX2:2].[O:4]-[H:5]", - "reference": { - "smarts": None, - "reaction_and_mechanism": None - }, - "comments": "Possible thioesterification with water elimination, but generally less straightforward than acid-halide route." - }, - - # ============================================================ - # Polyamidation - # ============================================================ - - "Amino Acid Polycondensation (Polyamidation)": { - "same_reactants": True, - "reactant_1": "amino_acid", - "product": "polyamide_chain", - "delete_atom": True, - "reaction": "[NX3;H2,H1;!$([N][C,S]=*):1]-[H:3].[CX3:2](=[O:4])[OX2H1:5]>>[NX3:1]-[CX3:2](=[O:4]).[O:5]-[H:3]", - "reference": { - "smarts": [ - "https://pubs.acs.org/doi/10.1021/acs.jcim.3c00329" - ], - "reaction_and_mechanism": [ - "https://pubs.acs.org/doi/10.1021/ed048pA734.1", - "https://pubs.acs.org/doi/10.1021/ed073pA312" - ] - }, - "comments": None - }, - - "Amino Acid and Amino Acid Polycondensation (Polyamidation)": { - "same_reactants": False, - "reactant_1": "amino_acid", - "reactant_2": "amino_acid", - "product": "polyamide_chain", - "delete_atom": True, - "reaction": "[NX3;H2,H1;!$([N][C,S]=*):1]-[H:3].[CX3:2](=[O:4])[OX2H1:5]>>[NX3:1]-[CX3:2](=[O:4]).[O:5]-[H:3]", - "reference": { - "smarts": [ - "https://pubs.acs.org/doi/10.1021/acs.jcim.3c00329" - ], - "reaction_and_mechanism": [ - "https://pubs.acs.org/doi/10.1021/ed048pA734.1", - "https://pubs.acs.org/doi/10.1021/ed073pA312" - ] - }, - "comments": None - }, - - "Di-Amine and Di-Carboxylic Acid Polycondensation (Polyamidation)": { - "same_reactants": False, - "reactant_1": "di_amine", - "reactant_2": "di_carboxylic_acid", - "product": "polyamide_chain", - "delete_atom": True, - "reaction": "[NX3;H2,H1;!$([N][C,S]=*):1]-[H:3].[CX3:2](=[O:4])[OX2H1:5]>>[NX3:1]-[CX3:2](=[O:4]).[O:5]-[H:3]", - "reference": { - "smarts": [ - "https://pubs.acs.org/doi/10.1021/acs.jcim.3c00329" - ], - "reaction_and_mechanism": [ - "https://pubs.acs.org/doi/10.1021/ed048pA734" - ] - }, - "comments": None - }, - - "Di-Amine and Di-Carboxylic Acid Halide Polycondensation (Polyamidation)": { - "same_reactants": False, - "reactant_1": "di_amine", - "reactant_2": "di_carboxylic_acid_halide", - "product": "polyamide_chain", - "delete_atom": True, - "reaction": "[NX3;H2,H1;!$([N][C,S]=*):1]-[H:3].[CX3:2](=[O:4])[Cl,Br,I:5]>>[NX3:1]-[CX3:2](=[O:4]).[Cl,Br,I:5]-[H:3]", - "reference": { - "smarts": [ - "https://pubs.acs.org/doi/10.1021/acs.jcim.3c00329" - ], - "reaction_and_mechanism": [ - "https://pubs.acs.org/doi/10.1021/ed048pA734" - ] - }, - "comments": None - }, - - # ============================================================ - # Mixed Polyester / Polythioester Formation - # ============================================================ - - "Hydroxy-Thiol and Di-Carboxylic Acid Halide Polycondensation through Hydroxy Group": { - "same_reactants": False, - "reactant_1": "hydroxy_thiol", - "reactant_2": "di_carboxylic_acid_halide", - "product": "mixed_polyester_polythioester_chain", - "delete_atom": True, - "reaction": "[CX3:1](=[O:3])[Cl,Br,I:4].[OX2H1;!$([O][C,S]=*):2]-[H:5]>>[CX3:1](=[O:3])-[OX2:2].[Cl,Br,I:4]-[H:5]", - "reference": { - "smarts": None, - "reaction_and_mechanism": None - }, - "comments": None - }, - - "Hydroxy-Thiol and Di-Carboxylic Acid Halide Polycondensation through Thiol Group": { - "same_reactants": False, - "reactant_1": "hydroxy_thiol", - "reactant_2": "di_carboxylic_acid_halide", - "product": "mixed_polyester_polythioester_chain", - "delete_atom": True, - "reaction": "[CX3:1](=[O:3])[Cl,Br,I:4].[SX2H1;!$([S][C,S]=*):2]-[H:5]>>[CX3:1](=[O:3])-[SX2:2].[Cl,Br,I:4]-[H:5]", - "reference": { - "smarts": None, - "reaction_and_mechanism": None - }, - "comments": None - }, - - # ============================================================ - # Polyurethane Formation - # ============================================================ - - "Diol and Di-Isocyanate Polyaddition(Polyurethane Formation)": { - "same_reactants": False, - "reactant_1": "diol", - "reactant_2": "di_isocyanate", - "product": "polyurethane_chain", - "delete_atom": False, - "reaction": "[OX2H1;!$([O][C,S]=*):1]-[H:3].[NX2:4]=[CX2:2]=[OX1:5]>>[OX2:1]-[CX3:2](=[OX1:5])-[NX3:4]-[H:3]", - "reference": { - "smarts": None, - "reaction_and_mechanism": None - }, - "comments": None - }, - - # ============================================================ - # Commented reactions - # ============================================================ - - # "Vinyl Addition Polymerization": { - # "same_reactants": True, - # "reactant_1": "vinyl", - # "product": "polyvinyl_chain", - # "delete_atom": False, - # "reaction": "[CH2:1]=[CH;H1,H0;!R:2].[CH2:3]=[CH;H1,H0;!R:4]>>[CH2:1]-[CH:2]-[CH2:3]-[CH:4]" - # }, - - # "Cyclic Olefin Addition Polymerization": { - # "same_reactants": True, - # "reactant_1": "cyclic_olefin", - # "product": "polycyclic_chain", - # "delete_atom": False, - # "reaction": "[CX3;R:1]=[CX3;R:2].[CX3;R:3]=[CX3;R:4]>>[CX4:1]-[CX4:2]-[CX4:3]-[CX4:4]" - # }, - - # "Vinyl Copolymerization": { - # "same_reactants": False, - # "reactant_1": "vinyl", - # "reactant_2": "vinyl", - # "product": "copolyvinyl_chain", - # "delete_atom": False, - # "reaction": "[CH2:1]=[CH;H1,H0;!R:2].[CH2:3]=[CH;H1,H0;!R:4]>>[CH2:1]-[CH:2]-[CH2:3]-[CH:4]" - # }, - - # "Cyclic Olefin and Vinyl Copolymerization": { - # "same_reactants": False, - # "reactant_1": "vinyl", - # "reactant_2": "cyclic_olefin", - # "product": "copolycyclicvinyl_chain", - # "delete_atom": False, - # "reaction": "[CH2:1]=[CH;H1,H0;!R:2].[CX3;R:3]=[CX3;R:4]>>[CX4:1]-[CX4:2]-[CH2:3]-[CH:4]" - # }, - - # "Cyclic Olefin Copolymerization": { - # "same_reactants": False, - # "reactant_1": "cyclic_olefin", - # "reactant_2": "cyclic_olefin", - # "product": "copolycyclic_chain", - # "delete_atom": False, - # "reaction": "[CX3;R:1]=[CX3;R:2].[CX3;R:3]=[CX3;R:4]>>[CX4:1]-[CX4:2]-[CX4:3]-[CX4:4]" - # }, - - # "Lactone Ring-Opening Polyesterification": { - # "same_reactants": False, - # "reactant_1": "lactone", - # "reactant_2": "initiator", - # "product": "polyester_chain", - # "delete_atom": False - # }, - - # "Cyclic Anhydride and Epoxide Polyesterification": { - # "same_reactants": False, - # "reactant_1": "cyclic_anhydride_monomer", - # "reactant_2": "diol_monomer", - # "product": "polyester_chain", - # "delete_atom": False - # }, - - # "Cyclic Anhydride and Epoxide Polyetherification": { - # "same_reactants": False, - # "reactant_1": "cyclic_anhydride", - # "reactant_2": "epoxide", - # "product": "polyether_chain", - # "delete_atom": False - # }, - - # "Epoxide Ring-Opening Polyetherification": { - # "same_reactants": False, - # "reactant_1": "epoxide", - # "reactant_2": "initiator", - # "product": "polyether_chain", - # "delete_atom": False - # }, - - # "Hindered Phenol Polyetherification": { - # "same_reactants": True, - # "reactant_1": "hindered_phenol", - # "product": "polyether_chain", - # "delete_atom": False - # }, - - # "Hindered Phenol Hindered Phenol Polyetherification": { - # "same_reactants": False, - # "reactant_1": "hindered_phenol", - # "reactant_2": "hindered_phenol", - # "product": "polyether_chain", - # "delete_atom": False - # }, - - # "Bis(p-halogenatedaryl)sulfone Diol (without thiol) polycondensation": { - # "same_reactants": False, - # "reactant_1": "bis(p-halogenatedaryl)sulfone", - # "reactant_2": "diol", - # "product": "polyether_chain", - # "delete_atom": True - # }, - - # "Bis(bis(p-fluoroaryl)ketone Diol (without thiol) polycondensation": { - # "same_reactants": False, - # "reactant_1": "bis(p-fluoroaryl)ketone_monomer", - # "reactant_2": "diol_monomer", - # "product": "polyether_chain", - # "delete_atom": True - # }, - - # "Lactam Ring-Opening Polyamidation": { - # "same_reactants": True, - # "reactant_1": "lactam_monomer", - # "product": "polyamide_chain", - # "delete_atom": False - # }, - - # "Di-cyclic Anhydride and Di-Primary Amine Polycondensation (Polyimidation)": { - # "same_reactants": False, - # "reactant_1": "di_cyclic_anhydride_monomer", - # "reactant_2": "di_amine_monomer", - # "product": "polyimide_chain", - # "delete_atom": True - # }, - - # "Di-Epoxide and Di-Isocyanate Polyamination": { - # "same_reactants": False, - # "reactant_1": "di_epoxide_monomer", - # "reactant_2": "di_isocyanate_monomer", - # "product": "polyamine_chain", - # "delete_atom": False, - # "reaction": "[NX2:3]=[CX2:4]=[OX1,SX1:5].[OX2,SX2;H1;!$([O,S]C=*):6]>>[NX3:3][CX3:4](=[OX1,SX1:5])[OX2,SX2;!$([O,S]C=*):6]" - # }, - } \ No newline at end of file diff --git a/AutoREACTER/detectors/reactions_library/__init__.py b/AutoREACTER/detectors/reactions_library/__init__.py new file mode 100644 index 0000000..8bd1e69 --- /dev/null +++ b/AutoREACTER/detectors/reactions_library/__init__.py @@ -0,0 +1,5 @@ +"""Polymer reactions organized by polymer/product family.""" + +from .registry import REACTIONS, ReactionLibrary, load_reactions + +__all__ = ["REACTIONS", "ReactionLibrary", "load_reactions"] diff --git a/AutoREACTER/detectors/reactions_library/cycloaddition_polymers.py b/AutoREACTER/detectors/reactions_library/cycloaddition_polymers.py new file mode 100644 index 0000000..3d2a79f --- /dev/null +++ b/AutoREACTER/detectors/reactions_library/cycloaddition_polymers.py @@ -0,0 +1,21 @@ +# REACTIONS = { +# 'Bis-Alkene Four-Center Cycloaddition Polymerization': { +# 'same_reactants': True, +# 'reactant_1': 'bis_alkene', +# 'product': 'polycyclobutane_chain', +# 'delete_atom': False, +# 'reaction': '[C:1]=[C:2].[C:3]=[C:4]>>[C:1]1-[C:2]-[C:3]-[C:4]-1', +# 'reference': {'smarts': None, 'reaction_and_mechanism': None}, +# 'comments': None +# }, +# 'Bis-Alkene and Bis-Alkene Four-Center Copolymerization': { +# 'same_reactants': False, +# 'reactant_1': 'bis_alkene', +# 'reactant_2': 'bis_alkene', +# 'product': 'polycyclobutane_chain', +# 'delete_atom': False, +# 'reaction': '[C:1]=[C:2].[C:3]=[C:4]>>[C:1]1-[C:2]-[C:3]-[C:4]-1', +# 'reference': {'smarts': None, 'reaction_and_mechanism': None}, +# 'comments': None +# } +# } \ No newline at end of file diff --git a/AutoREACTER/detectors/reactions_library/epoxy_polymers.py b/AutoREACTER/detectors/reactions_library/epoxy_polymers.py new file mode 100644 index 0000000..dac56bc --- /dev/null +++ b/AutoREACTER/detectors/reactions_library/epoxy_polymers.py @@ -0,0 +1,30 @@ +REACTIONS = { + 'Primary Amine and Epoxide Polyaddition (Epoxy-Amine, First Addition)': { + 'same_reactants': False, + 'reactant_1': 'primary_amine', + 'reactant_2': 'di_epoxide', + 'product': 'secondary_amine_hydroxyl_product', + 'delete_atom': False, + # FIXED: N attacks the less hindered CH2 (:2), O stays on the more hindered CH (:3) + 'reaction': '[NX3H2:1]-[H:6].[CH2;X4:2]1[OX2:5][CH1;X4:3]1>>[NX3:1][C:2][C:3][OX2:5][H:6]', + 'reference': { + 'smarts': None, + 'reaction_and_mechanism': None + }, + 'comments': None + }, + 'Secondary Amine and Epoxide Polyaddition (Epoxy-Amine, Second Addition / Crosslink)': { + 'same_reactants': False, + 'reactant_1': 'secondary_amine', + 'reactant_2': 'di_epoxide', + 'product': 'tertiary_amine_crosslink_product', + 'delete_atom': False, + # FIXED: N attacks the less hindered CH2 (:2), O stays on the more hindered CH (:3) + 'reaction': '[NX3H1:1]-[H:6].[CH2;X4:2]1[OX2:5][CH1;X4:3]1>>[NX3:1][C:2][C:3][OX2:5][H:6]', + 'reference': { + 'smarts': None, + 'reaction_and_mechanism': None + }, + 'comments': None + } +} \ No newline at end of file diff --git a/AutoREACTER/detectors/reactions_library/metathesis_polymers.py b/AutoREACTER/detectors/reactions_library/metathesis_polymers.py new file mode 100644 index 0000000..78fa98d --- /dev/null +++ b/AutoREACTER/detectors/reactions_library/metathesis_polymers.py @@ -0,0 +1,28 @@ +# REACTIONS = { +# 'ROMP Initiation': { +# 'same_reactants': False, +# 'reactant_1': 'romp_alkylidene', +# 'reactant_2': 'cyclic_olefin', +# 'product': 'romp_chain_end', +# 'delete_atom': False, +# 'reaction': '[Ru:1]=[C:2].[CX3;R:3]=[CX3;R:4]>>[Ru:1]=[CX3:4].[C:2]-[CX3:3]', +# 'reference': { +# 'smarts': None, +# 'reaction_and_mechanism': None +# }, +# 'comments': None +# }, +# 'ROMP Propagation': { +# 'same_reactants': False, +# 'reactant_1': 'romp_alkylidene', +# 'reactant_2': 'cyclic_olefin', +# 'product': 'romp_chain_end', +# 'delete_atom': False, +# 'reaction': '[Ru:1]=[C:2].[CX3;R:3]=[CX3;R:4]>>[Ru:1]=[CX3:4].[C:2]-[CX3:3]', +# 'reference': { +# 'smarts': None, +# 'reaction_and_mechanism': None +# }, +# 'comments': None +# } +# } \ No newline at end of file diff --git a/AutoREACTER/detectors/reactions_library/phenolic_resins.py b/AutoREACTER/detectors/reactions_library/phenolic_resins.py new file mode 100644 index 0000000..ff71d19 --- /dev/null +++ b/AutoREACTER/detectors/reactions_library/phenolic_resins.py @@ -0,0 +1,28 @@ +# REACTIONS = { +# 'Phenol and Formaldehyde Hydroxymethylation': { +# 'same_reactants': False, +# 'reactant_1': 'phenol', +# 'reactant_2': 'formaldehyde', +# 'product': 'hydroxymethyl_phenol', +# 'delete_atom': False, +# 'reaction': '[c:1]-[H:4].[CH2:2]=[OX1:3]>>[c:1]-[C:2]-[OX2:3]-[H:4]', +# 'reference': { +# 'smarts': None, +# 'reaction_and_mechanism': None +# }, +# 'comments': None +# }, +# 'Hydroxymethyl Phenol and Phenol Condensation (Methylene Bridge Formation)': { +# 'same_reactants': False, +# 'reactant_1': 'hydroxymethyl_phenol', +# 'reactant_2': 'phenol', +# 'product': 'phenol_formaldehyde_chain', +# 'delete_atom': True, +# 'reaction': '[c:1]-[CH2:2]-[OX2H1:3]-[H:6].[c:4]-[H:5]>>[c:1]-[C:2]-[c:4].[OX2:3](-[H:5])-[H:6]', +# 'reference': { +# 'smarts': None, +# 'reaction_and_mechanism': None +# }, +# 'comments': None +# } +# } \ No newline at end of file diff --git a/AutoREACTER/detectors/reactions_library/polyamides.py b/AutoREACTER/detectors/reactions_library/polyamides.py new file mode 100644 index 0000000..29b9e05 --- /dev/null +++ b/AutoREACTER/detectors/reactions_library/polyamides.py @@ -0,0 +1,104 @@ +REACTIONS = { + 'Amino Acid Polycondensation (Polyamidation)': { + 'same_reactants': True, + 'reactant_1': 'amino_acid', + 'product': 'polyamide_chain', + 'delete_atom': True, + 'reaction': '[NX3;H2,H1;!$([N][C,S]=*):1]-[H:3].[CX3:2](=[O:4])[OX2H1:5]>>[NX3:1]-[CX3:2](=[O:4]).[O:5]-[H:3]', + 'reference': { + 'smarts': ['https://pubs.acs.org/doi/10.1021/acs.jcim.3c00329'], + 'reaction_and_mechanism': [ + 'https://pubs.acs.org/doi/10.1021/ed048pA734.1', + 'https://pubs.acs.org/doi/10.1021/ed073pA312' + ] + }, + 'comments': None + }, + 'Amino Acid and Amino Acid Polycondensation (Polyamidation)': { + 'same_reactants': False, + 'reactant_1': 'amino_acid', + 'reactant_2': 'amino_acid', + 'product': 'polyamide_chain', + 'delete_atom': True, + 'reaction': '[NX3;H2,H1;!$([N][C,S]=*):1]-[H:3].[CX3:2](=[O:4])[OX2H1:5]>>[NX3:1]-[CX3:2](=[O:4]).[O:5]-[H:3]', + 'reference': { + 'smarts': ['https://pubs.acs.org/doi/10.1021/acs.jcim.3c00329'], + 'reaction_and_mechanism': [ + 'https://pubs.acs.org/doi/10.1021/ed048pA734.1', + 'https://pubs.acs.org/doi/10.1021/ed073pA312' + ] + }, + 'comments': None + }, + 'Di-Amine and Di-Carboxylic Acid Polycondensation (Polyamidation)': { + 'same_reactants': False, + 'reactant_1': 'di_amine', + 'reactant_2': 'di_carboxylic_acid', + 'product': 'polyamide_chain', + 'delete_atom': True, + 'reaction': '[NX3;H2,H1;!$([N][C,S]=*):1]-[H:3].[CX3:2](=[O:4])[OX2H1:5]>>[NX3:1]-[CX3:2](=[O:4]).[O:5]-[H:3]', + 'reference': { + 'smarts': ['https://pubs.acs.org/doi/10.1021/acs.jcim.3c00329'], + 'reaction_and_mechanism': [ + 'https://pubs.acs.org/doi/10.1021/ed048pA734.1' + ] + }, + 'comments': None + }, + 'Di-Amine and Di-Carboxylic Acid Halide Polycondensation (Polyamidation)': { + 'same_reactants': False, + 'reactant_1': 'di_amine', + 'reactant_2': 'di_carboxylic_acid_halide', + 'product': 'polyamide_chain', + 'delete_atom': True, + 'reaction': '[NX3;H2,H1;!$([N][C,S]=*):1]-[H:3].[CX3:2](=[O:4])[Cl,Br,I:5]>>[NX3:1]-[CX3:2](=[O:4]).[Cl,Br,I:5]-[H:3]', + 'reference': { + 'smarts': ['https://pubs.acs.org/doi/10.1021/acs.jcim.3c00329'], + 'reaction_and_mechanism': [ + 'https://pubs.acs.org/doi/10.1021/ed048pA734' + ] + }, + 'comments': None + }, + 'Hydrolytic Initiation of Caprolactam': { + 'same_reactants': False, + 'reactant_1': 'water', + 'reactant_2': 'lactam', + 'product': 'polyamide_chain', + 'delete_atom': False, + 'reaction': '[O:1]-[H:12].[CX3:2]1(=[OX1:3])-[CX4:7]-[CX4:8]-[CX4:9]-[CX4:10]-[CX4:11]-[NX3:4]1-[H:6]>>[O:1]-[CX3:2](=[OX1:3])-[CX4:7]-[CX4:8]-[CX4:9]-[CX4:10]-[CX4:11]-[NX3:4](-[H:6])-[H:12]', + 'reference': { + 'smarts': None, + 'reaction_and_mechanism': ['https://doi.org/10.1002/047147875X'] + }, + 'comments': None, + 'Notes': 'Validated on 2026-07-26, Passed' + }, + # 'Caprolactam Ring-Opening Polyamidation': { + # 'same_reactants': True, + # 'reactant_1': 'lactam', + # 'product': 'polyamide_chain', + # 'delete_atom': False, + # # Reactant 2 explicitly maps the 5 CH2 groups (maps 7 through 11) and uses '1' for the ring closure. + # # Product SMARTS removes the '.' and explicitly connects the opened chain. + # 'reaction': '[NX3:1]-[H:5].[CX3:2]1(=[OX1:3])-[CX4:7]-[CX4:8]-[CX4:9]-[CX4:10]-[CX4:11]-[NX3:4]1-[H:6]>>[NX3:1]-[CX3:2](=[OX1:3])-[CX4:7]-[CX4:8]-[CX4:9]-[CX4:10]-[CX4:11]-[NX3:4](-[H:5])-[H:6]', + # 'reference': { + # 'smarts': None, + # 'reaction_and_mechanism': None + # }, + # 'comments': None + # }, + # 'Lactam and Lactam Ring-Opening Copolyamidation': { + # 'same_reactants': False, + # 'reactant_1': 'lactam', + # 'reactant_2': 'lactam', + # 'product': 'polyamide_chain', + # 'delete_atom': False, + # 'reaction': '[NX3H1;R:1]-[H:2].[CX3;R:3](=[OX1:4])[NX3H1;R:5]-[H:6]>>[NX3:1]-[CX3:3](=[OX1:4]).[NX3:5](-[H:2])-[H:6]', + # 'reference': { + # 'smarts': None, + # 'reaction_and_mechanism': None + # }, + # 'comments': None + # } +} \ No newline at end of file diff --git a/AutoREACTER/detectors/reactions_library/polyanhydrides.py b/AutoREACTER/detectors/reactions_library/polyanhydrides.py new file mode 100644 index 0000000..cf8672c --- /dev/null +++ b/AutoREACTER/detectors/reactions_library/polyanhydrides.py @@ -0,0 +1,28 @@ +REACTIONS = { + 'Carboxylic Acid and Acid Halide Polycondensation (Polyanhydride Formation)': { + 'same_reactants': True, + 'reactant_1': 'carboxylic_acid_acid_halide', + 'product': 'polyanhydride_chain', + 'delete_atom': True, + 'reaction': '[CX3:1](=[O:3])[Cl,Br,I:4].[CX3:6](=[O:5])[OX2:2]-[H:7]>>[CX3:1](=[O:3])-[OX2:2]-[CX3:6](=[O:5]).[Cl,Br,I:4]-[H:7]', + 'reference': { + 'smarts': None, + 'reaction_and_mechanism': None + }, + 'notes': 'Atom maps 1 and 2 are reserved as AutoREACTER/LAMMPS bond/react initiator atoms.' + }, + + 'Carboxylic Acid and Acid Halide Copolycondensation (Polyanhydride Copolymerization)': { + 'same_reactants': False, + 'reactant_1': 'carboxylic_acid_acid_halide', + 'reactant_2': 'carboxylic_acid_acid_halide', + 'product': 'polyanhydride_chain', + 'delete_atom': True, + 'reaction': '[CX3:1](=[O:3])[Cl,Br,I:4].[CX3:6](=[O:5])[OX2:2]-[H:7]>>[CX3:1](=[O:3])-[OX2:2]-[CX3:6](=[O:5]).[Cl,Br,I:4]-[H:7]', + 'reference': { + 'smarts': None, + 'reaction_and_mechanism': None + }, + 'notes': 'Atom maps 1 and 2 are reserved as AutoREACTER/LAMMPS bond/react initiator atoms.' + } +} \ No newline at end of file diff --git a/AutoREACTER/detectors/reactions_library/polybenzimidazoles.py b/AutoREACTER/detectors/reactions_library/polybenzimidazoles.py new file mode 100644 index 0000000..31f07aa --- /dev/null +++ b/AutoREACTER/detectors/reactions_library/polybenzimidazoles.py @@ -0,0 +1,17 @@ +# """ +# This is an EDGE case for polybenzimidazole formation reactions. This needs to be futher studied and realease the reaction only after thorough validation. +# """ + +# REACTIONS = {'Tetra-Amine and Di-Carboxylic Acid Polycondensation (PBI Formation)': +# { +# 'same_reactants': False, +# 'reactant_1': 'tetra_amine', +# 'reactant_2': 'di_carboxylic_acid', +# 'product': 'polybenzimidazole_chain', +# 'delete_atom': True, +# 'reaction': '[c:7]([NX3H2:1](-[H:6])-[H:9])-[c:8]([NX3H2:2](-[H:10])-[H:11]).[CX3:3](=[OX1:4])[OX2H1:5]-[H:12]>>[c:7]1-[NX3:1](-[H:6])-[CX3:3]=[NX2:2]-[c:8]-1.[OX2:4](-[H:9])-[H:10].[OX2:5](-[H:11])-[H:12]', +# 'reference': {'smarts': None, +# 'reaction_and_mechanism': None}, +# 'comments': 'UNTESTED new chemistry; one benzimidazole ring-forming event with two mapped waters.' +# } +# } diff --git a/AutoREACTER/detectors/reactions_library/polycarbonates.py b/AutoREACTER/detectors/reactions_library/polycarbonates.py new file mode 100644 index 0000000..10fd705 --- /dev/null +++ b/AutoREACTER/detectors/reactions_library/polycarbonates.py @@ -0,0 +1,32 @@ +REACTIONS = { + 'Diol and Phosgene Polycondensation(Polycarbonate Formation)': + { + 'same_reactants': False, + 'reactant_1': 'diol', + 'reactant_2': 'phosgene', + 'product': 'polycarbonate_chain', + 'delete_atom': True, + 'reaction': '[OX2:1]-[H:4].[CX3:2](=[OX1:5])[Cl:3]>>[OX2:1]-[CX3:2](=[OX1:5]).[Cl:3]-[H:4]', + 'reference': + { + 'smarts': None, + 'reaction_and_mechanism': None + }, + 'comments': None, + }, + 'Diol and Diphenyl Carbonate Polycondensation(Transcarbonation)': + { + 'same_reactants': False, + 'reactant_1': 'diol', + 'reactant_2': 'diphenyl_carbonate', + 'product': 'polycarbonate_chain', + 'delete_atom': True, + 'reaction': '[OX2:1]-[H:4].[CX3:2](=[OX1:5])[OX2:3][c:6]>>[OX2:1]-[CX3:2](=[OX1:5]).[OX2:3](-[H:4])-[c:6]', + 'reference': + { + 'smarts': None, + 'reaction_and_mechanism': None + }, + 'comments': None + } + } \ No newline at end of file diff --git a/AutoREACTER/detectors/reactions_library/polyesters.py b/AutoREACTER/detectors/reactions_library/polyesters.py new file mode 100644 index 0000000..2c77c63 --- /dev/null +++ b/AutoREACTER/detectors/reactions_library/polyesters.py @@ -0,0 +1,139 @@ +REACTIONS = { + 'Hydroxy Carboxylic Acid Polycondensation(Polyesterification)': + { + 'same_reactants': True, + 'reactant_1': 'hydroxy_carboxylic_acid', + 'product': 'polyester_chain', + 'delete_atom': True, + 'reaction': '[OX2;!$([O][C,S]=*):1]-[H:3].[CX3:2](=[O:5])[OX2:4]>>[OX2:1]-[CX3:2](=[O:5]).[O:4]-[H:3]', + 'reference': { + 'smarts': 'https://pubs.acs.org/doi/10.1021/acs.jcim.3c00329', + 'reaction_and_mechanism': [ + 'https://pubs.acs.org/doi/10.1021/ed048pA734.1', + 'https://pubs.acs.org/doi/10.1021/ed073pA312' + ] + }, + 'comments': 'Fixed SMARTS to prevent RDKit explicit node valence crashes.' + }, + 'Hydroxy Carboxylic and Hydroxy Carboxylic Polycondensation(Polyesterification)': + { + 'same_reactants': False, + 'reactant_1': 'hydroxy_carboxylic_acid', + 'reactant_2': 'hydroxy_carboxylic_acid', + 'product': 'polyester_chain', + 'delete_atom': True, + 'reaction': '[OX2;!$([O][C,S]=*):1]-[H:3].[CX3:2](=[O:5])[OX2:4]>>[OX2:1]-[CX3:2](=[O:5]).[O:4]-[H:3]', + 'reference': { + 'smarts': 'https://pubs.acs.org/doi/10.1021/acs.jcim.3c00329', + 'reaction_and_mechanism': [ + 'https://pubs.acs.org/doi/10.1021/ed048pA734.1', + 'https://pubs.acs.org/doi/10.1021/ed073pA312' + ] + }, + 'comments': 'Fixed SMARTS to prevent RDKit explicit node valence crashes.' + }, + 'Hydroxy Acid Halides Polycondensation(Polyesterification)': + { + 'same_reactants': True, + 'reactant_1': 'hydroxy_acid_halide', + 'product': 'polyester_chain', + 'delete_atom': True, + 'reaction': '[OX2;!$([O][C,S]=*):1]-[H:3].[CX3:2](=[O:5])[Cl,Br,I:4]>>[OX2:1]-[CX3:2](=[O:5]).[Cl,Br,I:4]-[H:3]', + 'reference': { + 'smarts': None, + 'reaction_and_mechanism': ['https://pubs.acs.org/doi/10.1021/ed073pA312'] + }, + 'comments': 'Fixed SMARTS to prevent RDKit explicit node valence crashes.' + }, + 'Hydroxy Acid Halides Hydroxy Acid Halides Polycondensation(Polyesterification)': + { + 'same_reactants': False, + 'reactant_1': 'hydroxy_acid_halide', + 'reactant_2': 'hydroxy_acid_halide', + 'product': 'polyester_chain', + 'delete_atom': True, + 'reaction': '[OX2;!$([O][C,S]=*):1]-[H:3].[CX3:2](=[O:5])[Cl,Br,I:4]>>[OX2:1]-[CX3:2](=[O:5]).[Cl,Br,I:4]-[H:3]', + 'reference': { + 'smarts': None, + 'reaction_and_mechanism': ['https://pubs.acs.org/doi/10.1021/ed073pA312'] + }, + 'comments': 'Fixed SMARTS to prevent RDKit explicit node valence crashes.' + }, + 'Diol and Di-Carboxylic Acid Polycondensation(Polyesterification)': + { + 'same_reactants': False, + 'reactant_1': 'diol', + 'reactant_2': 'di_carboxylic_acid', + 'product': 'polyester_chain', + 'delete_atom': True, + 'reaction': '[CX3:1](=[O:3])[OX2:4].[OX2;!$([O][C,S]=*):2]-[H:5]>>[CX3:1](=[O:3])-[OX2:2].[O:4]-[H:5]', + 'reference': { + 'smarts': ['https://pubs.acs.org/doi/10.1021/acs.jcim.3c00329'], + 'reaction_and_mechanism': [ + 'https://pubs.acs.org/doi/10.1021/ed048pA734.1', + 'https://pubs.acs.org/doi/10.1021/ed073pA312' + ] + }, + 'comments': 'Fixed SMARTS to prevent RDKit explicit node valence crashes.' + }, + 'Diol and Di-Acid Halide Polycondensation(Polyesterification)': + { + 'same_reactants': False, + 'reactant_1': 'diol', + 'reactant_2': 'di_carboxylic_acid_halide', + 'product': 'polyester_chain', + 'delete_atom': True, + 'reaction': '[CX3:1](=[O:3])[Cl,Br,I:4].[OX2;!$([O][C,S]=*):2]-[H:5]>>[CX3:1](=[O:3])-[OX2:2].[Cl,Br,I:4]-[H:5]', + 'reference': { + 'smarts': ['https://pubs.acs.org/doi/10.1021/acs.jcim.3c00329'], + 'reaction_and_mechanism': [ + 'https://pubs.acs.org/doi/10.1021/ed048pA734.1', + 'https://pubs.acs.org/doi/10.1021/ed073pA312' + ] + }, + 'comments': 'Fixed SMARTS to prevent RDKit explicit node valence crashes.' + }, + # 'Diol and Di-Carboxylic Ester Polycondensation(Transesterification)': + # { + # 'same_reactants': False, + # 'reactant_1': 'diol', + # 'reactant_2': 'di_carboxylic_ester', + # 'product': 'polyester_chain', + # 'delete_atom': True, + # 'reaction': '[OX2;!$([O][C,S]=*):1]-[H:3].[CX3:2](=[O:5])[OX2:4][#6:6]>>[OX2:1]-[CX3:2](=[O:5]).[OX2:4](-[H:3])-[#6:6]', + # 'reference': { + # 'smarts': None, + # 'reaction_and_mechanism': None + # }, + # 'comments': None + # }, + # Skipped for later additinos after proper validations + # 'Lactone Ring-Opening Polyesterification': + # { + # 'same_reactants': False, + # 'reactant_1': 'lactone', + # 'reactant_2': 'lactone_initiator', + # 'product': 'polyester_chain', + # 'delete_atom': False, + # 'reaction': '[OX2:1]-[H:2].[CX3:3]1(=[OX1:4])-[CX4:6]-[CX4:7]-[CX4:8]-[CX4:9]-[CX4:10]-[OX2:5]1>>[OX2:1]-[CX3:3](=[OX1:4])-[CX4:6]-[CX4:7]-[CX4:8]-[CX4:9]-[CX4:10]-[OX2:5]-[H:2]', + # 'reference': { + # 'smarts': None, + # 'reaction_and_mechanism': None + # }, + # 'comments': None + # }, + # 'Cyclic Anhydride and Epoxide Polyesterification': + # { + # 'same_reactants': False, + # 'reactant_1': 'cyclic_anhydride', + # 'reactant_2': 'epoxide', + # 'product': 'polyester_chain', + # 'delete_atom': False, + # 'reaction': '[CX3:1]1(=[OX1:2])-[OX2:3]-[CX3:4](=[OX1:5])-[CX4:9]-[CX4:10]1.[CX4:6]2-[OX2:7]-[CX4:8]2>>[CX3:1](=[OX1:2])-[OX2:7]-[CX4:6]-[CX4:8]-[OX2:3]-[CX3:4](=[OX1:5])-[CX4:9]-[CX4:10]', + # 'reference': { + # 'smarts': None, + # 'reaction_and_mechanism': None + # }, + # 'comments': None + # } +} \ No newline at end of file diff --git a/AutoREACTER/detectors/reactions_library/polyethers.py b/AutoREACTER/detectors/reactions_library/polyethers.py new file mode 100644 index 0000000..b0c24d1 --- /dev/null +++ b/AutoREACTER/detectors/reactions_library/polyethers.py @@ -0,0 +1,92 @@ + +# REACTIONS = { +# 'Epoxide Ring-Opening Polyetherification': +# { +# 'same_reactants': False, +# 'reactant_1': 'epoxide', +# 'reactant_2': 'initiator', +# 'product': 'polyether_chain', +# 'delete_atom': False, +# 'reaction': '[OX2H1:1]-[H:2].[CX4:3]1[OX2:4][CX4:5]1>>[OX2:1]-[CX4:3]-[CX4:5]-[OX2:4]-[H:2]', +# 'reference': +# { +# 'smarts': None, +# 'reaction_and_mechanism': None +# }, +# 'comments': None +# }, +# 'Cyclic Anhydride and Epoxide Polyetherification': +# { +# 'same_reactants': False, +# 'reactant_1': 'cyclic_anhydride', +# 'reactant_2': 'epoxide', +# 'product': 'polyester_chain', +# 'delete_atom': False, +# 'reaction': '[CX3;R:1](=[OX1:2])[OX2;R:3][CX3;R:4](=[OX1:5]).[CX4:6]1[OX2:7][CX4:8]1>>[CX3:1](=[OX1:2])-[OX2:7]-[CX4:8]-[CX4:6]-[OX2:3]-[CX3:4](=[OX1:5])', +# 'reference': +# { +# 'smarts': None, +# 'reaction_and_mechanism': None +# }, +# 'comments': None +# }, +# 'Hindered Phenol Polyetherification': +# { +# 'same_reactants': True, +# 'reactant_1': 'hindered_phenol', +# 'product': 'polyether_chain', +# 'delete_atom': False, +# 'reaction': '[c:1]-[OX2H1:2]-[H:5].[cH1:3]-[H:4]>>[c:1]-[OX2:2]-[c:3].[H:4]-[H:5]', +# 'reference': +# { +# 'smarts': None, +# 'reaction_and_mechanism': None +# }, +# 'comments': None +# }, +# 'Hindered Phenol Hindered Phenol Polyetherification': +# { +# 'same_reactants': False, +# 'reactant_1': 'hindered_phenol', +# 'reactant_2': 'hindered_phenol', +# 'product': 'polyether_chain', +# 'delete_atom': False, +# 'reaction': '[c:1]-[OX2H1:2]-[H:5].[cH1:3]-[H:4]>>[c:1]-[OX2:2]-[c:3].[H:4]-[H:5]', +# 'reference': +# { +# 'smarts': None, +# 'reaction_and_mechanism': None +# }, +# 'comments': None +# }, +# 'Bis(p-halogenatedaryl)sulfone Diol (without thiol) polycondensation': +# { +# 'same_reactants': False, +# 'reactant_1': 'bis(p-halogenatedaryl)sulfone', +# 'reactant_2': 'diol', +# 'product': 'polyether_chain', +# 'delete_atom': True, +# 'reaction': '[c:1]([F,Cl,Br,I:3]).[OX2H1:2]-[H:4]>>[c:1]-[OX2:2].[F,Cl,Br,I:3]-[H:4]', +# 'reference': +# { +# 'smarts': None, +# 'reaction_and_mechanism': None +# }, +# 'comments': None +# }, +# 'Bis(bis(p-fluoroaryl)ketone Diol (without thiol) polycondensation': +# { +# 'same_reactants': False, +# 'reactant_1': 'bis(p-fluoroaryl)ketone_monomer', +# 'reactant_2': 'diol', +# 'product': 'polyether_chain', +# 'delete_atom': True, +# 'reaction': '[c:1]([F:3]).[OX2H1:2]-[H:4]>>[c:1]-[OX2:2].[F:3]-[H:4]', +# 'reference': +# { +# 'smarts': None, +# 'reaction_and_mechanism': None +# }, +# 'comments': None +# } +# } \ No newline at end of file diff --git a/AutoREACTER/detectors/reactions_library/polyimides.py b/AutoREACTER/detectors/reactions_library/polyimides.py new file mode 100644 index 0000000..d407f39 --- /dev/null +++ b/AutoREACTER/detectors/reactions_library/polyimides.py @@ -0,0 +1,15 @@ +# REACTIONS = { +# 'Tetra-Amine and Di-Carboxylic Acid Polycondensation (PBI Formation)': { +# 'same_reactants': False, +# 'reactant_1': 'tetra_amine', +# 'reactant_2': 'di_carboxylic_acid', +# 'product': 'polybenzimidazole_chain', +# 'delete_atom': True, +# 'reaction': '[c:7]([NX3H2:1](-[H:6])-[H:9])-[c:8]([NX3H2:2](-[H:10])-[H:11]).[CX3:3](=[OX1:4])[OX2H1:5]-[H:12]>>[c:7]1-[NX3:1](-[H:6])-[CX3:3]=[NX2:2]-[c:8]-1.[OX2:4](-[H:9])-[H:10].[OX2:5](-[H:11])-[H:12]', +# 'reference': { +# 'smarts': None, +# 'reaction_and_mechanism': None +# }, +# 'comments': None +# } +# } \ No newline at end of file diff --git a/AutoREACTER/detectors/reactions_library/polysiloxanes.py b/AutoREACTER/detectors/reactions_library/polysiloxanes.py new file mode 100644 index 0000000..83441ff --- /dev/null +++ b/AutoREACTER/detectors/reactions_library/polysiloxanes.py @@ -0,0 +1,40 @@ +REACTIONS = { + 'Dichlorosilane Hydrolysis to Silanol': { + 'same_reactants': False, + 'reactant_1': 'dichlorosilane', + 'reactant_2': 'water', + 'product': 'silanediol', + 'delete_atom': True, + 'reaction': '[Si:1]-[Cl:3].[OX2H2:2](-[H:4])-[H:5]>>[Si:1]-[OX2:2]-[H:4].[Cl:3]-[H:5]', + 'reference': { + 'smarts': None, + 'reaction_and_mechanism': None + }, + 'notes': 'Atom maps 1 and 2 are reserved as AutoREACTER/LAMMPS bond/react initiator atoms.' + }, + 'Silanediol Polycondensation(Polysiloxane Formation)': { + 'same_reactants': True, + 'reactant_1': 'silanediol', + 'product': 'polysiloxane_chain', + 'delete_atom': True, + 'reaction': '[Si:1]-[OX2H1:2]-[H:5].[Si:3]-[OX2H1:4]-[H:6]>>[Si:1]-[OX2:2]-[Si:3].[OX2:4](-[H:5])-[H:6]', + 'reference': { + 'smarts': None, + 'reaction_and_mechanism': None + }, + 'notes': None + }, + 'Silanediol and Silanediol Copolycondensation(Polysiloxane Formation)': { + 'same_reactants': False, + 'reactant_1': 'silanediol', + 'reactant_2': 'silanediol', + 'product': 'polysiloxane_chain', + 'delete_atom': True, + 'reaction': '[Si:1]-[OX2H1:2]-[H:5].[Si:3]-[OX2H1:4]-[H:6]>>[Si:1]-[OX2:2]-[Si:3].[OX2:4](-[H:5])-[H:6]', + 'reference': { + 'smarts': None, + 'reaction_and_mechanism': None + }, + 'notes': None + } +} \ No newline at end of file diff --git a/AutoREACTER/detectors/reactions_library/polysulfides.py b/AutoREACTER/detectors/reactions_library/polysulfides.py new file mode 100644 index 0000000..4b0482b --- /dev/null +++ b/AutoREACTER/detectors/reactions_library/polysulfides.py @@ -0,0 +1,15 @@ +# REACTIONS = { +# 'Organic Dihalide and Sodium Sulfide Polycondensation(Polysulfide Formation)': { +# 'same_reactants': False, +# 'reactant_1': 'organic_dihalide', +# 'reactant_2': 'sodium_sulfide', +# 'product': 'polysulfide_chain', +# 'delete_atom': True, +# 'reaction': '[CX4:1]-[Cl,Br,I:2].[S-2:3].[Na+:4].[Na+:5]>>[CX4:1]-[S-:3].[Cl-,Br-,I-:2].[Na+:4].[Na+:5]', +# 'reference': { +# 'smarts': None, +# 'reaction_and_mechanism': None +# }, +# 'comments': None +# } +# } \ No newline at end of file diff --git a/AutoREACTER/detectors/reactions_library/polythioesters.py b/AutoREACTER/detectors/reactions_library/polythioesters.py new file mode 100644 index 0000000..830a8a5 --- /dev/null +++ b/AutoREACTER/detectors/reactions_library/polythioesters.py @@ -0,0 +1,54 @@ +REACTIONS = { + 'Dithiol and Di-Carboxylic Acid Halide Polycondensation(Polythioesterification)': { + 'same_reactants': False, + 'reactant_1': 'dithiol', + 'reactant_2': 'di_carboxylic_acid_halide', + 'product': 'polythioester_chain', + 'delete_atom': True, + 'reaction': '[CX3:1](=[O:3])[Cl,Br,I:4].[SX2H1;!$([S][C,S]=*):2]-[H:5]>>[CX3:1](=[O:3])-[SX2:2].[Cl,Br,I:4]-[H:5]', + 'reference': { + 'smarts': None, + 'reaction_and_mechanism': None + }, + 'comments': None + }, + 'Dithiol and Di-Carboxylic Acid Polycondensation(Polythioesterification)': { + 'same_reactants': False, + 'reactant_1': 'dithiol', + 'reactant_2': 'di_carboxylic_acid', + 'product': 'polythioester_chain', + 'delete_atom': True, + 'reaction': '[CX3:1](=[O:3])[OX2H1:4].[SX2H1;!$([S][C,S]=*):2]-[H:5]>>[CX3:1](=[O:3])-[SX2:2].[O:4]-[H:5]', + 'reference': { + 'smarts': None, + 'reaction_and_mechanism': None + }, + 'comments': None + }, + 'Hydroxy-Thiol and Di-Carboxylic Acid Halide Polycondensation through Hydroxy Group': { + 'same_reactants': False, + 'reactant_1': 'hydroxy_thiol', + 'reactant_2': 'di_carboxylic_acid_halide', + 'product': 'mixed_polyester_polythioester_chain', + 'delete_atom': True, + 'reaction': '[CX3:1](=[O:3])[Cl,Br,I:4].[OX2H1;!$([O][C,S]=*):2]-[H:5]>>[CX3:1](=[O:3])-[OX2:2].[Cl,Br,I:4]-[H:5]', + 'reference': { + 'smarts': None, + 'reaction_and_mechanism': None + }, + 'comments': None + }, + 'Hydroxy-Thiol and Di-Carboxylic Acid Halide Polycondensation through Thiol Group': { + 'same_reactants': False, + 'reactant_1': 'hydroxy_thiol', + 'reactant_2': 'di_carboxylic_acid_halide', + 'product': 'mixed_polyester_polythioester_chain', + 'delete_atom': True, + 'reaction': '[CX3:1](=[O:3])[Cl,Br,I:4].[SX2H1;!$([S][C,S]=*):2]-[H:5]>>[CX3:1](=[O:3])-[SX2:2].[Cl,Br,I:4]-[H:5]', + 'reference': { + 'smarts': None, + 'reaction_and_mechanism': None + }, + 'comments': None + } +} \ No newline at end of file diff --git a/AutoREACTER/detectors/reactions_library/polyureas.py b/AutoREACTER/detectors/reactions_library/polyureas.py new file mode 100644 index 0000000..854b672 --- /dev/null +++ b/AutoREACTER/detectors/reactions_library/polyureas.py @@ -0,0 +1,15 @@ +REACTIONS = { + 'Di-Amine and Di-Isocyanate Polyaddition(Polyurea Formation)': { + 'same_reactants': False, + 'reactant_1': 'di_amine', + 'reactant_2': 'di_isocyanate', + 'product': 'polyurea_chain', + 'delete_atom': False, + 'reaction': '[NX3;H2:1]-[C:3].[NX2:4]=[CX2:2]=[OX1:5]>>[NX3:1]-[CX3:2](=[OX1:5])-[NX2:4]-[C:3]', + 'reference': { + 'smarts': None, + 'reaction_and_mechanism': None + }, + 'comments': None + } +} \ No newline at end of file diff --git a/AutoREACTER/detectors/reactions_library/polyurethanes.py b/AutoREACTER/detectors/reactions_library/polyurethanes.py new file mode 100644 index 0000000..3d908a6 --- /dev/null +++ b/AutoREACTER/detectors/reactions_library/polyurethanes.py @@ -0,0 +1,29 @@ +REACTIONS = { + 'Diol and Di-Isocyanate Polyaddition(Polyurethane Formation)': { + 'same_reactants': False, + 'reactant_1': 'diol', + 'reactant_2': 'di_isocyanate', + 'product': 'polyurethane_chain', + 'delete_atom': False, + 'reaction': '[OX2H1;!$([O][C,S]=*):1]-[H:3].[NX2:4]=[CX2:2]=[OX1:5]>>[OX2:1]-[CX3:2](=[OX1:5])-[NX3:4]-[H:3]', + 'reference': { + 'smarts': None, + 'reaction_and_mechanism': None + }, + 'comments': None + }, + + 'Dithiol and Di-Isocyanate Polyaddition(Polythiourethane Formation)': { + 'same_reactants': False, + 'reactant_1': 'dithiol', + 'reactant_2': 'di_isocyanate', + 'product': 'polythiourethane_chain', + 'delete_atom': False, + 'reaction': '[SX2H1;!$([S][C,S]=*):1]-[H:3].[NX2:4]=[CX2:2]=[OX1:5]>>[SX2:1]-[CX3:2](=[OX1:5])-[NX3:4]-[H:3]', + 'reference': { + 'smarts': None, + 'reaction_and_mechanism': None + }, + 'comments': None + } +} \ No newline at end of file diff --git a/AutoREACTER/detectors/reactions_library/registry.py b/AutoREACTER/detectors/reactions_library/registry.py new file mode 100644 index 0000000..957eead --- /dev/null +++ b/AutoREACTER/detectors/reactions_library/registry.py @@ -0,0 +1,260 @@ +"""Aggregate and validate all polymer-family reaction modules.""" + +from __future__ import annotations + +try: + from rdkit.Chem import rdChemReactions +except ImportError: # pragma: no cover + rdChemReactions = None + + +try: + from .polyesters import REACTIONS as POLYESTERS + # from .polyethers import REACTIONS as POLYETHERS + from .polyamides import REACTIONS as POLYAMIDES + from .polyanhydrides import REACTIONS as POLYANHYDRIDES + from .polythioesters import REACTIONS as POLYTHIOESTERS + from .polyurethanes import REACTIONS as POLYURETHANES + from .polyureas import REACTIONS as POLYUREAS + from .epoxy_polymers import REACTIONS as EPOXY_POLYMERS + from .vinyl_polymers import REACTIONS as VINYL_POLYMERS + from .polycarbonates import REACTIONS as POLYCARBONATES + # from .polyimides import REACTIONS as POLYIMIDES + # from .polybenzimidazoles import REACTIONS as POLYBENZIMIDAZOLES + # from .phenolic_resins import REACTIONS as PHENOLIC_RESINS + from .polysiloxanes import REACTIONS as POLYSILOXANES + # from .polysulfides import REACTIONS as POLYSULFIDES + from .thiol_ene_polymers import REACTIONS as THIOL_ENE_POLYMERS + # from .metathesis_polymers import REACTIONS as METATHESIS_POLYMERS + # from .cycloaddition_polymers import REACTIONS as CYCLOADDITION_POLYMERS + +except ImportError as e: + from polyesters import REACTIONS as POLYESTERS + from polyamides import REACTIONS as POLYAMIDES + from polyanhydrides import REACTIONS as POLYANHYDRIDES + from polythioesters import REACTIONS as POLYTHIOESTERS + from polyurethanes import REACTIONS as POLYURETHANES + from polyureas import REACTIONS as POLYUREAS + from epoxy_polymers import REACTIONS as EPOXY_POLYMERS + from vinyl_polymers import REACTIONS as VINYL_POLYMERS + from polycarbonates import REACTIONS as POLYCARBONATES + from polysiloxanes import REACTIONS as POLYSILOXANES + from thiol_ene_polymers import REACTIONS as THIOL_ENE_POLYMERS + + +_REACTION_MODULES = [ + POLYESTERS, + # POLYETHERS, + POLYAMIDES, + POLYANHYDRIDES, + POLYTHIOESTERS, + POLYURETHANES, + POLYUREAS, + EPOXY_POLYMERS, + VINYL_POLYMERS, + POLYCARBONATES, + # POLYIMIDES, + # POLYBENZIMIDAZOLES, + # PHENOLIC_RESINS, + POLYSILOXANES, + # POLYSULFIDES, + THIOL_ENE_POLYMERS, + # METATHESIS_POLYMERS, + # CYCLOADDITION_POLYMERS, +] + + +class ReactionLibraryValidationError(ValueError): + """Raised when a reaction-library SMARTS violates AutoREACTER rules.""" + + +def _atom_maps_in_templates(templates) -> set[int]: + """Return all nonzero atom-map numbers present in RDKit templates.""" + atom_maps: set[int] = set() + + for template in templates: + for atom in template.GetAtoms(): + atom_map = atom.GetAtomMapNum() + if atom_map: + atom_maps.add(atom_map) + + return atom_maps + + +def _has_bond_between_atom_maps( + templates, + atom_map_1: int, + atom_map_2: int, +) -> bool: + """Return True if any template contains a bond between two atom maps.""" + target = {atom_map_1, atom_map_2} + + for template in templates: + for bond in template.GetBonds(): + begin_map = bond.GetBeginAtom().GetAtomMapNum() + end_map = bond.GetEndAtom().GetAtomMapNum() + + if {begin_map, end_map} == target: + return True + + return False + + +def _validate_reaction_smarts( + reaction_name: str, + reaction: dict, +) -> list[str]: + """ + Validate one reaction-library entry. + + AutoREACTER convention: + atom maps :1 and :2 are reserved as LAMMPS bond/react initiator atoms. + + By default this validator requires: + - reaction["reaction"] exists + - maps 1 and 2 exist in reactants + - maps 1 and 2 exist in products + - products contain a bond between map 1 and map 2 + + A reaction can override this with: + "initiator_atom_maps": (a, b) + + A special reaction can skip this check with: + "validate_initiator_bond": False + """ + errors: list[str] = [] + + smarts = reaction.get("reaction") + + if not smarts: + return [f"{reaction_name}: missing required key 'reaction'"] + + if reaction.get("validate_initiator_bond", True) is False: + return errors + + if rdChemReactions is None: + return [f"{reaction_name}: RDKit is required to validate reaction SMARTS"] + + initiator_atom_maps = reaction.get("initiator_atom_maps", (1, 2)) + + if len(initiator_atom_maps) != 2: + return [ + f"{reaction_name}: initiator_atom_maps must contain exactly two atom maps" + ] + + initiator_1, initiator_2 = map(int, initiator_atom_maps) + + try: + rdkit_reaction = rdChemReactions.ReactionFromSmarts(smarts) + except Exception as error: + return [f"{reaction_name}: invalid reaction SMARTS: {error}"] + + if rdkit_reaction is None: + return [f"{reaction_name}: RDKit could not parse reaction SMARTS"] + + reactant_templates = [ + rdkit_reaction.GetReactantTemplate(i) + for i in range(rdkit_reaction.GetNumReactantTemplates()) + ] + + product_templates = [ + rdkit_reaction.GetProductTemplate(i) + for i in range(rdkit_reaction.GetNumProductTemplates()) + ] + + reactant_maps = _atom_maps_in_templates(reactant_templates) + product_maps = _atom_maps_in_templates(product_templates) + + required_maps = {initiator_1, initiator_2} + + missing_reactant_maps = required_maps - reactant_maps + missing_product_maps = required_maps - product_maps + + if missing_reactant_maps: + errors.append( + f"{reaction_name}: initiator atom maps missing from reactants: " + f"{sorted(missing_reactant_maps)}" + ) + + if missing_product_maps: + errors.append( + f"{reaction_name}: initiator atom maps missing from products: " + f"{sorted(missing_product_maps)}" + ) + + product_has_initiator_bond = _has_bond_between_atom_maps( + product_templates, + initiator_1, + initiator_2, + ) + + if not product_has_initiator_bond: + errors.append( + f"{reaction_name}: product does not contain required " + f"AutoREACTER initiator bond between atom maps " + f"{initiator_1} and {initiator_2}" + ) + + return errors + + +def validate_reactions(reactions: dict) -> None: + """Validate the merged AutoREACTER reaction library.""" + errors: list[str] = [] + + for reaction_name, reaction in reactions.items(): + if not isinstance(reaction, dict): + errors.append(f"{reaction_name}: reaction entry must be a dictionary") + continue + + errors.extend(_validate_reaction_smarts(reaction_name, reaction)) + + if errors: + message = "\n".join(f" - {error}" for error in errors) + raise ReactionLibraryValidationError( + "Reaction library validation failed:\n" + message + ) + + +def load_reactions() -> dict: + """Return one flat reaction dictionary with duplicate-name protection.""" + merged = {} + + for module in _REACTION_MODULES: + for reaction_name, reaction in module.items(): + if reaction_name in merged: + raise ValueError(f"Duplicate reaction name: {reaction_name}") + merged[reaction_name] = reaction + + validate_reactions(merged) + + return merged + + +REACTIONS = load_reactions() + + +class ReactionLibrary: + """Backward-compatible class exposing ``self.reactions``.""" + + def __init__(self): + self.reactions = load_reactions() + + +if __name__ == "__main__": + REACTIONS = load_reactions() + num = 0 + + with open("reactions.txt", "w") as f: + for reaction in REACTIONS.items(): + f.write(str(reaction) + "\n") + + reaction_len = len(REACTIONS) + + import os + + file_abs_path = os.path.abspath("reactions.txt") + print( + f"reactions.txt has been written to {file_abs_path}, " + f"num reactions: {reaction_len}" + ) \ No newline at end of file diff --git a/AutoREACTER/detectors/reactions_library/thiol_ene_polymers.py b/AutoREACTER/detectors/reactions_library/thiol_ene_polymers.py new file mode 100644 index 0000000..c14641e --- /dev/null +++ b/AutoREACTER/detectors/reactions_library/thiol_ene_polymers.py @@ -0,0 +1,15 @@ +REACTIONS = { + 'Dithiol and Diene Thiol-Ene Click Polymerization': { + 'same_reactants': False, + 'reactant_1': 'dithiol', + 'reactant_2': 'diene', + 'product': 'poly_thioether_chain', + 'delete_atom': False, + 'reaction': '[SX2H1:1]-[H:5].[CH2:2]=[C;!R:3]>>[SX2:1]-[CH2:2]-[C:3]-[H:5]', + 'reference': { + 'smarts': None, + 'reaction_and_mechanism': None + }, + 'comments': None + } +} \ No newline at end of file diff --git a/AutoREACTER/detectors/reactions_library/vinyl_polymers.py b/AutoREACTER/detectors/reactions_library/vinyl_polymers.py new file mode 100644 index 0000000..e7f2c27 --- /dev/null +++ b/AutoREACTER/detectors/reactions_library/vinyl_polymers.py @@ -0,0 +1,111 @@ +REACTIONS = { + 'Vinyl Addition Polymerization Initiation': { + 'same_reactants': True, + 'reactant_1': 'vinyl', + 'product': 'vinyl_chain_end_radical', + 'delete_atom': False, + 'reaction': '[CH2:1]=[C;!R:3].[CH2:2]=[C;!R:4]>>[CH2:1](-[C:3])-[CH2:2]-[C:4]', + 'reference': {'smarts': None, 'reaction_and_mechanism': None}, + 'comments': None +}, + +'Vinyl Addition Polymerization Propagation': { + 'same_reactants': False, + 'reactant_1': 'vinyl', + 'reactant_2': 'vinyl_chain_end_radical', + 'product': 'vinyl_chain_end_radical', + 'delete_atom': False, + 'reaction': '[CH2:2]=[C;!R:3].[C;!R;D3;v3:1]>>[C:1]-[CH2:2]-[C:3]', + 'reference': {'smarts': None, 'reaction_and_mechanism': None}, + 'comments': None +}, + +'Vinyl Copolymerization': { + 'same_reactants': False, + 'reactant_1': 'vinyl', + 'reactant_2': 'vinyl', + 'product': 'copolyvinyl_chain', + 'delete_atom': False, + 'reaction': '[CH2:1]=[C;!R:2].[CH2:3]=[C;!R:4]>>[CH2:1]-[C:2]-[CH2:3]-[C:4]', + 'reference': {'smarts': None, 'reaction_and_mechanism': None}, + 'comments': 'General vinyl copolymerization; supports terminal vinyl and methacrylate-style substituted vinyls' +}, + # 'Vinyl Radical Coupling Termination': { + # 'same_reactants': True, + # 'reactant_1': 'vinyl_chain_end_radical', + # 'product': 'vinyl_terminated_chain', + # 'delete_atom': False, + # 'reaction': '[C;!R;D3;v3;+0:1].[C;!R;D3;v3;+0:2]>>[C:1]-[C:2]', + # 'reference': {'smarts': None, 'reaction_and_mechanism': None}, + # 'comments': None + # }, + # 'Vinyl Addition Polymerization': { + # 'same_reactants': True, + # 'reactant_1': 'vinyl', + # 'product': 'polyvinyl_chain', + # 'delete_atom': False, + # 'reaction': '[CH2:1]=[CH;H1,H0;!R:2].[CH2:3]=[CH;H1,H0;!R:4]>>[CH2:1]-[CH:2]-[CH2:3]-[CH:4]', + # 'reference': {'smarts': None, 'reaction_and_mechanism': None}, + # 'comments': None + # }, + # 'Vinyl Copolymerization': { + # 'same_reactants': False, + # 'reactant_1': 'vinyl', + # 'reactant_2': 'vinyl', + # 'product': 'copolyvinyl_chain', + # 'delete_atom': False, + # 'reaction': '[CH2:1]=[CH;H1,H0;!R:2].[CH2:3]=[CH;H1,H0;!R:4]>>[CH2:1]-[CH:2]-[CH2:3]-[CH:4]', + # 'reference': {'smarts': None, 'reaction_and_mechanism': None}, + # 'comments': None + # }, + # Later Implementation + # 'Cyclic Olefin Addition Polymerization': { + # 'same_reactants': True, + # 'reactant_1': 'cyclic_olefin', + # 'product': 'polycyclic_chain', + # 'delete_atom': False, + # 'reaction': '[CX3;R:1]=[CX3;R:2].[CX3;R:3]=[CX3;R:4]>>[CX4:1]-[CX4:2]-[CX4:3]-[CX4:4]', + # 'reference': {'smarts': None, 'reaction_and_mechanism': None}, + # 'comments': None + # }, + # 'Cyclic Olefin and Vinyl Copolymerization': { + # 'same_reactants': False, + # 'reactant_1': 'vinyl', + # 'reactant_2': 'cyclic_olefin', + # 'product': 'copolycyclicvinyl_chain', + # 'delete_atom': False, + # 'reaction': '[CH2:1]=[C;!R:2].[C;R:3]=[C;R:4]>>[C:1]-[C:2]-[C:3]-[C:4]', + # 'reference': {'smarts': None, 'reaction_and_mechanism': None}, + # 'comments': None + # }, + # 'Cyclic Olefin Copolymerization': { + # 'same_reactants': False, + # 'reactant_1': 'cyclic_olefin', + # 'reactant_2': 'cyclic_olefin', + # 'product': 'copolycyclic_chain', + # 'delete_atom': False, + # 'reaction': '[CX3;R:1]=[CX3;R:2].[CX3;R:3]=[CX3;R:4]>>[CX4:1]-[CX4:2]-[CX4:3]-[CX4:4]', + # 'reference': {'smarts': None, 'reaction_and_mechanism': None}, + # 'comments': None + # } + + 'Tetrafluoroethylene Addition Polymerization Initiation': { + 'same_reactants': True, + 'reactant_1': 'tetrafluoroethylene', + 'product': 'vinyl_chain_end_radical', + 'delete_atom': False, + 'reaction': '[CX3:1](-[F:5])(-[F:6])=[C;!R:3](-[F:7])(-[F:8]).[CX3:2](-[F:9])(-[F:10])=[C;!R:4](-[F:11])(-[F:12])>>[CX3:1](-[F:5])(-[F:6])(-[C:3](-[F:7])(-[F:8]))-[CX3:2](-[F:9])(-[F:10])-[C;!R;D3;v3:4](-[F:11])(-[F:12])', + 'reference': {'smarts': None, 'reaction_and_mechanism': None}, + 'comments': 'Self-initiation cheat for TFE polymerization' + }, + 'Tetrafluoroethylene Addition Polymerization Propagation': { + 'same_reactants': False, + 'reactant_1': 'tetrafluoroethylene', + 'reactant_2': 'vinyl_chain_end_radical', + 'product': 'vinyl_chain_end_radical', + 'delete_atom': False, + 'reaction': '[CX3:2](-[F:5])(-[F:6])=[C;!R:3](-[F:7])(-[F:8]).[C;!R;D3;v3:1](-[F:9])(-[F:10])>>[C:1](-[F:9])(-[F:10])-[CX3:2](-[F:5])(-[F:6])-[C;!R;D3;v3:3](-[F:7])(-[F:8])', + 'reference': {'smarts': None, 'reaction_and_mechanism': None}, + 'comments': 'Propagation step carrying the radical center forward for TFE' + } +} \ No newline at end of file diff --git a/AutoREACTER/input_parser.py b/AutoREACTER/input_parser.py index be05278..7ae674b 100644 --- a/AutoREACTER/input_parser.py +++ b/AutoREACTER/input_parser.py @@ -2,6 +2,7 @@ import logging from dataclasses import dataclass from pathlib import Path + from typing import Any, Literal, Optional from PIL.Image import Image @@ -149,6 +150,9 @@ class SimulationSetup: density: list[float] force_field: str | None monomers: list[MonomerEntry] + loop : bool = True + input_json: dict | None = None + max_loop_count: int | None = None simulations: list[Simulation] | None = None composition_method: CompositionMethodType | None = None composition: dict[str, Any] | None = None @@ -203,6 +207,8 @@ def validate_inputs(self, inputs: dict) -> SimulationSetup: inputs.get("force_field", None) ) + loop, max_loop_count = self._validate_loop(inputs) + return SimulationSetup( simulation_name=simulation_name, temperature=validated_simulations["temperatures"], @@ -212,6 +218,9 @@ def validate_inputs(self, inputs: dict) -> SimulationSetup: composition_method=composition_method, composition=validated_simulations, force_field=force_field, + loop=loop, + max_loop_count=max_loop_count, + input_json=inputs, ) def molecule_representation_of_initial_molecules( @@ -939,6 +948,43 @@ def _validate_simulations( "systems": systems, "simulations": simulations, } + + def _validate_loop(self, inputs: dict) -> tuple[bool, int | None]: + """ + Validate the ``loop`` input. + + Returns: + A tuple containing: + - Whether looping is enabled. + - The maximum iteration count, or ``None`` when no limit is specified. + + Raises: + InputSchemaError: If ``loop`` is not a boolean, a positive integer, + or a supported loop keyword. + """ + loop_keywords = {"loop", "repeat", "iterations", "do_loop"} + loop_value = inputs.get("loop", True) + + if isinstance(loop_value, bool): + return loop_value, None + + if isinstance(loop_value, int): + if loop_value <= 0: + raise InputSchemaError("'loop' must be a positive integer.") + + logger.info( + "Looping enabled with maximum iterations set to %s", + loop_value, + ) + return True, loop_value + + if isinstance(loop_value, str) and loop_value in loop_keywords: + return True, None + + raise InputSchemaError( + "'loop' must be a boolean value, a positive integer, " + "or a supported loop keyword." + ) if __name__ == "__main__": diff --git a/AutoREACTER/reaction_preparation/deduplication_detector.py b/AutoREACTER/reaction_preparation/deduplication_detector.py new file mode 100644 index 0000000..a01d2c3 --- /dev/null +++ b/AutoREACTER/reaction_preparation/deduplication_detector.py @@ -0,0 +1,1384 @@ +""" +Graph-based reaction deduplication for RDKit molecules and LAMMPS +molecule templates. + +The comparison intentionally ignores coordinates, atom IDs, and bond IDs. +RDKit comparisons use chemical element, radical state, and bond type. +LAMMPS comparisons use atom type and bond type. +""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import TYPE_CHECKING + +import networkx as nx +from rdkit import Chem + +if TYPE_CHECKING: + from AutoREACTER.reaction_preparation.reaction_processor.prepare_reactions import ( + ReactionMetadata, + ) + from AutoREACTER.reaction_preparation.ff_wrapper.REACTER_files_builder import ( + TemplateFile, + ) + + +class DeduplicationDetector: + """Detect duplicate pre/post-reaction graph pairs.""" + + DEEP_CHECK = True + + NODE_ATTRIBUTE = "atom_label" + EDGE_ATTRIBUTE = "bond_label" + + RADICAL_COUNT_ATTRIBUTE = "radical_count" + RADICAL_PRESENT_ATTRIBUTE = "contains_radical" + RADICAL_SIGNATURE_ATTRIBUTE = "radical_signature" + + LAMMPS_COMPARISON_GROUP = "lammps" + RDKIT_COMPARISON_GROUP = "rdkit" + + _PRE_PHASE = "pre" + _POST_PHASE = "post" + + _BOND_RELATIONSHIP = "bond" + _ATOM_CORRESPONDENCE_RELATIONSHIP = "atom_correspondence" + + _LAMMPS_RELEVANT_SECTIONS = { + "Types", + "Bonds", + } + + _LAMMPS_SECTION_HEADERS = { + "Coords", + "Types", + "Charges", + "Molecules", + "Bonds", + "Angles", + "Dihedrals", + "Impropers", + "Special Bond Counts", + "Special Bonds", + } + + def __init__(self) -> None: + """Initialize independent graph-comparison caches.""" + self.seen_reactions: dict[str, list[nx.Graph]] = { + self.LAMMPS_COMPARISON_GROUP: [], + self.RDKIT_COMPARISON_GROUP: [], + } + + self.seen_reaction_pairs: dict[ + str, + list[tuple[nx.Graph, nx.Graph]], + ] = { + self.LAMMPS_COMPARISON_GROUP: [], + self.RDKIT_COMPARISON_GROUP: [], + } + + # ------------------------------------------------------------------ + # Duplicate-detection API + # ------------------------------------------------------------------ + + def is_duplicate( + self, + pre_template_graph: nx.Graph, + post_template_graph: nx.Graph, + comparison_group: str, + ) -> bool: + """ + Check whether an equivalent coupled pre/post pair was previously + cached. + + The pre- and post-reaction graphs are coupled using atom + correspondence edges. This requires one consistent atom mapping + to satisfy both reaction phases. + + This path is mainly used for RDKit molecules where pre and post + graphs have already been relabeled into the same atom-index space. + """ + coupled_graph = self._couple_graphs( + pre_template_graph=pre_template_graph, + post_template_graph=post_template_graph, + ) + + return self.is_duplicate_coupled_graph( + coupled_graph=coupled_graph, + comparison_group=comparison_group, + ) + + def is_duplicate_coupled_graph( + self, + coupled_graph: nx.Graph, + comparison_group: str, + ) -> bool: + """ + Check whether an already-coupled pre/post graph was previously cached. + + This is used for LAMMPS templates where atom correspondence comes from + the RXN_*.map Equivalences section instead of matching atom IDs. + """ + node_match = nx.algorithms.isomorphism.categorical_node_match( + ["phase", self.NODE_ATTRIBUTE], + [None, None], + ) + + edge_match = nx.algorithms.isomorphism.categorical_edge_match( + ["relationship", self.EDGE_ATTRIBUTE], + [None, None], + ) + + seen_graphs = self.seen_reactions.setdefault( + comparison_group, + [], + ) + + for seen_graph in seen_graphs: + if ( + coupled_graph.graph.get( + self.RADICAL_SIGNATURE_ATTRIBUTE + ) + != seen_graph.graph.get( + self.RADICAL_SIGNATURE_ATTRIBUTE + ) + ): + continue + + if ( + coupled_graph.number_of_nodes() + != seen_graph.number_of_nodes() + ): + continue + + if ( + coupled_graph.number_of_edges() + != seen_graph.number_of_edges() + ): + continue + + if nx.is_isomorphic( + coupled_graph, + seen_graph, + node_match=node_match, + edge_match=edge_match, + ): + return True + + seen_graphs.append(coupled_graph.copy()) + return False + + def is_duplicate_lammps_template_pair( + self, + pre_file_path: str | Path, + post_file_path: str | Path, + comparison_group: str | None = None, + ) -> bool: + """ + Check LAMMPS template duplication using: + - template_pre_*.molecule + - template_post_*.molecule + - RXN_*.map Equivalences + + This ignores coordinates, atom IDs, and bond IDs, but preserves + the pre-to-post atom correspondence from the LAMMPS map file. + """ + pre_file_path = Path(pre_file_path) + post_file_path = Path(post_file_path) + + if comparison_group is None: + comparison_group = self.LAMMPS_COMPARISON_GROUP + + pre_graph = self.lammps_molecule_to_networkx(pre_file_path) + post_graph = self.lammps_molecule_to_networkx(post_file_path) + + map_file_path = self._lammps_map_path_from_template_path( + pre_file_path + ) + + if not map_file_path.is_file(): + return self.is_duplicate_pair( + pre_graph=pre_graph, + post_graph=post_graph, + comparison_group=comparison_group, + ) + + pre_to_post_mapping = self._read_lammps_equivalences( + map_file_path + ) + + if not pre_to_post_mapping: + raise ValueError( + f"No Equivalences mapping found in {map_file_path}." + ) + + coupled_graph = self._couple_lammps_graphs( + pre_graph=pre_graph, + post_graph=post_graph, + pre_to_post_mapping=pre_to_post_mapping, + source=map_file_path, + ) + + return self.is_duplicate_coupled_graph( + coupled_graph=coupled_graph, + comparison_group=comparison_group, + ) + + @staticmethod + def _lammps_reaction_id_from_template_path( + file_path: str | Path, + ) -> str: + """ + Extract reaction ID from names like: + template_pre_22.molecule + template_post_22.molecule + template_pre_1_homo2.molecule + """ + file_path = Path(file_path) + + match = re.match( + r"template_(?:pre|post)_(.+)\.molecule$", + file_path.name, + ) + + if match is None: + raise ValueError( + f"Could not infer reaction ID from template file name: " + f"{file_path.name}" + ) + + return match.group(1) + + def _lammps_map_path_from_template_path( + self, + file_path: str | Path, + ) -> Path: + """Return the RXN_*.map path matching a template molecule path.""" + file_path = Path(file_path) + reaction_id = self._lammps_reaction_id_from_template_path( + file_path + ) + return file_path.with_name(f"RXN_{reaction_id}.map") + + @staticmethod + def _read_lammps_equivalences( + map_file_path: str | Path, + ) -> dict[int, int]: + """ + Read pre-to-post atom equivalences from a LAMMPS bond/react map file. + + Returns: + {pre_atom_id: post_atom_id} + """ + map_file_path = Path(map_file_path) + + section_headers = { + "InitiatorIDs", + "EdgeIDs", + "Equivalences", + "DeleteIDs", + } + + current_section: str | None = None + mapping: dict[int, int] = {} + + with map_file_path.open("r", encoding="utf-8") as file: + for raw_line in file: + line = raw_line.split("#", maxsplit=1)[0].strip() + + if not line: + continue + + if line in section_headers: + current_section = line + continue + + first_token = line.split()[0] + + if first_token in section_headers: + current_section = first_token + continue + + if current_section != "Equivalences": + continue + + parts = line.split() + + if len(parts) < 2: + continue + + try: + pre_atom_id = int(parts[0]) + post_atom_id = int(parts[1]) + except ValueError: + continue + + if ( + pre_atom_id in mapping + and mapping[pre_atom_id] != post_atom_id + ): + raise ValueError( + f"Conflicting Equivalences entry in " + f"{map_file_path}: pre atom {pre_atom_id} maps to " + f"both {mapping[pre_atom_id]} and {post_atom_id}." + ) + + mapping[pre_atom_id] = post_atom_id + + post_to_pre: dict[int, int] = {} + + for pre_atom_id, post_atom_id in mapping.items(): + if ( + post_atom_id in post_to_pre + and post_to_pre[post_atom_id] != pre_atom_id + ): + raise ValueError( + f"Non-bijective Equivalences section in " + f"{map_file_path}: post atom {post_atom_id} is mapped " + f"from both {post_to_pre[post_atom_id]} and " + f"{pre_atom_id}." + ) + + post_to_pre[post_atom_id] = pre_atom_id + + return mapping + + def _couple_lammps_graphs( + self, + pre_graph: nx.Graph, + post_graph: nx.Graph, + pre_to_post_mapping: dict[int, int], + source: Path | str, + ) -> nx.Graph: + """ + Couple LAMMPS pre/post template graphs using RXN_*.map equivalences. + + Unlike the RDKit coupling path, this does not require matching atom IDs + in pre and post files. The map file defines correspondence. + """ + coupled_graph = nx.Graph() + + coupled_graph.graph[self.RADICAL_SIGNATURE_ATTRIBUTE] = ( + pre_graph.graph.get(self.RADICAL_COUNT_ATTRIBUTE, 0), + post_graph.graph.get(self.RADICAL_COUNT_ATTRIBUTE, 0), + pre_graph.graph.get(self.RADICAL_PRESENT_ATTRIBUTE, False), + post_graph.graph.get(self.RADICAL_PRESENT_ATTRIBUTE, False), + ) + + self._add_phase_to_coupled_graph( + source_graph=pre_graph, + coupled_graph=coupled_graph, + phase=self._PRE_PHASE, + ) + + self._add_phase_to_coupled_graph( + source_graph=post_graph, + coupled_graph=coupled_graph, + phase=self._POST_PHASE, + ) + + for pre_atom_id, post_atom_id in pre_to_post_mapping.items(): + if pre_atom_id not in pre_graph: + raise ValueError( + f"Map file {source} references pre atom " + f"{pre_atom_id}, but that atom is not in the " + "pre-template graph." + ) + + if post_atom_id not in post_graph: + raise ValueError( + f"Map file {source} references post atom " + f"{post_atom_id}, but that atom is not in the " + "post-template graph." + ) + + coupled_graph.add_edge( + (self._PRE_PHASE, pre_atom_id), + (self._POST_PHASE, post_atom_id), + relationship=self._ATOM_CORRESPONDENCE_RELATIONSHIP, + **{ + self.EDGE_ATTRIBUTE: None, + }, + ) + + return coupled_graph + + def is_duplicate_pair( + self, + pre_graph: nx.Graph, + post_graph: nx.Graph, + comparison_group: str, + ) -> bool: + """ + Check whether an equivalent uncoupled pre/post graph pair was + previously cached. + + A reaction is considered a duplicate only when both its reactant + graph and product graph match the same cached reaction entry. + """ + node_match = nx.algorithms.isomorphism.categorical_node_match( + self.NODE_ATTRIBUTE, + None, + ) + + edge_match = nx.algorithms.isomorphism.categorical_edge_match( + self.EDGE_ATTRIBUTE, + None, + ) + + seen_pairs = self.seen_reaction_pairs.setdefault( + comparison_group, + [], + ) + + for seen_pre_graph, seen_post_graph in seen_pairs: + if ( + pre_graph.number_of_nodes() + != seen_pre_graph.number_of_nodes() + ): + continue + + if ( + pre_graph.number_of_edges() + != seen_pre_graph.number_of_edges() + ): + continue + + if ( + post_graph.number_of_nodes() + != seen_post_graph.number_of_nodes() + ): + continue + + if ( + post_graph.number_of_edges() + != seen_post_graph.number_of_edges() + ): + continue + + pre_matches = nx.is_isomorphic( + pre_graph, + seen_pre_graph, + node_match=node_match, + edge_match=edge_match, + ) + + if not pre_matches: + continue + + post_matches = nx.is_isomorphic( + post_graph, + seen_post_graph, + node_match=node_match, + edge_match=edge_match, + ) + + if post_matches: + return True + + seen_pairs.append( + ( + pre_graph.copy(), + post_graph.copy(), + ) + ) + + return False + + def compare_graphs( + self, + molecule_file_paths: list[str | Path], + ) -> dict[str, bool]: + """ + Compare LAMMPS pre/post molecule-template pairs. + + A pre-template filename must contain ``pre``. Its post-template + path is determined by replacing the first occurrence of ``pre`` + with ``post``. + """ + results: dict[str, bool] = {} + + for file_path_value in molecule_file_paths: + pre_file_path = Path(file_path_value) + + if "pre" not in pre_file_path.name: + continue + + post_file_path = pre_file_path.with_name( + pre_file_path.name.replace( + "pre", + "post", + 1, + ) + ) + + if not post_file_path.is_file(): + print( + "Skipping reaction because its post-template file " + f"does not exist: {post_file_path}" + ) + continue + + duplicate = self.is_duplicate_lammps_template_pair( + pre_file_path=pre_file_path, + post_file_path=post_file_path, + comparison_group=self.LAMMPS_COMPARISON_GROUP, + ) + + results[str(pre_file_path)] = duplicate + + status = "Duplicate" if duplicate else "Unique" + + print( + f"{status} reaction: " + f"{pre_file_path.name} -> {post_file_path.name}" + ) + + return results + + def compare_graphs_mol( + self, + reaction_metadata_items: list["ReactionMetadata"], + index_source: str = "template", + ) -> list["ReactionMetadata"]: + """ + Detect duplicate reactions using in-memory RDKit molecules. + + Each call performs one independent deduplication pass over the + supplied accumulated reaction pool. The RDKit coupled-graph cache + is therefore cleared before the comparison starts. + + Reactions that are already inactive are ignored. Repeated references + to the exact same ReactionMetadata object are removed from the + returned list without disabling the retained object. This matters + because setting ``activity_stats`` to False on one repeated reference + would otherwise disable every occurrence of that same object. + + For distinct ReactionMetadata objects, the first unique reaction is + retained. Later equivalent reactions are disabled by setting + ``activity_stats`` to False and are excluded from the returned pool. + """ + self.clear_cache(self.RDKIT_COMPARISON_GROUP) + + unique_reactions: list["ReactionMetadata"] = [] + retained_object_ids: set[int] = set() + + for reaction_index, reaction_metadata in enumerate( + reaction_metadata_items, + start=1, + ): + if not reaction_metadata.activity_stats: + continue + + reaction_object_id = id(reaction_metadata) + + if reaction_object_id in retained_object_ids: + continue + + reactant_mol = reaction_metadata.reactant_combined_RDmol + product_mol = reaction_metadata.product_combined_RDmol + + if reactant_mol is None: + raise ValueError( + f"Reaction {reaction_index} does not contain a " + "combined reactant RDKit molecule." + ) + + if product_mol is None: + raise ValueError( + f"Reaction {reaction_index} does not contain a " + "combined product RDKit molecule." + ) + + reactant_to_product_mapping = ( + self._select_reactant_to_product_mapping( + reaction_metadata=reaction_metadata, + reaction_index=reaction_index, + index_source=index_source, + ) + ) + + reactant_indices = set(reactant_to_product_mapping) + product_indices = set( + reactant_to_product_mapping.values() + ) + + product_to_reactant_mapping = { + product_idx: reactant_idx + for reactant_idx, product_idx + in reactant_to_product_mapping.items() + } + + if len(product_to_reactant_mapping) != len( + reactant_to_product_mapping + ): + raise ValueError( + f"Reaction {reaction_index} contains a non-bijective " + "reactant-to-product mapping." + ) + + pre_graph = self.rdkit_mol_to_networkx( + molecule=reactant_mol, + atom_idxs=reactant_indices, + ) + + post_graph = self.rdkit_mol_to_networkx( + molecule=product_mol, + atom_idxs=product_indices, + idx_relabel=product_to_reactant_mapping, + ) + + reactant_radical_count = self._count_radical_atoms( + reactant_mol + ) + product_radical_count = self._count_radical_atoms( + product_mol + ) + + pre_graph.graph[self.RADICAL_COUNT_ATTRIBUTE] = ( + reactant_radical_count + ) + post_graph.graph[self.RADICAL_COUNT_ATTRIBUTE] = ( + product_radical_count + ) + + pre_graph.graph[self.RADICAL_PRESENT_ATTRIBUTE] = ( + reactant_radical_count > 0 + ) + post_graph.graph[self.RADICAL_PRESENT_ATTRIBUTE] = ( + product_radical_count > 0 + or bool(getattr(reaction_metadata, "is_radical", False)) + ) + + duplicate = self.is_duplicate( + pre_template_graph=pre_graph, + post_template_graph=post_graph, + comparison_group=self.RDKIT_COMPARISON_GROUP, + ) + + if duplicate: + reaction_metadata.activity_stats = False + continue + + retained_object_ids.add(reaction_object_id) + unique_reactions.append(reaction_metadata) + + return unique_reactions + + @classmethod + def _one_neighbor_edge_environment_signature( + cls, + atom: Chem.Atom, + included_atom_indices: set[int], + ) -> tuple[tuple[str, int, bool, str, int, bool, str], ...]: + """ + Return a one-bond external chemical-environment signature for + boundary atoms. + + Only neighbors outside the restricted comparison graph are used. + This avoids walking into the next molecule or building a larger + shell. Internal atoms return an empty signature. + """ + external_neighbor_signatures = [] + + try: + atom.GetOwningMol().UpdatePropertyCache(strict=False) + except RuntimeError: + pass + + for bond in atom.GetBonds(): + neighbor = bond.GetOtherAtom(atom) + + if neighbor.GetIdx() in included_atom_indices: + continue + + external_neighbor_signatures.append( + ( + neighbor.GetSymbol(), + neighbor.GetFormalCharge(), + neighbor.GetIsAromatic(), + str(neighbor.GetHybridization()), + cls._safe_total_hydrogen_count(neighbor), + cls._is_radical_atom(neighbor), + str(bond.GetBondType()), + ) + ) + + return tuple(sorted(external_neighbor_signatures)) + + @staticmethod + def _safe_total_hydrogen_count(atom: Chem.Atom) -> int: + """Return total hydrogen count without failing on unsanitized mols.""" + try: + return atom.GetTotalNumHs() + except RuntimeError: + explicit_h_neighbors = sum( + neighbor.GetAtomicNum() == 1 + for neighbor in atom.GetNeighbors() + ) + return explicit_h_neighbors + atom.GetNumExplicitHs() + + def clear_cache( + self, + comparison_group: str | None = None, + ) -> None: + """ + Clear graph-comparison caches. + + Args: + comparison_group: + Specific cache group to clear. All groups are cleared + when omitted. + """ + if comparison_group is None: + for seen_graphs in self.seen_reactions.values(): + seen_graphs.clear() + + for seen_pairs in self.seen_reaction_pairs.values(): + seen_pairs.clear() + + return + + self.seen_reactions.setdefault( + comparison_group, + [], + ).clear() + + self.seen_reaction_pairs.setdefault( + comparison_group, + [], + ).clear() + + # ------------------------------------------------------------------ + # RDKit graph conversion + # ------------------------------------------------------------------ + + def rdkit_mol_to_networkx( + self, + molecule: Chem.Mol, + atom_idxs: set[int] | None = None, + idx_relabel: dict[int, int] | None = None, + ) -> nx.Graph: + """ + Convert an RDKit molecule into a NetworkX graph. + + Coordinates are not read or stored. + """ + if molecule is None: + raise ValueError( + "Cannot create a graph from a None RDKit molecule." + ) + + included_atom_indices = ( + atom_idxs + if atom_idxs is not None + else { + atom.GetIdx() + for atom in molecule.GetAtoms() + } + ) + + if idx_relabel is not None: + missing_relabels = sorted( + included_atom_indices - idx_relabel.keys() + ) + + if missing_relabels: + raise ValueError( + "The atom-index relabel mapping does not contain " + f"entries for atom indices {missing_relabels}." + ) + + graph = nx.Graph() + + for atom in molecule.GetAtoms(): + atom_index = atom.GetIdx() + + if atom_index not in included_atom_indices: + continue + + node_id = self._resolve_node_id( + atom_index=atom_index, + idx_relabel=idx_relabel, + ) + + is_radical = self._is_radical_atom(atom) + + if not self.DEEP_CHECK: + atom_label = ( + atom.GetSymbol(), + is_radical, + ) + else: + atom_label = ( + atom.GetSymbol(), + is_radical, + self._one_neighbor_edge_environment_signature( + atom=atom, + included_atom_indices=included_atom_indices, + ), + ) + + graph.add_node( + node_id, + **{ + self.NODE_ATTRIBUTE: atom_label, + }, + ) + + for bond in molecule.GetBonds(): + atom1_index = bond.GetBeginAtomIdx() + atom2_index = bond.GetEndAtomIdx() + + if ( + atom1_index not in included_atom_indices + or atom2_index not in included_atom_indices + ): + continue + + node1_id = self._resolve_node_id( + atom_index=atom1_index, + idx_relabel=idx_relabel, + ) + + node2_id = self._resolve_node_id( + atom_index=atom2_index, + idx_relabel=idx_relabel, + ) + + graph.add_edge( + node1_id, + node2_id, + **{ + self.EDGE_ATTRIBUTE: str( + bond.GetBondType() + ), + }, + ) + + return graph + + # ------------------------------------------------------------------ + # LAMMPS graph conversion + # ------------------------------------------------------------------ + + def lammps_molecule_to_networkx( + self, + file_path: str | Path, + ) -> nx.Graph: + """ + Convert a LAMMPS molecule-template file into a NetworkX graph. + + Only the ``Types`` and ``Bonds`` sections are included. + """ + file_path = Path(file_path) + + if not file_path.is_file(): + raise FileNotFoundError( + f"LAMMPS molecule file does not exist: {file_path}" + ) + + sections = self._read_lammps_sections(file_path) + + if "Types" not in sections: + raise ValueError( + f"Types section was not found in {file_path}." + ) + + graph = nx.Graph() + + self._add_lammps_atoms( + graph=graph, + type_lines=sections["Types"], + file_path=file_path, + ) + + self._add_lammps_bonds( + graph=graph, + bond_lines=sections.get("Bonds", []), + file_path=file_path, + ) + + return graph + + def compare_lammps_templates( + self, + template_files: list["TemplateFile"], + ) -> list["TemplateFile"]: + """ + Compare LAMMPS pre/post molecule-template pairs. + + Filters a list of TemplateFile objects by generating LAMMPS graphs + for their pre and post reaction files, detecting duplicates, and + returning a list containing only unique templates. + """ + self.clear_cache(self.LAMMPS_COMPARISON_GROUP) + + unique_templates: list["TemplateFile"] = [] + + for template in template_files: + if ( + template.pre_reaction_file is None + or template.post_reaction_file is None + ): + print( + f"Skipping template ID {template.reaction_id}: " + "Missing pre or post reaction file definitions." + ) + continue + + pre_file_path = template.pre_reaction_file.lmp_molecule_file + post_file_path = template.post_reaction_file.lmp_molecule_file + + if not pre_file_path.is_file(): + print( + f"Skipping template ID {template.reaction_id}: " + f"Pre-template file does not exist: {pre_file_path}" + ) + continue + + if not post_file_path.is_file(): + print( + f"Skipping template ID {template.reaction_id}: " + f"Post-template file does not exist: {post_file_path}" + ) + continue + + duplicate = self.is_duplicate_lammps_template_pair( + pre_file_path=pre_file_path, + post_file_path=post_file_path, + comparison_group=self.LAMMPS_COMPARISON_GROUP, + ) + + if not duplicate: + unique_templates.append(template) + + return unique_templates + + # ------------------------------------------------------------------ + # Reaction-index selection + # ------------------------------------------------------------------ + + def _select_reactant_to_product_mapping( + self, + reaction_metadata: "ReactionMetadata", + reaction_index: int, + index_source: str, + ) -> dict[int, int]: + """Select the atom-index mapping used for graph restriction.""" + if index_source == "template": + mapping = ( + reaction_metadata.template_reactant_to_product_mapping + ) + + if not mapping: + raise ValueError( + f"Reaction {reaction_index} does not contain a " + "template_reactant_to_product_mapping." + ) + + return mapping + + if index_source == "first_shell": + first_shell_indices = reaction_metadata.first_shell + full_mapping = ( + reaction_metadata.reactant_to_product_mapping + ) + + if not first_shell_indices: + raise ValueError( + f"Reaction {reaction_index} does not contain " + "first_shell indices." + ) + + if not full_mapping: + raise ValueError( + f"Reaction {reaction_index} does not contain a " + "reactant_to_product_mapping." + ) + + return { + reactant_index: full_mapping[reactant_index] + for reactant_index in first_shell_indices + if reactant_index in full_mapping + } + + raise ValueError( + f"Unsupported index_source {index_source!r}. " + "Expected 'template' or 'first_shell'." + ) + + # ------------------------------------------------------------------ + # Coupled-graph helpers + # ------------------------------------------------------------------ + + def _couple_graphs( + self, + pre_template_graph: nx.Graph, + post_template_graph: nx.Graph, + ) -> nx.Graph: + """ + Combine reactant and product graphs using atom-correspondence + edges. + + This RDKit path expects pre and post graph atom IDs to already match. + """ + pre_atom_ids = set(pre_template_graph.nodes) + post_atom_ids = set(post_template_graph.nodes) + + if pre_atom_ids != post_atom_ids: + missing_from_post = sorted( + pre_atom_ids - post_atom_ids + ) + + missing_from_pre = sorted( + post_atom_ids - pre_atom_ids + ) + + raise ValueError( + "Pre- and post-reaction graphs must contain matching " + "atom IDs. " + f"Missing from post graph: {missing_from_post}. " + f"Missing from pre graph: {missing_from_pre}." + ) + + coupled_graph = nx.Graph() + + coupled_graph.graph[self.RADICAL_SIGNATURE_ATTRIBUTE] = ( + pre_template_graph.graph.get( + self.RADICAL_COUNT_ATTRIBUTE, + 0, + ), + post_template_graph.graph.get( + self.RADICAL_COUNT_ATTRIBUTE, + 0, + ), + pre_template_graph.graph.get( + self.RADICAL_PRESENT_ATTRIBUTE, + False, + ), + post_template_graph.graph.get( + self.RADICAL_PRESENT_ATTRIBUTE, + False, + ), + ) + + self._add_phase_to_coupled_graph( + source_graph=pre_template_graph, + coupled_graph=coupled_graph, + phase=self._PRE_PHASE, + ) + + self._add_phase_to_coupled_graph( + source_graph=post_template_graph, + coupled_graph=coupled_graph, + phase=self._POST_PHASE, + ) + + for atom_id in pre_atom_ids: + coupled_graph.add_edge( + (self._PRE_PHASE, atom_id), + (self._POST_PHASE, atom_id), + relationship=self._ATOM_CORRESPONDENCE_RELATIONSHIP, + **{ + self.EDGE_ATTRIBUTE: None, + }, + ) + + return coupled_graph + + def _add_phase_to_coupled_graph( + self, + source_graph: nx.Graph, + coupled_graph: nx.Graph, + phase: str, + ) -> None: + """Add one reaction phase to a coupled graph.""" + for atom_id, attributes in source_graph.nodes(data=True): + atom_label = attributes.get(self.NODE_ATTRIBUTE) + + if atom_label is None: + raise ValueError( + f"Node {atom_id} is missing the required " + f"{self.NODE_ATTRIBUTE!r} attribute." + ) + + coupled_graph.add_node( + (phase, atom_id), + phase=phase, + **{ + self.NODE_ATTRIBUTE: atom_label, + }, + ) + + for atom1_id, atom2_id, attributes in source_graph.edges( + data=True + ): + bond_label = attributes.get(self.EDGE_ATTRIBUTE) + + if bond_label is None: + raise ValueError( + f"Edge {atom1_id}-{atom2_id} is missing the required " + f"{self.EDGE_ATTRIBUTE!r} attribute." + ) + + coupled_graph.add_edge( + (phase, atom1_id), + (phase, atom2_id), + relationship=self._BOND_RELATIONSHIP, + **{ + self.EDGE_ATTRIBUTE: bond_label, + }, + ) + + @staticmethod + def _resolve_node_id( + atom_index: int, + idx_relabel: dict[int, int] | None, + ) -> int: + """Resolve an RDKit atom index to its graph node ID.""" + if idx_relabel is None: + return atom_index + + return idx_relabel[atom_index] + + # ------------------------------------------------------------------ + # LAMMPS parsing helpers + # ------------------------------------------------------------------ + + def _read_lammps_sections( + self, + file_path: Path, + ) -> dict[str, list[str]]: + """Read relevant sections from a LAMMPS molecule file.""" + sections: dict[str, list[str]] = {} + current_section: str | None = None + + with file_path.open( + "r", + encoding="utf-8", + ) as file: + for raw_line in file: + line = raw_line.split( + "#", + maxsplit=1, + )[0].strip() + + if not line: + continue + + if line in self._LAMMPS_SECTION_HEADERS: + if line in self._LAMMPS_RELEVANT_SECTIONS: + current_section = line + sections.setdefault( + current_section, + [], + ) + else: + current_section = None + + continue + + if current_section is not None: + sections[current_section].append(line) + + return sections + + def _add_lammps_atoms( + self, + graph: nx.Graph, + type_lines: list[str], + file_path: Path, + ) -> None: + """Add atoms from a LAMMPS ``Types`` section.""" + for line in type_lines: + parts = line.split() + + if len(parts) < 2: + raise ValueError( + f"Invalid Types line in {file_path}: {line!r}" + ) + + try: + atom_id = int(parts[0]) + except ValueError as error: + raise ValueError( + f"Invalid atom ID in {file_path}: {line!r}" + ) from error + + atom_type = parts[1] + + if atom_id in graph: + raise ValueError( + f"Duplicate atom ID {atom_id} in {file_path}." + ) + + graph.add_node( + atom_id, + **{ + self.NODE_ATTRIBUTE: atom_type, + }, + ) + + def _add_lammps_bonds( + self, + graph: nx.Graph, + bond_lines: list[str], + file_path: Path, + ) -> None: + """Add bonds from a LAMMPS ``Bonds`` section.""" + for line in bond_lines: + parts = line.split() + + if len(parts) < 4: + raise ValueError( + f"Invalid Bonds line in {file_path}: {line!r}" + ) + + try: + bond_id = int(parts[0]) + atom1_id = int(parts[2]) + atom2_id = int(parts[3]) + except ValueError as error: + raise ValueError( + f"Invalid Bonds line in {file_path}: {line!r}" + ) from error + + bond_type = parts[1] + + self._validate_bond_atoms( + graph=graph, + bond_id=bond_id, + atom1_id=atom1_id, + atom2_id=atom2_id, + source=file_path, + ) + + graph.add_edge( + atom1_id, + atom2_id, + **{ + self.EDGE_ATTRIBUTE: bond_type, + }, + ) + + @staticmethod + def _validate_bond_atoms( + graph: nx.Graph, + bond_id: int, + atom1_id: int, + atom2_id: int, + source: Path | str, + ) -> None: + """Ensure both atoms referenced by a bond exist.""" + undefined_atoms = [ + atom_id + for atom_id in (atom1_id, atom2_id) + if atom_id not in graph + ] + + if undefined_atoms: + raise ValueError( + f"Bond {bond_id} references undefined atom IDs " + f"{undefined_atoms} in {source}." + ) + + @classmethod + def _count_radical_atoms( + cls, + molecule: Chem.Mol, + ) -> int: + """Count radical atoms in a complete RDKit molecule.""" + return sum( + cls._is_radical_atom(atom) + for atom in molecule.GetAtoms() + ) + + @staticmethod + def _is_radical_atom(atom: Chem.Atom) -> bool: + """Return True for an explicit or structurally under-valent radical atom. + + Progression normally sets ``NumRadicalElectrons`` on the cleaned + product. The structural fallback is needed because the original + ``RunReactants`` product stored in ``ReactionMetadata`` can retain + incomplete valence bookkeeping before later sanitization. + + The fallback is intentionally limited to neutral, non-aromatic carbon + atoms used by the current vinyl-radical implementation. + """ + if atom.GetNumRadicalElectrons() > 0: + return True + + if atom.GetAtomicNum() != 6: + return False + + if atom.GetFormalCharge() != 0: + return False + + if atom.GetIsAromatic(): + return False + + try: + atom.GetOwningMol().UpdatePropertyCache(strict=False) + if atom.GetNumImplicitHs() > 0: + return False + except RuntimeError: + pass + + graph_bond_valence = sum( + bond.GetBondTypeAsDouble() + for bond in atom.GetBonds() + ) + + explicit_hydrogen_neighbors = sum( + neighbor.GetAtomicNum() == 1 + for neighbor in atom.GetNeighbors() + ) + + effective_valence = graph_bond_valence + + if explicit_hydrogen_neighbors == 0: + effective_valence += atom.GetNumExplicitHs() + + return abs(effective_valence - 3.0) < 1.0e-6 + + +def _main() -> None: + """Run the standalone LAMMPS deduplication example.""" + folder_path = Path( + "/mnt/c/Users/janit/Documents/GitHub/AutoREACTER/" + "examples/AutoREACTER_outputs/" + "Epoxy_Test_Primary_Diamine_Diepoxy/" + ) + + if not folder_path.is_dir(): + raise NotADirectoryError( + f"Invalid folder path: {folder_path}" + ) + + pre_template_files = sorted( + file_path + for file_path in folder_path.glob("*.molecule") + if "pre" in file_path.name + ) + + print( + f"Found {len(pre_template_files)} " + "pre-reaction molecule files." + ) + + detector = DeduplicationDetector() + results = detector.compare_graphs(pre_template_files) + + print("\nDeduplication results:") + + for file_path, duplicate in results.items(): + status = "duplicate" if duplicate else "unique" + + print(f"{Path(file_path).name}: {status}") + + +if __name__ == "__main__": + _main() \ No newline at end of file diff --git a/AutoREACTER/reaction_preparation/ff_wrapper/FF_files/pcff.frc b/AutoREACTER/reaction_preparation/ff_wrapper/FF_files/pcff.frc index 07ecac8..dcf5e25 100644 --- a/AutoREACTER/reaction_preparation/ff_wrapper/FF_files/pcff.frc +++ b/AutoREACTER/reaction_preparation/ff_wrapper/FF_files/pcff.frc @@ -163,6 +163,7 @@ 1.0 1 p 30.97380 P 4 general phosphorous atom 1.0 1 p= 30.97380 P 5 phosphazene phosphorous atom 1.0 1 s 32.06400 S 2 sp3 sulfur + 1.0 1 s_m 32.06400 S 2 sulfone sulfur # Duplicated from base parameter 's' 1.0 1 s' 32.06400 S 1 S in thioketone group 1.0 1 s- 32.06400 S 1 partial double sulfur 1.0 1 s1 32.06400 S 2 sp3 sulfur involved in (S-S) group of disulfides @@ -487,6 +488,7 @@ 2.0 2 br op 0.3140 -0.3140 2.0 2 br p -0.2156 0.2156 2.0 2 br s -0.0437 0.0437 + 2.0 2 br s_m -0.0437 0.0437 # Duplicated from base parameter 's' 2.0 2 br s' 0.0034 -0.0034 2.0 2 br si -0.3273 0.3273 2.0 2 br sp 0.0034 -0.0034 @@ -523,6 +525,7 @@ 2.0 2 c p 0.0110 -0.0110 3.1 12 c p= -0.0500 0.0500 1.0 1 c s 0.0650 -0.0650 + 1.0 1 c s_m 0.0650 -0.0650 # Duplicated from base parameter 's' 2.2 9 c si -0.1350 0.1350 2.0 2 c si -0.1767 0.1767 1.0 4 c sio -0.1000 0.1000 @@ -550,6 +553,7 @@ 2.0 2 c- op 0.3241 -0.3241 2.0 2 c- p -0.0857 0.0857 2.0 2 c- s -0.0087 0.0087 + 2.0 2 c- s_m -0.0087 0.0087 # Duplicated from base parameter 's' 2.0 2 c- s- -0.1223 -0.3777 2.0 2 c- si -0.2775 0.2775 2.0 1 c= c= 0.0000 0.0000 @@ -578,6 +582,7 @@ 2.0 2 c= op 0.3583 -0.3583 2.0 2 c= p -0.0380 0.0380 2.0 2 c= s -0.0120 0.0120 + 2.0 2 c= s_m -0.0120 0.0120 # Duplicated from base parameter 's' 2.0 2 c= s' 0.0732 -0.0732 2.0 2 c= si -0.2270 0.2270 2.0 2 c= sp 0.0732 -0.0732 @@ -605,6 +610,7 @@ 2.0 2 c=1 op 0.3583 -0.3583 2.0 2 c=1 p -0.0380 0.0380 2.0 2 c=1 s -0.0120 0.0120 + 2.0 2 c=1 s_m -0.0120 0.0120 # Duplicated from base parameter 's' 2.0 2 c=1 s' 0.0732 -0.0732 2.0 2 c=1 si -0.2270 0.2270 2.0 2 c=1 sp 0.0732 -0.0732 @@ -633,6 +639,7 @@ 2.0 2 c=2 op 0.3583 -0.3583 2.0 2 c=2 p -0.0380 0.0380 2.0 2 c=2 s -0.0120 0.0120 + 2.0 2 c=2 s_m -0.0120 0.0120 # Duplicated from base parameter 's' 2.0 2 c=2 s' 0.0732 -0.0732 2.0 2 c=2 si -0.2270 0.2270 2.0 2 c=2 sp 0.0732 -0.0732 @@ -658,6 +665,7 @@ 1.0 1 c_0 op 0.0283 -0.0283 2.0 2 c_0 p -0.2396 0.2396 2.0 2 c_0 s -0.0140 0.0140 + 2.0 2 c_0 s_m -0.0140 0.0140 # Duplicated from base parameter 's' 2.0 3 c_0 s' 0.0000 0.0000 2.0 2 c_0 si -0.4405 0.4405 1.0 1 c_0 sp -0.0130 0.0130 @@ -685,6 +693,7 @@ 1.0 1 c_1 op 0.0283 -0.0283 2.0 2 c_1 p -0.2396 0.2396 2.0 2 c_1 s -0.0140 0.0140 + 2.0 2 c_1 s_m -0.0140 0.0140 # Duplicated from base parameter 's' 2.0 3 c_1 s' 0.0000 0.0000 2.0 2 c_1 si -0.4405 0.4405 1.0 1 c_1 sp -0.0130 0.0130 @@ -712,6 +721,7 @@ 2.0 2 cl p -0.2544 0.2544 3.1 12 cl p= -0.1200 0.1200 2.0 2 cl s -0.0898 0.0898 + 2.0 2 cl s_m -0.0898 0.0898 # Duplicated from base parameter 's' 2.0 2 cl s' -0.0457 0.0457 2.0 2 cl si -0.3598 0.3598 2.0 2 cl sp -0.0457 0.0457 @@ -738,6 +748,7 @@ 1.0 2 cp p -0.0380 0.0380 3.1 12 cp p= -0.0600 0.0600 2.0 2 cp s -0.0120 0.0120 + 2.0 2 cp s_m -0.0120 0.0120 # Duplicated from base parameter 's' 2.0 2 cp s' 0.0732 -0.0732 2.2 9 cp si -0.1170 0.1170 2.0 2 cp si -0.2270 0.2270 @@ -764,6 +775,7 @@ 2.0 2 ct o 0.0675 -0.0675 2.0 2 ct p -0.1335 0.1335 2.0 2 ct s -0.0522 0.0522 + 2.0 2 ct s_m -0.0522 0.0522 # Duplicated from base parameter 's' 2.0 2 ct si -0.3266 0.3266 2.0 4 cz oo 0.5000 -0.5000 2.0 4 cz oz 0.1000 -0.1000 @@ -785,6 +797,7 @@ 2.0 2 f p -0.3869 0.3869 3.1 12 f p= -0.1800 0.1800 2.0 2 f s -0.2380 0.2380 + 2.0 2 f s_m -0.2380 0.2380 # Duplicated from base parameter 's' 2.0 2 f s' -0.2011 0.2011 2.0 2 f si -0.4789 0.4789 2.0 2 f sp -0.2011 0.2011 @@ -794,6 +807,7 @@ 2.0 2 h p -0.0356 0.0356 3.1 12 h p= -0.0500 0.0500 2.0 2 h s 0.1392 -0.1392 + 2.0 2 h s_m 0.1392 -0.1392 # Duplicated from base parameter 's' 2.0 2 h s' 0.1932 -0.1932 2.2 9 h si -0.1260 0.1260 2.0 2 h si -0.1537 0.1537 @@ -835,6 +849,7 @@ 2.0 2 i op 0.3297 -0.3297 2.0 2 i p -0.2110 0.2110 2.0 2 i s -0.0345 0.0345 + 2.0 2 i s_m -0.0345 0.0345 # Duplicated from base parameter 's' 2.0 2 i s' 0.0140 -0.0140 2.0 2 i si -0.3263 0.3263 2.0 2 i sp 0.0140 -0.0140 @@ -853,6 +868,7 @@ 2.0 2 n p -0.3359 0.3359 3.1 12 n p= -0.1200 0.1200 2.0 2 n s -0.1753 0.1753 + 2.0 2 n s_m -0.1753 0.1753 # Duplicated from base parameter 's' 2.0 2 n s' -0.1346 0.1346 2.0 2 n si -0.4368 0.4368 2.0 2 n sp -0.1346 0.1346 @@ -869,6 +885,7 @@ 2.0 2 n+ op 0.3418 -0.0918 2.0 2 n+ p -0.1994 0.4494 2.0 2 n+ s -0.0255 0.2755 + 2.0 2 n+ s_m -0.0255 0.2755 # Duplicated from base parameter 's' 2.0 2 n+ s' 0.0159 0.2341 2.0 2 n+ si -0.3083 0.5583 2.0 2 n+ sp 0.0159 0.2341 @@ -885,6 +902,7 @@ 2.0 2 n= p -0.3359 0.3359 3.1 12 n= p= -0.3500 0.3500 2.0 2 n= s -0.1753 0.1753 + 2.0 2 n= s_m -0.1753 0.1753 # Duplicated from base parameter 's' 2.0 2 n= s' -0.1346 0.1346 2.0 2 n= si -0.4368 0.4368 2.0 2 n= sp -0.1346 0.1346 @@ -899,6 +917,7 @@ 2.0 2 n=1 op 0.1684 -0.1684 2.0 2 n=1 p -0.3359 0.3359 2.0 2 n=1 s -0.1753 0.1753 + 2.0 2 n=1 s_m -0.1753 0.1753 # Duplicated from base parameter 's' 2.0 2 n=1 s' -0.1346 0.1346 2.0 2 n=1 si -0.4368 0.4368 2.0 2 n=1 sp -0.1346 0.1346 @@ -913,6 +932,7 @@ 2.0 2 n=2 op 0.1684 -0.1684 2.0 2 n=2 p -0.3359 0.3359 2.0 2 n=2 s -0.1753 0.1753 + 2.0 2 n=2 s_m -0.1753 0.1753 # Duplicated from base parameter 's' 2.0 2 n=2 s' -0.1346 0.1346 2.0 2 n=2 si -0.4368 0.4368 2.0 2 n=2 sp -0.1346 0.1346 @@ -925,6 +945,7 @@ 2.0 2 na op 0.2369 -0.2369 2.0 2 na p -0.2518 0.2518 2.0 2 na s -0.0966 0.0966 + 2.0 2 na s_m -0.0966 0.0966 # Duplicated from base parameter 's' 2.0 2 na s' -0.0551 0.0551 2.0 2 na si -0.3501 0.3501 2.0 2 na sp -0.0551 0.0551 @@ -936,6 +957,7 @@ 2.0 2 nh op 0.3148 -0.3148 2.0 2 nh p -0.1375 0.1375 2.0 2 nh s 0.0046 -0.0046 + 2.0 2 nh s_m 0.0046 -0.0046 # Duplicated from base parameter 's' 2.0 2 nh s' 0.0454 -0.0454 2.0 2 nh si -0.2278 0.2278 2.0 2 nh sp 0.0454 -0.0454 @@ -946,6 +968,7 @@ 2.0 2 nn op 0.1684 -0.1684 2.0 2 nn p -0.3359 0.3359 2.0 2 nn s -0.1753 0.1753 + 2.0 2 nn s_m -0.1753 0.1753 # Duplicated from base parameter 's' 2.0 2 nn s' -0.1346 0.1346 2.0 2 nn si -0.4368 0.4368 2.0 2 nn sp -0.1346 0.1346 @@ -955,6 +978,7 @@ 2.0 2 np op 0.1684 -0.1684 2.0 2 np p -0.3359 0.3359 2.0 2 np s -0.1753 0.1753 + 2.0 2 np s_m -0.1753 0.1753 # Duplicated from base parameter 's' 2.0 2 np s' -0.1346 0.1346 2.0 2 np si -0.4368 0.4368 2.0 2 np sp -0.1346 0.1346 @@ -965,6 +989,7 @@ 2.0 2 o p -0.2548 0.2548 3.1 12 o p= -0.1400 0.1400 2.0 2 o s -0.1143 0.1143 + 2.0 2 o s_m -0.1143 0.1143 # Duplicated from base parameter 's' 2.0 2 o s' -0.0766 0.0766 2.0 2 o si -0.3425 0.3425 2.0 2 o sp -0.0766 0.0766 @@ -976,6 +1001,7 @@ 2.0 2 o_1 op 0.0000 0.0000 2.0 2 o_1 p -0.4933 0.4933 2.0 2 o_1 s -0.3386 0.3386 + 2.0 2 o_1 s_m -0.3386 0.3386 # Duplicated from base parameter 's' 2.0 2 o_1 s' -0.3024 0.3024 2.0 2 o_1 si -0.5883 0.5883 2.0 2 o_1 sp -0.3024 0.3024 @@ -984,6 +1010,7 @@ 2.0 2 op op 0.0000 0.0000 2.0 2 op p -0.4933 0.4933 2.0 2 op s -0.3386 0.3386 + 2.0 2 op s_m -0.3386 0.3386 # Duplicated from base parameter 's' 2.0 2 op s' -0.3024 0.3024 2.0 2 op si -0.5883 0.5883 2.0 2 op sp -0.3024 0.3024 @@ -992,14 +1019,20 @@ 3.0 10 oss sz -0.1309 0.1309 2.0 2 p p 0.0000 0.0000 2.0 2 p s 0.1600 -0.1600 + 2.0 2 p s_m 0.1600 -0.1600 # Duplicated from base parameter 's' 2.0 2 p s' 0.2106 -0.2106 2.0 2 p s- 0.1824 -0.6824 2.0 2 p si -0.1069 0.1069 2.0 2 p sp 0.2106 -0.2106 2.0 2 s s 0.0000 0.0000 + 2.0 2 s s_m 0.0000 0.0000 # Duplicated from base parameter 's' + 2.0 2 s_m s_m 0.0000 0.0000 # Duplicated from base parameter 's' 2.0 2 s s' 0.0455 -0.0455 + 2.0 2 s_m s' 0.0455 -0.0455 # Duplicated from base parameter 's' 2.0 2 s si -0.2634 0.2634 + 2.0 2 s_m si -0.2634 0.2634 # Duplicated from base parameter 's' 2.0 2 s sp 0.0455 -0.0455 + 2.0 2 s_m sp 0.0455 -0.0455 # Duplicated from base parameter 's' 2.0 2 s' s' 0.0000 0.0000 2.0 2 s' si -0.3172 0.3172 2.0 2 s' sp 0.0000 0.0000 @@ -1670,6 +1703,7 @@ 2.1 8 c h 1.1010 345.0000 -691.8900 844.6000 1.0 1 c h 1.1010 341.0000 -691.8900 844.6000 1.0 1 c n 1.4520 327.1657 -547.8990 526.5000 + 1.0 1 c2 nn 1.4520 327.1657 -547.8990 526.5000 # AutoREACTER addition; copied from base c n 1.0 1 c n+ 1.5185 293.1700 -603.7882 629.6900 1.1 1 c n= 1.4750 336.0000 0.0000 0.0000 1.1 1 c n=1 1.4750 336.0000 0.0000 0.0000 @@ -1683,6 +1717,8 @@ 2.1 6 c o_2 1.4457 326.7273 -608.5306 689.0333 2.0 5 c oz 1.4457 326.7273 -608.5306 689.0333 1.0 1 c s 1.8230 225.2768 -327.7057 488.9722 + 1.0 1 c s_m 1.8230 225.2768 -327.7057 488.9722 # Duplicated from base parameter 's' + 1.0 1 cp s_m 1.8230 225.2768 -327.7057 488.9722 # AutoREACTER addition; copied from base c s 2.2 9 c si 1.8995 189.6536 -279.4210 307.5135 1.0 4 c sio 1.9073 157.0049 -237.7023 356.0328 1.0 1 c+ nr 1.3834 380.4600 -814.4300 1153.3000 @@ -1702,6 +1738,7 @@ 1.0 1 c=2 h 1.0883 365.7679 -725.5404 781.6621 2.1 8 c=2 o= 1.1600 1112.0000 0.0000 0.0000 2.1 8 c=2 s' 1.5526 567.3600 0.0000 0.0000 + 2.1 8 c=2 s_m 1.5526 567.3600 0.0000 0.0000 # Duplicated from base parameter 's' 2.1 8 c_0 cp 1.4890 339.3574 -655.7236 670.2362 2.1 8 c_0 h 1.1220 304.8631 -623.3705 700.2828 2.1 8 c_0 o_1 1.2160 823.7948 -1878.7940 2303.5311 @@ -1749,6 +1786,7 @@ 2.1 8 h h 0.7414 414.0000 0.0000 0.0000 3.1 12 h p= 1.3861 285.2043 -575.6851 677.8456 1.0 1 h s 1.3261 275.1123 -531.3181 562.9630 + 1.0 1 h s_m 1.3261 275.1123 -531.3181 562.9630 # Duplicated from base parameter 's' 2.2 9 h si 1.4783 202.7798 -305.3603 280.2685 1.0 4 h sio 1.4802 187.1010 -280.7306 258.8998 1.0 1 h* n 1.0100 462.7500 -1053.6300 1545.7570 @@ -1756,6 +1794,7 @@ 1.0 1 h* na 1.0060 466.7400 -1073.6018 1251.1056 1.0 1 h* nh 1.0053 463.9230 -1050.8070 1284.7262 1.0 1 h* nn 1.0012 465.8608 -1066.2360 1496.5647 + 1.0 1 hn2 nn 1.0012 465.8608 -1066.2360 1496.5647 # AutoREACTER addition; copied from base h* nn 1.0 1 h* nr 1.0023 462.3900 -1044.6000 1468.7000 1.0 1 h* o 0.9650 532.5062 -1282.9050 2004.7658 1.2 3 h* o* 0.9700 563.2800 -1428.2200 1902.1200 @@ -1777,13 +1816,17 @@ 4.0 13 o p 1.6100 245.2000 0.0000 0.0000 2.1 8 o= o= 1.2074 847.4400 0.0000 0.0000 2.1 8 o= s' 1.4308 743.7600 0.0000 0.0000 + 2.1 8 o= s_m 1.4308 743.7600 0.0000 0.0000 # Duplicated from base parameter 's' 3.0 10 oas sz 1.5923 392.6680 -1004.4800 3452.8601 3.0 10 ob sz 1.6446 393.6690 -989.8420 1461.9800 3.0 10 osh sz 1.6125 420.0240 -845.6110 1438.6300 1.0 4 osi sio 1.6562 306.1232 -517.3424 673.7067 3.0 10 oss sz 1.6155 325.4430 -943.3640 1454.6700 1.0 1 s s 2.0559 197.6560 -196.1366 644.4103 + 1.0 1 s s_m 2.0559 197.6560 -196.1366 644.4103 # Duplicated from base parameter 's' + 1.0 1 s_m s_m 2.0559 197.6560 -196.1366 644.4103 # Duplicated from base parameter 's' 2.2 9 si si 2.3384 114.2164 -140.4212 80.7084 + 1.0 1 c_2 na 1.4570 365.8052 -699.6368 998.4842 # AutoREACTER addition; copied from base c na #quadratic_angle cff91_auto @@ -2133,7 +2176,7 @@ !--- --- ----- ----- ----- -------- -------- -------- -------- 3.0 10 oah az oah 119.5540 56.2161 67.5146 75.6704 3.0 10 oah az oas 135.8500 1.5716 -23.2602 24.2341 - 3.0 10 oah az ob 96.9383 41.2978 -101.1850 180.8230 + 3.0 10 oah az ob 96.9383 41.2978 -101.1850 180.8230 3.0 10 oas az oas 114.1500 112.9470 -37.6330 22.7467 3.0 10 oas az ob 97.0360 73.0531 -31.9551 5.5982 3.0 10 ob az ob 97.0360 73.0531 -31.9551 5.5982 @@ -2163,6 +2206,7 @@ 2.1 6 c c o_2 107.4100 63.3907 -13.4513 1.6650 2.0 5 c c oz 105.4100 63.3907 -13.4513 0.0000 1.0 1 c c s 112.5642 47.0276 -10.6790 -10.1687 + 1.0 1 c c s_m 112.5642 47.0276 -10.6790 -10.1687 # Duplicated from base parameter 's' 2.2 9 c c si 112.6700 39.5160 -7.4430 0.0000 1.0 1 c- c h 109.6700 37.9190 -7.3877 -8.0694 1.3 1 c- c n 100.5663 52.0966 -5.2642 -10.7045 @@ -2201,9 +2245,13 @@ 2.1 6 h c o_2 107.6880 65.4801 -10.3498 5.8866 2.0 5 h c oz 107.6880 70.4801 -10.3498 0.0000 1.0 1 h c s 107.8522 51.4949 -13.5270 7.0260 + 1.0 1 h c s_m 107.8522 51.4949 -13.5270 7.0260 # Duplicated from base parameter 's' 2.2 9 h c si 112.0355 28.7721 -13.9523 0.0000 1.0 4 h c sio 111.5360 30.2481 -15.5255 0.0000 1.0 1 s c s 111.5000 27.9677 0.0000 0.0000 + 1.0 1 s_m c s 111.5000 27.9677 0.0000 0.0000 # Duplicated from base parameter 's' + 1.0 1 s c s_m 111.5000 27.9677 0.0000 0.0000 # Duplicated from base parameter 's' + 1.0 1 s_m c s_m 111.5000 27.9677 0.0000 0.0000 # Duplicated from base parameter 's' 1.0 1 nr c+ nr 117.4500 83.9840 0.0000 0.0000 1.0 1 c c- o- 115.0600 59.0960 -15.1430 -12.9820 1.0 1 h c- o- 112.7500 61.1530 -14.0190 -13.2380 @@ -2405,10 +2453,19 @@ 3.1 12 o p= o 95.5000 87.7686 -4.5699 -17.8523 4.0 13 o p o 109.0000 45.0000 0.0000 0.0000 1.0 1 c s c 97.5000 57.6938 -5.0559 -11.8206 + 1.0 1 c s_m c 97.5000 57.6938 -5.0559 -11.8206 # Duplicated from base parameter 's' 1.0 1 c s h 96.8479 56.7336 14.2713 0.0000 + 1.0 1 c s_m h 96.8479 56.7336 14.2713 0.0000 # Duplicated from base parameter 's' 1.0 1 c s s 100.3000 57.2900 -6.5301 -11.8204 + 1.0 1 c s_m s 100.3000 57.2900 -6.5301 -11.8204 # Duplicated from base parameter 's' + 1.0 1 c s s_m 100.3000 57.2900 -6.5301 -11.8204 # Duplicated from base parameter 's' + 1.0 1 c s_m s_m 100.3000 57.2900 -6.5301 -11.8204 # Duplicated from base parameter 's' 1.0 1 h s h 94.3711 54.9676 0.0000 0.0000 + 1.0 1 h s_m h 94.3711 54.9676 0.0000 0.0000 # Duplicated from base parameter 's' 1.0 1 h s s 97.2876 54.4281 0.0000 0.0000 + 1.0 1 h s_m s 97.2876 54.4281 0.0000 0.0000 # Duplicated from base parameter 's' + 1.0 1 h s s_m 97.2876 54.4281 0.0000 0.0000 # Duplicated from base parameter 's' + 1.0 1 h s_m s_m 97.2876 54.4281 0.0000 0.0000 # Duplicated from base parameter 's' 2.1 8 o- s' o- 119.3290 135.0000 0.0000 0.0000 2.2 9 c si c 113.1855 36.2069 -20.3939 20.0172 2.2 9 c si h 112.0977 36.4832 -12.8094 0.0000 @@ -2434,6 +2491,10 @@ 3.0 10 osh sz osh 115.0310 68.3381 49.4314 116.2400 3.0 10 osh sz oss 110.6700 117.5060 -49.8921 0.0000 3.0 10 oss sz oss 110.6120 154.1860 -68.6595 23.6292 + 1.0 1 cp cp s_m 112.5642 47.0276 -10.6790 -10.1687 # AutoREACTER addition; copied from base c c s + 1.0 1 cp s_m cp 97.5000 57.6938 -5.0559 -11.8206 # AutoREACTER addition; copied from base c s c + 1.0 1 cp s_m o= 113.1000 42.3000 0.0000 0.0000 # AutoREACTER addition; based on auto * s o + 1.0 1 o= s_m o= 115.0000 50.0000 0.0000 0.0000 # AutoREACTER addition; generic O=S=O sulfone angle #torsion_1 cff91_auto @@ -2707,6 +2768,7 @@ 2.1 6 c c c o_2 0.0000 0.0 0.0000 0.0 -0.2500 0.0 2.0 5 c c c oz -3.6896 0.0 0.0000 0.0 0.0000 0.0 1.0 1 c c c s -0.7017 0.0 0.0201 0.0 0.1040 0.0 + 1.0 1 c c c s_m -0.7017 0.0 0.0201 0.0 0.1040 0.0 # Duplicated from base parameter 's' 2.2 9 c c c si 0.0000 0.0 0.0514 0.0 -0.1430 0.0 1.3 1 c- c c c_1 0.0972 0.0 0.0722 0.0 -0.2581 0.0 1.3 1 c- c c cp 0.0972 0.0 0.0722 0.0 -0.2581 0.0 @@ -2733,6 +2795,7 @@ 1.0 1 c_1 c c n 0.0972 0.0 0.0722 0.0 -0.2581 0.0 1.3 1 c_1 c c o -0.0858 0.0 -0.1320 0.0 -0.5909 0.0 1.3 1 c_1 c c s 0.0972 0.0 0.0722 0.0 -0.2581 0.0 + 1.3 1 c_1 c c s_m 0.0972 0.0 0.0722 0.0 -0.2581 0.0 # Duplicated from base parameter 's' 2.1 8 cl c c cl 0.0000 0.0 0.0000 0.0 -0.1000 0.0 2.1 8 cl c c f 0.0000 0.0 0.0000 0.0 -0.1000 0.0 2.1 8 cl c c h 0.0000 0.0 0.0000 0.0 -0.1000 0.0 @@ -2755,9 +2818,11 @@ 2.1 6 h c c o_2 0.0000 0.0 0.0000 0.0 -0.2500 0.0 2.0 5 h c c oz -3.6896 0.0 0.0000 0.0 0.0000 0.0 1.0 1 h c c s -0.2078 0.0 -0.1060 0.0 -0.3595 0.0 + 1.0 1 h c c s_m -0.2078 0.0 -0.1060 0.0 -0.3595 0.0 # Duplicated from base parameter 's' 2.2 9 h c c si 0.0000 0.0 0.0514 0.0 -0.1430 0.0 1.3 1 n c c o -0.1820 0.0 -0.1084 0.0 -0.7047 0.0 1.3 1 n c c s 0.0972 0.0 0.0722 0.0 -0.2581 0.0 + 1.3 1 n c c s_m 0.0972 0.0 0.0722 0.0 -0.2581 0.0 # Duplicated from base parameter 's' 2.1 7 n_2 c c n_2 0.0000 0.0 0.0060 0.0 -0.1441 0.0 2.1 7 n_2 c c o_2 0.0000 0.0 0.0000 0.0 -0.1441 0.0 1.0 1 na c c na 0.3805 0.0 0.3547 0.0 -0.1102 0.0 @@ -2765,6 +2830,9 @@ 2.1 6 o_2 c c o_2 -0.6070 0.0 0.0060 0.0 -0.1441 0.0 2.0 5 oz c c oz -0.6070 0.0 0.0060 0.0 -0.1441 0.0 1.0 1 s c c s -1.2002 0.0 -1.2999 0.0 -0.1626 0.0 + 1.0 1 s_m c c s -1.2002 0.0 -1.2999 0.0 -0.1626 0.0 # Duplicated from base parameter 's' + 1.0 1 s c c s_m -1.2002 0.0 -1.2999 0.0 -0.1626 0.0 # Duplicated from base parameter 's' + 1.0 1 s_m c c s_m -1.2002 0.0 -1.2999 0.0 -0.1626 0.0 # Duplicated from base parameter 's' 1.0 1 c c c- o- 1.7311 0.0 1.8510 0.0 -0.1933 0.0 1.0 1 h c c- o- -2.5999 0.0 1.0488 0.0 -0.2089 0.0 1.3 1 n c c- o- 0.0899 0.0 0.1220 0.0 0.0905 0.0 @@ -2849,6 +2917,7 @@ 2.1 8 na c_1 c2 c2 0.1693 0.0 -0.0090 0.0 -0.0687 0.0 # AutoREACTER addition; copied from c c c_1 n_2 reversed; mapped n_2->na and c->c2 2.1 8 c c c_1 o_1 0.0442 0.0 0.0292 0.0 0.0562 0.0 2.1 8 c c c_1 o_2 1.8341 0.0 2.0603 0.0 -0.0195 0.0 + 2.1 8 o_2 c_1 c c= 1.8341 0.0 2.0603 0.0 -0.0195 0.0 # AutoREACTER addition; copied from c c c_1 o_2 reversed; mapped terminal c -> c= 2.1 8 oh c_1 c2 c2 1.8341 0.0 2.0603 0.0 -0.0195 0.0 # AutoREACTER addition; copied from c c c_1 o_2 reversed; mapped o_2->oh and c->c2 2.1 8 h c c_1 n_2 0.1693 0.0 -0.0090 0.0 -0.0687 0.0 2.1 8 h c c_1 o_1 -0.1804 0.0 0.0012 0.0 0.0371 0.0 @@ -2918,13 +2987,29 @@ 2.0 5 h c oz cz 0.0000 0.0 0.0000 0.0 -0.1932 0.0 2.0 5 oz c oz cz 0.0000 0.0 0.0000 0.0 -0.1932 0.0 1.0 1 c c s c -0.5073 0.0 0.0155 0.0 -0.0671 0.0 + 1.0 1 c c s_m c -0.5073 0.0 0.0155 0.0 -0.0671 0.0 # Duplicated from base parameter 's' 1.0 1 c c s h -0.4871 0.0 -0.4514 0.0 -0.1428 0.0 + 1.0 1 c c s_m h -0.4871 0.0 -0.4514 0.0 -0.1428 0.0 # Duplicated from base parameter 's' 1.0 1 c c s s -0.6269 0.0 -0.9598 0.0 -0.4957 0.0 + 1.0 1 c c s_m s -0.6269 0.0 -0.9598 0.0 -0.4957 0.0 # Duplicated from base parameter 's' + 1.0 1 c c s s_m -0.6269 0.0 -0.9598 0.0 -0.4957 0.0 # Duplicated from base parameter 's' + 1.0 1 c c s_m s_m -0.6269 0.0 -0.9598 0.0 -0.4957 0.0 # Duplicated from base parameter 's' 1.0 1 h c s c -0.3338 0.0 -0.0684 0.0 -0.1706 0.0 + 1.0 1 h c s_m c -0.3338 0.0 -0.0684 0.0 -0.1706 0.0 # Duplicated from base parameter 's' 1.0 1 h c s h -0.5374 0.0 -0.5091 0.0 -0.1361 0.0 + 1.0 1 h c s_m h -0.5374 0.0 -0.5091 0.0 -0.1361 0.0 # Duplicated from base parameter 's' 1.0 1 h c s s -0.0610 0.0 -0.6387 0.0 -0.3072 0.0 + 1.0 1 h c s_m s -0.0610 0.0 -0.6387 0.0 -0.3072 0.0 # Duplicated from base parameter 's' + 1.0 1 h c s s_m -0.0610 0.0 -0.6387 0.0 -0.3072 0.0 # Duplicated from base parameter 's' + 1.0 1 h c s_m s_m -0.0610 0.0 -0.6387 0.0 -0.3072 0.0 # Duplicated from base parameter 's' 1.0 1 s c s c -1.9835 0.0 -1.9213 0.0 -0.3816 0.0 + 1.0 1 s_m c s c -1.9835 0.0 -1.9213 0.0 -0.3816 0.0 # Duplicated from base parameter 's' + 1.0 1 s c s_m c -1.9835 0.0 -1.9213 0.0 -0.3816 0.0 # Duplicated from base parameter 's' + 1.0 1 s_m c s_m c -1.9835 0.0 -1.9213 0.0 -0.3816 0.0 # Duplicated from base parameter 's' 1.0 1 s c s h -0.0591 0.0 -0.6235 0.0 -0.0788 0.0 + 1.0 1 s_m c s h -0.0591 0.0 -0.6235 0.0 -0.0788 0.0 # Duplicated from base parameter 's' + 1.0 1 s c s_m h -0.0591 0.0 -0.6235 0.0 -0.0788 0.0 # Duplicated from base parameter 's' + 1.0 1 s_m c s_m h -0.0591 0.0 -0.6235 0.0 -0.0788 0.0 # Duplicated from base parameter 's' 2.2 9 c c si c 0.0000 0.0 0.0000 0.0 -0.0657 0.0 2.2 9 c c si cp 0.0000 0.0 0.0000 0.0 -0.0657 0.0 2.2 9 c c si h 0.0000 0.0 0.0000 0.0 -0.0657 0.0 @@ -2959,15 +3044,15 @@ 2.1 6 o_1 c_1 cp cp 0.0000 0.0 0.9063 0.0 0.0000 0.0 2.1 6 o_2 c_1 cp cp 0.0000 0.0 0.9063 0.0 0.0000 0.0 1.0 1 h c_1 n c_1 0.1907 0.0 1.1212 0.0 0.0426 0.0 - 1.0 1 hn c_1 na c2 0.1907 0.0 1.1212 0.0 0.0426 0.0 + 1.0 1 hn c_1 na c2 0.1907 0.0 1.1212 0.0 0.0426 0.0 # AutoREACTER addition; copied from h c_1 n c_1; c2/hn mapped to c_1/h and na mapped to n 1.0 1 n c_1 n h* -0.7358 0.0 0.4643 0.0 -1.1098 0.0 1.0 1 o_1 c_1 n c 0.8297 0.0 3.7234 0.0 -0.0495 0.0 - 1.0 1 o_1 c_1 na c2 0.8297 0.0 3.7234 0.0 -0.0495 0.0 + 1.0 1 o_1 c_1 na c2 0.8297 0.0 3.7234 0.0 -0.0495 0.0 # AutoREACTER addition; copied from o_1 c_1 n c; mapped c->c2 and n->na 1.0 1 o_1 c_1 n c_1 -0.4066 0.0 1.2513 0.0 -0.7507 0.0 1.0 1 o_1 c_1 n h* -1.6938 0.0 2.7386 0.0 -0.3360 0.0 - 1.0 1 o_1 c_1 na hn -1.6938 0.0 2.7386 0.0 -0.3360 0.0 + 1.0 1 o_1 c_1 na hn -1.6938 0.0 2.7386 0.0 -0.3360 0.0 # AutoREACTER addition; copied from o_1 c_1 n h*; mapped h*->hn and n->na 2.1 8 c c_1 n_2 c -0.7532 0.0 2.7392 0.0 0.0907 0.0 - 2.1 8 c2 c_1 na c2 -0.7532 0.0 2.7392 0.0 0.0907 0.0 + 2.1 8 c2 c_1 na c2 -0.7532 0.0 2.7392 0.0 0.0907 0.0 # AutoREACTER addition; copied from c c_1 n_2 c; mapped n_2->na and c->c2 2.1 8 c c_1 n_2 hn2 -0.8236 0.0 2.1467 0.0 -0.2142 0.0 2.1 8 cp c_1 n_2 c -1.1077 0.0 2.0082 0.0 0.0000 0.0 2.1 8 cp c_1 n_2 cp -1.1077 0.0 2.0082 0.0 0.0000 0.0 @@ -3176,6 +3261,63 @@ 2.1 8 oh c_1 cg hc -0.6359 0.0 1.4807 0.0 -0.0438 0.0 # AutoREACTER addition; copied from h c c_1 o_2 reversed; mapped o_2->oh, c->cg, h->hc 2.1 8 oh c_1 c2 na 1.8341 0.0 2.0603 0.0 -0.0195 0.0 # AutoREACTER addition; copied from c c c_1 o_2 reversed; mapped o_2->oh, c->c2/na 2.1 8 oh c_1 cg na 1.8341 0.0 2.0603 0.0 -0.0195 0.0 # AutoREACTER addition; copied from c c c_1 o_2 reversed; mapped o_2->oh, c->cg/na + 1.0 1 c c s c -0.5073 0.0 0.0155 0.0 -0.0671 0.0 # AutoREACTER addition; generic aliphatic-sulfur dihedral + 1.0 1 cp cp s_m cp -0.5073 0.0 0.0155 0.0 -0.0671 0.0 # AutoREACTER addition; mapped from c c s c for cp-cp-s_m-cp + 1.0 1 cp cp s_m o= 0.2433 0.0 0.0000 0.0 0.1040 0.0 # AutoREACTER addition; mapped for cp-cp-s_m-o= + 1.0 1 c1 c2 nn c2 0.0883 0.0 0.0000 0.0 -0.0198 0.0 # AutoREACTER addition; mapped from c c c c for c1-c2-nn-c2 + 1.0 1 c1 c2 nn cp 0.0972 0.0 0.0722 0.0 -0.2581 0.0 # AutoREACTER addition; mapped from c c c n for c1-c2-nn-cp + 1.0 1 c1 c2 nn hn -0.0228 0.0 0.0280 0.0 -0.1863 0.0 # AutoREACTER addition; mapped from h c c n for c1-c2-nn-hn + 1.0 1 cp cp nn hn2 0.0143 0.0 -0.0132 0.0 0.0091 0.0 # AutoREACTER addition; mapped from c c n c_1 for cp-cp-nn-hn2 + 1.0 1 hc c1 c2 nn 0.0972 0.0 0.0722 0.0 -0.2581 0.0 # AutoREACTER addition; mapped for hc-c1-c2-nn + 1.0 1 hc c2 nn c2 0.0000 0.0 0.0514 0.0 -0.1430 0.0 # AutoREACTER addition; mapped for hc-c2-nn-c2 + 1.0 1 hc c2 nn cp 0.0972 0.0 0.0722 0.0 -0.2581 0.0 # AutoREACTER addition; mapped for hc-c2-nn-cp + 1.0 1 hc c2 nn hn -0.0228 0.0 0.0280 0.0 -0.1863 0.0 # AutoREACTER addition; mapped for hc-c2-nn-hn + 1.0 1 hn2 nn cp cp 0.0143 0.0 -0.0132 0.0 0.0091 0.0 # AutoREACTER addition; mapped for hn2-nn-cp-cp + 1.0 1 o= s_m o= * 0.0860 0.0 5.1995 0.0 0.0000 0.0 # AutoREACTER addition; mapped for o=-s_m-o=-* + 1.0 1 cp cp cp s_m 0.0000 0.0 4.8498 0.0 0.0000 0.0 # AutoREACTER addition; mapped from cp-cp-cp-o + 1.0 1 h cp cp s_m 0.0000 0.0 1.7234 0.0 0.0000 0.0 # AutoREACTER addition; mapped from h-cp-cp-o + 1.0 1 hc cp cp s_m 0.0000 0.0 1.7234 0.0 0.0000 0.0 # AutoREACTER addition; mapped from h-cp-cp-o + 1.0 1 c= c1 cp cp 0.0000 0.0 0.5000 0.0 0.0000 0.0 # AutoREACTER addition; mapped from c= c=1 cp cp + 1.0 1 c2 c2 n=2 ct 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero to pass validator + 1.0 1 c2 n=2 ct o_1 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero to pass validator + 1.0 1 hc c2 n=2 ct 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero to pass validator + 1.0 1 o_1 c=2 na c2 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero to pass validator + 1.0 1 o_1 c=2 na hn 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero to pass validator + 1.0 1 sc c=2 na c2 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero to pass validator + 1.0 1 sc c=2 na hn 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero to pass validator + 1.0 1 c2 c1 ct nt 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for linear nitrile + 1.0 1 c2 c=2 ct nt 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for linear nitrile + 1.0 1 c= c1 ct nt 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for linear nitrile + 1.0 1 c= c=1 ct nt 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for linear nitrile + 1.0 1 hc c1 ct nt 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for linear nitrile + 1.0 1 hc c=1 ct nt 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for linear nitrile + 1.0 1 hc c=2 ct nt 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for linear nitrile + 1.0 1 c2 na c_2 na 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for c2-na-c_2-na + 1.0 1 c2 na c_2 o_1 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for c2-na-c_2-o_1 + 1.0 1 hc c2 na c_2 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for hc-c2-na-c_2 + 1.0 1 hn na c_2 na 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for hn-na-c_2-na + 1.0 1 hn na c_2 o_1 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for hn-na-c_2-na + 1.0 1 o_1 c_1 c1 o_2 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for validator + 1.0 1 o_1 c_1 c1 oh 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for validator + 1.0 1 o_2 c_1 c1 o_2 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for validator + 1.0 1 o_2 c_1 c1 oh 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for validator + 1.0 1 c_1 c1 o_2 c_1 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for validator + 1.0 1 c= c1 o_2 c_1 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for validator + 1.0 1 o_1 c_1 c2 o_2 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for validator + 1.0 1 o_1 c_1 c2 oh 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for validator + 1.0 1 o_2 c_1 c2 o_2 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for validator + 1.0 1 o_2 c_1 c2 oh 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for validator + 1.0 1 oh c_1 c1 c3 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for validator + 1.0 1 oh c_1 c1 hc 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for validator + 1.0 1 oh c_1 c1 o_2 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for validator + 1.0 1 oh c_1 c1 oh 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for validator + 1.0 1 oh c_1 c2 o_2 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for validator + 1.0 1 oh c_1 c2 oh 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for validator + 1.0 1 hn2 na c_2 o_1 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for validator + 1.0 1 hn2 na c_2 na 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for validator + 1.0 1 hn na c_2 o_1 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for validator + 2.1 8 o_1 c_1 c c= 0.0442 0.0 0.0292 0.0 0.0562 0.0 # AutoREACTER addition; copied from c c c_1 o_1 reversed; mapped terminal c -> c= + #wilson_out_of_plane cff91 > E = K * (Chi - Chi0)^2 @@ -3374,6 +3516,7 @@ 2.0 3 p 4.2950 0.21500 3.1 12 p= 4.3000 0.21500 2.0 1 s 4.0270 0.07100 + 2.0 1 s_m 4.0270 0.07100 # Duplicated from base parameter 's' 2.1 8 s' 4.0270 0.25000 2.0 1 s' 4.0270 0.07100 2.2 9 si 4.4500 0.19000 diff --git a/AutoREACTER/reaction_preparation/ff_wrapper/REACTER_files_builder.py b/AutoREACTER/reaction_preparation/ff_wrapper/REACTER_files_builder.py index 1114a48..0eb6161 100644 --- a/AutoREACTER/reaction_preparation/ff_wrapper/REACTER_files_builder.py +++ b/AutoREACTER/reaction_preparation/ff_wrapper/REACTER_files_builder.py @@ -26,6 +26,7 @@ from typing import Optional import datetime import re +from AutoREACTER.reaction_preparation.deduplication_detector import DeduplicationDetector from AutoREACTER.reaction_preparation.ff_wrapper.ff_wrapper import FFFiles from AutoREACTER.reaction_preparation.ff_wrapper.modifiers_molecule_files import ( modify_types, modify_charges, modify_coords, @@ -92,7 +93,7 @@ def __init__( ) self.force_field = self.updated_inputs_with_3d_mols.force_field - + def _get_ending_integer(self, s: str) -> int | None: """ @@ -782,5 +783,12 @@ def molecule_template_preparation(self, session: "Session") -> None: session.reacter_files = reacter_files - + + # print (session.reacter_files) + detector = DeduplicationDetector() + template_files = session.reacter_files.template_files + # print("Comparing LAMMPS templates for duplicates...") + session.reacter_files.template_files = detector.compare_lammps_templates( + template_files=template_files + ) return None \ No newline at end of file diff --git a/AutoREACTER/reaction_preparation/ff_wrapper/ff_wrapper.py b/AutoREACTER/reaction_preparation/ff_wrapper/ff_wrapper.py index b8e69d7..6fef10a 100644 --- a/AutoREACTER/reaction_preparation/ff_wrapper/ff_wrapper.py +++ b/AutoREACTER/reaction_preparation/ff_wrapper/ff_wrapper.py @@ -1,8 +1,7 @@ from pathlib import Path from dataclasses import dataclass from typing import Optional, TYPE_CHECKING -from AutoREACTER.input_parser import SimulationSetup -from AutoREACTER.reaction_preparation.reaction_processor.prepare_reactions import ReactionMetadata + if TYPE_CHECKING: from AutoREACTER.session import Session diff --git a/AutoREACTER/reaction_preparation/ff_wrapper/lunar_client/lunar_executor.py b/AutoREACTER/reaction_preparation/ff_wrapper/lunar_client/lunar_executor.py index 0995aec..4ba2b28 100644 --- a/AutoREACTER/reaction_preparation/ff_wrapper/lunar_client/lunar_executor.py +++ b/AutoREACTER/reaction_preparation/ff_wrapper/lunar_client/lunar_executor.py @@ -174,6 +174,7 @@ def run_bond_react_merge( all2lmp_results: list[All2LMPResult] ) -> FFFiles: """Executes bond_react_merge.py to create the final unified simulation setup.""" + print(f"[LUNAR bond_react_merge] Running bond_react_merge with input file {merge_input_file_path}") env = os.environ.copy() env["QT_QPA_PLATFORM"] = "offscreen" subprocess.run( @@ -183,7 +184,8 @@ def run_bond_react_merge( "-files", f"infile:{merge_input_file_path.name}", "-atomstyle", "full", "-tl", "T", - "-wrd", "T", + "-wrd", "F", + "-map", "F" ], cwd=str(self.cache_bond_react_merge), env=env, diff --git a/AutoREACTER/reaction_preparation/ff_wrapper/molecule_3d_preparation.py b/AutoREACTER/reaction_preparation/ff_wrapper/molecule_3d_preparation.py index 6500e37..a9316f9 100644 --- a/AutoREACTER/reaction_preparation/ff_wrapper/molecule_3d_preparation.py +++ b/AutoREACTER/reaction_preparation/ff_wrapper/molecule_3d_preparation.py @@ -219,69 +219,160 @@ def _optimization( cache_dir: Path, separate_fragments: bool = False, ) -> Path: - """Embed a molecule in 3D, optionally separate fragments, optimize geometry, - and save the result as a .mol file. + """Repair, embed, optimize, and save a molecule without adding hydrogens.""" - The optimization process includes: - 1. Sanitization and property cache update - 2. 3D coordinate embedding using ETKDG method - 3. Optional fragment separation for multi-molecule complexes - 4. Geometry optimization using MMFF force field - 5. File saving + # Work on a copy so the ReactionMetadata molecule and its indexing remain + # unchanged outside this 3D preparation step. + mol = Chem.Mol(mol) + n_atoms_start = mol.GetNumAtoms(onlyExplicit=True) - Args: - molecule_name: Name used for the output file. - mol: RDKit molecule to be optimized. - cache_dir: Directory where the .mol file will be saved. - separate_fragments: Whether to separate disconnected fragments before optimization. + try: + mol = self._repair_reaction_molecule_for_3d(mol) + except Exception as error: + raise OptimizationError( + f"Failed to repair molecule {molecule_name} before 3D embedding: " + f"{error}" + ) from error - Returns: - Path to the saved .mol file. + # Remove any old or partial conformers before embedding. + mol.RemoveAllConformers() - Raises: - OptimizationError: If atom count changes or optimization fails. - """ - # Record initial atom count for integrity check - n_atoms_start = mol.GetNumAtoms(onlyExplicit=True) + params = AllChem.ETKDGv3() + params.randomSeed = 0xF00D + + # --- ADDED PARAMETERS FOR STERICALLY CONGESTED POLYMERS --- + # Use random coordinates for large, flexible, or dense macro-structures + params.useRandomCoords = True + # Force RDKit to output a structure even if the distance bounds aren't perfectly smoothed + params.ignoreSmoothingFailures = True + # ---------------------------------------------------------- - # Update property cache and sanitize molecule - mol.UpdatePropertyCache(strict=False) - Chem.SanitizeMol( - mol, - sanitizeOps=Chem.SanitizeFlags.SANITIZE_ALL - ^ Chem.SanitizeFlags.SANITIZE_PROPERTIES, - ) + embed_result = AllChem.EmbedMolecule(mol, params) - # Generate initial 3D coordinates using ETKDG method - result = AllChem.EmbedMolecule(mol, AllChem.ETKDG()) - if result == -1: - raise OptimizationError(f"Failed to embed molecule {molecule_name} in 3D.") + if embed_result == -1: + raise OptimizationError( + f"Failed to embed molecule {molecule_name} in 3D." + ) - # Separate fragments if this is a complex (reactants or products) if separate_fragments: mol = self._separate_fragments_3d(mol) - # Perform geometry optimization using MMFF force field - ff_result = AllChem.MMFFOptimizeMolecule(mol) + # MMFF generally works for these carbon radicals, but keep a UFF fallback + # for structures for which MMFF lacks parameters. + if AllChem.MMFFHasAllMoleculeParams(mol): + ff_result = AllChem.MMFFOptimizeMolecule( + mol, + maxIters=1000, + ) + force_field_name = "MMFF" + elif AllChem.UFFHasAllMoleculeParams(mol): + ff_result = AllChem.UFFOptimizeMolecule( + mol, + maxIters=1000, + ) + force_field_name = "UFF" + else: + ff_result = None + force_field_name = None + print( + f"Warning: no MMFF or UFF parameters are available for " + f"{molecule_name}. Saving the embedded geometry without " + "force-field optimization." + ) + if ff_result == -1: - print(f"MMFF optimization failed for {molecule_name}.") + raise OptimizationError( + f"{force_field_name} optimization failed for {molecule_name}." + ) + if ff_result == 1: - print(f"Warning: MMFF optimization did not converge for {molecule_name}.") + print( + f"Warning: {force_field_name} optimization did not converge " + f"for {molecule_name}." + ) - # Verify atom count integrity (no atoms lost during optimization) - if n_atoms_start != mol.GetNumAtoms(onlyExplicit=True): + n_atoms_end = mol.GetNumAtoms(onlyExplicit=True) + + if n_atoms_start != n_atoms_end: raise OptimizationError( - f"Atom count mismatch for {molecule_name}: started with {n_atoms_start} " - f"explicit atoms but ended with {mol.GetNumAtoms(onlyExplicit=True)}." + f"Atom count mismatch for {molecule_name}: started with " + f"{n_atoms_start} explicit atoms but ended with {n_atoms_end}." ) - # Ensure cache directory exists os.makedirs(cache_dir, exist_ok=True) - # Save optimized structure to file output_path = Path(cache_dir) / f"{molecule_name}.mol" print(f"Saving optimized {molecule_name} to {output_path}") - Chem.MolToMolFile(mol, str(output_path)) + Chem.MolToMolFile( + mol, + str(output_path), + includeStereo=True, + kekulize=False, + ) return output_path + + def _repair_reaction_molecule_for_3d(self, mol: Mol) -> Mol: + """Repair RDKit reaction-product valence bookkeeping before 3D embedding. + + RunReactants may produce atoms that have both: + 1. explicit hydrogen atoms as neighbors, and + 2. a nonzero NumExplicitHs property inherited from the product SMARTS. + + That double-counts hydrogens and can produce apparent carbon valences of + five or six. This method changes atom properties only; it does not add or + remove atoms. + + Neutral, non-aromatic carbon atoms with bond valence three are retained as + carbon-centered radicals instead of being given an implicit hydrogen. + """ + repaired = Chem.RWMol(Chem.Mol(mol)) + repaired.UpdatePropertyCache(strict=False) + Chem.FastFindRings(repaired) + + for atom in repaired.GetAtoms(): + explicit_h_neighbors = sum( + neighbor.GetAtomicNum() == 1 + for neighbor in atom.GetNeighbors() + ) + + # The hydrogen atoms already exist as real graph atoms. Clear only the + # duplicate SMARTS hydrogen-count property. + if explicit_h_neighbors > 0: + if atom.GetNumExplicitHs() > 0: + atom.SetNumExplicitHs(0) + + # Do not let RDKit add another implicit hydrogen during sanitization. + atom.SetNoImplicit(True) + + if atom.GetAtomicNum() != 6: + continue + + bond_valence = sum( + bond.GetValenceContrib(atom) + for bond in atom.GetBonds() + ) + + # Preserve neutral carbon-centered radical chain ends. + if ( + not atom.GetIsAromatic() + and atom.GetFormalCharge() == 0 + and abs(bond_valence - 3.0) < 1.0e-6 + ): + atom.SetNoImplicit(True) + atom.SetNumRadicalElectrons(1) + + # Radical-radical coupling gives each carbon its fourth valence. + elif bond_valence >= 4.0: + atom.SetNumRadicalElectrons(0) + + repaired_mol = repaired.GetMol() + repaired_mol.ClearComputedProps() + repaired_mol.UpdatePropertyCache(strict=False) + + # Full sanitization is now safe because the duplicate hydrogen counts + # and radical valences have been repaired. + Chem.SanitizeMol(repaired_mol) + + return repaired_mol \ No newline at end of file diff --git a/AutoREACTER/reaction_preparation/reaction_processor/fragment_comparison.py b/AutoREACTER/reaction_preparation/reaction_processor/fragment_comparison.py deleted file mode 100644 index bbea598..0000000 --- a/AutoREACTER/reaction_preparation/reaction_processor/fragment_comparison.py +++ /dev/null @@ -1,238 +0,0 @@ -# THIS WILL BE NEW PLACE HOLDER FOR NETWORKX CODE, NOT TO BE DELETED - -# """ -# Chemical Fragment Extraction and Comparison Utility -# This module provides functions to extract specific fragments from RDKit molecules, -# cap open valences with placeholder atoms (Francium), and compare these fragments -# against a history of processed structures to identify unique chemical transformations. -# """ - -# from rdkit import Chem - -# import copy - -# def dict_keys_to_list(input_dict): -# """ -# Converts a dictionary of atom mappings into two separate lists of indices. - -# Args: -# input_dict (dict): A dictionary where keys represent reactant atom indices -# and values represent product atom indices. - -# Returns: -# tuple: (reactant_indices, product_indices) as lists of integers. -# """ -# # Convert keys and values to integers to ensure consistent indexing -# reactant_indices = [int(k) for k in input_dict.keys()] -# product_indices = [int(v) for v in input_dict.values()] -# return reactant_indices, product_indices - - -# def extract_fragment_by_indices(mol, atom_indices_to_keep): -# """ -# Creates a new molecule containing only the atoms specified by the provided indices. -# All other atoms are removed. - -# Args: -# mol (rdkit.Chem.rdchem.Mol): The source RDKit molecule. -# atom_indices_to_keep (list): List of atom indices to retain in the fragment. - -# Returns: -# rdkit.Chem.rdchem.Mol: The extracted molecular fragment, or None if input is invalid. -# """ -# if mol is None: -# return None - -# # 1. Convert to an RWMol (Read-Write Molecule) object to allow structural editing -# rwmol = Chem.RWMol(mol) - -# # 2. Identify atoms to remove -# # We find the difference between all atoms in the molecule and the ones we want to keep -# all_indices = [a.GetIdx() for a in mol.GetAtoms()] -# atom_indices_to_remove = [idx for idx in all_indices if idx not in atom_indices_to_keep] - -# # 3. Sort indices in reverse (descending) order -# # Crucial: Removing atoms shifts the indices of subsequent atoms. -# # Removing from highest index to lowest prevents this shifting issue. -# sorted_indices = sorted(atom_indices_to_remove, reverse=True) - -# # 4. Iterate and remove atoms from the RWMol -# for idx in sorted_indices: -# rwmol.RemoveAtom(idx) - -# # Convert back to a standard Molecule object -# new_mol = rwmol.GetMol() - -# # 5. Attempt Sanitization -# # Fragments often have "broken" valences. We try to sanitize to refresh -# # molecular properties, but wrap it in a try-except to prevent valence errors -# # from crashing the execution. -# try: -# Chem.SanitizeMol(new_mol) -# except Exception: -# # If sanitization fails (e.g., due to invalid valences), we proceed with the raw fragment -# pass - -# return new_mol - -# def cap_open_valences_with_fr(new_mol_fragment, francium_atomic_num=87): -# """ -# Identifies atoms with unsatisfied valences and attaches Francium (Fr) atoms -# as placeholders. This is useful for maintaining structural context in fragments. - -# Args: -# new_mol_fragment (rdkit.Chem.rdchem.Mol): The fragment to cap. -# francium_atomic_num (int): The atomic number to use for capping (default 87 for Fr). - -# Returns: -# dict: A dictionary containing the RDKit object, SMILES string, and InChI string. -# """ -# # Handle empty or null fragments -# if new_mol_fragment is None or new_mol_fragment.GetNumAtoms() == 0: -# return {"object": None, "smiles": "", "inchi": ""} - -# rw_mol = Chem.RWMol(new_mol_fragment) - -# # Use a static list of atoms to avoid iterator invalidation during modification -# atoms = list(rw_mol.GetAtoms()) - -# for atom in atoms: -# atom_idx = atom.GetIdx() -# try: -# # Determine the expected valence for the atom type -# default_valence = Chem.GetPeriodicTable().GetDefaultValence(atom.GetAtomicNum()) - -# # Calculate the current valence based on existing bonds -# current_valence = sum(bond.GetBondTypeAsDouble() for bond in atom.GetBonds()) - -# # Handle cases where default valence might be a tuple (multiple oxidation states) -# base_valence = default_valence[0] if isinstance(default_valence, tuple) else default_valence - -# # Calculate how many bonds are "missing" -# open_valences = max(0, int(base_valence) - int(current_valence)) - -# # Add Francium atoms for each open valence -# if open_valences > 0: -# for _ in range(open_valences): -# fr_atom = Chem.Atom(francium_atomic_num) -# fr_idx = rw_mol.AddAtom(fr_atom) -# # Connect the placeholder atom with a single bond -# rw_mol.AddBond(atom_idx, fr_idx, Chem.BondType.SINGLE) -# except Exception: -# # Skip atoms where valence cannot be determined (e.g., certain metals) -# continue - -# # Finalize the molecule after capping -# capped_mol = rw_mol.GetMol() -# try: -# Chem.SanitizeMol(capped_mol) -# except Exception as e: -# # Some fragments (e.g., highly unusual or intentionally "broken" structures) -# # may fail RDKit sanitization. We keep the unsanitized molecule to preserve -# # behavior, but log the issue for easier debugging. -# print(f"Warning: RDKit sanitization failed for capped fragment: {e}") - -# return { -# "object": capped_mol, -# "smiles": Chem.MolToSmiles(capped_mol), -# "inchi": Chem.MolToInchi(capped_mol), -# } - -# def compare_fragments(mol1_info, mol2_info): -# """ -# Compares two molecular info dictionaries to determine if they represent -# the same chemical structure. - -# Args: -# mol1_info (dict): Dictionary containing 'smiles' and/or 'inchi'. -# mol2_info (dict): Dictionary containing 'smiles' and/or 'inchi'. - -# Returns: -# bool: True if molecules match by SMILES or InChI, False otherwise. -# """ -# if not mol1_info or not mol2_info: -# return False - -# # Check SMILES identity -# smiles1, smiles2 = mol1_info.get("smiles"), mol2_info.get("smiles") -# if smiles1 and smiles2 and smiles1 == smiles2: -# return True - -# # Check InChI identity (more robust for tautomers/stereoisomers in some cases) -# inchi1, inchi2 = mol1_info.get("inchi"), mol2_info.get("inchi") -# if inchi1 and inchi2 and inchi1 == inchi2: -# return True -# return False - -# def compare_rdkit_fragments(processed_dict, combined_reactant_mol, combined_product_mol, template_mapped_dict): -# """ -# Main logic to extract fragments from a reaction and check if this specific -# transformation has been encountered before. - -# Args: -# processed_dict (dict): A history of previously seen fragments. -# combined_reactant_mol (rdkit.Chem.rdchem.Mol): The full reactant molecule. -# combined_product_mol (rdkit.Chem.rdchem.Mol): The full product molecule. -# template_mapped_dict (dict): Mapping of atom indices involved in the reaction. - -# Returns: -# tuple: (bool, updated_processed_dict). True if the fragment pair was already known. -# """ -# # Create deep copies to avoid modifying the original molecules in memory -# react_mol = copy.deepcopy(combined_reactant_mol) -# prod_mol = copy.deepcopy(combined_product_mol) - -# # Get the indices of the atoms involved in the reaction center -# react_indices, prod_indices = dict_keys_to_list(template_mapped_dict) - -# # Process Reactant Fragment -# raw_react_frag = extract_fragment_by_indices(react_mol, react_indices) -# try: -# react_smiles = Chem.MolToSmiles(raw_react_frag) if raw_react_frag else "" -# except Exception: -# react_smiles = "" -# try: -# react_inchi = Chem.MolToInchi(raw_react_frag) if raw_react_frag else "" -# except Exception: -# react_inchi = "" -# raw_react_info = {"smiles": react_smiles, "inchi": react_inchi} -# capped_react_info = cap_open_valences_with_fr(raw_react_frag) - -# # Process Product Fragment -# raw_prod_frag = extract_fragment_by_indices(prod_mol, prod_indices) -# try: -# prod_smiles = Chem.MolToSmiles(raw_prod_frag) if raw_prod_frag else "" -# except Exception: -# prod_smiles = "" -# try: -# prod_inchi = Chem.MolToInchi(raw_prod_frag) if raw_prod_frag else "" -# except Exception: -# prod_inchi = "" -# raw_prod_info = {"smiles": prod_smiles, "inchi": prod_inchi} -# capped_prod_info = cap_open_valences_with_fr(raw_prod_frag) - -# # Compare against history -# for proc_id, history in processed_dict.items(): -# hist_react = history['reactant_info'] -# hist_prod = history['product_info'] - -# # Check if current reactant matches history (either raw or capped) -# react_match = compare_fragments(hist_react, raw_react_info) or \ -# compare_fragments(hist_react, capped_react_info) - -# # Check if current product matches history (either raw or capped) -# prod_match = compare_fragments(hist_prod, raw_prod_info) or \ -# compare_fragments(hist_prod, capped_prod_info) - -# # If both reactant and product fragments match an entry, it's a duplicate -# if react_match and prod_match: -# return True, processed_dict - -# # If it's a new transformation, add it to the history -# new_id = len(processed_dict) + 1 -# processed_dict[new_id] = { -# "reactant_info": capped_react_info, -# "product_info": capped_prod_info -# } - -# return False, processed_dict diff --git a/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py b/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py index 9c2d951..0b140e5 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py @@ -1,70 +1,108 @@ -""" -Module for preparing chemical reactions for analysis, including atom mapping between reactants and products, -reaction metadata extraction, and visualization utilities using RDKit. +"""Prepare chemical reactions and generate reaction metadata. -This module processes reaction SMARTS, applies atom mappings, identifies key reaction features (e.g., first shell, -initiators, byproducts), and generates metadata and visualizations for downstream analysis. It also includes validation checks to ensure mapping -consistency and completeness. +This module processes reaction SMARTS, applies atom mappings, identifies +reaction features such as first-shell atoms, initiators, and byproducts, and +generates metadata and visualizations for downstream analysis. """ # WARNING: -# When modifying this file for dataframe or any other indexing variables use idx and idxs, do not use index or indices or similar. +# For dataframe and mapping variables, use idx and idxs rather than index or +# indices. Other naming can cause mapping-validation errors. from dataclasses import dataclass from functools import reduce from pathlib import Path from typing import Dict, List, Optional, TYPE_CHECKING +import pandas as pd +from PIL.Image import Image from rdkit import Chem from rdkit.Chem import AllChem, Draw, rdmolops -from PIL.Image import Image -import pandas as pd -from AutoREACTER.detectors.reaction_detector import ReactionInstance +from AutoREACTER.detectors.reaction_detector import ( + FunctionalGroupInfo, + ReactionInstance, +) +from AutoREACTER.reaction_preparation.ff_wrapper.REACTER_files_builder import ( + logger, +) +from AutoREACTER.reaction_preparation.reaction_processor.reaction_progression import ( + ReactionProgression, +) from AutoREACTER.reaction_preparation.reaction_processor.utils import ( - add_dict_as_new_columns, add_column_safe, compare_set, prepare_paths + add_column_safe, + add_dict_as_new_columns, + compare_set, + prepare_paths, +) +from AutoREACTER.reaction_preparation.reaction_processor.walker import ( + reaction_atom_walker, ) -from AutoREACTER.reaction_preparation.reaction_processor.walker import reaction_atom_walker -# Use TYPE_CHECKING to prevent circular imports with session.py if TYPE_CHECKING: from AutoREACTER.session import Session class MappingError(Exception): - """Custom exception raised when atom mapping between reactants and products fails or is inconsistent.""" + """Raised when reactant-to-product atom mapping is invalid.""" class SMARTSParsingError(Exception): - """Custom exception raised when parsing SMILES or reaction SMARTS fails.""" + """Raised when a reactant SMILES string cannot be parsed.""" + + +class ZeroActiveReactionsError(Exception): + """Raised when no active reactions remain in the dataset.""" @dataclass(slots=True) class ReactionMetadata: + """Store molecular structures, mappings, and analysis for one reaction. + + This dataclass holds all information required to describe a single + prepared reaction, including the combined reactant and product RDKit + molecules, forward and reverse atom mappings, detected reaction-site + features, file paths, and activity status. + + Attributes: + reaction_id: Unique integer identifier assigned to this reaction. + reactant_combined_RDmol: Combined reactant RDKit molecule. + product_combined_RDmol: Combined product RDKit molecule. + reactant_to_product_mapping: Mapping from reactant atom index to + product atom index. + product_to_reactant_mapping: Mapping from product atom index to + reactant atom index. + template_reactant_to_product_mapping: Subset mapping covering the + reaction template atoms (reactant index -> product index). + edge_atoms: Atom indices that form the boundary of the reaction + template in the reactant molecule. + first_shell: Reactant atom indices identified as the first-shell + reaction environment (mapped atoms in the product with template + map numbers below 999). + initiators: The two reactant atom indices whose product counterparts + carry template map numbers 1 and 2. + byproduct_indices: Reactant indices of atoms that end up in the + smallest product fragment when atoms are deleted during reaction. + reaction_smarts: SMARTS string describing the reaction transformation. + reactant_smiles: Combined SMILES representation of the reactants. + product_smiles: Combined SMILES representation of the products. + csv_path: Path to the CSV file storing the atom mapping dataframe. + reaction_dataframe: DataFrame containing atom mappings and reaction + feature columns (first_shell, initiators, byproduct_idx, etc.). + delete_atom: Whether atoms are removed from the reactants during the + reaction (e.g., condensation byproducts). + delete_atom_idx: Primary reactant index of the atom to delete, if any. + reactant_combined_3Dmol_path: Path to the 3D reactant structure file, + if generated. + product_combined_3Dmol_path: Path to the 3D product structure file, + if generated. + is_radical: Whether the reaction involves radical species. + radical_atom_idxs: Tuple of reactant indices flagged as radical atoms. + activity_stats: True if this reaction is active and unique; set to + False for duplicate or failed reactions so they can be filtered + downstream. """ - Stores comprehensive metadata for a single reaction including molecular structures, - atom mappings, and analysis results. - reaction_id: Unique identifier for the reaction instance - reactant_combined_mol: RDKit molecule object representing combined reactants - product_combined_mol: RDKit molecule object representing combined products - reactant_to_product_mapping: Dictionary mapping reactant atom indices to product atom indices - product_to_reactant_mapping: Dictionary mapping product atom indices back to reactant atom indices - template_reactant_to_product_mapping: Optional dictionary mapping reactant indices in the template to product indices - edge_atoms: Optional list of reactant atom indices that are at the edge of the local environment - first_shell: Optional list of reactant atom indices in the first coordination shell (reaction center) - initiators: Optional list of reactant atom indices that are initiators (map numbers 1 or 2) - byproduct_indices: Optional list of reactant atom indices corresponding to detected byproducts - reaction_smarts: Optional string of the reaction SMARTS pattern - reactant_smarts: Optional string of the combined reactant SMILES - product_smarts: Optional string of the combined product SMILES - csv_path: Optional Path to the CSV file containing atom mappings and analysis - reaction_dataframe: Optional pandas DataFrame containing detailed mapping and analysis results - delete_atom: Boolean indicating whether the reaction involves a delete atom (byproduct) - delete_atom_idx: Optional integer index of the reactant atom that corresponds to the byproduct - reactant_combined_3Dmol_path: Optional Path to the 3D structure file for the combined reactants - product_combined_3Dmol_path: Optional Path to the 3D structure file for the combined products - activity_stats: Boolean indicating whether this reaction should be included in activity statistics (e.g., not a duplicate) - """ + reaction_id: int reactant_combined_RDmol: Chem.Mol product_combined_RDmol: Chem.Mol @@ -84,247 +122,453 @@ class ReactionMetadata: delete_atom_idx: Optional[int] = None reactant_combined_3Dmol_path: Optional[Path] = None product_combined_3Dmol_path: Optional[Path] = None + is_radical: bool = False + radical_atom_idxs: Optional[tuple[int, ...]] = () activity_stats: bool = True class PrepareReactions: - """Processes chemical reactions: builds atom mappings, identifies reaction centers, and detects byproducts.""" + """Build reaction products, atom mappings, metadata, and visualizations. + + This class orchestrates the conversion of detected reaction instances + (``ReactionInstance`` objects) into fully mapped ``ReactionMetadata`` + objects. It runs RDKit reaction transforms, validates atom mappings, + identifies first-shell atoms and initiators, detects byproducts, and + writes per-reaction CSV files to the staging cache. + + Attributes: + session: Shared AutoREACTER session providing inputs, directories, and + state such as the reaction ID counter. + inputs: Shortcut to ``session.inputs``. + staging_dir: Root staging directory for the run. + cache: Working cache directory (currently the same as ``staging_dir``). + csv_cache: Subdirectory where per-reaction mapping CSVs are saved. + """ def __init__(self, session: "Session"): - """Initialize using the shared AutoREACTER session object.""" + """Initialize the processor with the shared AutoREACTER session. + + Args: + session: The active AutoREACTER ``Session`` object. A reaction ID + counter is attached to the session if it does not already + exist. + """ self.session = session self.inputs = session.inputs - - # In AutoREACTER, staging_dir is the working/cache directory. self.staging_dir = Path(session.staging_dir) self.cache = self.staging_dir self.csv_cache = prepare_paths(self.cache, "csv_cache") - # --- PUBLIC --- + if not hasattr(session, "reaction_id_counter"): + session.reaction_id_counter = 0 + + def prepare_reactions(self, session): + """Prepare initial reactions and optionally run reaction progression. + + This is the main entry point. It first builds the initial set of + mapped reactions, then optionally enters the looping/progression + phase when ``session.inputs.loop`` is enabled. In both cases it + verifies that at least one active reaction remains. + + Args: + session: The active AutoREACTER ``Session`` object. + + Returns: + The same ``Session`` object, with ``reaction_metadata`` populated. + + Raises: + ZeroActiveReactionsError: If no active reactions remain after + preparation (and progression, if enabled). + """ + prepared_reactions = self._prepare_reactions_stage(session) + session.reaction_metadata = prepared_reactions + + if session.inputs.loop: + progression = ReactionProgression(session, preparer=self) + final_reactions = progression.reaction_progression() + session.reaction_metadata = final_reactions + self._zero_active_reactions_error(final_reactions) + else: + self._zero_active_reactions_error(prepared_reactions) + + return session + + def _zero_active_reactions_error( + self, + reaction_metadata: list[ReactionMetadata], + ) -> None: + """Raise an error when the metadata contains no active reactions. + + Args: + reaction_metadata: List of ``ReactionMetadata`` objects to inspect. - def prepare_reactions(self, session: "Session") -> list[ReactionMetadata]: + Raises: + ZeroActiveReactionsError: If none of the reactions have + ``activity_stats`` set to True. """ - Main pipeline: processes reaction instances, detects duplicates, and enriches metadata with template mappings. - + if any(reaction.activity_stats for reaction in reaction_metadata): + return + + raise ZeroActiveReactionsError( + "No active reactions found in the dataset. " + "This is an AutoREACTER error indicating that no active " + "reactions were found in the dataset. Please raise an issue at " + "https://github.com/NanoCIPHER-Lab/AutoREACTER/issues" + ) + + def _prepare_reactions_stage( + self, + session: "Session", + loop: bool = False, + ) -> list[ReactionMetadata]: + """Process reaction instances and add template mapping metadata. + + Converts detected reactions into mapped metadata, removes duplicate + reactions, and then walks the local environment around each reaction + to determine template-level mappings and edge atoms. The enriched + dataframes are saved to CSV for downstream use. + Args: - session: The main Session object containing reaction instances to process - + session: The active ``Session`` or, in recursive calls, a list-like + object containing ``ReactionInstance`` objects. ``loop`` mode + changes how reactants are sourced from each instance. + loop: If True, reactants are taken directly from the RDKit + molecules attached to the reaction instance (used during the + progression/looping stage). + Returns: - List of processed ReactionMetadata objects with template mappings and edge atoms + A list of unique ``ReactionMetadata`` objects with template + mappings and edge atoms populated for active reactions. """ - reaction_instances = session.reaction_instances - - # Process and filter reactions - reactions_metadata = self._process_reaction_instances(reaction_instances) - unique_reaction_metadata = self._detect_duplicates(reactions_metadata) - + try: + reaction_instances = session.reaction_instances + except AttributeError: + reaction_instances = session + + reaction_metadata = self._process_reaction_instances( + reaction_instances, + loop=loop, + ) + unique_reaction_metadata = self._detect_duplicates( + reaction_metadata + ) + for reaction in unique_reaction_metadata: - # Skip reactions marked as duplicates if not reaction.activity_stats: continue - - combined_reactant_molecule_object = reaction.reactant_combined_RDmol + + reactant_mol = reaction.reactant_combined_RDmol reaction_dataframe = reaction.reaction_dataframe csv_save_path = reaction.csv_path - - # Build full atom mapping dictionary from dataframe - fully_mapped_dict = reaction_dataframe.set_index("reactant_idx")["product_idx"].to_dict() + + # Extract the full atom mapping and first-shell list from the + # dataframe; these are needed to determine the template subset. + fully_mapped_dict = reaction_dataframe.set_index( + "reactant_idx" + )["product_idx"].to_dict() first_shell = reaction_dataframe["first_shell"].dropna().tolist() - - # Generate template mapping by walking reaction graph + + # Walk outward from the first-shell atoms to find the minimal + # template mapping and the edge atoms of the reaction site. template_mapped_dict, edge_atoms = reaction_atom_walker( - combined_reactant_molecule_object, + reactant_mol, first_shell, - fully_mapped_dict + fully_mapped_dict, ) - - # Add template mapping and edge atoms to dataframe + + # Attach template mapping columns and edge-atom list to the + # dataframe, then persist the updated version to CSV. reaction_dataframe = add_dict_as_new_columns( reaction_dataframe, template_mapped_dict, - titles=["template_reactant_idx", "template_product_idx"] + titles=[ + "template_reactant_idx", + "template_product_idx", + ], ) - - # Add edge atoms as a new column in the dataframe reaction_dataframe = add_column_safe( reaction_dataframe, edge_atoms, - "edge_atoms" + "edge_atoms", ) - - # Save updated dataframe back to CSV and update metadata + reaction.reaction_dataframe = reaction_dataframe.copy() - # Save the updated dataframe with template mappings and edge atoms to CSV reaction_dataframe.to_csv(csv_save_path, index=False) - # Update metadata with template mapping and edge atoms reaction.edge_atoms = edge_atoms - # Store the template mapping in the metadata for later use - reaction.template_reactant_to_product_mapping = template_mapped_dict + reaction.template_reactant_to_product_mapping = ( + template_mapped_dict + ) - # Store the finalized metadata inside the session - session.reaction_metadata = unique_reaction_metadata return unique_reaction_metadata - - # --- PIPELINE STEPS (PRIVATE) --- - - def _process_reaction_instances(self, detected_reactions: list[ReactionInstance]) -> list[ReactionMetadata]: - """ - Converts ReactionInstance objects into ReactionMetadata by building molecules and running reactions. - + + def _process_reaction_instances( + self, + detected_reactions: list[ReactionInstance], + loop: bool = False, + ) -> list[ReactionMetadata]: + """Convert reaction instances into mapped reaction metadata. + + For each detected reaction, this method prepares the reactant RDKit + molecules (from SMILES in the initial stage or from existing molecules + in loop mode), builds the RDKit reaction object, and delegates product + generation and mapping to ``_process_reaction_products``. + Args: - detected_reactions: List of detected reaction instances - + detected_reactions: List of ``ReactionInstance`` objects produced + by the reaction detector. + loop: Whether the call is part of the reaction progression loop. + Returns: - List of ReactionMetadata objects with atom mappings + A list of ``ReactionMetadata`` objects, one per successfully + mapped product set. """ - csv_cache = self.csv_cache - reaction_metadata = [] + reaction_metadata: list[ReactionMetadata] = [] for reaction in detected_reactions: - rxn_smarts = reaction.reaction_smarts - reactant_smiles_1 = reaction.monomer_1.smiles + if loop and ( + reaction.monomer_1.rdkit_mol is None + or ( + reaction.monomer_2 is not None + and reaction.monomer_2.rdkit_mol is None + ) + ): + logger.warning( + "Skipping reaction %s: monomer rdkit_mol is None " + "(likely failed sanitization upstream).", + reaction.reaction_name, + ) + continue + same_reactants = reaction.same_reactants - - # Handle case where both reactants are identical - if same_reactants: - reactant_smiles_2 = reactant_smiles_1 - else: - reactant_smiles_2 = reaction.monomer_2.smiles + forced_idxs_1 = None + forced_idxs_2 = None + + if loop: + # In loop mode, restrict accepted initiators to atoms that + # belong to the previously matched functional groups. This + # prevents the reaction from jumping to an unrelated site + # during polymerization-like progression. + forced_idxs_1 = self._flatten_fg_indexes( + reaction.functional_group_1 + ) + if reaction.functional_group_2 is not None: + forced_idxs_2 = self._flatten_fg_indexes( + reaction.functional_group_2 + ) - delete_atoms = reaction.delete_atom + mol_reactant_1 = self._copy_loop_reactant_mol( + reaction.monomer_1 + ) + monomer_2 = ( + reaction.monomer_1 + if same_reactants + else reaction.monomer_2 + ) + mol_reactant_2 = self._copy_loop_reactant_mol( + monomer_2 + ) - # Build reaction and reactant molecules - rxn = self._build_reaction(rxn_smarts) - # This function also runs the reaction and builds metadata for each product set, including atom mappings and byproduct detection - mol_reactant_1, mol_reactant_2 = self._build_reactants(reactant_smiles_1, reactant_smiles_2) - # Build reaction tuple based on whether reactants are the same or different. If same, only one ordering is needed. If different, both orderings are processed to account for reaction directionality. - reaction_tuple = self._build_reaction_tuple(same_reactants, mol_reactant_1, mol_reactant_2) + # The ReactionInstance already defines reactant-slot order. + reaction_tuple = [[mol_reactant_1, mol_reactant_2]] + else: + reactant_smiles_1 = reaction.monomer_1.smiles + reactant_smiles_2 = ( + reactant_smiles_1 + if same_reactants + else reaction.monomer_2.smiles + ) + mol_reactant_1, mol_reactant_2 = self._build_reactants( + reactant_smiles_1, + reactant_smiles_2, + ) + reaction_tuple = self._build_reaction_tuple( + same_reactants, + mol_reactant_1, + mol_reactant_2, + ) - # Process products and build metadata + rxn = self._build_reaction(reaction.reaction_smarts) reaction_metadata = self._process_reaction_products( - rxn, - csv_cache, - reaction_tuple, - delete_atoms, - reaction_metadata + rxn=rxn, + csv_cache=self.csv_cache, + reaction_tuple=reaction_tuple, + delete_atoms=reaction.delete_atom, + reaction_metadata=reaction_metadata, + forced_indexes_1=forced_idxs_1, + forced_indexes_2=forced_idxs_2, ) - + return reaction_metadata - def _detect_duplicates(self, reaction_metadata_list: list[ReactionMetadata]) -> list[ReactionMetadata]: - """ - Filters duplicate reactions based on reactant and product molecules. - - Args: - reaction_metadata_list: List of reaction metadata to filter - - Returns: - List of unique reactions; duplicates marked with activity_stats=False - """ - unique_metadata: list[ReactionMetadata] = [] - - for reaction in reaction_metadata_list: - # Compare current reaction's reactants and products against unique reactions collected so far - reactants = reaction.reactant_combined_RDmol - products = reaction.product_combined_RDmol + def _copy_loop_reactant_mol(self, monomer_role) -> Chem.Mol: + """Copy a loop-mode reactant without changing generated products. - # Keep reaction if it's unique, otherwise mark as duplicate - if compare_set(unique_metadata, reactants, products): - unique_metadata.append(reaction) - else: - reaction.activity_stats = False - - return unique_metadata - - def _process_reaction_products(self, - rxn: Chem.rdChemReactions.ChemicalReaction, - csv_cache: Path, - reaction_tuple: list, - delete_atoms: bool = True, - reaction_metadata: Optional[list[ReactionMetadata]] = None - ) -> list[ReactionMetadata]: + Initial input monomers are stored as RDKit molecules without explicit + hydrogens before the loop starts, so they still need ``Chem.AddHs``. + Generated products already passed through reaction-progression + sanitization and may contain explicit hydrogens, radical electrons, + and no-implicit flags. Adding hydrogens to those products again can + change the representation that is later sent to LUNAR. """ - Runs reactions on reactant pairs and builds metadata for each product set. - + if monomer_role is None or monomer_role.rdkit_mol is None: + raise SMARTSParsingError( + "Loop-mode reactant is missing its RDKit molecule." + ) + + loop_mol = Chem.Mol(monomer_role.rdkit_mol) + loop_mol.UpdatePropertyCache(strict=False) + + if getattr(monomer_role, "is_monomer", False): + return Chem.AddHs(loop_mol) + + return loop_mol + + def _process_reaction_products( + self, + rxn: Chem.rdChemReactions.ChemicalReaction, + csv_cache: Path, + reaction_tuple: list, + delete_atoms: bool = True, + reaction_metadata: Optional[list[ReactionMetadata]] = None, + forced_indexes_1: Optional[set] = None, + forced_indexes_2: Optional[set] = None, + ) -> list[ReactionMetadata]: + """Run reactions and build metadata for each generated product set. + + Applies the RDKit reaction to each reactant pair, attempts reverse + ordering on failure, builds atom mappings, validates them, identifies + first-shell atoms and initiators, detects byproducts, and stores the + result as ``ReactionMetadata`` with a persistent CSV file. + Args: - rxn: RDKit ChemicalReaction object - csv_cache: Path to cache directory for saving CSVs - reaction_tuple: List of reactant pairs to process - delete_atoms: Whether to detect and track byproducts - reaction_metadata: Accumulator list for metadata objects - + rxn: RDKit ``ChemicalReaction`` object built from SMARTS. + csv_cache: Directory where mapping CSV files are written. + reaction_tuple: List of [reactant_1, reactant_2] pairs to react. + delete_atoms: Whether the reaction removes a byproduct fragment. + reaction_metadata: Mutable list to append new metadata to. A new + list is created if None is supplied. + forced_indexes_1: Allowed initiator atom indices for reactant 1 + (used in loop mode). None disables the filter. + forced_indexes_2: Allowed initiator atom indices for reactant 2 + (used in loop mode). None disables the filter. + Returns: - Updated list of ReactionMetadata objects + The updated list of ``ReactionMetadata`` objects. """ if reaction_metadata is None: reaction_metadata = [] - + for pair in reaction_tuple: - r1, r2 = Chem.Mol(pair[0]), Chem.Mol(pair[1]) - - # Assign unique map numbers and isotopes to track atoms through reaction + r1 = Chem.Mol(pair[0]) + r2 = Chem.Mol(pair[1]) self._assign_atom_map_numbers_and_set_isotopes(r1, r2) - # Run the reaction to get products + products = rxn.RunReactants((r1, r2)) - - # If no products are generated, skip to the next reactant pair if not products: - continue - - # Process each product set generated by the reaction - for product_set in products: - df = pd.DataFrame(columns=["reactant_idx", "product_idx"]) + print( + "Reaction failed in default order, " + "trying reverse order..." + ) + products = rxn.RunReactants((r2, r1)) - # Combine molecules for mapping - reactant_combined = Chem.CombineMols(r1, r2) - if len(product_set) == 1: - product_combined = product_set[0] - else: - product_combined = reduce(Chem.CombineMols, product_set) - - # Restore atom map numbers from isotopes (which survive the reaction) - self._reassign_atom_map_numbers_by_isotope(product_combined) - - # Build bidirectional atom index mappings - mapping_dict, df = self._build_atom_index_mapping(reactant_combined, product_combined) - reverse_mapping = {v: k for k, v in mapping_dict.items()} - - # Restore map numbers for visualization - self._reveal_template_map_numbers(product_combined) + if not products: + print( + "Reaction failed in both orders, " + "skipping this reactant pair." + ) + print( + f"\n[ERROR] RDKit failed to react " + f"{r1.GetNumAtoms()} atoms with " + f"{r2.GetNumAtoms()} atoms." + ) + print(f"Reactant 1 SMILES: {Chem.MolToSmiles(r1)}") + print(f"Reactant 2 SMILES: {Chem.MolToSmiles(r2)}") + print( + "Reaction SMARTS: " + f"{Chem.rdChemReactions.ReactionToSmarts(rxn)}\n" + ) - # Validate mapping consistency - self._validate_mapping(df, reactant_combined, product_combined) + for product_set in products: + reactant_combined = Chem.CombineMols(r1, r2) + product_combined = ( + product_set[0] + if len(product_set) == 1 + else reduce(Chem.CombineMols, product_set) + ) - # Identify atoms involved in reaction center and initiators - first_shell, initiator_idxs = self._assign_first_shell_and_initiators( + self._reassign_atom_map_numbers_by_isotope( + product_combined + ) + mapping_dict, mapping_df = self._build_atom_index_mapping( reactant_combined, product_combined, - reverse_mapping ) + reverse_mapping = { + product_idx: reactant_idx + for reactant_idx, product_idx in mapping_dict.items() + } - # Detect byproducts (smallest fragments) - byproduct_reactant_idxs = self._detect_byproducts(product_combined, reverse_mapping, delete_atoms) - - # Combine all mapping data into single dataframe - df_combined = pd.concat([ - df, - pd.Series(first_shell, name="first_shell"), - pd.Series(initiator_idxs, name="initiators"), - pd.Series(byproduct_reactant_idxs, name="byproduct_idx") - ], axis=1).astype(pd.Int64Dtype()) + self._reveal_template_map_numbers(product_combined) + self._validate_mapping( + mapping_df, + reactant_combined, + product_combined, + ) - total_products = len(reaction_metadata) + 1 + first_shell, initiator_idxs = ( + self._assign_first_shell_and_initiators( + reactant_combined, + product_combined, + reverse_mapping, + ) + ) - # Clear isotopes before saving to restore normal chemistry - self._clear_isotopes(reactant_combined, product_combined) + # When loop-mode restrictions are active, discard product sets + # whose initiators fall outside the allowed functional-group + # atom sets. + if ( + forced_indexes_1 is not None + or forced_indexes_2 is not None + ) and not self._initiators_within_forced_indexes( + initiator_idxs, + r1.GetNumAtoms(), + forced_indexes_1, + forced_indexes_2, + ): + continue + + byproduct_reactant_idxs = self._detect_byproducts( + product_combined, + reverse_mapping, + delete_atoms, + ) - # Save mapping dataframe to CSV - df_combined.to_csv(csv_cache / f"reaction_{total_products}.csv", index=False) + reaction_df = pd.concat( + [ + mapping_df, + pd.Series(first_shell, name="first_shell"), + pd.Series(initiator_idxs, name="initiators"), + pd.Series( + byproduct_reactant_idxs, + name="byproduct_idx", + ), + ], + axis=1, + ).astype(pd.Int64Dtype()) + + self.session.reaction_id_counter += 1 + reaction_id = self.session.reaction_id_counter + csv_path = csv_cache / f"reaction_{reaction_id}.csv" + + self._clear_isotopes( + reactant_combined, + product_combined, + ) + reaction_df.to_csv(csv_path, index=False) - # Create and store metadata object reaction_metadata.append( ReactionMetadata( - reaction_id=total_products, + reaction_id=reaction_id, reactant_combined_RDmol=reactant_combined, product_combined_RDmol=product_combined, reactant_to_product_mapping=mapping_dict, @@ -332,304 +576,534 @@ def _process_reaction_products(self, first_shell=first_shell, initiators=initiator_idxs, byproduct_indices=byproduct_reactant_idxs, - csv_path=csv_cache / f"reaction_{total_products}.csv", - reaction_dataframe=df_combined, + csv_path=csv_path, + reaction_dataframe=reaction_df, delete_atom=delete_atoms, - delete_atom_idx=byproduct_reactant_idxs[0] if byproduct_reactant_idxs else None, - activity_stats=True + delete_atom_idx=( + byproduct_reactant_idxs[0] + if byproduct_reactant_idxs + else None + ), + activity_stats=True, ) ) return reaction_metadata - - # --- CORE REACTION LOGIC --- - - def _assign_first_shell_and_initiators(self, - reactant_combined: Chem.Mol, - product_combined: Chem.Mol, - reversed_mapping_dict: dict[int, int]) -> tuple[list[int], list[int]]: + + def _detect_duplicates( + self, + reaction_metadata_list: list[ReactionMetadata], + ) -> list[ReactionMetadata]: + """Return unique reaction metadata based on reactants and products. + + Two reactions are considered duplicates if their combined reactant and + combined product molecules are structurally identical. Duplicate + entries are retained in the returned list but marked inactive via + ``activity_stats = False``. + + Args: + reaction_metadata_list: List of ``ReactionMetadata`` objects to + deduplicate. + + Returns: + The same list, with duplicate reactions flagged as inactive. + """ + unique_metadata: list[ReactionMetadata] = [] + + for reaction in reaction_metadata_list: + if compare_set( + unique_metadata, + reaction.reactant_combined_RDmol, + reaction.product_combined_RDmol, + ): + unique_metadata.append(reaction) + else: + reaction.activity_stats = False + + return unique_metadata + + def _flatten_fg_indexes( + self, + fg: Optional["FunctionalGroupInfo"], + ) -> Optional[set]: + """Flatten functional-group match indexes into one allowed idx set. + + A functional group can have multiple SMARTS matches, each match being + a tuple or list of atom indices. This helper merges all matches for + both functional group slots into a single set of allowed atom indices. + + Args: + fg: ``FunctionalGroupInfo`` object, or None. + + Returns: + A set of atom indices, or None if no matches are available. """ - Identifies atoms in the first coordination shell (atoms with map numbers < 999) and initiator atoms. - Initiators are atoms with map numbers 1 or 2 (typically the reactive centers). - + if fg is None: + return None + + combined = set() + if fg.fg_1_indexes: + combined.update( + atom_idx + for match in fg.fg_1_indexes + for atom_idx in match + ) + if fg.fg_2_indexes: + combined.update( + atom_idx + for match in fg.fg_2_indexes + for atom_idx in match + ) + + return combined or None + + def _initiators_within_forced_indexes( + self, + initiator_idxs: list[int], + r1_atom_count: int, + forced_indexes_1: Optional[set], + forced_indexes_2: Optional[set], + ) -> bool: + """Check initiator atoms against their reactants' allowed idx sets. + + Initiator indices are global indices into the combined reactant + molecule. Indices below ``r1_atom_count`` belong to reactant 1; the + rest belong to reactant 2 after subtracting the offset. + + Args: + initiator_idxs: Reactant indices of the two initiator atoms. + r1_atom_count: Number of atoms in reactant 1 before combining. + forced_indexes_1: Allowed indices for reactant 1, or None. + forced_indexes_2: Allowed indices for reactant 2, or None. + + Returns: + True if every initiator lies within its respective allowed set, + or if no restriction is applied to that reactant. + """ + for idx in initiator_idxs: + if idx < r1_atom_count: + if ( + forced_indexes_1 is not None + and idx not in forced_indexes_1 + ): + return False + else: + local_idx = idx - r1_atom_count + if ( + forced_indexes_2 is not None + and local_idx not in forced_indexes_2 + ): + return False + + return True + + def _index_based_reaction_preparation( + self, + reaction_instances, + ): + """Prepare index-based reaction instances in loop mode. + + Creates a fresh ``PrepareReactions`` instance and runs the preparation + stage with ``loop=True``. This helper is typically invoked by the + progression machinery when reactions need to be re-prepared after each + loop iteration. + + Args: + reaction_instances: Collection of ``ReactionInstance`` objects. + + Returns: + List of ``ReactionMetadata`` objects produced in loop mode. + """ + prepare_reactions = PrepareReactions(self.session) + return prepare_reactions._prepare_reactions_stage( + reaction_instances, + loop=True, + ) + + def _assign_first_shell_and_initiators( + self, + reactant_combined: Chem.Mol, + product_combined: Chem.Mol, + reversed_mapping_dict: dict[int, int], + ) -> tuple[list[int], list[int]]: + """Identify first-shell atoms and the two reaction initiators. + + First-shell atoms are defined as product atoms whose atom map number + is below 999 (i.e., they were assigned a template map number by the + reaction SMARTS) and that can be traced back to a reactant atom via + the reverse mapping. The two atoms whose product counterparts have map + numbers 1 and 2 are labeled as initiators. + Args: - reactant_combined: Combined reactant molecule - product_combined: Combined product molecule - reversed_mapping_dict: Product idx -> Reactant idx mapping - + reactant_combined: Combined reactant molecule with isotope-based + tracking map numbers still intact on product-mapped atoms. + product_combined: Combined product molecule with template map + numbers revealed via ``_reveal_template_map_numbers``. + reversed_mapping_dict: Mapping from product atom index to reactant + atom index. + Returns: - Tuple of (first_shell atom indices, initiator atom indices) - + A tuple of (first_shell, initiator_idxs), where each is a list of + reactant atom indices. + Raises: - ValueError: If exactly 2 initiators are not found + ValueError: If a mapped product atom has no reverse mapping, or if + the number of initiator atoms is not exactly two. """ first_shell = [] initiator_idxs = [] - for p_atom in product_combined.GetAtoms(): - # Only process atoms with valid map numbers (< 999 indicates non-byproduct) - if p_atom.GetAtomMapNum() < 999: - p_idx = p_atom.GetIdx() + for product_atom in product_combined.GetAtoms(): + map_num = product_atom.GetAtomMapNum() + if map_num >= 999: + continue - if p_idx not in reversed_mapping_dict: - raise ValueError(f"Mapping error: product atom {p_idx} not found in mapping_dict") + product_idx = product_atom.GetIdx() + if product_idx not in reversed_mapping_dict: + raise ValueError( + f"Mapping error: product atom {product_idx} " + "not found in mapping_dict" + ) - r_idx = reversed_mapping_dict[p_idx] - atom = reactant_combined.GetAtomWithIdx(r_idx) - atom.SetAtomMapNum(p_atom.GetAtomMapNum()) + reactant_idx = reversed_mapping_dict[product_idx] + reactant_atom = reactant_combined.GetAtomWithIdx( + reactant_idx + ) + reactant_atom.SetAtomMapNum(map_num) + first_shell.append(reactant_idx) - first_shell.append(r_idx) + if map_num in (1, 2): + initiator_idxs.append(reactant_idx) - # Initiators are atoms with map numbers 1 or 2 - if p_atom.GetAtomMapNum() in [1, 2]: - initiator_idxs.append(r_idx) - if len(initiator_idxs) != 2: - raise ValueError(f"Expected 2 initiators, got {len(initiator_idxs)}: {initiator_idxs}") + raise ValueError( + f"Expected 2 initiators, got {len(initiator_idxs)}: " + f"{initiator_idxs}" + ) return first_shell, initiator_idxs - - def _detect_byproducts(self, - product_combined: Chem.Mol, - reversed_mapping_dict: dict[int, int], - delete_atoms: bool) -> list[int]: - """ - Identifies byproduct atoms (smallest molecular fragment) and maps them back to reactant space. - + + def _detect_byproducts( + self, + product_combined: Chem.Mol, + reversed_mapping_dict: dict[int, int], + delete_atoms: bool, + ) -> list[int]: + """Map atoms in the smallest product fragment to reactant idxs. + + When ``delete_atoms`` is True, the reaction is assumed to produce a + removable byproduct. The smallest disconnected fragment in the product + is treated as that byproduct, and its atoms are translated back to the + reactant-index space using the reverse mapping. + Args: - product_combined: Combined product molecule - reversed_mapping_dict: Product idx -> Reactant idx mapping - delete_atoms: Whether to perform byproduct detection - + product_combined: Combined product molecule, possibly containing + multiple disconnected fragments. + reversed_mapping_dict: Mapping from product atom index to reactant + atom index. + delete_atoms: If False, an empty list is returned immediately. + Returns: - List of reactant indices corresponding to byproduct atoms + Reactant indices of the atoms composing the detected byproduct. """ if not delete_atoms: return [] - # Get tuples of original atom indices for each fragment - frags_indices = rdmolops.GetMolFrags(product_combined) - - # Find the tuple with the smallest number of atoms - smallest_frag_indices = min(frags_indices, key=len) + fragment_idxs = rdmolops.GetMolFrags(product_combined) + smallest_fragment_idxs = min(fragment_idxs, key=len) - byproduct_reactant_indices = [] + return [ + reversed_mapping_dict[product_idx] + for product_idx in smallest_fragment_idxs + if product_idx in reversed_mapping_dict + ] - # Map byproduct product indices back to reactant indices - for p_idx in smallest_frag_indices: - if p_idx in reversed_mapping_dict: - byproduct_reactant_indices.append(reversed_mapping_dict[p_idx]) + def _validate_mapping( + self, + df: pd.DataFrame, + reactant: Chem.Mol, + product: Chem.Mol, + ) -> None: + """Validate mapping columns, uniqueness, bounds, and completeness. + + Ensures that every atom in both the reactant and product molecules is + accounted for exactly once in the mapping dataframe and that all + indices are within bounds. - return byproduct_reactant_indices - - def _validate_mapping(self, df: pd.DataFrame, reactant: Chem.Mol, product: Chem.Mol) -> None: - """ - Validates atom mapping consistency: checks for required columns, duplicates, bounds, and completeness. - Args: - df: Dataframe containing reactant_idx and product_idx columns - reactant: Reactant molecule - product: Product molecule - + df: DataFrame containing at least ``reactant_idx`` and + ``product_idx`` columns. + reactant: Combined reactant RDKit molecule. + product: Combined product RDKit molecule. + Raises: - MappingError: If any validation check fails + MappingError: If the dataframe is empty, missing required columns, + unbalanced, contains duplicates, has out-of-bounds indices, or + does not cover every atom in either molecule. """ - # Ensure dataframe exists and has required columns if df is None or df.empty: - raise MappingError("Mapping validation failed: empty dataframe") + raise MappingError( + "Mapping validation failed: empty dataframe" + ) - # Check for required columns required_cols = {"reactant_idx", "product_idx"} if not required_cols.issubset(df.columns): - raise MappingError(f"Mapping validation error: required columns {required_cols} not found in dataframe.") + raise MappingError( + "Mapping validation error: required columns " + f"{required_cols} not found in dataframe." + ) - # Extract indices and perform validation checks r_idxs = df["reactant_idx"].dropna().tolist() p_idxs = df["product_idx"].dropna().tolist() - # Atom counts must match if len(r_idxs) != len(p_idxs): - raise MappingError(f"Mapping validation error: mismatch in atom counts between reactant and product.") - - # No duplicate mappings (1-to-1 mapping required) + raise MappingError( + "Mapping validation error: mismatch in atom counts " + "between reactant and product." + ) if len(set(r_idxs)) != len(r_idxs): - raise MappingError(f"Mapping validation error: duplicate indices found in reactant mapping.") + raise MappingError( + "Mapping validation error: duplicate idxs found in " + "reactant mapping." + ) if len(set(p_idxs)) != len(p_idxs): - raise MappingError(f"Mapping validation error: duplicate indices found in product mapping.") - - # Indices must be within molecule bounds + raise MappingError( + "Mapping validation error: duplicate idxs found in " + "product mapping." + ) if any(idx >= reactant.GetNumAtoms() for idx in r_idxs): - raise MappingError(f"Mapping validation error: reactant index out of bounds.") + raise MappingError( + "Mapping validation error: reactant idx out of bounds." + ) if any(idx >= product.GetNumAtoms() for idx in p_idxs): - raise MappingError(f"Mapping validation error: product index out of bounds.") - - # All atoms must be mapped (complete mapping) + raise MappingError( + "Mapping validation error: product idx out of bounds." + ) if len(r_idxs) != reactant.GetNumAtoms(): - raise MappingError(f"Mapping validation error: incomplete mapping for reactant.") + raise MappingError( + "Mapping validation error: incomplete mapping for " + "reactant." + ) if len(p_idxs) != product.GetNumAtoms(): - raise MappingError(f"Mapping validation error: incomplete mapping for product.") + raise MappingError( + "Mapping validation error: incomplete mapping for product." + ) + + def _assign_atom_map_numbers_and_set_isotopes( + self, + r1: Chem.Mol, + r2: Chem.Mol, + ) -> None: + """Assign tracking map numbers and isotopes to reactant atoms. + + Atoms in reactant 1 are tagged with 1001-based numbers, and atoms in + reactant 2 with 2001-based numbers. Both the atom map number and the + isotope are set to the same value so that product atoms can later be + traced back to their originating reactant atoms regardless of how the + reaction SMARTS rewrites the molecule. - # --- ATOM MAPPING --- - - def _assign_atom_map_numbers_and_set_isotopes(self, r1: Chem.Mol, r2: Chem.Mol) -> None: - """ - Assigns unique map numbers and isotopes to reactant atoms for tracking through reaction. - Isotopes survive RDKit's reaction engine, allowing atom identity recovery post-reaction. - Args: - r1: First reactant molecule - r2: Second reactant molecule + r1: First reactant molecule; modified in place. + r2: Second reactant molecule; modified in place. """ - # Assign map numbers 1001+ to first reactant atoms for atom in r1.GetAtoms(): idx = 1001 + atom.GetIdx() atom.SetAtomMapNum(idx) - atom.SetIsotope(idx) # Isotope survives the reaction + atom.SetIsotope(idx) - # Assign map numbers 2001+ to second reactant atoms for atom in r2.GetAtoms(): idx = 2001 + atom.GetIdx() atom.SetAtomMapNum(idx) - atom.SetIsotope(idx) # Isotope survives the reaction - - def _reassign_atom_map_numbers_by_isotope(self, mol: Chem.Mol) -> None: - """ - Restores atom map numbers from isotope values after reaction. - RDKit's reaction engine preserves isotopes, allowing recovery of original atom identities. - + atom.SetIsotope(idx) + + def _reassign_atom_map_numbers_by_isotope( + self, + mol: Chem.Mol, + ) -> None: + """Restore product atom map numbers from tracking isotopes. + + After RDKit runs the reaction, atoms that survive from the reactants + retain their original isotope values. This method copies those values + back into the atom map number slot and clears the isotope so that the + product can be aligned with the reactant via ``_build_atom_index_mapping``. + Args: - mol: Product molecule with isotope information + mol: Product molecule; modified in place. """ for atom in mol.GetAtoms(): surviving_idx = atom.GetIsotope() if surviving_idx != 0: - atom.SetAtomMapNum(surviving_idx) # Restore original map number - atom.SetIsotope(0) # Clear isotope to restore normal chemistry + atom.SetAtomMapNum(surviving_idx) + atom.SetIsotope(0) + + def _build_atom_index_mapping( + self, + reactant_combined: Chem.Mol, + product_combined: Chem.Mol, + ) -> tuple[dict[int, int], pd.DataFrame]: + """Build reactant-to-product atom mapping using map numbers. + + Matches atoms across the combined reactant and product molecules by + their shared atom map numbers. Atoms with map number 0 (e.g., newly + added hydrogens or atoms that lost their tag) are ignored because they + cannot be traced unambiguously. - def _build_atom_index_mapping(self, - reactant_combined: Chem.Mol, - product_combined: Chem.Mol) -> tuple[dict[int, int], pd.DataFrame]: - """ - Builds bidirectional atom index mapping between reactants and products using map numbers. - Args: - reactant_combined: Combined reactant molecule - product_combined: Combined product molecule - + reactant_combined: Combined reactant molecule with tracking map + numbers on surviving atoms. + product_combined: Combined product molecule with matching map + numbers on surviving atoms. + Returns: - Tuple of (mapping dict: reactant_idx -> product_idx, dataframe with mapping) + A tuple of (mapping_dict, mapping_df). ``mapping_dict`` maps + reactant atom index to product atom index. ``mapping_df`` contains + the same data in two columns, ``reactant_idx`` and ``product_idx``. """ - mapping_dict = {} - - # Pre-index product atoms by map number for O(1) lookup product_map = { atom.GetAtomMapNum(): atom.GetIdx() for atom in product_combined.GetAtoms() if atom.GetAtomMapNum() != 0 } - + + mapping_dict = {} rows = [] - for r_atom in reactant_combined.GetAtoms(): - r_map_num = r_atom.GetAtomMapNum() - # Match reactant atom to product atom via map number - if r_map_num != 0 and r_map_num in product_map: - r_idx = r_atom.GetIdx() - p_idx = product_map[r_map_num] + for reactant_atom in reactant_combined.GetAtoms(): + reactant_map_num = reactant_atom.GetAtomMapNum() + if ( + reactant_map_num == 0 + or reactant_map_num not in product_map + ): + continue - mapping_dict[r_idx] = p_idx - rows.append({ - "reactant_idx": r_idx, - "product_idx": p_idx - }) + reactant_idx = reactant_atom.GetIdx() + product_idx = product_map[reactant_map_num] + mapping_dict[reactant_idx] = product_idx + rows.append( + { + "reactant_idx": reactant_idx, + "product_idx": product_idx, + } + ) - df = pd.DataFrame(rows) - return mapping_dict, df + return mapping_dict, pd.DataFrame(rows) def _reveal_template_map_numbers(self, mol: Chem.Mol) -> None: - """ - Restores map numbers from RDKit's internal 'old_mapno' property for visualization. - RDKit stores original map numbers in this property after reaction execution. - + """Restore template map numbers from RDKit's ``old_mapno`` property. + + RDKit reaction SMARTS with atom maps stores the original template map + number in the ``old_mapno`` atom property. Copying it back to the atom + map number makes the reaction-center atoms visible to downstream first- + shell and initiator detection. + Args: - mol: Product molecule + mol: Product molecule; modified in place. """ for atom in mol.GetAtoms(): - if atom.HasProp('old_mapno'): - map_num = atom.GetIntProp('old_mapno') - atom.SetAtomMapNum(map_num) + if atom.HasProp("old_mapno"): + atom.SetAtomMapNum(atom.GetIntProp("old_mapno")) + + def _clear_isotopes( + self, + mol_1: Chem.Mol, + mol_2: Chem.Mol, + ) -> None: + """Remove tracking isotopes from two molecules. - def _clear_isotopes(self, mol_1: Chem.Mol, mol_2: Chem.Mol) -> None: - """ - Clears isotope values from molecules to restore normal chemistry after using isotopes for atom tracking. - Args: - mol_1: First molecule to clear - mol_2: Second molecule to clear + mol_1: First molecule; modified in place. + mol_2: Second molecule; modified in place. """ for atom in mol_1.GetAtoms(): atom.SetIsotope(0) for atom in mol_2.GetAtoms(): atom.SetIsotope(0) - # --- BUILDERS --- - - def _build_reaction(self, rxn_smarts: str) -> Chem.rdChemReactions.ChemicalReaction: - """Builds RDKit ChemicalReaction object from SMARTS string.""" - return AllChem.ReactionFromSmarts(rxn_smarts) - - def _build_reactants(self, reactant_smiles_1: str, reactant_smiles_2: str) -> tuple[Chem.Mol, Chem.Mol]: + def _build_reaction( + self, + rxn_smarts: str, + ) -> Chem.rdChemReactions.ChemicalReaction: + """Build an RDKit reaction from a SMARTS string. + + Args: + rxn_smarts: Reaction SMARTS describing the transformation. + + Returns: + An RDKit ``ChemicalReaction`` object ready for ``RunReactants``. """ - Builds reactant molecules from SMILES strings with explicit hydrogens added. - + return AllChem.ReactionFromSmarts(rxn_smarts) + + def _build_reactants( + self, + reactant_smiles_1: str, + reactant_smiles_2: str, + ) -> tuple[Chem.Mol, Chem.Mol]: + """Build two explicit-hydrogen reactant molecules from SMILES. + Args: - reactant_smiles_1: SMILES string for first reactant - reactant_smiles_2: SMILES string for second reactant - + reactant_smiles_1: SMILES string for the first reactant. + reactant_smiles_2: SMILES string for the second reactant. + Returns: - Tuple of (reactant1 molecule, reactant2 molecule) with explicit hydrogens + A tuple of two RDKit molecules with explicit hydrogens added. + + Raises: + SMARTSParsingError: If either SMILES string cannot be parsed by + RDKit. """ mol_reactant_1 = Chem.MolFromSmiles(reactant_smiles_1) if mol_reactant_1 is None: - raise SMARTSParsingError(f"Failed to parse first reactant SMILES: {reactant_smiles_1!r}") - mol_reactant_1 = Chem.AddHs(mol_reactant_1) - + raise SMARTSParsingError( + "Failed to parse first reactant SMILES: " + f"{reactant_smiles_1!r}" + ) + mol_reactant_2 = Chem.MolFromSmiles(reactant_smiles_2) if mol_reactant_2 is None: - raise SMARTSParsingError(f"Failed to parse second reactant SMILES: {reactant_smiles_2!r}") - mol_reactant_2 = Chem.AddHs(mol_reactant_2) - - return mol_reactant_1, mol_reactant_2 - - def _build_reaction_tuple(self, same_reactants: bool, mol_reactant_1: Chem.Mol, mol_reactant_2: Chem.Mol) -> list: - """ - Builds list of reactant pairs to process. If reactants are identical, returns single pair. - Otherwise returns both orderings to account for reaction directionality. - + raise SMARTSParsingError( + "Failed to parse second reactant SMILES: " + f"{reactant_smiles_2!r}" + ) + + return Chem.AddHs(mol_reactant_1), Chem.AddHs(mol_reactant_2) + + def _build_reaction_tuple( + self, + same_reactants: bool, + mol_reactant_1: Chem.Mol, + mol_reactant_2: Chem.Mol, + ) -> list: + """Build the ordered reactant pairs to pass to RDKit. + + When the two reactants are the same molecule, only one ordering is + needed. Otherwise, both orderings are attempted so that asymmetric + SMARTS can match either reactant in either slot. + Args: - same_reactants: Whether both reactants are identical - mol_reactant_1: First reactant molecule - mol_reactant_2: Second reactant molecule - + same_reactants: True if reactant 1 and reactant 2 are identical. + mol_reactant_1: First reactant molecule. + mol_reactant_2: Second reactant molecule. + Returns: - List of reactant pairs [[r1, r2], ...] to process + A list of [reactant_1, reactant_2] pairs. """ if same_reactants: return [[mol_reactant_1, mol_reactant_1]] - return [[mol_reactant_1, mol_reactant_2], [mol_reactant_2, mol_reactant_1]] - - # --- HELPERS --- - + return [ + [mol_reactant_1, mol_reactant_2], + [mol_reactant_2, mol_reactant_1], + ] + def _is_consecutive(self, num_list: list[int]) -> bool: - """ - Checks if list contains consecutive integers with no duplicates. - + """Return whether values are unique consecutive integers. + Args: - num_list: List of integers to check - + num_list: List of integers to inspect. + Returns: - True if list is consecutive and has no duplicates, False otherwise + True if the list is non-empty, contains no duplicates, and spans a + contiguous range of integers; otherwise False. """ if not num_list: return False @@ -639,22 +1113,29 @@ def _is_consecutive(self, num_list: list[int]) -> bool: and max(num_list) - min(num_list) + 1 == len(num_list) ) - # --- VISUALIZATION --- - def reaction_templates_highlighted_image_grid( self, session: "Session", highlight_type: str = "template", ) -> Image: - """ - Generates grid image of reactions with highlighted atoms based on type. - + """Generate a two-column grid of highlighted reaction structures. + + Renders each active reaction as a reactant/product pair, highlighting + atoms according to the chosen highlight type. Supported types are: + ``template`` (template reaction atoms), ``edge`` (edge atoms of the + reaction template), ``initiators`` (reaction initiators), and + ``delete`` (byproduct atoms). + Args: - session: The Session object containing reaction metadata to visualize - highlight_type: Type of atoms to highlight - "template", "edge", "initiators", or "delete" - + session: The active AutoREACTER ``Session`` object containing the + populated ``reaction_metadata`` list. + highlight_type: Category of atoms to highlight. One of + ``"template"``, ``"edge"``, ``"initiators"``, or ``"delete"``. + Returns: - PIL Image containing 2-column grid of reactant-product pairs with highlighted atoms + A PIL ``Image`` containing the grid, or None if no reaction + metadata is available. Atom map numbers are cleared before drawing + to keep the visualization uncluttered. """ metadata_list = session.reaction_metadata if not metadata_list: @@ -668,69 +1149,92 @@ def reaction_templates_highlighted_image_grid( for metadata in metadata_list: reactant = Chem.RWMol(metadata.reactant_combined_RDmol) product = Chem.RWMol(metadata.product_combined_RDmol) - names.extend([f"pre_{metadata.reaction_id}", f"post_{metadata.reaction_id}"]) + names.extend( + [ + f"pre_{metadata.reaction_id}", + f"post_{metadata.reaction_id}", + ] + ) - # Clear atom maps for clean visualization + # Remove atom map numbers so they do not clutter the image. for atom in reactant.GetAtoms(): atom.SetAtomMapNum(0) for atom in product.GetAtoms(): atom.SetAtomMapNum(0) - df = metadata.reaction_dataframe + reaction_df = metadata.reaction_dataframe atoms: List[int] = [] color_map: Dict[int, tuple] = {} - # Select atoms to highlight based on type if highlight_type == "template": - atoms = list((metadata.template_reactant_to_product_mapping or {}).keys()) - for a in atoms: - color_map[a] = (0.2, 0.6, 1.0) # blue - + atoms = list( + ( + metadata.template_reactant_to_product_mapping + or {} + ).keys() + ) + color_map = { + atom_idx: (0.2, 0.6, 1.0) + for atom_idx in atoms + } elif highlight_type == "edge": atoms = metadata.edge_atoms or [] - for a in atoms: - color_map[a] = (1.0, 0.4, 0.0) # orange - + color_map = { + atom_idx: (1.0, 0.4, 0.0) + for atom_idx in atoms + } elif highlight_type == "initiators": - atoms = df["initiators"].dropna().astype(int).tolist() if df is not None else [] - for a in atoms: - color_map[a] = (0.0, 0.8, 0.2) # green - + if reaction_df is not None: + atoms = ( + reaction_df["initiators"] + .dropna() + .astype(int) + .tolist() + ) + color_map = { + atom_idx: (0.0, 0.8, 0.2) + for atom_idx in atoms + } elif highlight_type == "delete": if metadata.delete_atom and metadata.byproduct_indices: atoms = metadata.byproduct_indices - for a in atoms: - color_map[a] = (1.0, 0.0, 0.0) # red + color_map = { + atom_idx: (1.0, 0.0, 0.0) + for atom_idx in atoms + } mols.extend([reactant, product]) - - # Build atom mappings for highlighting - forward_map = metadata.reactant_to_product_mapping - reactant_atoms = atoms - # Map reactant atoms to product atoms - product_atoms = [] - for r_idx in reactant_atoms: - if r_idx in forward_map: - product_atoms.append(forward_map[r_idx]) - - # Build color maps for reactant and product - reactant_color_map = {a: color_map[a] for a in reactant_atoms} - product_color_map = {p: color_map[r] for r, p in forward_map.items() if r in reactant_atoms} - - highlight_lists.append(reactant_atoms) - highlight_lists.append(product_atoms) - highlight_colors.append(reactant_color_map) - highlight_colors.append(product_color_map) + # Translate reactant highlight atoms to product indices using the + # forward mapping so the same atoms are colored on both sides of + # the reaction arrow. + forward_map = metadata.reactant_to_product_mapping + product_atoms = [ + forward_map[reactant_idx] + for reactant_idx in atoms + if reactant_idx in forward_map + ] + reactant_color_map = { + atom_idx: color_map[atom_idx] + for atom_idx in atoms + } + product_color_map = { + product_idx: color_map[reactant_idx] + for reactant_idx, product_idx in forward_map.items() + if reactant_idx in atoms + } + + highlight_lists.extend([atoms, product_atoms]) + highlight_colors.extend( + [reactant_color_map, product_color_map] + ) - # Generate grid image with 2 molecules per row - img = Draw.MolsToGridImage( + return Draw.MolsToGridImage( mols, legends=names, molsPerRow=2, highlightAtomLists=highlight_lists, highlightAtomColors=highlight_colors, - subImgSize=(400, 400), + subImgSize=(1000, 1000), useSVG=False, ) - return img \ No newline at end of file diff --git a/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py new file mode 100644 index 0000000..c6f0882 --- /dev/null +++ b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py @@ -0,0 +1,801 @@ +""" +Iterative reaction-progression engine for AutoREACTER. + +This module drives the discovery of follow-up reactions by repeatedly +re-detecting functional groups in products produced during earlier +reaction-generation rounds. Detected functional groups are turned into +new reaction instances, prepared into fully described reaction metadata, +deduplicated, and then fed back into the next loop iteration. A hard +iteration cap and a pool-growth guard prevent unbounded execution. +""" + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from rdkit import Chem + +from AutoREACTER.detectors.functional_groups_detector import ( + FunctionalGroupsDetector, +) +from AutoREACTER.detectors.reaction_detector import ReactionDetector +from AutoREACTER.reaction_preparation.deduplication_detector import ( + DeduplicationDetector, +) +from AutoREACTER.reaction_preparation.reaction_processor.warning_asci import ( + print_warning, +) + +if TYPE_CHECKING: + from AutoREACTER.detectors.functional_groups_detector import MonomerRole + from AutoREACTER.reaction_preparation.reaction_processor.prepare_reactions import ( + ReactionInstance, + ReactionMetadata, + ) + from AutoREACTER.session import Session + + +# Prevent progression from continuing indefinitely when newly generated +# products keep exposing additional detectable functional groups. +MAX_LOOP = 5 + + +@dataclass(slots=True) +class MonomerRoleforIndexBasedFGDetection: + """Describe a molecule prepared for index-based functional-group detection. + + These lightweight containers bridge the gap between prepared reaction + products and the index-based functional-group detector. The stored atom + indexes refer to positions in the parent reaction template, which makes + it possible to trace any newly detected functional group back to the + product (and ultimately the reactant) that produced it. + + Attributes: + smiles: Canonical SMILES of the prepared product. + name: Generated identifier for this potential monomer. + indexes_in_template: Atom indexes within the reaction template. + is_monomer: Whether this molecule should be treated as a monomer. + is_looped: Whether this molecule has already been processed by a + progression iteration. + rdkit_mol: Optional RDKit molecule object for downstream use. + """ + + smiles: str + name: str + indexes_in_template: list[int] + is_monomer: bool = False + is_looped: bool = False + rdkit_mol: Chem.Mol | None = None + + +@dataclass(slots=True) +class ReactionProgressionSession: + """Track state shared across reaction-progression iterations. + + This object is attached to the main ``Session`` so that other stages of + the pipeline can inspect or update progression-related bookkeeping. + + Attributes: + monomer_roles: Roles that have been produced by progression and + are eligible for subsequent functional-group detection. + iteration: Current progression loop iteration (1-indexed). + """ + + monomer_roles: list["MonomerRole"] = field(default_factory=list) + iteration: int = 0 + + +class ReactionProgression: + """Coordinate iterative functional-group detection and reaction generation. + + The progression workflow repeatedly broadens the set of considered + monomers by using products from the previous round. In each iteration: + + 1. Active products are sanitized and converted into monomer-like roles. + 2. Functional groups are re-detected in those products by index. + 3. Compatible reactions are detected from the expanded monomer pool. + 4. Reaction instances are prepared into full ``ReactionMetadata``. + 5. Radical centers are annotated before deduplication. + 6. Duplicate reaction products are merged. + 7. The loop continues if the active reaction pool has grown. + + The class also owns the cleanup and sanitization of RDKit product + molecules, including special handling for radical carbons that are + deliberately under-valent in the underlying reaction SMARTS. + """ + + def __init__(self, session: "Session", preparer=None): + """Initialize detectors and attach progression state to a session. + + Args: + session: The active AutoREACTER ``Session`` that contains the + current monomer roles and reaction metadata. + preparer: An existing ``PrepareReactions`` instance used to + convert ``ReactionInstance`` objects into ``ReactionMetadata``. + """ + self.session = session + self.preparer = preparer + + # Attach a dedicated progression sub-session to the main session so + # that loop state is visible elsewhere in the pipeline. + self.session.reaction_progression_session = ( + ReactionProgressionSession() + ) + + self.fg_detector = FunctionalGroupsDetector() + self.rxn_detector = ReactionDetector() + self.deduplication_detector = DeduplicationDetector() + + # Display the runtime warning banner (e.g. about experimental + # progression behavior or licensing). + print_warning() + + def reaction_progression( + self, + max_loop: int = MAX_LOOP, + ) -> list["ReactionMetadata"]: + """Run progression until no further useful reactions are generated. + + Each iteration detects functional groups in generated products, finds + compatible reactions, prepares them, and removes duplicates. The loop + stops when no new functional groups or reactions are found, when the + active reaction pool does not grow, or when ``max_loop`` is reached. + + Args: + max_loop: Maximum number of progression iterations. + + Returns: + Prepared and deduplicated reaction metadata accumulated across + all iterations. + """ + # Respect a user-supplied global loop limit if one was configured. + if self.session.inputs.max_loop_count is not None: + max_loop = self.session.inputs.max_loop_count + print( + f"Overriding default max_loop of {MAX_LOOP} with " + f"user-specified max_loop_count of {max_loop}." + ) + # Avoid sleeping in library code; callers control pacing. + + iteration = 0 + # Start from the monomer roles already present in the session. + monomer_roles_in_loop = list(self.session.monomer_roles) + # Accumulate reaction metadata across iterations for deduplication. + all_prepared_reactions = list(self.session.reaction_metadata) + + while iteration < max_loop: + iteration += 1 + self.session.reaction_progression_session.iteration = iteration + + if iteration == 1: + # On the first pass, convert input monomer SMILES into + # explicit RDKit molecules so detectors can work on them. + self._populate_monomer_roles() + else: + print( + f"Starting iteration {iteration} " + "of the reaction progression loop." + ) + + # Mark roles from earlier iterations so detectors can distinguish + # already processed molecules from newly added molecules. + self._set_is_looped_flag(monomer_roles_in_loop) + + # Snapshot the pool size so we can decide whether this iteration + # produced any genuinely new chemistry. + initial_reaction_pool_size = self._count_active_reactions( + self.session.reaction_metadata + ) + print( + f"Initial reaction pool size at iteration {iteration}: " + f"{initial_reaction_pool_size}" + ) + + # Convert active products into the form required by the + # index-based functional-group detector. + roles_for_fg_detection = ( + self._prepare_products_for_idx_based_fg_detection() + ) + fg_detection_results = ( + self.fg_detector.index_based_functional_groups_detector( + roles_for_fg_detection + ) + ) + + # No new functional groups means no new chemistry is possible. + if not fg_detection_results: + print( + f"No new functional groups detected in iteration " + f"{iteration}. Ending the reaction progression loop." + ) + break + + # Expand the monomer pool with functional groups found in this + # iteration's products. + monomer_roles_in_loop.extend(fg_detection_results) + self.session.monomer_roles = monomer_roles_in_loop + + # Search for reactions that involve the expanded monomer set. + reaction_instances = ( + self.rxn_detector.index_based_reaction_detector( + monomer_roles_in_loop + ) + ) + + if not reaction_instances: + print( + f"No new reactions detected in iteration {iteration}. " + "Ending the reaction progression loop." + ) + break + + if not isinstance(reaction_instances, list): + reaction_instances = list(reaction_instances) + + # Convert raw reaction instances into fully prepared metadata. + prepared_reactions = self._index_based_reaction_preparation( + reaction_instances=reaction_instances + ) + + # Radical identity must be available before NetworkX + # deduplication, because equivalent products may differ only in + # how radical centers are represented. + self._annotate_radicals_before_deduplication( + prepared_reactions + ) + + all_prepared_reactions.extend(prepared_reactions) + self.session.reaction_metadata = all_prepared_reactions + + # Equivalent products can be generated through different reaction + # paths, so deduplication occurs after preparation. + all_prepared_reactions = ( + self.deduplication_detector.compare_graphs_mol( + all_prepared_reactions + ) + ) + self.session.reaction_metadata = all_prepared_reactions + + deduplicated_reaction_count = self._count_active_reactions( + all_prepared_reactions + ) + + # If the pool did not grow, further iterations are unlikely to + # yield new chemistry. + if self._loop_break_condition( + size_before=initial_reaction_pool_size, + size_after=deduplicated_reaction_count, + ): + return self._store_reactions(all_prepared_reactions) + + return all_prepared_reactions + + def _index_based_reaction_preparation( + self, + reaction_instances: list["ReactionInstance"], + ) -> list["ReactionMetadata"]: + """Convert detected reaction instances into reaction metadata. + + This is a thin wrapper around the preparer's loop-aware preparation + stage, which builds full ``ReactionMetadata`` records (products, + mappings, activity statistics, etc.) from the raw instances. + + Args: + reaction_instances: Reaction instances produced by the detector. + + Returns: + Fully prepared reaction metadata. + """ + return self.preparer._prepare_reactions_stage( + reaction_instances, + loop=True, + ) + + def _prepare_products_for_idx_based_fg_detection( + self, + ) -> list[MonomerRoleforIndexBasedFGDetection]: + """Prepare active products for index-based functional-group detection. + + Active reaction products are converted into cleaned SMILES strings + and sanitized RDKit molecules. Their template atom indexes are + retained so that functional groups detected in the product can be + traced back to the reaction that produced them. + + Returns: + A list of roles ready for index-based functional-group detection. + """ + prepared_monomer_roles: list[ + MonomerRoleforIndexBasedFGDetection + ] = [] + + for reaction in self.session.reaction_metadata: + # Skip reactions that were deactivated during preparation. + if not reaction.activity_stats: + continue + + product_mol = reaction.product_combined_RDmol + product_is_single_fragment = ( + len(Chem.GetMolFrags(product_mol)) == 1 + ) + indexes_in_template, product_mol = self._get_product_idxs( + reaction.template_reactant_to_product_mapping, + product_mol, + ) + + sanitized_mol, success = self._sanitize_molecule(product_mol) + + # Record radical metadata when sanitization succeeds; otherwise + # mark the product as non-radical. + if success and sanitized_mol is not None: + # Keep the stage handoff molecule consistent. For + # non-deletion, single-fragment products, the sanitized + # molecule has the same atom indexing as the stored product + # and can safely become the source for both the current + # post-template and the next loop reactant. + if ( + not reaction.delete_atom + and product_is_single_fragment + ): + reaction.product_combined_RDmol = Chem.Mol( + sanitized_mol + ) + + self._set_reaction_radical_metadata( + reaction, + sanitized_mol, + ) + else: + reaction.is_radical = False + reaction.radical_atom_idxs = () + + if not success: + print( + f"Skipping reaction product {reaction.reaction_id}: " + "RDKit molecule sanitization failed." + ) + + prepared_monomer_roles.append( + MonomerRoleforIndexBasedFGDetection( + smiles=self._get_product_smiles(sanitized_mol), + name=f"new_{reaction.reaction_id}", + indexes_in_template=indexes_in_template, + rdkit_mol=sanitized_mol, + ) + ) + + return prepared_monomer_roles + + def _store_reactions( + self, + reactions: list["ReactionMetadata"], + ) -> list["ReactionMetadata"]: + """Persist reaction metadata in the session and return it. + + Args: + reactions: Final deduplicated reaction metadata. + + Returns: + The same list, now stored on the session. + """ + self.session.reaction_metadata = reactions + return reactions + + def _sanitize_molecule( + self, + mol: Chem.Mol, + ) -> tuple[Chem.Mol | None, bool]: + """Clean and sanitize a product while preserving radical centers. + + RDKit ``RunReactants`` output is often not directly sanitizable, + especially when the reaction SMARTS intentionally leaves a carbon + under-valent (radical). This method strips tracking properties, + recomputes ring information, and adjusts explicit hydrogens and + radical electrons until the molecule either sanitizes cleanly or + is returned in the best possible state. + + Args: + mol: Raw product molecule from reaction preparation. + + Returns: + A tuple of (sanitized molecule or best-effort molecule, + success flag indicating whether RDKit sanitization succeeded). + """ + cleaned_mol = self._clean_product(mol) + patched_mol = Chem.RWMol(cleaned_mol) + + # Rebuild valence and ring state without raising on the first error. + patched_mol.UpdatePropertyCache(strict=False) + Chem.FastFindRings(patched_mol) + + for atom in patched_mol.GetAtoms(): + # Focus on carbons, where radical centers are expected. + if atom.GetAtomicNum() != 6: + continue + + # Disable automatic implicit-H addition so we can manage valence + # explicitly for the radical center. + atom.SetNoImplicit(True) + + heavy_valence = int( + sum( + bond.GetValenceContrib(atom) + for bond in atom.GetBonds() + ) + ) + explicit_hs = atom.GetNumExplicitHs() + radical_electrons = atom.GetNumRadicalElectrons() + + # A carbon that has reached valence four is no longer radical. + if ( + heavy_valence + explicit_hs >= 4 + and radical_electrons > 0 + ): + atom.SetNumRadicalElectrons(0) + radical_electrons = 0 + + # Reduce explicit hydrogens when reaction output is over-valent. + if ( + heavy_valence + + explicit_hs + + radical_electrons + > 4 + ): + explicit_hs = max( + 0, + 4 - heavy_valence - radical_electrons, + ) + atom.SetNumExplicitHs(explicit_hs) + + # Preserve a neutral, trivalent carbon as the new radical center. + if ( + heavy_valence + explicit_hs == 3 + and atom.GetFormalCharge() == 0 + ): + atom.SetNumRadicalElectrons(1) + + patched_mol = patched_mol.GetMol() + patched_mol.ClearComputedProps() + + # First attempt: sanitize the valence-patched molecule. + try: + Chem.SanitizeMol(patched_mol) + return patched_mol, True + except Exception: + pass + + # Second attempt: explicitly mark under-valent carbons as radicals + # and try sanitization again. + radical_fixed_mol = self._fix_radical_and_sanitize( + patched_mol + ) + + try: + Chem.SanitizeMol(radical_fixed_mol) + return radical_fixed_mol, True + except Exception: + # If sanitization still fails, return the best-effort molecule + # with updated caches so downstream code can still inspect it. + radical_fixed_mol.UpdatePropertyCache(strict=False) + Chem.FastFindRings(radical_fixed_mol) + return radical_fixed_mol, False + + def _fix_radical_and_sanitize( + self, + raw_mol: Chem.Mol, + query: str = "[CH;X3;v3]", + ) -> Chem.Mol: + """Represent deliberately under-valent carbons as radicals. + + ``RunReactants`` output can be unsanitized. The radical carbon is + deliberately under-valent in the reaction SMARTS, so this method adds + the radical electron needed for RDKit sanitization and SMILES + round-tripping. + + Args: + raw_mol: Molecule that may contain an under-valent radical carbon. + query: SMARTS used to identify the radical carbon. Defaults to a + neutral carbon with one hydrogen, three explicit connections, + and total valence three. + + Returns: + Molecule with radical valence represented explicitly. + """ + mol = Chem.RWMol(raw_mol) + mol.UpdatePropertyCache(strict=False) + Chem.FastFindRings(mol) + + query_mol = Chem.MolFromSmarts(query) + hits = mol.GetSubstructMatches(query_mol) + + for match in hits: + atom = mol.GetAtomWithIdx(match[0]) + atom.SetNoImplicit(True) + + # Ensure the matched carbon has exactly one explicit hydrogen. + if atom.GetTotalNumHs() != 1: + atom.SetNumExplicitHs(1) + + # Add the single radical electron that completes the valence + # representation. + atom.SetNumRadicalElectrons(1) + + return mol.GetMol() + + def _clean_product(self, mol: Chem.Mol) -> Chem.Mol: + """Return a copy of a molecule stripped of preparation artifacts. + + Atom maps, isotope labels, and internal RDKit tracking properties are + removed so that downstream SMILES are canonical and do not carry + state from the reaction engine. + + Args: + mol: Molecule to clean. + + Returns: + A new molecule with atom maps, isotopes, and tracking properties + cleared. + """ + cleaned_mol = Chem.Mol(mol) + + for atom in cleaned_mol.GetAtoms(): + atom.SetAtomMapNum(0) + atom.SetIsotope(0) + + if atom.HasProp("old_mapno"): + atom.ClearProp("old_mapno") + if atom.HasProp("react_atom_idx"): + atom.ClearProp("react_atom_idx") + + return cleaned_mol + + def _get_product_smiles(self, mol: Chem.Mol) -> str: + """Convert a cleaned product molecule to canonical SMILES. + + Args: + mol: Product molecule (may be ``None`` if sanitization failed). + + Returns: + Canonical SMILES string, or an empty string if conversion fails. + """ + cleaned_mol = self._clean_product(mol) + + try: + return Chem.MolToSmiles(cleaned_mol) + except Exception: + return "" + + def _get_product_idxs( + self, + template_reactant_to_product_mapping: dict[int, int], + mol: Chem.Mol, + ) -> tuple[list[int], Chem.Mol]: + """Return product indexes and the molecule containing those indexes. + + When a product contains disconnected fragments, only the fragment with + the greatest number of heavy atoms is retained and the indexes are + remapped into that fragment. + + Args: + template_reactant_to_product_mapping: Mapping from reactant atom + indexes to product atom indexes in the original template. + mol: Product molecule, possibly multi-fragment. + + Returns: + A tuple of (list of remapped product indexes, retained fragment). + """ + product = Chem.Mol(mol) + product_idxs = list( + template_reactant_to_product_mapping.values() + ) + + if len(Chem.GetMolFrags(product)) > 1: + product, product_idxs = self._keep_largest_fragment( + product, + product_idxs, + ) + + return product_idxs, product + + def _keep_largest_fragment( + self, + mol: Chem.Mol, + product_idxs: list[int], + ) -> tuple[Chem.Mol, list[int]]: + """Keep the largest heavy-atom fragment and remap its atom indexes. + + Args: + mol: Multi-fragment product molecule. + product_idxs: Product-side atom indexes to retain. + + Returns: + A tuple of (largest fragment molecule, product indexes remapped + into that fragment). + + Raises: + ValueError: If no fragments could be extracted from the molecule. + """ + fragment_atom_mappings: list[tuple[int, ...]] = [] + fragments = Chem.GetMolFrags( + mol, + asMols=True, + sanitizeFrags=True, + fragsMolAtomMapping=fragment_atom_mappings, + ) + + if not fragments: + raise ValueError( + "No fragments found in the product molecule." + ) + + # Select the fragment with the most heavy atoms. + largest_fragment_position = max( + range(len(fragments)), + key=lambda position: ( + fragments[position].GetNumHeavyAtoms() + ), + ) + largest_fragment = fragments[largest_fragment_position] + original_atom_idxs = fragment_atom_mappings[ + largest_fragment_position + ] + + # Build a map from original atom indexes to their positions in the + # largest fragment. + original_to_new_idx = { + original_idx: new_idx + for new_idx, original_idx in enumerate(original_atom_idxs) + } + remapped_product_idxs = [ + original_to_new_idx[product_idx] + for product_idx in product_idxs + if product_idx in original_to_new_idx + ] + + return largest_fragment, remapped_product_idxs + + def _set_is_looped_flag( + self, + monomer_roles: list["MonomerRole"], + ) -> None: + """Mark supplied monomer roles as processed by the current loop. + + Args: + monomer_roles: Monomer roles to flag as looped. + """ + for monomer_role in monomer_roles: + monomer_role.is_looped = True + + def _populate_monomer_roles(self) -> None: + """Create RDKit molecules for roles identified as monomers. + + Input monomers are typically supplied as SMILES; this method ensures + that each one has an associated RDKit molecule before detection runs. + """ + for monomer in self.session.monomer_roles: + if monomer.is_monomer: + monomer.rdkit_mol = self._smiles_to_rdkit_mol( + monomer.smiles + ) + + def _smiles_to_rdkit_mol( + self, + smiles: str, + ) -> Chem.Mol | None: + """Parse a SMILES string into an RDKit molecule. + + Args: + smiles: SMILES string to parse. + + Returns: + The parsed RDKit molecule, or ``None`` if parsing fails. + """ + return Chem.MolFromSmiles(smiles) + + def _loop_break_condition( + self, + size_before: int, + size_after: int, + ) -> bool: + """Return whether the active reaction pool failed to grow. + + Args: + size_before: Number of active reactions before deduplication. + size_after: Number of active reactions after deduplication. + + Returns: + ``True`` if the pool did not grow and the loop should stop. + """ + if size_after <= size_before: + print( + "Breaking the loop as the pool did not grow " + f"(before={size_before}, after={size_after})." + ) + return True + + return False + + def _count_active_reactions( + self, + reactions: list["ReactionMetadata"], + ) -> int: + """Count reactions included in activity statistics. + + Args: + reactions: Reaction metadata to inspect. + + Returns: + Number of reactions with truthy ``activity_stats``. + """ + return sum( + bool(reaction.activity_stats) + for reaction in reactions + ) + + def _set_reaction_radical_metadata( + self, + reaction: "ReactionMetadata", + sanitized_product: Chem.Mol, + ) -> None: + """Store product radical atoms in reactant-index space. + + Deduplication relabels product atoms into reactant-index space, so + radical indexes are converted through + ``product_to_reactant_mapping``. + + Args: + reaction: Reaction metadata to annotate. + sanitized_product: Sanitized product molecule in which radical + electrons have already been assigned. + """ + product_radical_idxs = { + atom.GetIdx() + for atom in sanitized_product.GetAtoms() + if atom.GetNumRadicalElectrons() > 0 + } + radical_reactant_idxs = { + reaction.product_to_reactant_mapping[product_idx] + for product_idx in product_radical_idxs + if product_idx in reaction.product_to_reactant_mapping + } + + reaction.is_radical = bool(radical_reactant_idxs) + reaction.radical_atom_idxs = tuple( + sorted(radical_reactant_idxs) + ) + + def _annotate_radicals_before_deduplication( + self, + reactions: list["ReactionMetadata"], + ) -> None: + """Sanitize products and record radical atoms before deduplication. + + This must run before ``compare_graphs_mol`` because the deduplication + step relies on consistent radical annotation to distinguish otherwise + isomorphic products. + + Args: + reactions: Newly prepared reaction metadata to annotate. + """ + for reaction in reactions: + if not reaction.activity_stats: + continue + + product_mol = reaction.product_combined_RDmol + + if product_mol is None: + reaction.is_radical = False + reaction.radical_atom_idxs = () + continue + + sanitized_mol, success = self._sanitize_molecule( + product_mol + ) + + if not success or sanitized_mol is None: + reaction.is_radical = False + reaction.radical_atom_idxs = () + continue + + self._set_reaction_radical_metadata( + reaction, + sanitized_mol, + ) diff --git a/AutoREACTER/reaction_preparation/reaction_processor/warning_asci.py b/AutoREACTER/reaction_preparation/reaction_processor/warning_asci.py new file mode 100644 index 0000000..a1fbf33 --- /dev/null +++ b/AutoREACTER/reaction_preparation/reaction_processor/warning_asci.py @@ -0,0 +1,26 @@ + +# (unused import removed) + +def ascii_art(message: str) -> None: + message = message.upper() + print(f"WARNING: {message}") + print( +""" + ____ ____ _ _______ ____ _____ _____ ____ _____ ______ _ _ _ +|_ _| |_ _|/ \ |_ __ \ |_ \|_ _||_ _||_ \|_ _|.' ___ | | | | | | | + \ \ /\ / / / _ \ | |__) | | \ | | | | | \ | | / .' \_| | | | | | | + \ \/ \/ / / ___ \ | __ / | |\ \| | | | | |\ \| | | | ____ | | | | | | + \ /\ /_/ / \ \_ _| | \ \_ _| |_\ |_ _| |_ _| |_\ |_\ `.___] | |_| |_| |_| + \/ \/|____| |____||____| |___||_____|\____||_____||_____|\____|`._____.' (_) (_) (_) + +""" + ) + + +def print_warning() -> None: + message = ( + "Entering the reaction progression loop is still in the beta phase. " + "Caution: results can be chemically inaccurate." + ) + ascii_art(message) + diff --git a/AutoREACTER/sim_setup/system_property_calculations.py b/AutoREACTER/sim_setup/system_property_calculations.py index effe212..1f8ce2c 100644 --- a/AutoREACTER/sim_setup/system_property_calculations.py +++ b/AutoREACTER/sim_setup/system_property_calculations.py @@ -1,4 +1,5 @@ import math +from rdkit import Chem from rdkit.Chem import Descriptors from AutoREACTER.input_parser import SimulationSetup @@ -56,11 +57,17 @@ def process_all(self) -> SimulationSetup: def _populate_monomer_properties(self) -> None: """ Populate each active monomer with basic structural properties derived from its RDKit molecule. - + For every monomer whose status is True: - - num_atoms is set to the heavy atom count. - - molecular_weight is set using RDKit's Descriptors.MolWt (g/mol). - + - num_atoms is set to the FULL atom count (heavy atoms + explicit hydrogens), + since this must match the atom count of the final built system, not just + the heavy-atom skeleton. Using AddHs() on a temporary copy avoids mutating + monomer.rdkit_mol, which other stages (e.g. functional group / reaction + detection) still expect in its original heavy-atom-only form. + - molecular_weight is set using RDKit's Descriptors.MolWt (g/mol). Unaffected + by explicit vs. implicit H representation -- MolWt already accounts for + implicit hydrogens correctly. + Raises: NoneMonomerError: If a monomer is marked active but has no RDKit Mol object. """ @@ -75,8 +82,14 @@ def _populate_monomer_properties(self) -> None: f"Monomer with ID {monomer.id} has no RDKit Mol object." ) - monomer.num_atoms = monomer.rdkit_mol.GetNumAtoms() + # count on an AddHs'd copy so num_atoms reflects the true final + # atom count (heavy + explicit H), matching what total_atoms means + # to the rest of the simulation-setup pipeline. Original rdkit_mol + # is left untouched for downstream heavy-atom-based logic. + mol_with_hs = Chem.AddHs(monomer.rdkit_mol) + monomer.num_atoms = mol_with_hs.GetNumAtoms() monomer.molecular_weight = Descriptors.MolWt(monomer.rdkit_mol) + def _calculate_replica_properties(self) -> None: """ diff --git a/AutoREACTER/sim_setup/writers/pre_eq_writer.py b/AutoREACTER/sim_setup/writers/pre_eq_writer.py index 6771f47..18893dc 100644 --- a/AutoREACTER/sim_setup/writers/pre_eq_writer.py +++ b/AutoREACTER/sim_setup/writers/pre_eq_writer.py @@ -126,18 +126,18 @@ def write_pre_eq_file(self, simulation: Simulation) -> str: "#------------Stage 1: NVT Temperature Ramp------------", "# (25,000 steps × 1 fs timestep)", f"{'fix':<16} nvt_1 all nvt temp 298.15 {simulation.temperature} 100.0", - f"{'run':<16} 25000", + f"{'run':<16} 50000", f"{'unfix':<16} nvt_1", "", - "#------------Stage 2: NPT Equilibration------------", - "# Isotropic pressure control at 0 atm while maintaining target temperature.", - "# This allows the box volume to adjust to the correct density at the", - "# desired temperature and pressure.", - f"{'fix':<16} npt_2 all npt temp {simulation.temperature} {simulation.temperature} 100.0 iso 0.0 0.0 1000.0", - f"{'run':<16} 25000", - f"{'unfix':<16} npt_2", - "", - "#------------Stage 3: Final NVT Equilibration------------", + # "#------------Stage 2: NPT Equilibration------------", + # "# Isotropic pressure control at 0 atm while maintaining target temperature.", + # "# This allows the box volume to adjust to the correct density at the", + # "# desired temperature and pressure.", + # f"{'fix':<16} npt_2 all npt temp {simulation.temperature} {simulation.temperature} 100.0 iso 0.0 0.0 1000.0", + # f"{'run':<16} 25000", + # f"{'unfix':<16} npt_2", + # "", + "#------------Stage 2: Final NVT Equilibration------------", "# Extended constant-volume equilibration at the final temperature.", "# This stabilizes the system after the density adjustment in the NPT stage.", f"{'fix':<16} nvt_3 all nvt temp {simulation.temperature} {simulation.temperature} 100.0", diff --git a/AutoREACTER/sim_setup/writers/rxn_first_stage_writer.py b/AutoREACTER/sim_setup/writers/rxn_first_stage_writer.py index 41bb2c3..ade793b 100644 --- a/AutoREACTER/sim_setup/writers/rxn_first_stage_writer.py +++ b/AutoREACTER/sim_setup/writers/rxn_first_stage_writer.py @@ -140,20 +140,26 @@ def write_first_stage_reaction_files(self, simulation: Simulation) -> str: lines.append("#------------Define Reaction Templates------------") rxn_commands: list[str] = [] - for i, template in enumerate(rf.template_files, 1): - pre_id = f"mol_pre_{i}" - post_id = f"mol_post_{i}" + for template in (rf.template_files): # Extract filenames from the dataclass fields pre_file = template.pre_reaction_file.lmp_molecule_file.name post_file = template.post_reaction_file.lmp_molecule_file.name map_file = template.map_file.name + id = template.reaction_id + pre_id = f"mol_pre_{id}" + post_id = f"mol_post_{id}" - lines.append(f"{'molecule':<16} {pre_id} {pre_file}") - lines.append(f"{'molecule':<16} {post_id} {post_file}\n") - + lines.append(f"{'molecule':<16} {pre_id:<16} {pre_file}") + lines.append(f"{'molecule':<16} {post_id:<16} {post_file}\n") + rxn_stp = f"rxn_stp_{id}" rxn_str = ( - f"react rxn_stp_{i} all 1 0.0 3.5 {pre_id} {post_id} {map_file} " + f"react " + f"{rxn_stp:<15} " + f"all 1 0.0 3.5 " + f"{pre_id:<14} " + f"{post_id:<15} " + f"{map_file:<15} " f"stabilize_steps 60 rescale_charges yes" ) rxn_commands.append(rxn_str) @@ -162,8 +168,8 @@ def write_first_stage_reaction_files(self, simulation: Simulation) -> str: lines.extend([ "", - f"{'fix':<16} rxns all bond/react stabilization yes statted_grp 0.03 &", - f"{'':<16} {all_reactions}", + f"{'fix':<16}rxns all bond/react stabilization yes statted_grp 0.03 &", + f"{'':<16}{all_reactions}", "", "", "# Note: If atoms are being deleted during the reaction, ensure you use the correct Map file", diff --git a/docs/source/change_log.md b/docs/source/change_log.md index 62a703d..3a32b24 100644 --- a/docs/source/change_log.md +++ b/docs/source/change_log.md @@ -22,6 +22,30 @@ This serves two purposes: At release time, you can move the Unreleased section changes into a new release version section. --> +## [0.3] - [2026-08-xx] +### Added + +* **Reaction Progression:** Multi-stage reaction template generation to handle multi stage reaction progressions, copolymerizations and small molecules which needs few templates for the polymerization reactions. +* **Looping Control:** A new loop control parameter in the input file to prevent the explosion of unnecessary reactions. Defaults to `5` loops, but can be optimized by setting `loop: ` or disabled entirely with `loop: False`. +* **Advanced Detection:** Index-based functional group detection and index-based reaction detection to accurately identify reacting atoms post-first reaction. +* **Pathway Deduplication:** NetworkX-based deduplication detection to actively filter redundant reaction pathways. +* **Reaction Libraries:** Robust library modules for various polymer chemistries, including Epoxies, Polyamides, Polyesters, Polycarbonates, Polysiloxanes, Polyureas, Polyurethanes, and Vinyl polymers. +* **Force Field Parameters:** PCFF force field additions, specifically including new `s_m` sulfone parameters. + +### Changed + +* **Library Organization:** Centralized the reaction libraries into dedicated modules to improve maintainability and expandability. +* **Structure Generation:** Improved 3D molecule embedding to better handle highly congested polymer structures. + +### Removed + +* **Legacy Code:** Removed legacy compatibility shims (`_compat.py`). + +### Fixed + +* Addressed various stability and progression issues tracked in recent bug reports. +* Improved Error handling when no reaction instances are found for the specified monomer combinations. + ## [0.2.3] - [2026-06-24] ### Added diff --git a/docs/source/supported-reactions.md b/docs/source/supported-reactions.md index bc2ff2f..3971263 100644 --- a/docs/source/supported-reactions.md +++ b/docs/source/supported-reactions.md @@ -1,12 +1,12 @@ ## Supported Reactions -AutoREACTER is currently in **v0.2.2-beta**. At this stage of development, the reaction library is limited to selected step-growth polymerization reactions, including **polycondensation**, **transesterification**, and **polyaddition** reactions. +AutoREACTER is currently in **v{{ autoreacter_version }}**. At this stage of development, the reaction library supports a broad range of step-growth and chain-growth polymerization reactions, including **polycondensation**, **transesterification**, **polyaddition**, **hydrolysis initiation**, and **addition polymerization**. The core `Detector` module automatically identifies the following functional groups and maps them to their respective reaction pathways. **Important:** If your `input.json` contains monomers with functional groups outside of this list, AutoREACTER will classify them as *non-reactive molecules* (which you can choose to retain as solvents/additives or discard). -**NOTE:** Certain force fields do not support all atom types; for example, iodine ``(I)`` is sometimes unsupported. +**NOTE:** Certain force fields do not support all atom types; for example, iodine `(I)` is sometimes unsupported. --- @@ -14,25 +14,34 @@ The core `Detector` module automatically identifies the following functional gro These reactions form ester linkages (`-COO-`) and typically release water (`H₂O`), alcohols (`R-OH`), or hydrogen halides (e.g., `HCl`) as byproducts. -* **Hydroxy–Carboxylic Acid Polycondensation** +* **Hydroxy Carboxylic Acid Polycondensation** +* *Reactants:* `-OH` + `-COOH` - * *Reactants:* `-OH` + `-COOH` -* **Hydroxy Acid Halide Polycondensation** +* **Hydroxy Carboxylic Acid and Hydroxy Carboxylic Acid Polycondensation** +* *Reactants:* `-OH` + `-COOH` (Intermolecular) - * *Reactants:* `-OH` + `-COX` where `X = Cl, Br, I` -* **Diol + Di-Carboxylic Acid Polycondensation** +* **Hydroxy Acid Halides Polycondensation** +* *Reactants:* `-OH` + `-COX` where `X = Cl, Br, I` - * *Reactants:* Two `-OH` groups + Two `-COOH` groups -* **Diol + Di-Acid Halide Polycondensation** +* **Hydroxy Acid Halides Hydroxy Acid Halides Polycondensation** +* *Reactants:* `-OH` + `-COX` where `X = Cl, Br, I` (Intermolecular) - * *Reactants:* Two `-OH` groups + Two `-COX` groups where `X = Cl, Br, I` -* **Diol + Di-Carboxylic Ester Transesterification** +* **Diol and Di-Carboxylic Acid Polycondensation** +* *Reactants:* Two `-OH` groups + Two `-COOH` groups + + +* **Diol and Di-Acid Halide Polycondensation** +* *Reactants:* Two `-OH` groups + Two `-COX` groups where `X = Cl, Br, I` + + +* **Diol and Di-Carboxylic Ester Polycondensation (Transesterification)** +* *Reactants:* Two `-OH` groups + Two ester groups (`-COOR`) + - * *Reactants:* Two `-OH` groups + Two ester groups (`-COOR`) --- @@ -41,20 +50,25 @@ These reactions form ester linkages (`-COO-`) and typically release water (`H₂ These reactions form amide linkages (`-CONH-`) and typically release water (`H₂O`) or hydrogen halides (e.g., `HCl`) as byproducts. * **Amino Acid Polycondensation** +* *Reactants:* `-NH₂` / `-NH-` + `-COOH` - * *Reactants:* `-NH₂` / `-NH-` + `-COOH` -* **Amino Acid + Amino Acid Polycondensation** +* **Amino Acid and Amino Acid Polycondensation** +* *Reactants:* `-NH₂` / `-NH-` + `-COOH` (Intermolecular) - * *Reactants:* `-NH₂` / `-NH-` + `-COOH` -* **Diamine + Di-Carboxylic Acid Polycondensation** +* **Di-Amine and Di-Carboxylic Acid Polycondensation** +* *Reactants:* Two amine groups (`-NH₂` / `-NH-`) + Two `-COOH` groups - * *Reactants:* Two amine groups (`-NH₂` / `-NH-`) + Two `-COOH` groups -* **Diamine + Di-Carboxylic Acid Halide Polycondensation** +* **Di-Amine and Di-Carboxylic Acid Halide Polycondensation** +* *Reactants:* Two amine groups (`-NH₂` / `-NH-`) + Two `-COX` groups where `X = Cl, Br, I` + + +* **Hydrolytic Initiation of Caprolactam** +* *Reactants:* Water (`H₂O`) + Lactam ring opening + - * *Reactants:* Two amine groups (`-NH₂` / `-NH-`) + Two `-COX` groups where `X = Cl, Br, I` --- @@ -62,9 +76,14 @@ These reactions form amide linkages (`-CONH-`) and typically release water (`H These reactions form anhydride linkages (`-CO-O-CO-`) and typically release hydrogen halides (e.g., `HCl`) as byproducts. -* **Carboxylic Acid + Acid Halide Polycondensation** +* **Carboxylic Acid and Acid Halide Polycondensation** +* *Reactants:* `-COOH` + `-COX` where `X = Cl, Br, I` + + +* **Carboxylic Acid and Acid Halide Copolycondensation** +* *Reactants:* Mixed `-COOH` + `-COX` copolymerization systems + - * *Reactants:* `-COOH` + `-COX` where `X = Cl, Br, I` --- @@ -72,13 +91,14 @@ These reactions form anhydride linkages (`-CO-O-CO-`) and typically release hydr These reactions form thioester linkages (`-COS-`) and typically release water (`H₂O`) or hydrogen halides (e.g., `HCl`) as byproducts. -* **Dithiol + Di-Carboxylic Acid Polycondensation** +* **Dithiol and Di-Carboxylic Acid Halide Polycondensation** +* *Reactants:* Two `-SH` groups + Two `-COX` groups where `X = Cl, Br, I` - * *Reactants:* Two `-SH` groups + Two `-COOH` groups -* **Dithiol + Di-Carboxylic Acid Halide Polycondensation** +* **Dithiol and Di-Carboxylic Acid Polycondensation** +* *Reactants:* Two `-SH` groups + Two `-COOH` groups + - * *Reactants:* Two `-SH` groups + Two `-COX` groups where `X = Cl, Br, I` --- @@ -86,25 +106,119 @@ These reactions form thioester linkages (`-COS-`) and typically release water (` These reactions are supported for hydroxy–thiol monomers reacting with acid halides. Depending on the reacting group, either an ester or thioester linkage can be formed. -* **Hydroxy–Thiol + Di-Carboxylic Acid Halide through Hydroxy Group** +* **Hydroxy-Thiol and Di-Carboxylic Acid Halide Polycondensation through Hydroxy Group** +* *Reactants:* `-OH` + `-COX` where `X = Cl, Br, I` + + +* **Hydroxy-Thiol and Di-Carboxylic Acid Halide Polycondensation through Thiol Group** +* *Reactants:* `-SH` + `-COX` where `X = Cl, Br, I` + + + +--- + +### 6. Polyurethane, Polythiourethane, and Polyurea Formation + +These reactions form urethane, thiourethane, or urea linkages via **polyaddition** pathways. + +* **Diol and Di-Isocyanate Polyaddition (Polyurethane Formation)** +* *Reactants:* Two `-OH` groups + Two isocyanate groups (`-NCO`) + + +* **Dithiol and Di-Isocyanate Polyaddition (Polythiourethane Formation)** +* *Reactants:* Two `-SH` groups + Two isocyanate groups (`-NCO`) + + +* **Di-Amine and Di-Isocyanate Polyaddition (Polyurea Formation)** +* *Reactants:* Two amine groups (`-NH₂` / `-NH-`) + Two isocyanate groups (`-NCO`) + + + +--- + +### 7. Epoxy-Amine Addition and Crosslinking + +These reactions model step-growth/network formation between amine curing agents and epoxy rings. + +* **Primary Amine and Epoxide Polyaddition (First Addition)** +* *Reactants:* Primary amine (`-NH₂`) + Epoxide ring + + +* **Secondary Amine and Epoxide Polyaddition (Second Addition / Crosslink)** +* *Reactants:* Secondary amine (`-NH-`) + Epoxide ring + + + +--- + +### 8. Vinyl and Fluoropolymer Addition Polymerization + +These chain-growth pathways model radical initiation, propagation, and copolymerization of vinyl and fluorinated monomers. + +* **Vinyl Addition Polymerization Initiation** +* *Reactants:* Vinyl double bonds (`-CH=C-`) + + +* **Vinyl Addition Polymerization Propagation** +* *Reactants:* Vinyl monomer + Chain-end radical + + +* **Vinyl Copolymerization** +* *Reactants:* Mixed vinyl monomer systems + + +* **Tetrafluoroethylene Addition Polymerization Initiation** +* *Reactants:* Tetrafluoroethylene (`TFE`) self-initiation + + +* **Tetrafluoroethylene Addition Polymerization Propagation** +* *Reactants:* Tetrafluoroethylene monomer + TFE radical chain-end + + + +--- + +### 9. Polycarbonate Formation + +These reactions build carbonate linkages via condensation or transcarbonation. + +* **Diol and Phosgene Polycondensation (Polycarbonate Formation)** +* *Reactants:* Two `-OH` groups + Phosgene (`COCl₂`) + + +* **Diol and Diphenyl Carbonate Polycondensation (Transcarbonation)** +* *Reactants:* Two `-OH` groups + Diphenyl carbonate + + + +--- + +### 10. Polysiloxane Formation + +These pathways handle hydrolysis of chlorosilanes and condensation of silanols into silicone chains. + +* **Dichlorosilane Hydrolysis to Silanol** +* *Reactants:* Dichlorosilane (`-Si-Cl`) + Water (`H₂O`) + + +* **Silanediol Polycondensation (Polysiloxane Formation)** +* *Reactants:* Silanediols (`-Si-OH`) + - * *Reactants:* `-OH` + `-COX` where `X = Cl, Br, I` +* **Silanediol and Silanediol Copolycondensation (Polysiloxane Formation)** +* *Reactants:* Mixed silanediol systems -* **Hydroxy–Thiol + Di-Carboxylic Acid Halide through Thiol Group** - * *Reactants:* `-SH` + `-COX` where `X = Cl, Br, I` --- -### 6. Polyurethane Formation +### 11. Thiol-Ene Click Polymerization -These reactions form urethane linkages (`-O-CO-NH-`). Unlike most polycondensation reactions, this reaction is a **polyaddition** reaction. +* **Dithiol and Diene Thiol-Ene Click Polymerization** +* *Reactants:* Dithiol (`-SH`) + Diene (`-C=C-`) -* **Diol + Di-Isocyanate Polyaddition** - * *Reactants:* Two `-OH` groups + Two isocyanate groups (`-NCO`) --- -NOTE: If you would like support for a specific reaction, please open an issue on - [AutoREACTER GitHub Repository](https://github.com/NanoCIPHER-Lab/AutoREACTER). +NOTE: If you would like support for a specific reaction, please open an issue on [AutoREACTER GitHub Repository](https://github.com/NanoCIPHER-Lab/AutoREACTER). \ No newline at end of file diff --git a/examples/test.json b/examples/test.json new file mode 100644 index 0000000..54e6bd1 --- /dev/null +++ b/examples/test.json @@ -0,0 +1,19 @@ +{ + "simulation_name": "TFE_Test", + "simulations": [ + { + "tag": "tfe_test", + "temperature": 300, + "density": 1.5, + "monomer_counts": { + "tetrafluoroethylene": 200 + } + } + ], + "monomers": [ + { + "name": "tetrafluoroethylene", + "smiles": "C(=C(F)F)(F)F" + } + ] +} diff --git a/examples/test_epoxy.json b/examples/test_epoxy.json new file mode 100644 index 0000000..5558925 --- /dev/null +++ b/examples/test_epoxy.json @@ -0,0 +1,24 @@ +{ + "simulation_name": "Epoxy_Test_Primary_Diamine_Diepoxy", + "simulations": [ + { + "tag": "epoxy_test", + "temperature": 300, + "density": 1.0, + "monomer_counts": { + "di_epoxy": 200, + "primary_diamine": 100 + } + } + ], + "monomers": [ + { + "name": "di_epoxy", + "smiles": "C1OC1COCCOCC1CO1" + }, + { + "name": "primary_diamine", + "smiles": "NCCCCCN" + } + ] +} \ No newline at end of file diff --git a/examples/test_ethelene.json b/examples/test_ethelene.json new file mode 100644 index 0000000..622948a --- /dev/null +++ b/examples/test_ethelene.json @@ -0,0 +1,20 @@ +{ + "simulation_name": "Ethene_Test", + "loop": 9, + "simulations": [ + { + "tag": "vinyl_test", + "temperature": 300, + "density": 1.0, + "monomer_counts": { + "ethene": 200 + } + } + ], + "monomers": [ + { + "name": "ethene", + "smiles": "C=C" + } + ] +} \ No newline at end of file diff --git a/examples/test_glycine.json b/examples/test_glycine.json new file mode 100644 index 0000000..cb53b3a --- /dev/null +++ b/examples/test_glycine.json @@ -0,0 +1,19 @@ +{ + "simulation_name": "Glycine_Test", + "simulations": [ + { + "tag": "glycine_test", + "temperature": 300, + "density": 1.0, + "monomer_counts": { + "glycine": 200 + } + } + ], + "monomers": [ + { + "name": "glycine", + "smiles": "NCC(=O)O" + } + ] +} \ No newline at end of file diff --git a/examples/test_styrene.json b/examples/test_styrene.json new file mode 100644 index 0000000..6cdcfb7 --- /dev/null +++ b/examples/test_styrene.json @@ -0,0 +1,19 @@ +{ + "simulation_name": "Styrene_Test", + "simulations": [ + { + "tag": "vinyl_test", + "temperature": 300, + "density": 1.0, + "monomer_counts": { + "styrene": 2000 + } + } + ], + "monomers": [ + { + "name": "styrene", + "smiles": "c1ccccc1C=C" + } + ] +} \ No newline at end of file diff --git a/test.ipynb b/test.ipynb new file mode 100644 index 0000000..b37645b --- /dev/null +++ b/test.ipynb @@ -0,0 +1,59 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "7642e888", + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "import json\n", + "from importlib.resources import files\n", + "\n", + "\n", + "\n", + " return reaction_rules" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "8af2ad56", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[{'name': 'epoxy_polymerization', 'if_reactions': 'Amine Epoxy Addition First Stage', 'required_reactions': ['Amine Epoxy Addition Second Stage'], 'fg_additon': {'primary_amine': 'secondary_amine'}}]\n" + ] + } + ], + "source": [ + "action = _add_progessive_chemistries()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "base", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.5" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tests/test_input_parser.py b/tests/test_input_parser.py index f1df078..3c85c00 100644 --- a/tests/test_input_parser.py +++ b/tests/test_input_parser.py @@ -1,364 +1,924 @@ +""" +Comprehensive unit tests for :mod:`AutoREACTER.input_parser`. + +The suite exercises the current counts-mode and ratio-mode schemas, numeric +validation, force-field normalization, SMILES handling, monomer construction, +loop configuration, and the public ``validate_inputs`` workflow. + +Run from the repository root with either: + + python -m unittest tests.test_input_parser -v + +or: + + pytest -q tests/test_input_parser.py +""" + +from __future__ import annotations + +import copy import unittest -#this should import the main class from the code +from unittest.mock import patch + +from rdkit import Chem + from AutoREACTER.input_parser import ( - InputParser, - NumericFieldError, - InputSchemaError, CompatibilityError, DuplicateMonomerError, InputConflictError, - SmilesValidationError + InputParser, + InputSchemaError, + MonomerEntry, + NumericFieldError, + Simulation, + SimulationSetup, + SmilesValidationError, ) -class TestInputParser(unittest.TestCase) : - - def setUp(self): - """Runs automatically before every test to give us a fresh parser.""" - self.parser = InputParser() - #=============== - # Tests for: _validate_temperature - #=============== - - def test_validate_temperature_valid(self): - """Good Ending: A valid positive temp should return as a float.""" - result = self.parser._validate_temperature(300) - self.assertEqual(result, 300.0) - - def test_validate_temperature_negative(self): - """Bad Ending: A negative temp has to raise a NumericFieldError.""" - with self.assertRaises(NumericFieldError): - self.parser._validate_temperature(-150) - def test_validate_temperature_zero(self): - """Bad Ending: Absolute zero has to raise a NumericFieldError.""" - with self.assertRaises(NumericFieldError): - self.parser._validate_temperature(0) +class InputParserTestCase(unittest.TestCase): + """Shared fixtures and helpers for input-parser tests.""" - def test_validate_temperature_wrong_type(self): - """Bad Ending: If a string or boolean is given instead of a number it has to fail.""" - with self.assertRaises(NumericFieldError): - self.parser._validate_temperature("room_temperature") - - #=============== - # Tests for: _validate_density - #=============== - def test_validate_density_valid(self): - """Good Ending: Valid positive density returns as float.""" - result = self.parser._validate_density(0.85) - self.assertEqual(result, 0.85) - - def test_validate_density_negative_or_zero(self): - """Bad Ending: Rejects zero or negative density values.""" - with self.assertRaises(NumericFieldError): - self.parser._validate_density(0) - with self.assertRaises(NumericFieldError): - self.parser._validate_density(-0.5) - - #================ - # Tests for: _validate_force_field - #================ - - def test_validate_force_field_default(self): - """Good Ending: If None then defaults to 'PCFF'.""" - result = self.parser._validate_force_field(None) - self.assertEqual(result, "PCFF") - - def test_validate_force_field_canonical(self): - """Good path: Normalizes force-field capitalization and aliases.""" - result = self.parser._validate_force_field("pcff-iff") - self.assertEqual(result, "PCFF-IFF") - - def test_validate_force_field_unsupported (self): - """Bad Ending: Passing a completely random name should raise an InputSchemaError.""" - with self.assertRaises(InputSchemaError): - self.parser._validate_force_field("NotAForceField") - - def test_validate_force_field_incompatible(self): - """Bad Ending: 'OPLSAA' is recognized but incompatible, so it should raise a CompatibilityError.""" - with self.assertRaises(CompatibilityError): - self.parser._validate_force_field("oplsaa") - - # ========================================== - # Tests for: validate_no_duplicate_smiles - # ========================================== - - def test_validate_no_duplicate_smiles_good_path(self): - """Good Ending: Adding a unique SMILES appends it to the tracker list.""" - tracker_list = ["CCO", "C1=CC(=CC(=C1)N)N"] - new_smiles = "ClC(=O)c1cc(cc(c1)C(Cl)=O)C(Cl)=O" - - result = self.parser.validate_no_duplicate_smiles(new_smiles, tracker_list) - - # Verify the list grew to 3 items and includes this new molecule - self.assertEqual(len(result), 3) - self.assertIn(new_smiles, result) - - def test_validate_no_duplicate_smiles_raises_error(self): - """Bad Ending: Adding an existing SMILES triggers DuplicateMonomerError.""" - tracker_list = ["CCO", "C1=CC(=CC(=C1)N)N"] - duplicate_smiles = "CCO" # Already present! - - with self.assertRaises(DuplicateMonomerError): - self.parser.validate_no_duplicate_smiles(duplicate_smiles, tracker_list) + def setUp(self) -> None: + """Create a fresh parser before every test.""" + self.parser = InputParser() - # ========================================== - # Tests for: _validate_smiles - # ========================================== + @staticmethod + def counts_input() -> dict: + """Return a fresh, valid counts-mode input dictionary.""" + return { + "simulation_name": "counts_example", + "force_field": "pcff", + "loop": False, + "simulations": [ + { + "tag": "small", + "temperature": 300, + "density": 0.80, + "monomer_counts": { + "ethanol": 2, + "water": 1, + }, + }, + { + "tag": "large", + "temperature": 350.0, + "density": 0.95, + "monomer_counts": { + "ethanol": 20, + "water": 10, + }, + }, + ], + "monomers": [ + { + "name": "ethanol", + "smiles": "CCO", + }, + { + "name": "water", + "smiles": "O", + }, + ], + } - def test_validate_smiles_valid(self): - """Good Ending: A valid SMILES should return canonical SMILES and an RDKit Mol.""" - smiles, mol = self.parser._validate_smiles("CCO") + @staticmethod + def ratio_input() -> dict: + """Return a fresh, valid ratio-mode input dictionary.""" + return { + "simulation_name": "ratio_example", + "force_field": "PCFF-IFF", + "simulations": [ + { + "tag": "small", + "temperature": 300, + "density": 0.80, + "total_atoms": 1000, + "monomer_ratios": { + "ethanol": 2.0, + "water": 1.0, + }, + }, + { + "tag": "large", + "temperature": 350, + "density": 0.95, + "total_atoms": 10000, + "monomer_ratios": { + "ethanol": 2.0, + "water": 1.0, + }, + }, + ], + "monomers": [ + { + "name": "ethanol", + "smiles": "CCO", + }, + { + "name": "water", + "smiles": "O", + }, + ], + } - self.assertEqual(smiles, "CCO") - self.assertIsNotNone(mol) - def test_validate_smiles_empty_raises_error(self): - """Bad Ending: Empty SMILES should raise SmilesValidationError.""" - with self.assertRaises(SmilesValidationError): - self.parser._validate_smiles("") +class TestBasicFormat(InputParserTestCase): + """Tests for top-level input structure validation.""" - def test_validate_smiles_invalid_raises_error(self): - """Bad Ending: Invalid SMILES should raise SmilesValidationError.""" - with self.assertRaises(SmilesValidationError): - self.parser._validate_smiles("not_a_smiles") - - # ========================================== - # Tests for: validate_basic_format - # ========================================== - - def test_validate_basic_format_valid(self): - """Good Ending: A minimal valid top-level input should pass basic format validation.""" + def test_validate_basic_format_accepts_required_keys(self) -> None: inputs = { - "simulation_name": "test_sim", + "simulation_name": "demo", "simulations": [], "monomers": [], } self.assertIsNone(self.parser.validate_basic_format(inputs)) - def test_validate_basic_format_missing_key(self): - """Bad Ending: Missing required top-level keys should raise InputSchemaError.""" - inputs = { - "simulation_name": "test_sim", + def test_validate_basic_format_rejects_non_dictionary(self) -> None: + for value in (None, [], "input", 12): + with self.subTest(value=value): + with self.assertRaises(InputSchemaError): + self.parser.validate_basic_format(value) + + def test_validate_basic_format_rejects_each_missing_key(self) -> None: + valid = { + "simulation_name": "demo", "simulations": [], + "monomers": [], } - with self.assertRaises(InputSchemaError): - self.parser.validate_basic_format(inputs) + for missing_key in tuple(valid): + with self.subTest(missing_key=missing_key): + inputs = valid.copy() + inputs.pop(missing_key) - def test_validate_basic_format_wrong_type(self): - """Bad Ending: Non-dictionary input should raise InputSchemaError.""" - with self.assertRaises(InputSchemaError): - self.parser.validate_basic_format(["not", "a", "dict"]) + with self.assertRaises(InputSchemaError): + self.parser.validate_basic_format(inputs) - # ========================================== - # Tests for: _get_inputs_mode - # ========================================== - def test_get_inputs_mode_counts(self): - """Good Ending: Simulations using monomer_counts should be detected as counts mode.""" +class TestCompositionModeDetection(InputParserTestCase): + """Tests for counts/ratio mode inference.""" + + def test_get_inputs_mode_detects_counts(self) -> None: simulations = [ { - "tag": "10k", - "temperature": 300, - "density": 0.8, - "monomer_counts": {"tmc": 1}, + "tag": "a", + "monomer_counts": {"ethanol": 1}, } ] - result = self.parser._get_inputs_mode(simulations) - - self.assertEqual(result, "counts") + self.assertEqual(self.parser._get_inputs_mode(simulations), "counts") - def test_get_inputs_mode_ratio(self): - """Good Ending: Simulations using monomer_ratios should be detected as ratio mode.""" + def test_get_inputs_mode_detects_ratio(self) -> None: simulations = [ { - "tag": "10k", - "temperature": 300, - "density": 0.8, - "total_atoms": 10000, - "monomer_ratios": {"tmc": 1.0}, + "tag": "a", + "monomer_ratios": {"ethanol": 1.0}, } ] - result = self.parser._get_inputs_mode(simulations) + self.assertEqual(self.parser._get_inputs_mode(simulations), "ratio") - self.assertEqual(result, "ratio") + def test_get_inputs_mode_rejects_empty_or_non_list_value(self) -> None: + for value in (None, {}, (), [], "simulations"): + with self.subTest(value=value): + with self.assertRaises(InputSchemaError): + self.parser._get_inputs_mode(value) - def test_get_inputs_mode_mixed_modes_raises_error(self): - """Bad Ending: Mixing counts and ratio modes should raise InputConflictError.""" - simulations = [ - { - "tag": "counts_system", - "temperature": 300, - "density": 0.8, - "monomer_counts": {"tmc": 1}, - }, - { - "tag": "ratio_system", - "temperature": 300, - "density": 0.8, - "total_atoms": 10000, - "monomer_ratios": {"tmc": 1.0}, - }, - ] + def test_get_inputs_mode_rejects_non_dictionary_simulation(self) -> None: + with self.assertRaises(InputSchemaError): + self.parser._get_inputs_mode(["not-a-dictionary"]) + def test_get_inputs_mode_rejects_missing_composition_field(self) -> None: + with self.assertRaises(InputSchemaError): + self.parser._get_inputs_mode( + [ + { + "tag": "a", + "temperature": 300, + "density": 0.8, + } + ] + ) + + def test_get_inputs_mode_rejects_both_fields_in_one_simulation(self) -> None: with self.assertRaises(InputConflictError): - self.parser._get_inputs_mode(simulations) + self.parser._get_inputs_mode( + [ + { + "tag": "a", + "monomer_counts": {"ethanol": 1}, + "monomer_ratios": {"ethanol": 1.0}, + } + ] + ) + + def test_get_inputs_mode_rejects_mixed_modes(self) -> None: + with self.assertRaises(InputConflictError): + self.parser._get_inputs_mode( + [ + { + "tag": "counts", + "monomer_counts": {"ethanol": 1}, + }, + { + "tag": "ratio", + "monomer_ratios": {"ethanol": 1.0}, + }, + ] + ) + + +class TestNumericValidation(InputParserTestCase): + """Tests for temperature and density helpers.""" + + def test_validate_temperature_returns_float(self) -> None: + self.assertEqual(self.parser._validate_temperature(300), 300.0) + self.assertEqual(self.parser._validate_temperature(300.5), 300.5) + + def test_validate_temperature_rejects_non_numeric_and_boolean(self) -> None: + for value in (None, "300", True, False, [], {}): + with self.subTest(value=value): + with self.assertRaises(NumericFieldError): + self.parser._validate_temperature(value) + + def test_validate_temperature_rejects_zero_and_negative_values(self) -> None: + for value in (0, -1, -273.15): + with self.subTest(value=value): + with self.assertRaises(NumericFieldError): + self.parser._validate_temperature(value) + + def test_validate_density_returns_float(self) -> None: + self.assertEqual(self.parser._validate_density(1), 1.0) + self.assertEqual(self.parser._validate_density(0.85), 0.85) + + def test_validate_density_rejects_non_numeric_and_boolean(self) -> None: + for value in (None, "0.85", True, False, [], {}): + with self.subTest(value=value): + with self.assertRaises(NumericFieldError): + self.parser._validate_density(value) + + def test_validate_density_rejects_zero_and_negative_values(self) -> None: + for value in (0, -0.1, -1): + with self.subTest(value=value): + with self.assertRaises(NumericFieldError): + self.parser._validate_density(value) + + +class TestForceFieldValidation(InputParserTestCase): + """Tests for force-field defaults, aliases, and compatibility.""" + + def test_validate_force_field_defaults_to_pcff(self) -> None: + self.assertEqual(self.parser._validate_force_field(None), "PCFF") + + def test_validate_force_field_normalizes_supported_aliases(self) -> None: + aliases = { + "pcff": "PCFF", + " PCFF-IFF ": "PCFF-IFF", + "cvff": "CVFF", + "cvff-iff": "CVFF-IFF", + "clayff": "Clay-FF", + "clay-ff": "Clay-FF", + "dreiding": "DREIDING", + "drieding": "DREIDING", + } - def test_get_inputs_mode_both_counts_and_ratios_raises_error(self): - """Bad Ending: One simulation cannot contain both counts and ratios.""" - simulations = [ - { - "tag": "bad_system", - "temperature": 300, - "density": 0.8, - "monomer_counts": {"tmc": 1}, - "monomer_ratios": {"tmc": 1.0}, - } - ] + for raw_value, expected in aliases.items(): + with self.subTest(raw_value=raw_value): + self.assertEqual( + self.parser._validate_force_field(raw_value), + expected, + ) - with self.assertRaises(InputConflictError): - self.parser._get_inputs_mode(simulations) + def test_validate_force_field_rejects_empty_or_non_string_value(self) -> None: + for value in ("", " ", 12, True, [], {}): + with self.subTest(value=value): + with self.assertRaises(InputSchemaError): + self.parser._validate_force_field(value) - # ========================================== - # Tests for: validate_inputs - # ========================================== + def test_validate_force_field_rejects_unknown_name(self) -> None: + with self.assertRaises(InputSchemaError): + self.parser._validate_force_field("not-a-force-field") - def test_validate_inputs_counts_mode_valid(self): - """Good Ending: Full valid counts-mode input should produce a SimulationSetup object.""" - inputs = { - "simulation_name": "test_counts", - "simulations": [ - { - "tag": "10k", - "temperature": 300, - "density": 0.8, - "monomer_counts": { - "tmc": 1, - "mpd": 1, - }, - } - ], - "monomers": [ + def test_validate_force_field_rejects_currently_incompatible_fields(self) -> None: + for value in ("oplsaa", "opls", "opls-aa", "gaff"): + with self.subTest(value=value): + with self.assertRaises(CompatibilityError): + self.parser._validate_force_field(value) + + +class TestSmilesValidation(InputParserTestCase): + """Tests for SMILES parsing, canonicalization, and duplicates.""" + + def test_validate_smiles_returns_canonical_smiles_and_molecule(self) -> None: + smiles, mol = self.parser._validate_smiles(" C(C)O ") + + self.assertEqual(smiles, "CCO") + self.assertIsInstance(mol, Chem.Mol) + + def test_validate_smiles_rejects_empty_or_non_string_values(self) -> None: + for value in (None, "", " ", 10, True, [], {}): + with self.subTest(value=value): + with self.assertRaises(SmilesValidationError): + self.parser._validate_smiles(value) + + def test_validate_smiles_rejects_invalid_smiles(self) -> None: + with self.assertRaises(SmilesValidationError): + self.parser._validate_smiles("not_a_smiles") + + def test_validate_no_duplicate_smiles_appends_unique_value(self) -> None: + seen = ["O"] + + result = self.parser.validate_no_duplicate_smiles("CCO", seen) + + self.assertIs(result, seen) + self.assertEqual(result, ["O", "CCO"]) + + def test_validate_no_duplicate_smiles_rejects_duplicate_value(self) -> None: + with self.assertRaises(DuplicateMonomerError): + self.parser.validate_no_duplicate_smiles("CCO", ["CCO"]) + + +class TestMoleculeProperties(InputParserTestCase): + """Tests for RDKit-derived monomer properties.""" + + def test_derive_molecule_properties_includes_hydrogen_atoms(self) -> None: + mol = Chem.MolFromSmiles("CCO") + self.assertIsNotNone(mol) + + num_atoms, molecular_weight = self.parser._derive_molecule_properties( + mol + ) + + self.assertEqual(num_atoms, 9) + self.assertAlmostEqual(molecular_weight, 46.069, places=2) + + def test_int_to_dict_retains_legacy_shape(self) -> None: + self.assertEqual(self.parser._int_to_dict(7), {"_": 7}) + + +class TestLegacyCompositionValidation(InputParserTestCase): + """Tests for the legacy composition validation helper.""" + + def test_validate_composition_accepts_counts_targets(self) -> None: + composition = { + "targets": [ + {"tag": "a"}, + {"tag": "b"}, + ] + } + + self.assertIs( + self.parser._validate_composition(composition, "counts"), + composition, + ) + + def test_validate_composition_accepts_ratio_total_atoms(self) -> None: + composition = { + "targets": [ + {"tag": "a", "total_atoms": 1000}, + {"tag": "b", "total_atoms": 2000}, + ] + } + + self.assertIs( + self.parser._validate_composition(composition, "ratio"), + composition, + ) + + def test_validate_composition_rejects_missing_or_empty_targets(self) -> None: + for composition in ({}, {"targets": None}, {"targets": []}): + with self.subTest(composition=composition): + with self.assertRaises(InputSchemaError): + self.parser._validate_composition( + composition, + "counts", + ) + + def test_validate_composition_rejects_duplicate_tags(self) -> None: + with self.assertRaises(InputSchemaError): + self.parser._validate_composition( { - "name": "tmc", - "smiles": "ClC(=O)c1cc(cc(c1)C(Cl)=O)C(Cl)=O", + "targets": [ + {"tag": "same"}, + {"tag": "same"}, + ] }, + "counts", + ) + + def test_validate_composition_rejects_invalid_ratio_total_atoms(self) -> None: + for value in (None, 0, -1, 1.5, True): + with self.subTest(value=value): + with self.assertRaises(NumericFieldError): + self.parser._validate_composition( + { + "targets": [ + { + "tag": "a", + "total_atoms": value, + } + ] + }, + "ratio", + ) + + def test_validate_composition_rejects_total_atoms_in_counts_mode(self) -> None: + with self.assertRaises(InputSchemaError): + self.parser._validate_composition( { - "name": "mpd", - "smiles": "C1=CC(=CC(=C1)N)N", + "targets": [ + { + "tag": "a", + "total_atoms": 1000, + } + ] }, - ], - } + "counts", + ) + + +class TestSimulationValidation(InputParserTestCase): + """Tests for individual and grouped simulation validation.""" + + def test_validate_single_simulation_accepts_counts_mode(self) -> None: + simulation = Simulation( + tag="a", + temperature=300.0, + density=0.8, + monomer_counts={"ethanol": 0}, + ) + + self.assertIsNone( + self.parser._validate_single_simulation(simulation, "counts") + ) + + def test_validate_single_simulation_accepts_ratio_mode(self) -> None: + simulation = Simulation( + tag="a", + temperature=300.0, + density=0.8, + monomer_ratios={"ethanol": 0.0}, + total_atoms=1000, + ) + + self.assertIsNone( + self.parser._validate_single_simulation(simulation, "ratio") + ) + + def test_validate_single_simulation_rejects_invalid_common_fields(self) -> None: + cases = ( + Simulation( + tag="", + temperature=300.0, + density=0.8, + monomer_counts={"ethanol": 1}, + ), + Simulation( + tag="a", + temperature=0, + density=0.8, + monomer_counts={"ethanol": 1}, + ), + Simulation( + tag="a", + temperature=300.0, + density=0, + monomer_counts={"ethanol": 1}, + ), + ) + + expected_errors = ( + InputSchemaError, + NumericFieldError, + NumericFieldError, + ) + + for simulation, expected_error in zip(cases, expected_errors): + with self.subTest(simulation=simulation): + with self.assertRaises(expected_error): + self.parser._validate_single_simulation( + simulation, + "counts", + ) + + def test_validate_single_simulation_rejects_invalid_count(self) -> None: + simulation = Simulation( + tag="a", + temperature=300.0, + density=0.8, + monomer_counts={"ethanol": -1}, + ) - result = self.parser.validate_inputs(inputs) + with self.assertRaises(NumericFieldError): + self.parser._validate_single_simulation(simulation, "counts") - self.assertEqual(result.simulation_name, "test_counts") - self.assertEqual(result.composition_method, "counts") - self.assertEqual(result.force_field, "PCFF") - self.assertEqual(len(result.monomers), 2) - self.assertEqual(len(result.simulations), 1) + def test_validate_single_simulation_rejects_invalid_ratio(self) -> None: + simulation = Simulation( + tag="a", + temperature=300.0, + density=0.8, + monomer_ratios={"ethanol": -0.1}, + total_atoms=1000, + ) + + with self.assertRaises(NumericFieldError): + self.parser._validate_single_simulation(simulation, "ratio") + + def test_validate_simulations_normalizes_counts_mode(self) -> None: + systems = copy.deepcopy(self.counts_input()["simulations"]) + + result = self.parser._validate_simulations(systems, "counts") + + self.assertEqual(result["method"], "counts") + self.assertEqual(result["temperatures"], [300.0, 350.0]) + self.assertEqual(result["density"], [0.8, 0.95]) + self.assertEqual(len(result["simulations"]), 2) + self.assertTrue( + all( + isinstance(simulation, Simulation) + for simulation in result["simulations"] + ) + ) + + def test_validate_simulations_normalizes_ratio_mode(self) -> None: + systems = copy.deepcopy(self.ratio_input()["simulations"]) + + result = self.parser._validate_simulations(systems, "ratio") + + self.assertEqual(result["method"], "ratio") + self.assertEqual(result["temperatures"], [300.0, 350.0]) + self.assertEqual(result["density"], [0.8, 0.95]) + self.assertEqual( + [simulation.total_atoms for simulation in result["simulations"]], + [1000, 10000], + ) + + def test_validate_simulations_rejects_duplicate_tags(self) -> None: + systems = copy.deepcopy(self.counts_input()["simulations"]) + systems[1]["tag"] = systems[0]["tag"] + + with self.assertRaises(InputSchemaError): + self.parser._validate_simulations(systems, "counts") + + def test_validate_simulations_rejects_total_atoms_in_counts_mode(self) -> None: + systems = copy.deepcopy(self.counts_input()["simulations"]) + systems[0]["total_atoms"] = 1000 + + with self.assertRaises(InputSchemaError): + self.parser._validate_simulations(systems, "counts") + + def test_validate_simulations_rejects_different_ratios_between_systems( + self, + ) -> None: + systems = copy.deepcopy(self.ratio_input()["simulations"]) + systems[1]["monomer_ratios"]["water"] = 2.0 + + with self.assertRaises(InputSchemaError): + self.parser._validate_simulations(systems, "ratio") + + +class TestSystemMonomerKeys(InputParserTestCase): + """Tests for agreement between monomer definitions and systems.""" - def test_validate_inputs_ratio_mode_valid(self): - """Good Ending: Full valid ratio-mode input should produce a SimulationSetup object.""" + def test_validate_system_monomer_keys_accepts_exact_names(self) -> None: + inputs = self.counts_input() + systems = inputs["simulations"] + + self.assertIsNone( + self.parser._validate_system_monomer_keys( + inputs, + systems, + "counts", + ) + ) + + def test_validate_system_monomer_keys_assigns_default_name(self) -> None: inputs = { - "simulation_name": "test_ratio", - "simulations": [ - { - "tag": "10k", - "temperature": 300, - "density": 0.8, - "total_atoms": 10000, - "monomer_ratios": { - "tmc": 1.0, - "mpd": 1.0, - }, - } - ], "monomers": [ { - "name": "tmc", - "smiles": "ClC(=O)c1cc(cc(c1)C(Cl)=O)C(Cl)=O", - }, - { - "name": "mpd", - "smiles": "C1=CC(=CC(=C1)N)N", + "smiles": "CCO", + } + ] + } + systems = [ + { + "tag": "a", + "monomer_counts": { + "data_1": 1, }, - ], + } + ] + + self.parser._validate_system_monomer_keys( + inputs, + systems, + "counts", + ) + + self.assertEqual(inputs["monomers"][0]["name"], "data_1") + + def test_validate_system_monomer_keys_rejects_unknown_name(self) -> None: + inputs = self.counts_input() + systems = copy.deepcopy(inputs["simulations"]) + systems[0]["monomer_counts"]["unknown"] = 1 + + with self.assertRaises(InputSchemaError): + self.parser._validate_system_monomer_keys( + inputs, + systems, + "counts", + ) + + def test_validate_system_monomer_keys_rejects_missing_name(self) -> None: + inputs = self.counts_input() + systems = copy.deepcopy(inputs["simulations"]) + systems[0]["monomer_counts"].pop("water") + + with self.assertRaises(InputSchemaError): + self.parser._validate_system_monomer_keys( + inputs, + systems, + "counts", + ) + + +class TestMonomerEntryValidation(InputParserTestCase): + """Tests for construction of MonomerEntry objects.""" + + def test_validate_monomer_entry_builds_counts_map(self) -> None: + inputs = self.counts_input() + systems = copy.deepcopy(inputs["simulations"]) + + monomers = self.parser._validate_monomer_entry( + inputs, + "counts", + systems, + ) + + self.assertEqual(len(monomers), 2) + self.assertTrue( + all(isinstance(monomer, MonomerEntry) for monomer in monomers) + ) + self.assertEqual( + monomers[0].count, + { + "small": 2, + "large": 20, + }, + ) + self.assertIsNone(monomers[0].ratio) + self.assertEqual(monomers[0].smiles, "CCO") + self.assertIsInstance(monomers[0].rdkit_mol, Chem.Mol) + self.assertGreater(monomers[0].num_atoms, 0) + self.assertGreater(monomers[0].molecular_weight, 0.0) + + def test_validate_monomer_entry_builds_ratio_value(self) -> None: + inputs = self.ratio_input() + systems = copy.deepcopy(inputs["simulations"]) + + monomers = self.parser._validate_monomer_entry( + inputs, + "ratio", + systems, + ) + + self.assertEqual(monomers[0].ratio, 2.0) + self.assertIsNone(monomers[0].count) + + def test_validate_monomer_entry_rejects_non_list_monomers(self) -> None: + inputs = self.counts_input() + inputs["monomers"] = {} + + with self.assertRaises(InputSchemaError): + self.parser._validate_monomer_entry( + inputs, + "counts", + inputs["simulations"], + ) + + def test_validate_monomer_entry_rejects_non_dictionary_entry(self) -> None: + inputs = self.counts_input() + inputs["monomers"] = ["CCO"] + + with self.assertRaises(InputSchemaError): + self.parser._validate_monomer_entry( + inputs, + "counts", + inputs["simulations"], + ) + + def test_validate_monomer_entry_rejects_negative_count(self) -> None: + inputs = self.counts_input() + inputs["simulations"][0]["monomer_counts"]["ethanol"] = -1 + + with self.assertRaises(NumericFieldError): + self.parser._validate_monomer_entry( + inputs, + "counts", + inputs["simulations"], + ) + + def test_validate_monomer_entry_rejects_negative_ratio(self) -> None: + inputs = self.ratio_input() + inputs["simulations"][0]["monomer_ratios"]["ethanol"] = -1.0 + + with self.assertRaises(NumericFieldError): + self.parser._validate_monomer_entry( + inputs, + "ratio", + inputs["simulations"], + ) + + +class TestLegacyNumericFields(InputParserTestCase): + """Tests for the retained legacy numeric validator.""" + + def test_validate_numeric_fields_accepts_valid_values(self) -> None: + inputs = { + "density": 0.8, + "temperature": [300, 350.0], + "number_of_monomers": { + "data_1": 1, + "data_2": 2, + }, + } + + self.assertIsNone(self.parser._validate_numeric_fields(inputs)) + + def test_validate_numeric_fields_rejects_invalid_density(self) -> None: + inputs = { + "density": 0, + "temperature": 300, + "number_of_monomers": {"data_1": 1}, + } + + with self.assertRaises(NumericFieldError): + self.parser._validate_numeric_fields(inputs) + + def test_validate_numeric_fields_rejects_invalid_temperature(self) -> None: + inputs = { + "density": 0.8, + "temperature": [300, False], + "number_of_monomers": {"data_1": 1}, + } + + with self.assertRaises(NumericFieldError): + self.parser._validate_numeric_fields(inputs) + + def test_validate_numeric_fields_rejects_invalid_monomer_count(self) -> None: + inputs = { + "density": 0.8, + "temperature": 300, + "number_of_monomers": {"data_1": 0}, } + with self.assertRaises(NumericFieldError): + self.parser._validate_numeric_fields(inputs) + + +class TestLoopValidation(InputParserTestCase): + """Tests for loop configuration.""" + + def test_validate_loop_defaults_to_enabled(self) -> None: + self.assertEqual(self.parser._validate_loop({}), (True, None)) + + def test_validate_loop_accepts_booleans(self) -> None: + self.assertEqual( + self.parser._validate_loop({"loop": True}), + (True, None), + ) + self.assertEqual( + self.parser._validate_loop({"loop": False}), + (False, None), + ) + + def test_validate_loop_accepts_positive_integer( + self, + ) -> None: + self.assertEqual( + self.parser._validate_loop({"loop": 4}), + (True, 4), + ) + + def test_validate_loop_accepts_supported_keywords(self) -> None: + for keyword in ("loop", "repeat", "iterations", "do_loop"): + with self.subTest(keyword=keyword): + self.assertEqual( + self.parser._validate_loop({"loop": keyword}), + (True, None), + ) + + def test_validate_loop_rejects_invalid_values(self) -> None: + for value in (0, -1, 1.5, None, "yes", [], {}): + with self.subTest(value=value): + with self.assertRaises(InputSchemaError): + self.parser._validate_loop({"loop": value}) + + +class TestValidateInputsWorkflow(InputParserTestCase): + """End-to-end tests for the public validation entry point.""" + + def test_validate_inputs_builds_counts_setup(self) -> None: + inputs = self.counts_input() + result = self.parser.validate_inputs(inputs) - self.assertEqual(result.simulation_name, "test_ratio") - self.assertEqual(result.composition_method, "ratio") + self.assertIsInstance(result, SimulationSetup) + self.assertEqual(result.simulation_name, "counts_example") + self.assertEqual(result.composition_method, "counts") self.assertEqual(result.force_field, "PCFF") + self.assertFalse(result.loop) + self.assertIsNone(result.max_loop_count) + self.assertEqual(result.temperature, [300.0, 350.0]) + self.assertEqual(result.density, [0.8, 0.95]) self.assertEqual(len(result.monomers), 2) - self.assertEqual(len(result.simulations), 1) + self.assertEqual(len(result.simulations), 2) + self.assertEqual( + result.monomers[0].count, + { + "small": 2, + "large": 20, + }, + ) - def test_validate_inputs_unknown_monomer_in_system_raises_error(self): - """Bad Ending: System composition cannot reference undefined monomers.""" + def test_validate_inputs_builds_ratio_setup(self) -> None: + inputs = self.ratio_input() + + result = self.parser.validate_inputs(inputs) + + self.assertIsInstance(result, SimulationSetup) + self.assertEqual(result.simulation_name, "ratio_example") + self.assertEqual(result.composition_method, "ratio") + self.assertEqual(result.force_field, "PCFF-IFF") + self.assertTrue(result.loop) + self.assertEqual( + [simulation.total_atoms for simulation in result.simulations], + [1000, 10000], + ) + self.assertEqual(result.monomers[0].ratio, 2.0) + self.assertIsNone(result.monomers[0].count) + + @patch("AutoREACTER.input_parser.time.sleep") + def test_validate_inputs_stores_integer_loop_limit( + self, + sleep_mock, + ) -> None: + inputs = self.counts_input() + inputs["loop"] = 10 # Triggers the warning and the sleep(5) + + result = self.parser.validate_inputs(inputs) + + self.assertTrue(result.loop) + self.assertEqual(result.max_loop_count, 10) # Matches the new input + sleep_mock.assert_called_once_with(5) + + def test_validate_inputs_assigns_default_monomer_name(self) -> None: inputs = { - "simulation_name": "bad_unknown_monomer", + "simulation_name": "default_name", "simulations": [ { - "tag": "10k", + "tag": "a", "temperature": 300, "density": 0.8, "monomer_counts": { - "tmc": 1, - "unknown": 1, + "data_1": 1, }, } ], "monomers": [ { - "name": "tmc", - "smiles": "ClC(=O)c1cc(cc(c1)C(Cl)=O)C(Cl)=O", - }, + "smiles": "CCO", + } ], } + result = self.parser.validate_inputs(inputs) + + self.assertEqual(result.monomers[0].name, "data_1") + self.assertEqual(result.monomers[0].data_id, "data_1") + + def test_validate_inputs_rejects_unknown_system_monomer(self) -> None: + inputs = self.counts_input() + inputs["simulations"][0]["monomer_counts"]["unknown"] = 1 + with self.assertRaises(InputSchemaError): self.parser.validate_inputs(inputs) - def test_validate_inputs_missing_monomer_in_system_raises_error(self): - """Bad Ending: Every defined monomer must appear in each system composition.""" - inputs = { - "simulation_name": "bad_missing_monomer", - "simulations": [ - { - "tag": "10k", - "temperature": 300, - "density": 0.8, - "monomer_counts": { - "tmc": 1, - }, - } - ], - "monomers": [ - { - "name": "tmc", - "smiles": "ClC(=O)c1cc(cc(c1)C(Cl)=O)C(Cl)=O", - }, - { - "name": "mpd", - "smiles": "C1=CC(=CC(=C1)N)N", - }, - ], - } + def test_validate_inputs_rejects_missing_system_monomer(self) -> None: + inputs = self.counts_input() + inputs["simulations"][0]["monomer_counts"].pop("water") with self.assertRaises(InputSchemaError): self.parser.validate_inputs(inputs) - def test_validate_inputs_duplicate_monomer_smiles_raises_error(self): - """Bad Ending: Duplicate monomer SMILES should raise DuplicateMonomerError.""" + def test_validate_inputs_rejects_canonically_duplicate_smiles(self) -> None: inputs = { - "simulation_name": "bad_duplicate_smiles", + "simulation_name": "duplicates", "simulations": [ { - "tag": "10k", + "tag": "a", "temperature": 300, "density": 0.8, "monomer_counts": { @@ -374,7 +934,7 @@ def test_validate_inputs_duplicate_monomer_smiles_raises_error(self): }, { "name": "ethanol_b", - "smiles": "CCO", + "smiles": "C(C)O", }, ], } @@ -383,5 +943,44 @@ def test_validate_inputs_duplicate_monomer_smiles_raises_error(self): self.parser.validate_inputs(inputs) +class TestMoleculeRepresentation(InputParserTestCase): + """Tests for initial-molecule extraction and image-grid integration.""" + + def test_molecule_representation_returns_molecules_and_legends(self) -> None: + setup = self.parser.validate_inputs(self.counts_input()) + + molecules, legends = ( + self.parser.molecule_representation_of_initial_molecules(setup) + ) + + self.assertEqual(len(molecules), 2) + self.assertTrue(all(isinstance(mol, Chem.Mol) for mol in molecules)) + self.assertEqual(legends, ["ethanol", "water"]) + + @patch("AutoREACTER.input_parser.Draw.MolsToGridImage") + def test_initial_molecules_image_grid_delegates_to_rdkit( + self, + grid_mock, + ) -> None: + setup = self.parser.validate_inputs(self.counts_input()) + + class DummySession: + inputs = setup + + expected_image = object() + grid_mock.return_value = expected_image + + result = self.parser.initial_molecules_image_grid(DummySession()) + + self.assertIs(result, expected_image) + grid_mock.assert_called_once() + + args, kwargs = grid_mock.call_args + self.assertEqual(len(args[0]), 2) + self.assertEqual(kwargs["molsPerRow"], 3) + self.assertEqual(kwargs["subImgSize"], (400, 400)) + self.assertEqual(kwargs["legends"], ["ethanol", "water"]) + + if __name__ == "__main__": - unittest.main() + unittest.main(verbosity=2) \ No newline at end of file