From be64abc783b5997ee7cca80a4097a9e5f2b708d2 Mon Sep 17 00:00:00 2001 From: Janitha Mahanthe <119646255+janitha-mahanthe@users.noreply.github.com> Date: Sat, 27 Jun 2026 14:11:32 -0600 Subject: [PATCH 001/104] Add reaction progression and detector stubs Introduces scaffold code for upcoming reaction workflow features: a new `ReactionProgression` processor module with a configurable `MAX_LOOP` constant and placeholder `reaction_progression` method, plus an `index_based_functional_groups_detector` placeholder in `FunctionalGroupsDetector`. These changes establish integration points without altering current behavior yet. --- AutoREACTER/detectors/functional_groups_detector.py | 5 +++++ .../reaction_processor/reaction_progression.py | 11 +++++++++++ 2 files changed, 16 insertions(+) create mode 100644 AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py diff --git a/AutoREACTER/detectors/functional_groups_detector.py b/AutoREACTER/detectors/functional_groups_detector.py index c9bed73..5aceca4 100644 --- a/AutoREACTER/detectors/functional_groups_detector.py +++ b/AutoREACTER/detectors/functional_groups_detector.py @@ -364,6 +364,11 @@ def functional_groups_detector( session.monomer_roles = monomer_roles return None # Return session with updated monomer_roles; visualization handled separately. + def index_based_functional_groups_detector( + self, session: "Session", + ) -> None: + pass + def _functional_groups_detector_for_visualization( self, session: Session 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..8042227 --- /dev/null +++ b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py @@ -0,0 +1,11 @@ +MAX_LOOP = 5 # Maximum number of iterations for the reaction progression loop. Users should be able to adjust this value based on their specific needs and the complexity of the reactions being analyzed. +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from AutoREACTER.session import Session + + + + +class ReactionProgression: + def reaction_progression(self, session: "Session", max_loop: int = MAX_LOOP) -> None: + pass # Placeholder for the reaction progression logic. This method will be implemented to handle the progression of reactions based on the session data and the specified maximum loop iterations. \ No newline at end of file From 65d16df342a3789e7891719cf15bfd8dd55392d8 Mon Sep 17 00:00:00 2001 From: Janitha Mahanthe <119646255+janitha-mahanthe@users.noreply.github.com> Date: Sun, 28 Jun 2026 12:07:59 -0600 Subject: [PATCH 002/104] Add PoolSpecies model and mol population helper Introduce a new `species_pool.py` module with a `PoolSpecies` dataclass to represent reaction pool entries, including monomer flags, SMILES, RDKit molecule objects, and template atom indices. Add `_populate_mols(pool)` to build RDKit molecules from monomer SMILES and initialize `template_idxes` from atom indices, centralizing species preparation logic for downstream reaction processing. --- .../reaction_processor/species_pool.py | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 AutoREACTER/reaction_preparation/reaction_processor/species_pool.py diff --git a/AutoREACTER/reaction_preparation/reaction_processor/species_pool.py b/AutoREACTER/reaction_preparation/reaction_processor/species_pool.py new file mode 100644 index 0000000..623be99 --- /dev/null +++ b/AutoREACTER/reaction_preparation/reaction_processor/species_pool.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from rdkit import Chem +from typing import Optional + +@dataclass(slots=True) +class PoolSpecies: + """ + Represents a species in the reaction pool. + """ + species_id: str + is_monomer: bool + smiles: Optional[str] = None + mol: Optional[Chem.Mol] = None + template_idxes: Optional[list[int]] = None + + +def _populate_mols(pool: list[PoolSpecies]) -> list[PoolSpecies]: + """ + Populate the `mol` attribute of each monomer species in the pool based on its SMILES string. + Args: + pool (list[PoolSpecies]): The list of species in the pool. + + Returns: + list[PoolSpecies]: The updated list of species with populated `mol` attributes. + """ + for species in pool: + if species.smiles and species.mol is None and species.is_monomer: + species.mol = Chem.MolFromSmiles(species.smiles) + atoms = [] + for atom in species.mol.GetAtoms(): + atoms.append(atom.GetIdx()) + species.template_idxes = atoms + + + + return pool From a087fd226c7bd70650f6f38520346eaaa83957bc Mon Sep 17 00:00:00 2001 From: Janitha Mahanthe <119646255+janitha-mahanthe@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:26:40 -0600 Subject: [PATCH 003/104] Document PoolSpecies fields and mol population Expand `PoolSpecies` docstring to list its key attributes, and clarify `_populate_mols` documentation to note that it fills both `mol` and `template_idxes` for monomer species derived from SMILES. --- .../reaction_processor/species_pool.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/AutoREACTER/reaction_preparation/reaction_processor/species_pool.py b/AutoREACTER/reaction_preparation/reaction_processor/species_pool.py index 623be99..11768f2 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/species_pool.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/species_pool.py @@ -8,6 +8,12 @@ class PoolSpecies: """ Represents a species in the reaction pool. + Attributes: + species_id (str): The unique identifier of the species. + is_monomer (bool): Indicates whether the species is a monomer. + smiles (Optional[str]): The SMILES representation of the species. + mol (Optional[Chem.Mol]): The RDKit Mol object of the species. + template_idxes (Optional[list[int]]): The list of atom indices used as a template. """ species_id: str is_monomer: bool @@ -18,12 +24,12 @@ class PoolSpecies: def _populate_mols(pool: list[PoolSpecies]) -> list[PoolSpecies]: """ - Populate the `mol` attribute of each monomer species in the pool based on its SMILES string. + Populate the `mol` and `template_idxes` attributes of each monomer species in the pool based on its SMILES string. Args: pool (list[PoolSpecies]): The list of species in the pool. Returns: - list[PoolSpecies]: The updated list of species with populated `mol` attributes. + list[PoolSpecies]: The updated list of species with populated `mol` and `template_idxes` attributes. """ for species in pool: if species.smiles and species.mol is None and species.is_monomer: @@ -32,7 +38,4 @@ def _populate_mols(pool: list[PoolSpecies]) -> list[PoolSpecies]: for atom in species.mol.GetAtoms(): atoms.append(atom.GetIdx()) species.template_idxes = atoms - - - return pool From 2f6d8423eaf295945cd7a8633b7838b2475265bf Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:01:23 -0400 Subject: [PATCH 004/104] Add index-based functional group detector helper --- .../detectors/functional_groups_detector.py | 33 ++++++++++++++++--- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/AutoREACTER/detectors/functional_groups_detector.py b/AutoREACTER/detectors/functional_groups_detector.py index 5aceca4..f738039 100644 --- a/AutoREACTER/detectors/functional_groups_detector.py +++ b/AutoREACTER/detectors/functional_groups_detector.py @@ -1,5 +1,7 @@ from __future__ import annotations from typing import TYPE_CHECKING + +from numpy import indices """ * Monomer Functionality Detection Module -------------------------------------- @@ -364,11 +366,6 @@ def functional_groups_detector( session.monomer_roles = monomer_roles return None # Return session with updated monomer_roles; visualization handled separately. - def index_based_functional_groups_detector( - self, session: "Session", - ) -> None: - pass - def _functional_groups_detector_for_visualization( self, session: Session @@ -406,6 +403,32 @@ def _functional_groups_detector_for_visualization( ) ) return monomer_roles_visualization + + def _detect_functional_groups_by_index(self, mol: Chem.Mol, smarts: str, indices: list[int]) -> bool: + """ + mol: RDKit Mol object + smarts: SMARTS pattern string + indices: iterable of atom indices you care about + + Returns: bool + True if any of the specified indices match the SMARTS pattern, False otherwise + """ + target_indices = set(indices) + results = {} + + patt = Chem.MolFromSmarts(smarts) + if patt is None: + # invalid SMARTS + results["present"] = False + results["error"] = "invalid SMARTS" + return results + + matches = mol.GetSubstructMatches(patt, uniquify=True) # tuple of tuples of atom idx + + # check if ANY match shares AT LEAST ONE atom with your target indices + matching_hits = [m for m in matches if target_indices.intersection(m)] + + return bool(matching_hits) def functional_group_highlighted_molecules_image_grid(self, session: Session) -> Image: """Convert monomer roles with detected functionalities into visualizations. From df45b60c21f3cb5f38df07023a6f30f3c62a1028 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:50:08 -0400 Subject: [PATCH 005/104] Add index-based functional groups detector --- .../detectors/functional_groups_detector.py | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/AutoREACTER/detectors/functional_groups_detector.py b/AutoREACTER/detectors/functional_groups_detector.py index f738039..0f6fc57 100644 --- a/AutoREACTER/detectors/functional_groups_detector.py +++ b/AutoREACTER/detectors/functional_groups_detector.py @@ -429,6 +429,97 @@ def _detect_functional_groups_by_index(self, mol: Chem.Mol, smarts: str, indices matching_hits = [m for m in matches if target_indices.intersection(m)] return bool(matching_hits) + + def index_based_functional_groups_detector( + self, session: "Session" + ) -> None: + """ + Detect functional groups across a list of monomers and categorize them into roles. + + Iterates over predefined monomer_types, matches each against the monomer's SMILES, + and collects valid detections. Prints matches for debugging/user feedback. + + Args: + session (Session): Validated Session object containing monomers. + + Returns: + None (results stored in session.monomer_roles for downstream use). + + Notes: + - Matches criteria: 'vinyl'/'mono' (>=1 primary), 'di_identical' (>=2 primary), + 'di_different' (>=1 each pattern). + """ + + monomers = session.inputs.monomers + monomer_roles = [] + + for monomer in monomers: + smiles = monomer.smiles + 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") + + functionality_count, count_1, count_2, functional_matches = ( + self.detect_monomer_functionality( + monomer.rdkit_mol, + ftype, + smarts_1, + smarts_2, + ) + ) + + # Determine if this functionality matches criteria. + if functionality_count > 0: + all_matches.extend(functional_matches) + + # Log detected functionality for debugging/user feedback. + print(f"{smiles} has functionality: {functional_group['group_name']}") + + if functional_group.get("comments"): + print(f"Note: {smiles} - {functional_group['comments']}") + + detected_functionalities.append( + FunctionalGroupInfo( + functionality_type=ftype, + fg_name=functional_group["group_name"], + fg_smarts_1=smarts_1, + fg_count_1=count_1, + fg_smarts_2=smarts_2, + fg_count_2=count_2, + ) + ) + # Debug print for match details. + # print( + # f"Monomer {monomer.name} (SMILES: {smiles}) matches {ftype} " + # f"with {functional_group['group_name']} (Count 1: {count_1}, Count 2: {count_2})" + # ) + + # Add to roles if any functionalities detected. + if detected_functionalities: + monomer_roles.append( + MonomerRole( + smiles=smiles, + name=monomer.name, + functionalities=tuple(detected_functionalities), + ) + ) + + # Store results in session for potential downstream use. + if not monomer_roles: + raise RuntimeError( + "No functional groups were detected in any input monomer. " + "Either the input molecules are not valid polymerizable monomers, " + "or AutoREACTER does not yet support these monomer types. " + "Please open a feature request or issue if you think support should be added: " + "https://github.com/NanoCIPHER-Lab/AutoREACTER/issues" + ) + session.monomer_roles = monomer_roles + return None # Return session with updated monomer_roles; visualization handled separately. def functional_group_highlighted_molecules_image_grid(self, session: Session) -> Image: """Convert monomer roles with detected functionalities into visualizations. From b537e59cd739188c8addb586504581414e826c22 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:53:36 -0400 Subject: [PATCH 006/104] Add DetectedChemistryFilter to collect chemistries --- .../detected_chemistry_filter.py | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 AutoREACTER/reaction_preparation/reaction_processor/detected_chemistry_filter.py diff --git a/AutoREACTER/reaction_preparation/reaction_processor/detected_chemistry_filter.py b/AutoREACTER/reaction_preparation/reaction_processor/detected_chemistry_filter.py new file mode 100644 index 0000000..aaeacdc --- /dev/null +++ b/AutoREACTER/reaction_preparation/reaction_processor/detected_chemistry_filter.py @@ -0,0 +1,87 @@ +from __future__ import annotations # 1. Must be the first line + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from AutoREACTER.session import Session + + +@dataclass(slots=True) +class DetectedChemistries: + functional_groups: list[str] # Unique functional group SMARTS detected in available reactions + reactions: dict[str, str] # reaction_name -> reaction_smarts + + +class DetectedChemistryFilter: + """ + Collects available detected chemistry from reaction instances. + + This class extracts: + - unique functional group SMARTS used by detected reactions + - unique reaction names and their reaction SMARTS + """ + + def __init__(self, session: Session): + self.reaction_instances = session.reaction_instances or [] + + def _add_to_the_list(self, list_to_add: list[str], item: Optional[str]) -> None: + """ + Adds an item to the list if it is not None and not already present. + """ + if item is not None and item not in list_to_add: + list_to_add.append(item) + + def _add_to_the_dict(self, dict_to_add: dict[str, str], key: str, value: str) -> None: + """ + Adds a key-value pair to the dictionary if the key is not already present. + """ + if key not in dict_to_add: + dict_to_add[key] = value + + def filter(self) -> DetectedChemistries: + """ + Extracts available functional group SMARTS and reaction SMARTS + from detected reaction instances. + + Returns: + DetectedChemistries: Available functional groups and reactions. + """ + available_functional_groups: list[str] = [] + available_reactions: dict[str, str] = {} + + for reaction_instance in self.reaction_instances: + reaction_name = reaction_instance.reaction_name + reaction_smarts = reaction_instance.reaction_smarts + + self._add_to_the_dict( + available_reactions, + reaction_name, + reaction_smarts, + ) + + functional_group_1 = reaction_instance.functional_group_1 + self._add_to_the_list( + available_functional_groups, + functional_group_1.fg_smarts_1, + ) + self._add_to_the_list( + available_functional_groups, + functional_group_1.fg_smarts_2, + ) + + functional_group_2 = reaction_instance.functional_group_2 + if functional_group_2 is not None: + self._add_to_the_list( + available_functional_groups, + functional_group_2.fg_smarts_1, + ) + self._add_to_the_list( + available_functional_groups, + functional_group_2.fg_smarts_2, + ) + + return DetectedChemistries( + functional_groups=available_functional_groups, + reactions=available_reactions, + ) \ No newline at end of file From 986977e05d5f0ea61ecef62c6e01aa01b9f46924 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:18:59 -0400 Subject: [PATCH 007/104] Add epoxy/amine reaction rules and progression --- .../detectors/functional_groups_library.py | 8 ++++++++ .../detectors/functional_groups_rules.json | 0 AutoREACTER/detectors/reaction_rules.json | 11 +++++++++++ AutoREACTER/detectors/reactions_library.py | 14 +++++++++++++- .../reaction_processor/reaction_progression.py | 15 ++++++++++++++- 5 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 AutoREACTER/detectors/functional_groups_rules.json create mode 100644 AutoREACTER/detectors/reaction_rules.json diff --git a/AutoREACTER/detectors/functional_groups_library.py b/AutoREACTER/detectors/functional_groups_library.py index 1408d62..09389dd 100644 --- a/AutoREACTER/detectors/functional_groups_library.py +++ b/AutoREACTER/detectors/functional_groups_library.py @@ -120,6 +120,14 @@ def __init__(self): "comments": None, }, + + "diepoxy_monomer": { + "functionality_type": "di_identical", + "smarts_1": "[OX2r3:1]1[#6r3][#6r3]1", + "group_name": "di_epoxide", + "comments": None, + } + # ============================================================ # Commented functional groups # ============================================================ diff --git a/AutoREACTER/detectors/functional_groups_rules.json b/AutoREACTER/detectors/functional_groups_rules.json new file mode 100644 index 0000000..e69de29 diff --git a/AutoREACTER/detectors/reaction_rules.json b/AutoREACTER/detectors/reaction_rules.json new file mode 100644 index 0000000..6b69488 --- /dev/null +++ b/AutoREACTER/detectors/reaction_rules.json @@ -0,0 +1,11 @@ +{ + "multi_step_reactions": [ + { + "name": "epoxy_polymerization", + "required_reactions": [ + "first_step_reaction", + "second_step_reaction" + ] + } + ] +} \ No newline at end of file diff --git a/AutoREACTER/detectors/reactions_library.py b/AutoREACTER/detectors/reactions_library.py index c4ed848..7d7b711 100644 --- a/AutoREACTER/detectors/reactions_library.py +++ b/AutoREACTER/detectors/reactions_library.py @@ -339,6 +339,18 @@ def __init__(self): }, "comments": None }, + "Amine Epoxy Addition First Stage": { + "same_reactants": False, + "reactant_1": "amine", + "reactant_2": "epoxide", + "product": "beta_hydroxy_secondary_amine", + "delete_atom": False, + "reaction": "[NX3;H2,H1:1].[OX2r3:2]1[CX4r3:3][CX4r3:4]1>>[NX3:1]-[CX4:3]-[CX4:4]-[OX2H1:2]", + "reference": { + "reaction_and_mechanism": [] + } + } + } # ============================================================ # Commented reactions @@ -473,4 +485,4 @@ def __init__(self): # "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 + #} \ 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 index 8042227..d530113 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py @@ -1,11 +1,24 @@ MAX_LOOP = 5 # Maximum number of iterations for the reaction progression loop. Users should be able to adjust this value based on their specific needs and the complexity of the reactions being analyzed. from typing import TYPE_CHECKING +from dataclasses import dataclass if TYPE_CHECKING: from AutoREACTER.session import Session +from AutoREACTER.reaction_preparation.reaction_processor.detected_chemistry_filter import DetectedChemistryFilter, DetectedChemistries +@dataclass(slots=True) +class ReactionProgressionSession: + """ + Holds the state of the reaction progression process, including detected chemistries and the current iteration count. + """ + iteration: int = 0 # Current iteration count of the reaction progression loop. + detected_chemistries: DetectedChemistries + class ReactionProgression: + + def __init__(self, session: "Session"): + self.detected_chemistry_filter = DetectedChemistryFilter(session) def reaction_progression(self, session: "Session", max_loop: int = MAX_LOOP) -> None: - pass # Placeholder for the reaction progression logic. This method will be implemented to handle the progression of reactions based on the session data and the specified maximum loop iterations. \ No newline at end of file + pass \ No newline at end of file From 2489ddf4143f7034f08351135e7d5e63bb318ea2 Mon Sep 17 00:00:00 2001 From: Janitha Mahanthe <119646255+janitha-mahanthe@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:11:30 -0400 Subject: [PATCH 008/104] Add epoxy-amine FGs, atom indexes, and warning util - Add fg_1_indexes and fg_2_indexes fields to FunctionalGroupInfo for storing matched atom indices - Expand functional group library with epoxy-amine polymerization entries (diepoxy, primary amine, secondary amine) with improved SMARTS and documentation - Update reaction_rules.json with required_fgs field for epoxy_polymerization - Add _add_progessive_chemistries stub in detected_chemistry_filter.py - Add warning_asci.py with ASCII warning banner for reaction progression beta loop --- .../detectors/functional_groups_detector.py | 4 ++ .../detectors/functional_groups_library.py | 64 ++++++++++++++++--- AutoREACTER/detectors/reaction_rules.json | 1 + .../detected_chemistry_filter.py | 14 +++- .../reaction_processor/warning_asci.py | 21 ++++++ 5 files changed, 94 insertions(+), 10 deletions(-) create mode 100644 AutoREACTER/reaction_preparation/reaction_processor/warning_asci.py diff --git a/AutoREACTER/detectors/functional_groups_detector.py b/AutoREACTER/detectors/functional_groups_detector.py index 0f6fc57..a5b9dba 100644 --- a/AutoREACTER/detectors/functional_groups_detector.py +++ b/AutoREACTER/detectors/functional_groups_detector.py @@ -129,16 +129,20 @@ 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[int, ...]] = None fg_smarts_2: Optional[str] = None fg_count_2: Optional[int] = None + fg_2_indexes: Optional[Tuple[int, ...]] = None @dataclass(slots=True, frozen=True) diff --git a/AutoREACTER/detectors/functional_groups_library.py b/AutoREACTER/detectors/functional_groups_library.py index 09389dd..047d5d2 100644 --- a/AutoREACTER/detectors/functional_groups_library.py +++ b/AutoREACTER/detectors/functional_groups_library.py @@ -1,12 +1,29 @@ """ -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. +Functional group library for epoxy polymerization chemistry. + +This library focuses on monomers and curing-agent functional groups relevant to +epoxy polymerization / epoxy curing systems. It includes epoxides and common +epoxy-reactive groups such as primary amines, secondary amines, thiols, alcohols, +carboxylic acids, and cyclic anhydrides. + +Each entry defines: + - functionality_type: + "mono" : one reactive functional group + "di_identical" : two identical reactive functional groups + "di_different" : two different reactive functional groups + + - smarts_1 / smarts_2: + SMARTS patterns used for substructure matching + + - group_name: + functional group label used by the detector + + - comments: + optional chemistry notes """ + class FunctionalGroupsLibrary: def __init__(self): self.monomer_types = { @@ -120,13 +137,44 @@ def __init__(self): "comments": None, }, + # ============================================================ + # Epoxy / Amine Functional Monomers + # Relevant for epoxy-amine polymerization + # + # Polymer-forming epoxy monomer: + # must contain two epoxide groups, i.e. diepoxy + # + # Primary monoamine: + # one -NH2 group has two active hydrogens + # can react with two epoxide groups in two stages + # + # Stage 1: + # primary amine + epoxide -> secondary amine + # + # Stage 2: + # secondary amine + epoxide -> tertiary amine + # ============================================================ "diepoxy_monomer": { "functionality_type": "di_identical", - "smarts_1": "[OX2r3:1]1[#6r3][#6r3]1", + "smarts_1": "[OX2r3:1]1[#6r3:2][#6r3:3]1", "group_name": "di_epoxide", - "comments": None, - } + "comments": "Difunctional epoxide monomer. Required on the epoxy side for epoxy-amine polymerization.", + }, + + "primary_amine_monomer": { + "functionality_type": "mono", + "smarts_1": "[NX3;H2;!$([N][C,S]=*):1]", + "group_name": "primary_amine", + "comments": "Mono primary amine. One -NH2 group has two active hydrogens and can react with two epoxide groups.", + }, + + "secondary_amine_monomer": { + "functionality_type": "mono", + "smarts_1": "[NX3;H1;!$([N][C,S]=*):1]", + "group_name": "secondary_amine", + "comments": "Mono secondary amine. Represents the second-stage reactive amine after primary amine reacts once with epoxide. By itself, a mono secondary amine reacts only once with epoxide and is not a true polymer-forming monomer.", + }, # ============================================================ # Commented functional groups diff --git a/AutoREACTER/detectors/reaction_rules.json b/AutoREACTER/detectors/reaction_rules.json index 6b69488..fb020f2 100644 --- a/AutoREACTER/detectors/reaction_rules.json +++ b/AutoREACTER/detectors/reaction_rules.json @@ -2,6 +2,7 @@ "multi_step_reactions": [ { "name": "epoxy_polymerization", + "required_fgs": [] , "required_reactions": [ "first_step_reaction", "second_step_reaction" diff --git a/AutoREACTER/reaction_preparation/reaction_processor/detected_chemistry_filter.py b/AutoREACTER/reaction_preparation/reaction_processor/detected_chemistry_filter.py index aaeacdc..447fb19 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/detected_chemistry_filter.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/detected_chemistry_filter.py @@ -2,6 +2,8 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Optional +import json +from pathlib import Path if TYPE_CHECKING: from AutoREACTER.session import Session @@ -13,6 +15,7 @@ class DetectedChemistries: reactions: dict[str, str] # reaction_name -> reaction_smarts + class DetectedChemistryFilter: """ Collects available detected chemistry from reaction instances. @@ -26,7 +29,7 @@ def __init__(self, session: Session): self.reaction_instances = session.reaction_instances or [] def _add_to_the_list(self, list_to_add: list[str], item: Optional[str]) -> None: - """ + """ Adds an item to the list if it is not None and not already present. """ if item is not None and item not in list_to_add: @@ -84,4 +87,11 @@ def filter(self) -> DetectedChemistries: return DetectedChemistries( functional_groups=available_functional_groups, reactions=available_reactions, - ) \ No newline at end of file + ) + +def _add_progessive_chemistries(): + rules_location = "AutoREACTER/detectors" + reactions_rules_location = Path(rules_location) / "reaction_rules.json" + with open(reactions_rules_location, "r") as file: + reaction_rules = json.load(file) + \ No newline at end of file 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..629be0c --- /dev/null +++ b/AutoREACTER/reaction_preparation/reaction_processor/warning_asci.py @@ -0,0 +1,21 @@ + +def ascii_art(message: str) -> None: + message = message.upper() + print(f"WARNING: {message}") + """ _ _ _ _ +| | | | (_) | | | | +| | | | __ _ _ __ _ __ _ _ __ __ _| | | | +| |/\| |/ _` | '__| '_ \| | '_ \ / _` | | | | +\ /\ / (_| | | | | | | | | | | (_| |_|_|_| + \/ \/ \__,_|_| |_| |_|_|_| |_|\__, (_|_|_) + __/ | + |___/ +""" + + +def print_warning() -> None: + message = "Warning " \ + "Entering to the reaction progression Loop still in the Beta phase" \ + "Caution: Can be chemically inaccurate" + ascii_art(message) + From a01caea99363feaa75c214acb80076f141a5d170 Mon Sep 17 00:00:00 2001 From: Janitha Mahanthe <119646255+janitha-mahanthe@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:08:54 -0400 Subject: [PATCH 009/104] Add staged amine-epoxy reaction rules --- .../detectors/functional_groups_library.py | 2 +- AutoREACTER/detectors/reaction_rules.json | 9 ++++++--- AutoREACTER/detectors/reactions_library.py | 20 +++++++++++++++---- .../detected_chemistry_filter.py | 18 ++++++++++------- 4 files changed, 34 insertions(+), 15 deletions(-) diff --git a/AutoREACTER/detectors/functional_groups_library.py b/AutoREACTER/detectors/functional_groups_library.py index 047d5d2..e540ce0 100644 --- a/AutoREACTER/detectors/functional_groups_library.py +++ b/AutoREACTER/detectors/functional_groups_library.py @@ -155,7 +155,7 @@ def __init__(self): # secondary amine + epoxide -> tertiary amine # ============================================================ - "diepoxy_monomer": { + "di_epoxy_monomer": { "functionality_type": "di_identical", "smarts_1": "[OX2r3:1]1[#6r3:2][#6r3:3]1", "group_name": "di_epoxide", diff --git a/AutoREACTER/detectors/reaction_rules.json b/AutoREACTER/detectors/reaction_rules.json index fb020f2..1493150 100644 --- a/AutoREACTER/detectors/reaction_rules.json +++ b/AutoREACTER/detectors/reaction_rules.json @@ -2,11 +2,14 @@ "multi_step_reactions": [ { "name": "epoxy_polymerization", - "required_fgs": [] , + "if_reactions": "Amine Epoxy Addition First Stage", "required_reactions": [ - "first_step_reaction", - "second_step_reaction" + "Amine Epoxy Addition Second Stage" ] + , + "fg_additon": { + "primary_amine": "secondary_amine" + } } ] } \ No newline at end of file diff --git a/AutoREACTER/detectors/reactions_library.py b/AutoREACTER/detectors/reactions_library.py index 7d7b711..7d97bb5 100644 --- a/AutoREACTER/detectors/reactions_library.py +++ b/AutoREACTER/detectors/reactions_library.py @@ -341,16 +341,27 @@ def __init__(self): }, "Amine Epoxy Addition First Stage": { "same_reactants": False, - "reactant_1": "amine", + "reactant_1": "primary_amine", "reactant_2": "epoxide", "product": "beta_hydroxy_secondary_amine", "delete_atom": False, - "reaction": "[NX3;H2,H1:1].[OX2r3:2]1[CX4r3:3][CX4r3:4]1>>[NX3:1]-[CX4:3]-[CX4:4]-[OX2H1:2]", + "reaction": "[NX3;H2:1].[OX2r3:2]1[CX4r3:3][CX4r3:4]1>>[NX3;H1:1]-[CX4:3]-[CX4:4]-[OX2H1:2]", "reference": { "reaction_and_mechanism": [] } + }, + + "Amine Epoxy Addition Second Stage": { + "same_reactants": False, + "reactant_1": "secondary_amine", + "reactant_2": "epoxide", + "product": "beta_hydroxy_tertiary_amine", + "delete_atom": False, + "reaction": "[NX3;H1:1].[OX2r3:2]1[CX4r3:3][CX4r3:4]1>>[NX3;H0:1]-[CX4:3]-[CX4:4]-[OX2H1:2]", + "reference": { + "reaction_and_mechanism": [] } - } + }, # ============================================================ # Commented reactions @@ -485,4 +496,5 @@ def __init__(self): # "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 + #} + } \ No newline at end of file diff --git a/AutoREACTER/reaction_preparation/reaction_processor/detected_chemistry_filter.py b/AutoREACTER/reaction_preparation/reaction_processor/detected_chemistry_filter.py index 447fb19..c324fb8 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/detected_chemistry_filter.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/detected_chemistry_filter.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Optional import json -from pathlib import Path +from importlib.resources import files if TYPE_CHECKING: from AutoREACTER.session import Session @@ -89,9 +89,13 @@ def filter(self) -> DetectedChemistries: reactions=available_reactions, ) -def _add_progessive_chemistries(): - rules_location = "AutoREACTER/detectors" - reactions_rules_location = Path(rules_location) / "reaction_rules.json" - with open(reactions_rules_location, "r") as file: - reaction_rules = json.load(file) - \ No newline at end of file + + + + def _add_progessive_chemistries(self): + rules_file = files("AutoREACTER.detectors").joinpath("reaction_rules.json") + + with rules_file.open("r", encoding="utf-8") as file: + reaction_rules = json.load(file) + + return reaction_rules \ No newline at end of file From c2a93bc96bc63e8fdef6539e7ac86a4dc5986d05 Mon Sep 17 00:00:00 2001 From: Janitha Mahanthe <119646255+janitha-mahanthe@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:21:58 -0400 Subject: [PATCH 010/104] WIP: add progressive chemistry rules skeleton --- .../detected_chemistry_filter.py | 14 ++-- test.ipynb | 66 +++++++++++++++++++ 2 files changed, 76 insertions(+), 4 deletions(-) create mode 100644 test.ipynb diff --git a/AutoREACTER/reaction_preparation/reaction_processor/detected_chemistry_filter.py b/AutoREACTER/reaction_preparation/reaction_processor/detected_chemistry_filter.py index c324fb8..6470042 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/detected_chemistry_filter.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/detected_chemistry_filter.py @@ -83,7 +83,13 @@ def filter(self) -> DetectedChemistries: available_functional_groups, functional_group_2.fg_smarts_2, ) - + rules = self._add_progessive_chemistries() + for reaction in available_reactions: + for rule in rules: + print(f"yet to implement for reaction: {reaction}, rule: {rule}" + in here we need to add functional groups with meta data + and then add to the set. + ) return DetectedChemistries( functional_groups=available_functional_groups, reactions=available_reactions, @@ -92,10 +98,10 @@ def filter(self) -> DetectedChemistries: - def _add_progessive_chemistries(self): + def _add_progessive_chemistries(): rules_file = files("AutoREACTER.detectors").joinpath("reaction_rules.json") with rules_file.open("r", encoding="utf-8") as file: reaction_rules = json.load(file) - - return reaction_rules \ No newline at end of file + rules = reaction_rules["multi_step_reactions"] + return rules \ No newline at end of file diff --git a/test.ipynb b/test.ipynb new file mode 100644 index 0000000..56f21e4 --- /dev/null +++ b/test.ipynb @@ -0,0 +1,66 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "7642e888", + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "import json\n", + "from importlib.resources import files\n", + "\n", + "\n", + "def _add_progessive_chemistries():\n", + " rules_file = files(\"AutoREACTER.detectors\").joinpath(\"reaction_rules.json\")\n", + "\n", + " with rules_file.open(\"r\", encoding=\"utf-8\") as file:\n", + " reaction_rules = json.load(file)\n", + " rules = reaction_rules[\"multi_step_reactions\"]\n", + " print(rules)\n", + "\n", + " return reaction_rules" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "8af2ad56", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'multi_step_reactions': [{'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 +} From 7df0749a0af1bd94de54022f8de66e127ed26f7f Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Thu, 9 Jul 2026 09:51:53 -0400 Subject: [PATCH 011/104] Add epoxy simulation example config --- examples/test_epoxy.json | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 examples/test_epoxy.json diff --git a/examples/test_epoxy.json b/examples/test_epoxy.json new file mode 100644 index 0000000..00fb094 --- /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": "NCCN" + } + ] +} \ No newline at end of file From 8ab6dd8ed473239c8f053d0751f1f94f8f736a32 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:10:05 -0400 Subject: [PATCH 012/104] Implement index-based functional group detection --- .../detectors/functional_groups_detector.py | 129 +++++++----- .../detectors/functional_groups_library.py | 6 +- AutoREACTER/detectors/reactions_library.py | 30 +-- .../reaction_processor/prepare_reactions.py | 4 +- .../reaction_progression.py | 190 +++++++++++++++++- 5 files changed, 285 insertions(+), 74 deletions(-) diff --git a/AutoREACTER/detectors/functional_groups_detector.py b/AutoREACTER/detectors/functional_groups_detector.py index a5b9dba..d14395f 100644 --- a/AutoREACTER/detectors/functional_groups_detector.py +++ b/AutoREACTER/detectors/functional_groups_detector.py @@ -1,5 +1,5 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, List from numpy import indices """ @@ -118,6 +118,7 @@ logger = logging.getLogger(__name__) # Module-level logger for future diagnostics. if TYPE_CHECKING: from AutoREACTER.session import Session + from AutoREACTER.detectors.functional_groups_detector import MonomerRoleforIndexBasedFGDetection @dataclass(slots=True) @@ -145,7 +146,7 @@ class FunctionalGroupInfo: fg_2_indexes: Optional[Tuple[int, ...]] = None -@dataclass(slots=True, frozen=True) +@dataclass(slots=True) class MonomerRole: """ Immutable dataclass representing a monomer with its detected functional groups. @@ -158,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: @@ -355,6 +360,7 @@ def functional_groups_detector( smiles=smiles, name=monomer.name, functionalities=tuple(detected_functionalities), + is_monomer=True ) ) @@ -435,57 +441,90 @@ def _detect_functional_groups_by_index(self, mol: Chem.Mol, smarts: str, indices return bool(matching_hits) def index_based_functional_groups_detector( - self, session: "Session" + self, monomer_roles_in: list[MonomerRoleforIndexBasedFGDetection] ) -> None: """ - Detect functional groups across a list of monomers and categorize them into roles. + 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 SMILES, - and collects valid detections. Prints matches for debugging/user feedback. + 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: - session (Session): Validated Session object containing monomers. + monomer_roles_in (list[MonomerRoleforIndexBasedFGDetection]): List of monomer + roles to process, each carrying the atom indices of interest. Returns: - None (results stored in session.monomer_roles for downstream use). + list[MonomerRole] | bool: List of MonomerRole objects with index-filtered + functionalities, or False if none detected. Notes: - - Matches criteria: 'vinyl'/'mono' (>=1 primary), 'di_identical' (>=2 primary), - 'di_different' (>=1 each pattern). + - 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. """ - - monomers = session.inputs.monomers - monomer_roles = [] - for monomer in monomers: - smiles = monomer.smiles + monomer_roles_out = [] + + for monomer in monomer_roles_in: + if monomer.is_looped: + continue # Skip already processed monomers + + mol = monomer.rdkit_mol + print(f"Processing monomer: {monomer.name} with SMILES: {monomer.smiles}") + + 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") - functionality_count, count_1, count_2, functional_matches = ( - self.detect_monomer_functionality( - monomer.rdkit_mol, - ftype, - smarts_1, - 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 - # Determine if this functionality matches criteria. 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"{smiles} has functionality: {functional_group['group_name']}") + print(f"{monomer.smiles} has functionality: {functional_group['group_name']}") if functional_group.get("comments"): - print(f"Note: {smiles} - {functional_group['comments']}") + print(f"Note: {monomer.smiles} - {functional_group['comments']}") detected_functionalities.append( FunctionalGroupInfo( @@ -493,37 +532,33 @@ def index_based_functional_groups_detector( 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, ) ) - # Debug print for match details. - # print( - # f"Monomer {monomer.name} (SMILES: {smiles}) matches {ftype} " - # f"with {functional_group['group_name']} (Count 1: {count_1}, Count 2: {count_2})" - # ) # Add to roles if any functionalities detected. if detected_functionalities: - monomer_roles.append( + monomer_roles_out.append( MonomerRole( - smiles=smiles, + 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 in session for potential downstream use. - if not monomer_roles: - raise RuntimeError( - "No functional groups were detected in any input monomer. " - "Either the input molecules are not valid polymerizable monomers, " - "or AutoREACTER does not yet support these monomer types. " - "Please open a feature request or issue if you think support should be added: " - "https://github.com/NanoCIPHER-Lab/AutoREACTER/issues" - ) - session.monomer_roles = monomer_roles - return None # Return session with updated monomer_roles; visualization handled separately. + + # 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 index e540ce0..3f38c5c 100644 --- a/AutoREACTER/detectors/functional_groups_library.py +++ b/AutoREACTER/detectors/functional_groups_library.py @@ -157,21 +157,21 @@ def __init__(self): "di_epoxy_monomer": { "functionality_type": "di_identical", - "smarts_1": "[OX2r3:1]1[#6r3:2][#6r3:3]1", + "smarts_1": "[CX4;R1]1[OX2;R1][CX4;R1]1", "group_name": "di_epoxide", "comments": "Difunctional epoxide monomer. Required on the epoxy side for epoxy-amine polymerization.", }, "primary_amine_monomer": { "functionality_type": "mono", - "smarts_1": "[NX3;H2;!$([N][C,S]=*):1]", + "smarts_1": "[NX3H2;!$(NC=O);!$(NC=[N,O,S])]", "group_name": "primary_amine", "comments": "Mono primary amine. One -NH2 group has two active hydrogens and can react with two epoxide groups.", }, "secondary_amine_monomer": { "functionality_type": "mono", - "smarts_1": "[NX3;H1;!$([N][C,S]=*):1]", + "smarts_1": "[NX3H1;!$(NC=O);!$(NC=[N,O,S])]", "group_name": "secondary_amine", "comments": "Mono secondary amine. Represents the second-stage reactive amine after primary amine reacts once with epoxide. By itself, a mono secondary amine reacts only once with epoxide and is not a true polymer-forming monomer.", }, diff --git a/AutoREACTER/detectors/reactions_library.py b/AutoREACTER/detectors/reactions_library.py index 7d97bb5..4dd9a14 100644 --- a/AutoREACTER/detectors/reactions_library.py +++ b/AutoREACTER/detectors/reactions_library.py @@ -339,28 +339,32 @@ def __init__(self): }, "comments": None }, - "Amine Epoxy Addition First Stage": { + "Primary Amine and Epoxide Polyaddition (Epoxy-Amine, First Addition)": { "same_reactants": False, "reactant_1": "primary_amine", - "reactant_2": "epoxide", - "product": "beta_hydroxy_secondary_amine", + "reactant_2": "di_epoxide", + "product": "secondary_amine_hydroxyl_product", "delete_atom": False, - "reaction": "[NX3;H2:1].[OX2r3:2]1[CX4r3:3][CX4r3:4]1>>[NX3;H1:1]-[CX4:3]-[CX4:4]-[OX2H1:2]", + "reaction": "[NX3H2:1]-[H:6].[CX4:2]1[OX2:5][CX4:3]1>>[NX3:1][C:2][C:3][OX2:5][H:6]", "reference": { - "reaction_and_mechanism": [] - } + "smarts": None, + "reaction_and_mechanism": None + }, + "comments": "Primary amine (-NH2) opens one epoxide ring. One N-H is consumed and transferred to the epoxide oxygen as -OH; nitrogen becomes a secondary amine with one remaining N-H, still reactive toward a second epoxide." }, - "Amine Epoxy Addition Second Stage": { + "Secondary Amine and Epoxide Polyaddition (Epoxy-Amine, Second Addition / Crosslink)": { "same_reactants": False, - "reactant_1": "secondary_amine", - "reactant_2": "epoxide", - "product": "beta_hydroxy_tertiary_amine", + "reactant_1": "secondary_amine_monomer", + "reactant_2": "di_epoxy_monomer", + "product": "tertiary_amine_crosslink_product", "delete_atom": False, - "reaction": "[NX3;H1:1].[OX2r3:2]1[CX4r3:3][CX4r3:4]1>>[NX3;H0:1]-[CX4:3]-[CX4:4]-[OX2H1:2]", + "reaction": "[NX3H1:1]-[H:6].[CX4:2]1[OX2:5][CX4:3]1>>[NX3:1][C:2][C:3][OX2:5][H:6]", "reference": { - "reaction_and_mechanism": [] - } + "smarts": None, + "reaction_and_mechanism": None + }, + "comments": "Secondary amine's remaining N-H opens a second epoxide ring. Nitrogen becomes a fully substituted tertiary amine (network crosslink point); no reactive N-H remains on this nitrogen." }, # ============================================================ diff --git a/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py b/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py index 9c2d951..eeea4bd 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py @@ -29,7 +29,7 @@ # Use TYPE_CHECKING to prevent circular imports with session.py if TYPE_CHECKING: from AutoREACTER.session import Session - +from AutoREACTER.reaction_preparation.reaction_processor.reaction_progression import ReactionProgression class MappingError(Exception): """Custom exception raised when atom mapping between reactants and products fails or is inconsistent.""" @@ -163,6 +163,8 @@ def prepare_reactions(self, session: "Session") -> list[ReactionMetadata]: # Store the finalized metadata inside the session session.reaction_metadata = unique_reaction_metadata + reaction_progression = ReactionProgression(session) + return unique_reaction_metadata # --- PIPELINE STEPS (PRIVATE) --- diff --git a/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py index d530113..3bfbf2e 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py @@ -1,24 +1,194 @@ -MAX_LOOP = 5 # Maximum number of iterations for the reaction progression loop. Users should be able to adjust this value based on their specific needs and the complexity of the reactions being analyzed. +MAX_LOOP = 5 # Maximum number of iterations for the reaction progression loop. + from typing import TYPE_CHECKING -from dataclasses import dataclass +from dataclasses import dataclass, field + +from rdkit import Chem + +from AutoREACTER.detectors.functional_groups_detector import FunctionalGroupsDetector + if TYPE_CHECKING: from AutoREACTER.session import Session + from AutoREACTER.detectors.functional_groups_detector import MonomerRole + -from AutoREACTER.reaction_preparation.reaction_processor.detected_chemistry_filter import DetectedChemistryFilter, DetectedChemistries +@dataclass(slots=True) +class MonomerRoleforIndexBasedFGDetection: + """ + Represents a monomer role with its associated properties for + index-based functional group detection. + """ + 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: """ - Holds the state of the reaction progression process, including detected chemistries and the current iteration count. + Placeholder for future reaction progression state. """ - iteration: int = 0 # Current iteration count of the reaction progression loop. - detected_chemistries: DetectedChemistries + monomer_roles: list["MonomerRole"] = field(default_factory=list) + iteration: int = 0 class ReactionProgression: - def __init__(self, session: "Session"): - self.detected_chemistry_filter = DetectedChemistryFilter(session) - def reaction_progression(self, session: "Session", max_loop: int = MAX_LOOP) -> None: - pass \ No newline at end of file + self.session = session + self.session.reaction_progression_session = ReactionProgressionSession() + + self.fg_detector = FunctionalGroupsDetector() + self.reaction_progression() + + def reaction_progression(self, max_loop: int = MAX_LOOP) -> None: + """ + Progresses the reaction by iteratively applying detected chemistries + to the session's molecules. + + Args: + max_loop (int): Maximum number of iterations for the reaction progression loop. + """ + iteration = 0 + + while iteration < max_loop: + iteration += 1 + self.session.reaction_progression_session.iteration = iteration + + if iteration == 1: + self._populate_monomer_roles() + + if iteration > 1: + print( + f"Starting iteration {iteration} " + f"of the reaction progression loop." + ) + + size_of_pool = self._set_is_monomer_flag() + + print(self.session.monomer_roles) # Debug print + monomer_roles_for_idx_based_fg_detection = self._prepare_products_for_idx_based_fg_detection() + fg_detection_results = self.fg_detector.index_based_functional_groups_detector( + monomer_roles_for_idx_based_fg_detection + ) + # self.fg_detector._detect_functional_groups_by_index(self.session) + + if self._loop_break_condition(size_of_pool): + break + + def _prepare_products_for_idx_based_fg_detection( + self, + ) -> list[MonomerRoleforIndexBasedFGDetection]: + """ + Prepares generated reaction products for index-based functional group detection. + """ + monomer_roles_for_idx_based_fg_detection = [] + reaction_metadata = self.session.reaction_metadata + + for reaction in reaction_metadata: + monomer_roles_for_idx_based_fg_detection.append( + MonomerRoleforIndexBasedFGDetection( + smiles=self._get_product_smiles(reaction.product_combined_RDmol), + name=f"new_{reaction.reaction_id}", + indexes_in_template=self._get_product_index( + reaction.template_reactant_to_product_mapping + ), + is_monomer=False, + is_looped=False, + rdkit_mol=self._sanitize_molecule(reaction.product_combined_RDmol), + + ) + ) + + return monomer_roles_for_idx_based_fg_detection + + def _sanitize_molecule(self, mol: Chem.Mol) -> Chem.Mol | None: + """ + Sanitizes an RDKit molecule object to ensure it is chemically valid. + + Args: + mol (Chem.Mol): The RDKit molecule object to sanitize. + + Returns: + Chem.Mol | None: The sanitized RDKit molecule object, or None if sanitization fails. + """ + try: + Chem.SanitizeMol(mol) + return mol + except Exception: + return None + + def _get_product_smiles(self, mol: Chem.Mol) -> str: + """ + Converts an RDKit molecule object to its corresponding SMILES string. + """ + try: + return Chem.MolToSmiles(mol) + except Exception: + return "" + + def _get_product_index( + self, + template_reactant_to_product_mapping: dict, + ) -> list[int]: + """ + Retrieves product indexes from the reactant-to-product mapping. + + Args: + template_reactant_to_product_mapping (dict): Mapping from reactant + indices to product indices. + + Returns: + list[int]: Corresponding product indexes. + """ + product_indexes = [] + + for product_idx in template_reactant_to_product_mapping.values(): + product_indexes.append(product_idx) + print(f"Product indexes: {product_indexes}") # Debug print + return product_indexes + + def _set_is_monomer_flag(self) -> int: + """ + Sets the is_looped flag for each monomer role in the session. + + Returns: + int: Number of monomer roles before functional group detection. + """ + for monomer_role in self.session.monomer_roles: + monomer_role.is_looped = True + + return len(self.session.monomer_roles) + + def _populate_monomer_roles(self) -> None: + """ + Populates RDKit molecule objects for monomers marked as monomers. + """ + 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: + """ + Converts a SMILES string to an RDKit molecule object. + + Args: + smiles (str): The SMILES string to convert. + + Returns: + Chem.Mol | None: The corresponding RDKit molecule object. + """ + return Chem.MolFromSmiles(smiles) + + def _loop_break_condition(self, size_of_pool: int) -> bool: + if size_of_pool <= len(self.session.monomer_roles): + print( + f"Breaking the loop as the size of the pool ({size_of_pool}) " + f"is less than or equal to the number of monomer roles " + f"({len(self.session.monomer_roles)})." + ) + return True + + return False \ No newline at end of file From 4b02a381ac4fe7c23fb5a4b6e84c52b629e6cd3a Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:21:23 -0400 Subject: [PATCH 013/104] Clean reaction products in progression Copy loop monomer roles before extending them, and strip atom maps/isotopes from product molecules before sanitizing or generating SMILES. --- .../reaction_processor/reaction_progression.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py index 3bfbf2e..2d288fc 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py @@ -52,11 +52,11 @@ def reaction_progression(self, max_loop: int = MAX_LOOP) -> None: max_loop (int): Maximum number of iterations for the reaction progression loop. """ iteration = 0 + monomer_roles_in_loop = self.session.monomer_roles.copy() # Create a copy to avoid modifying the original list during iteration while iteration < max_loop: iteration += 1 - self.session.reaction_progression_session.iteration = iteration - + if iteration == 1: self._populate_monomer_roles() @@ -73,8 +73,8 @@ def reaction_progression(self, max_loop: int = MAX_LOOP) -> None: fg_detection_results = self.fg_detector.index_based_functional_groups_detector( monomer_roles_for_idx_based_fg_detection ) - # self.fg_detector._detect_functional_groups_by_index(self.session) - + monomer_roles_in_loop.extend(fg_detection_results) + print(monomer_roles_in_loop) # Debug print if self._loop_break_condition(size_of_pool): break @@ -114,16 +114,23 @@ def _sanitize_molecule(self, mol: Chem.Mol) -> Chem.Mol | None: Returns: Chem.Mol | None: The sanitized RDKit molecule object, or None if sanitization fails. """ + self._clean_product(mol) # Clean the product before sanitization try: Chem.SanitizeMol(mol) return mol except Exception: return None + + def _clean_product(self, products: Chem.Mol) -> Chem.Mol | None: + for atom in products.GetAtoms(): + atom.SetAtomMapNum(0) + atom.SetIsotope(0) def _get_product_smiles(self, mol: Chem.Mol) -> str: """ Converts an RDKit molecule object to its corresponding SMILES string. """ + self._clean_product(mol) # Clean the product before converting to SMILES try: return Chem.MolToSmiles(mol) except Exception: From e6516ad6f80a07f0c294eb3a7ed29f2cec669f1e Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:36:05 -0400 Subject: [PATCH 014/104] Add index-based reaction detection in progression --- AutoREACTER/detectors/reaction_detector.py | 134 ++++++++++++++++++ .../reaction_progression.py | 81 ++++++++--- 2 files changed, 193 insertions(+), 22 deletions(-) diff --git a/AutoREACTER/detectors/reaction_detector.py b/AutoREACTER/detectors/reaction_detector.py index 5d7b68e..73b6911 100644 --- a/AutoREACTER/detectors/reaction_detector.py +++ b/AutoREACTER/detectors/reaction_detector.py @@ -241,6 +241,140 @@ def reaction_detector(self, session: "Session") -> None: ) 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/reaction_preparation/reaction_processor/reaction_progression.py b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py index 2d288fc..5b52789 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py @@ -1,11 +1,12 @@ MAX_LOOP = 5 # Maximum number of iterations for the reaction progression loop. -from typing import TYPE_CHECKING 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 if TYPE_CHECKING: from AutoREACTER.session import Session @@ -20,7 +21,7 @@ class MonomerRoleforIndexBasedFGDetection: """ smiles: str name: str - indexes_in_template: list[int] + indexes_in_template: list[int] is_monomer: bool = False is_looped: bool = False rdkit_mol: Chem.Mol | None = None @@ -41,6 +42,8 @@ def __init__(self, session: "Session"): self.session.reaction_progression_session = ReactionProgressionSession() self.fg_detector = FunctionalGroupsDetector() + self.rxn_detector = ReactionDetector() + self.reaction_progression() def reaction_progression(self, max_loop: int = MAX_LOOP) -> None: @@ -52,11 +55,13 @@ def reaction_progression(self, max_loop: int = MAX_LOOP) -> None: max_loop (int): Maximum number of iterations for the reaction progression loop. """ iteration = 0 - monomer_roles_in_loop = self.session.monomer_roles.copy() # Create a copy to avoid modifying the original list during iteration + monomer_roles_in_loop = self.session.monomer_roles.copy() + reaction_instances = self.session.reaction_instances.copy() while iteration < max_loop: iteration += 1 - + self.session.reaction_progression_session.iteration = iteration + if iteration == 1: self._populate_monomer_roles() @@ -69,12 +74,34 @@ def reaction_progression(self, max_loop: int = MAX_LOOP) -> None: size_of_pool = self._set_is_monomer_flag() print(self.session.monomer_roles) # Debug print - monomer_roles_for_idx_based_fg_detection = self._prepare_products_for_idx_based_fg_detection() - fg_detection_results = self.fg_detector.index_based_functional_groups_detector( - monomer_roles_for_idx_based_fg_detection + + monomer_roles_for_idx_based_fg_detection = ( + self._prepare_products_for_idx_based_fg_detection() + ) + + fg_detection_results = ( + self.fg_detector.index_based_functional_groups_detector( + monomer_roles_for_idx_based_fg_detection ) + ) + + if not fg_detection_results: + print( + f"No new functional groups detected in iteration {iteration}. " + f"Ending the reaction progression loop." + ) + break + monomer_roles_in_loop.extend(fg_detection_results) + + rxns = self.rxn_detector.index_based_reaction_detector( + monomer_roles_in_loop + ) + reaction_instances.extend(rxns) + + print(len(reaction_instances)) # Debug print print(monomer_roles_in_loop) # Debug print + if self._loop_break_condition(size_of_pool): break @@ -88,49 +115,57 @@ def _prepare_products_for_idx_based_fg_detection( reaction_metadata = self.session.reaction_metadata for reaction in reaction_metadata: + product_mol = reaction.product_combined_RDmol + monomer_roles_for_idx_based_fg_detection.append( MonomerRoleforIndexBasedFGDetection( - smiles=self._get_product_smiles(reaction.product_combined_RDmol), + smiles=self._get_product_smiles(product_mol), name=f"new_{reaction.reaction_id}", indexes_in_template=self._get_product_index( reaction.template_reactant_to_product_mapping - ), + ), is_monomer=False, is_looped=False, - rdkit_mol=self._sanitize_molecule(reaction.product_combined_RDmol), - + rdkit_mol=self._sanitize_molecule(product_mol), ) ) return monomer_roles_for_idx_based_fg_detection - + def _sanitize_molecule(self, mol: Chem.Mol) -> Chem.Mol | None: """ - Sanitizes an RDKit molecule object to ensure it is chemically valid. + Sanitizes an RDKit molecule object. Args: - mol (Chem.Mol): The RDKit molecule object to sanitize. + mol (Chem.Mol): RDKit molecule object. Returns: - Chem.Mol | None: The sanitized RDKit molecule object, or None if sanitization fails. + Chem.Mol | None: Sanitized molecule, or None if sanitization fails. """ - self._clean_product(mol) # Clean the product before sanitization + self._clean_product(mol) + try: Chem.SanitizeMol(mol) return mol except Exception: return None - - def _clean_product(self, products: Chem.Mol) -> Chem.Mol | None: - for atom in products.GetAtoms(): + + def _clean_product(self, mol: Chem.Mol) -> Chem.Mol: + """ + Removes atom map numbers and isotope labels from a product molecule. + """ + for atom in mol.GetAtoms(): atom.SetAtomMapNum(0) atom.SetIsotope(0) + return mol + def _get_product_smiles(self, mol: Chem.Mol) -> str: """ Converts an RDKit molecule object to its corresponding SMILES string. """ - self._clean_product(mol) # Clean the product before converting to SMILES + self._clean_product(mol) + try: return Chem.MolToSmiles(mol) except Exception: @@ -154,7 +189,9 @@ def _get_product_index( for product_idx in template_reactant_to_product_mapping.values(): product_indexes.append(product_idx) + print(f"Product indexes: {product_indexes}") # Debug print + return product_indexes def _set_is_monomer_flag(self) -> int: @@ -182,10 +219,10 @@ def _smiles_to_rdkit_mol(self, smiles: str) -> Chem.Mol | None: Converts a SMILES string to an RDKit molecule object. Args: - smiles (str): The SMILES string to convert. + smiles (str): SMILES string. Returns: - Chem.Mol | None: The corresponding RDKit molecule object. + Chem.Mol | None: RDKit molecule object. """ return Chem.MolFromSmiles(smiles) From 5d86d4e9d233a1345cce4a107d67c372d6305cd1 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:34:01 -0400 Subject: [PATCH 015/104] Add index-constrained reaction progression prep --- AutoREACTER/detectors/reactions_library.py | 4 +- .../ff_wrapper/ff_wrapper.py | 3 +- .../reaction_processor/prepare_reactions.py | 199 +++++++++++++----- .../reaction_progression.py | 39 ++-- 4 files changed, 174 insertions(+), 71 deletions(-) diff --git a/AutoREACTER/detectors/reactions_library.py b/AutoREACTER/detectors/reactions_library.py index 4dd9a14..25f7fb9 100644 --- a/AutoREACTER/detectors/reactions_library.py +++ b/AutoREACTER/detectors/reactions_library.py @@ -355,8 +355,8 @@ def __init__(self): "Secondary Amine and Epoxide Polyaddition (Epoxy-Amine, Second Addition / Crosslink)": { "same_reactants": False, - "reactant_1": "secondary_amine_monomer", - "reactant_2": "di_epoxy_monomer", + "reactant_1": "secondary_amine", + "reactant_2": "di_epoxide", "product": "tertiary_amine_crosslink_product", "delete_atom": False, "reaction": "[NX3H1:1]-[H:6].[CX4:2]1[OX2:5][CX4:3]1>>[NX3:1][C:2][C:3][OX2:5][H:6]", 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/reaction_processor/prepare_reactions.py b/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py index eeea4bd..9a03245 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py @@ -15,12 +15,13 @@ from pathlib import Path from typing import Dict, List, Optional, TYPE_CHECKING +from AutoREACTER.reaction_preparation.ff_wrapper.REACTER_files_builder import logger 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.reaction_processor.utils import ( add_dict_as_new_columns, add_column_safe, compare_set, prepare_paths ) @@ -29,6 +30,7 @@ # Use TYPE_CHECKING to prevent circular imports with session.py if TYPE_CHECKING: from AutoREACTER.session import Session + from AutoREACTER.reaction_preparation.reaction_processor.reaction_progression import ReactionProgression class MappingError(Exception): @@ -101,21 +103,35 @@ def __init__(self, session: "Session"): self.csv_cache = prepare_paths(self.cache, "csv_cache") # --- PUBLIC --- + def prepare_reactions(self, session): + prepared_reactions = self._prepare_reactions_stage(session) + session.reaction_metadata = prepared_reactions # set BEFORE progression needs it + + reaction_progression = ReactionProgression(session) + added_reaction_progression = reaction_progression.reaction_progression() - def prepare_reactions(self, session: "Session") -> list[ReactionMetadata]: + prepared_reactions.extend(added_reaction_progression) + session.reaction_metadata = prepared_reactions # update with full final list + return session + + def _prepare_reactions_stage(self, session: "Session", loop: bool = False) -> list[ReactionMetadata]: """ Main pipeline: processes reaction instances, detects duplicates, and enriches metadata with template mappings. Args: session: The main Session object containing reaction instances to process + loop: Boolean indicating whether this is a looped call (default: False) Returns: List of processed ReactionMetadata objects with template mappings and edge atoms """ - reaction_instances = session.reaction_instances - + try: + reaction_instances = session.reaction_instances + except AttributeError: + reaction_instances = session + # Process and filter reactions - reactions_metadata = self._process_reaction_instances(reaction_instances) + reactions_metadata = self._process_reaction_instances(reaction_instances, loop=loop) unique_reaction_metadata = self._detect_duplicates(reactions_metadata) for reaction in unique_reaction_metadata: @@ -162,14 +178,13 @@ def prepare_reactions(self, session: "Session") -> list[ReactionMetadata]: reaction.template_reactant_to_product_mapping = template_mapped_dict # Store the finalized metadata inside the session - session.reaction_metadata = unique_reaction_metadata - reaction_progression = ReactionProgression(session) - return unique_reaction_metadata # --- PIPELINE STEPS (PRIVATE) --- - def _process_reaction_instances(self, detected_reactions: list[ReactionInstance]) -> list[ReactionMetadata]: + def _process_reaction_instances( + self, detected_reactions: list[ReactionInstance], loop: bool = False + ) -> list[ReactionMetadata]: """ Converts ReactionInstance objects into ReactionMetadata by building molecules and running reactions. @@ -179,40 +194,89 @@ def _process_reaction_instances(self, detected_reactions: list[ReactionInstance] Returns: List of ReactionMetadata objects with atom mappings """ + + csv_cache = self.csv_cache reaction_metadata = [] for reaction in detected_reactions: + # SMARTS template associated with this detected reaction. rxn_smarts = reaction.reaction_smarts - reactant_smiles_1 = reaction.monomer_1.smiles - 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 + 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( + f"Skipping reaction {reaction.reaction_name}: monomer rdkit_mol is None " + f"(likely failed sanitization upstream)." + ) + continue + same_reactants = reaction.same_reactants delete_atoms = reaction.delete_atom - # Build reaction and reactant molecules + forced_indexes_1 = None + forced_indexes_2 = None + + if loop: + # FORCED-REACTION MODE: use the actual index-scoped rdkit_mol (not a + # fresh SMILES reparse) so atom indices line up with fg_*_indexes. + # AddHs only appends new explicit H atoms at the end, so heavy-atom + # indices — the ones that matter for the FG match indices — are preserved. + mol_reactant_1 = Chem.AddHs(Chem.Mol(reaction.monomer_1.rdkit_mol)) + forced_indexes_1 = self._flatten_fg_indexes(reaction.functional_group_1) + + # Handle case where both reactants are identical + if same_reactants: + mol_reactant_2 = Chem.AddHs(Chem.Mol(reaction.monomer_1.rdkit_mol)) + else: + mol_reactant_2 = Chem.AddHs(Chem.Mol(reaction.monomer_2.rdkit_mol)) + + if reaction.functional_group_2 is not None: + forced_indexes_2 = self._flatten_fg_indexes(reaction.functional_group_2) + + # Direction is already known from the ReactionInstance (monomer_1 -> + # reactant slot 1, monomer_2 -> reactant slot 2), so unlike the normal + # path we do NOT also try the swapped ordering. + 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) + 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) - # Process products and build metadata reaction_metadata = self._process_reaction_products( rxn, csv_cache, reaction_tuple, delete_atoms, - reaction_metadata + reaction_metadata, + forced_indexes_1=forced_indexes_1, + forced_indexes_2=forced_indexes_2, ) - + return reaction_metadata + def _flatten_fg_indexes(self, fg: Optional["FunctionalGroupInfo"]) -> Optional[set]: + """ + Flattens a FunctionalGroupInfo's recorded match indexes (fg_1_indexes and, + if present, fg_2_indexes for di_different groups) into a single allowed + atom-index set for forced-reaction checking. + + Returns None only if the FG has no recorded indexes at all (no forcing applied). + """ + 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 _detect_duplicates(self, reaction_metadata_list: list[ReactionMetadata]) -> list[ReactionMetadata]: """ Filters duplicate reactions based on reactant and product molecules. @@ -238,13 +302,15 @@ def _detect_duplicates(self, reaction_metadata_list: list[ReactionMetadata]) -> 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]: + 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]: """ Runs reactions on reactant pairs and builds metadata for each product set. @@ -260,16 +326,17 @@ def _process_reaction_products(self, """ 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 + + # Assign atom map numbers and isotopes to reactants for tracking through the reaction self._assign_atom_map_numbers_and_set_isotopes(r1, r2) - # Run the reaction to get products + + # Run the reaction and get product sets products = rxn.RunReactants((r1, r2)) - - # If no products are generated, skip to the next reactant pair + + # Skip if no products were generated if not products: continue @@ -277,21 +344,18 @@ def _process_reaction_products(self, for product_set in products: df = pd.DataFrame(columns=["reactant_idx", "product_idx"]) - # 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) + # Reassign atom map numbers based on isotopes to recover original reactant identities 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 + # Reveal original template map numbers for visualization and validation self._reveal_template_map_numbers(product_combined) # Validate mapping consistency @@ -299,15 +363,19 @@ def _process_reaction_products(self, # Identify atoms involved in reaction center and initiators first_shell, initiator_idxs = self._assign_first_shell_and_initiators( - reactant_combined, - product_combined, - reverse_mapping + reactant_combined, product_combined, reverse_mapping ) - # Detect byproducts (smallest fragments) + # FORCED-INDEX CHECK: discard this product if the atom that actually + # reacted isn't inside the given index set for its side. + if forced_indexes_1 is not None or forced_indexes_2 is not None: + if 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) - # Combine all mapping data into single dataframe df_combined = pd.concat([ df, pd.Series(first_shell, name="first_shell"), @@ -316,14 +384,9 @@ def _process_reaction_products(self, ], axis=1).astype(pd.Int64Dtype()) total_products = len(reaction_metadata) + 1 - - # Clear isotopes before saving to restore normal chemistry self._clear_isotopes(reactant_combined, product_combined) - - # Save mapping dataframe to CSV df_combined.to_csv(csv_cache / f"reaction_{total_products}.csv", index=False) - # Create and store metadata object reaction_metadata.append( ReactionMetadata( reaction_id=total_products, @@ -345,6 +408,36 @@ def _process_reaction_products(self, return reaction_metadata # --- CORE REACTION LOGIC --- + + 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: + """ + Checks whether the atoms that actually reacted (the two 'initiators') fall + within the forced index constraint for their respective side. + + Combined-mol index ranges follow Chem.CombineMols(r1, r2) ordering: + [0, r1_atom_count) belongs to r1, [r1_atom_count, ...) belongs to r2. + """ + 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): + from AutoREACTER.reaction_preparation.reaction_processor.prepare_reactions import PrepareReactions + prepare_reactions = PrepareReactions(self.session) + prepared_reactions = prepare_reactions._prepare_reactions_stage(reaction_instances, loop=True) + return prepared_reactions def _assign_first_shell_and_initiators(self, reactant_combined: Chem.Mol, diff --git a/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py index 5b52789..75ac270 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py @@ -11,6 +11,7 @@ if TYPE_CHECKING: from AutoREACTER.session import Session from AutoREACTER.detectors.functional_groups_detector import MonomerRole + from AutoREACTER.reaction_preparation.reaction_processor.prepare_reactions import ReactionInstance, ReactionMetadata @dataclass(slots=True) @@ -40,13 +41,10 @@ class ReactionProgression: def __init__(self, session: "Session"): self.session = session self.session.reaction_progression_session = ReactionProgressionSession() - self.fg_detector = FunctionalGroupsDetector() self.rxn_detector = ReactionDetector() - self.reaction_progression() - - def reaction_progression(self, max_loop: int = MAX_LOOP) -> None: + def reaction_progression(self, max_loop: int = MAX_LOOP) -> list["ReactionMetadata"]: """ Progresses the reaction by iteratively applying detected chemistries to the session's molecules. @@ -101,10 +99,25 @@ def reaction_progression(self, max_loop: int = MAX_LOOP) -> None: print(len(reaction_instances)) # Debug print print(monomer_roles_in_loop) # Debug print - - if self._loop_break_condition(size_of_pool): - break - + prepared_reactions = self._index_based_reaction_preparation( + reaction_instances=reaction_instances + ) + print(prepared_reactions) # Debug print + if self._loop_break_condition( + size_before=size_of_pool, size_after=len(monomer_roles_in_loop) + ): + return prepared_reactions + return prepared_reactions + + def _index_based_reaction_preparation( + self, reaction_instances: list["ReactionInstance"] + ) -> list["ReactionMetadata"]: + from AutoREACTER.reaction_preparation.reaction_processor.prepare_reactions import PrepareReactions + + prepare_reactions = PrepareReactions(self.session) + prepared_reactions = prepare_reactions._prepare_reactions_stage(reaction_instances) + return prepared_reactions + def _prepare_products_for_idx_based_fg_detection( self, ) -> list[MonomerRoleforIndexBasedFGDetection]: @@ -226,13 +239,11 @@ def _smiles_to_rdkit_mol(self, smiles: str) -> Chem.Mol | None: """ return Chem.MolFromSmiles(smiles) - def _loop_break_condition(self, size_of_pool: int) -> bool: - if size_of_pool <= len(self.session.monomer_roles): + def _loop_break_condition(self, size_before: int, size_after: int) -> bool: + if size_after <= size_before: print( - f"Breaking the loop as the size of the pool ({size_of_pool}) " - f"is less than or equal to the number of monomer roles " - f"({len(self.session.monomer_roles)})." + f"Breaking the loop as the pool did not grow " + f"(before={size_before}, after={size_after})." ) return True - return False \ No newline at end of file From 5004246d6458c0eac31bf8b961e0d8fda742e793 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:39:54 -0400 Subject: [PATCH 016/104] Refactor prepare_reactions.py code style Clean up whitespace, trailing spaces, and newlines throughout the file. Reorganize section comments for better clarity (e.g., rename '--- PUBLIC ---' and '--- PIPELINE STEPS (PRIVATE) ---' to more descriptive headers). Reorder `_detect_duplicates` and `_flatten_fg_indexes` methods. Add missing newline at end of file. --- .../reaction_processor/prepare_reactions.py | 249 +++++++++--------- 1 file changed, 131 insertions(+), 118 deletions(-) diff --git a/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py b/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py index 9a03245..bce72c8 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py @@ -3,7 +3,7 @@ reaction metadata extraction, and visualization utilities using RDKit. 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 +initiators, byproducts), and generates metadata and visualizations for downstream analysis. It also includes validation checks to ensure mapping consistency and completeness. """ @@ -44,7 +44,7 @@ class SMARTSParsingError(Exception): @dataclass(slots=True) class ReactionMetadata: """ - Stores comprehensive metadata for a single reaction including molecular structures, + 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 @@ -92,6 +92,8 @@ class ReactionMetadata: class PrepareReactions: """Processes chemical reactions: builds atom mappings, identifies reaction centers, and detects byproducts.""" + # --- INITIALIZATION AND PUBLIC WORKFLOW --- + def __init__(self, session: "Session"): """Initialize using the shared AutoREACTER session object.""" self.session = session @@ -102,7 +104,6 @@ def __init__(self, session: "Session"): self.cache = self.staging_dir self.csv_cache = prepare_paths(self.cache, "csv_cache") - # --- PUBLIC --- def prepare_reactions(self, session): prepared_reactions = self._prepare_reactions_stage(session) session.reaction_metadata = prepared_reactions # set BEFORE progression needs it @@ -117,11 +118,11 @@ def prepare_reactions(self, session): def _prepare_reactions_stage(self, session: "Session", loop: bool = False) -> list[ReactionMetadata]: """ Main pipeline: processes reaction instances, detects duplicates, and enriches metadata with template mappings. - + Args: session: The main Session object containing reaction instances to process loop: Boolean indicating whether this is a looped call (default: False) - + Returns: List of processed ReactionMetadata objects with template mappings and edge atoms """ @@ -133,27 +134,27 @@ def _prepare_reactions_stage(self, session: "Session", loop: bool = False) -> li # Process and filter reactions reactions_metadata = self._process_reaction_instances(reaction_instances, loop=loop) unique_reaction_metadata = self._detect_duplicates(reactions_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 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() first_shell = reaction_dataframe["first_shell"].dropna().tolist() - + # Generate template mapping by walking reaction graph template_mapped_dict, edge_atoms = reaction_atom_walker( combined_reactant_molecule_object, first_shell, fully_mapped_dict ) - + # Add template mapping and edge atoms to dataframe reaction_dataframe = add_dict_as_new_columns( reaction_dataframe, @@ -167,7 +168,7 @@ def _prepare_reactions_stage(self, session: "Session", loop: bool = False) -> li 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 @@ -179,23 +180,24 @@ def _prepare_reactions_stage(self, session: "Session", loop: bool = False) -> li # Store the finalized metadata inside the session return unique_reaction_metadata - - # --- PIPELINE STEPS (PRIVATE) --- - + + + # --- REACTION INSTANCE AND PRODUCT PROCESSING --- + def _process_reaction_instances( self, detected_reactions: list[ReactionInstance], loop: bool = False ) -> list[ReactionMetadata]: """ Converts ReactionInstance objects into ReactionMetadata by building molecules and running reactions. - + Args: detected_reactions: List of detected reaction instances - + Returns: List of ReactionMetadata objects with atom mappings """ - + csv_cache = self.csv_cache reaction_metadata = [] @@ -257,51 +259,6 @@ def _process_reaction_instances( return reaction_metadata - def _flatten_fg_indexes(self, fg: Optional["FunctionalGroupInfo"]) -> Optional[set]: - """ - Flattens a FunctionalGroupInfo's recorded match indexes (fg_1_indexes and, - if present, fg_2_indexes for di_different groups) into a single allowed - atom-index set for forced-reaction checking. - - Returns None only if the FG has no recorded indexes at all (no forcing applied). - """ - 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 _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 - - # 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, @@ -313,14 +270,14 @@ def _process_reaction_products(self, ) -> list[ReactionMetadata]: """ Runs reactions on reactant pairs and builds metadata for each product set. - + 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 - + Returns: Updated list of ReactionMetadata objects """ @@ -339,7 +296,7 @@ def _process_reaction_products(self, # Skip if no products were generated 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"]) @@ -406,8 +363,54 @@ def _process_reaction_products(self, ) return reaction_metadata - - # --- CORE REACTION LOGIC --- + + + 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 + + # 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 + + + # --- PROGRESSION AND FORCED-INDEX HANDLING --- + + def _flatten_fg_indexes(self, fg: Optional["FunctionalGroupInfo"]) -> Optional[set]: + """ + Flattens a FunctionalGroupInfo's recorded match indexes (fg_1_indexes and, + if present, fg_2_indexes for di_different groups) into a single allowed + atom-index set for forced-reaction checking. + + Returns None only if the FG has no recorded indexes at all (no forcing applied). + """ + 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, @@ -432,29 +435,33 @@ def _initiators_within_forced_indexes( 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): from AutoREACTER.reaction_preparation.reaction_processor.prepare_reactions import PrepareReactions prepare_reactions = PrepareReactions(self.session) prepared_reactions = prepare_reactions._prepare_reactions_stage(reaction_instances, loop=True) return prepared_reactions - - def _assign_first_shell_and_initiators(self, - reactant_combined: Chem.Mol, - product_combined: Chem.Mol, + + + # --- REACTION-CENTER ANALYSIS AND VALIDATION --- + + 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]]: """ 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). - + Args: reactant_combined: Combined reactant molecule product_combined: Combined product molecule reversed_mapping_dict: Product idx -> Reactant idx mapping - + Returns: Tuple of (first_shell atom indices, initiator atom indices) - + Raises: ValueError: If exactly 2 initiators are not found """ @@ -478,24 +485,25 @@ def _assign_first_shell_and_initiators(self, # 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}") return first_shell, initiator_idxs - - def _detect_byproducts(self, - product_combined: Chem.Mol, - reversed_mapping_dict: dict[int, int], + + + 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. - + Args: product_combined: Combined product molecule reversed_mapping_dict: Product idx -> Reactant idx mapping delete_atoms: Whether to perform byproduct detection - + Returns: List of reactant indices corresponding to byproduct atoms """ @@ -504,7 +512,7 @@ def _detect_byproducts(self, # 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) @@ -516,16 +524,17 @@ def _detect_byproducts(self, byproduct_reactant_indices.append(reversed_mapping_dict[p_idx]) 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 - + Raises: MappingError: If any validation check fails """ @@ -565,12 +574,12 @@ def _validate_mapping(self, df: pd.DataFrame, reactant: Chem.Mol, product: Chem. raise MappingError(f"Mapping validation error: incomplete mapping for product.") # --- 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 @@ -586,12 +595,13 @@ def _assign_atom_map_numbers_and_set_isotopes(self, r1: Chem.Mol, r2: Chem.Mol) 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. - + Args: mol: Product molecule with isotope information """ @@ -601,28 +611,28 @@ def _reassign_atom_map_numbers_by_isotope(self, mol: Chem.Mol) -> None: atom.SetAtomMapNum(surviving_idx) # Restore original map number atom.SetIsotope(0) # Clear isotope to restore normal chemistry - def _build_atom_index_mapping(self, - reactant_combined: Chem.Mol, + 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 - + Returns: Tuple of (mapping dict: reactant_idx -> product_idx, dataframe with mapping) """ 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 } - + rows = [] for r_atom in reactant_combined.GetAtoms(): r_map_num = r_atom.GetAtomMapNum() @@ -645,7 +655,7 @@ 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. - + Args: mol: Product molecule """ @@ -657,7 +667,7 @@ def _reveal_template_map_numbers(self, mol: Chem.Mol) -> None: 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 @@ -667,20 +677,21 @@ def _clear_isotopes(self, mol_1: Chem.Mol, mol_2: Chem.Mol) -> None: for atom in mol_2.GetAtoms(): atom.SetIsotope(0) - # --- BUILDERS --- - + # --- REACTION AND REACTANT 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]: """ Builds reactant molecules from SMILES strings with explicit hydrogens added. - + Args: reactant_smiles_1: SMILES string for first reactant reactant_smiles_2: SMILES string for second reactant - + Returns: Tuple of (reactant1 molecule, reactant2 molecule) with explicit hydrogens """ @@ -688,24 +699,25 @@ def _build_reactants(self, reactant_smiles_1: str, reactant_smiles_2: str) -> tu 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) - + 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. - + Args: same_reactants: Whether both reactants are identical mol_reactant_1: First reactant molecule mol_reactant_2: Second reactant molecule - + Returns: List of reactant pairs [[r1, r2], ...] to process """ @@ -713,16 +725,17 @@ def _build_reaction_tuple(self, same_reactants: bool, mol_reactant_1: Chem.Mol, return [[mol_reactant_1, mol_reactant_1]] return [[mol_reactant_1, mol_reactant_2], [mol_reactant_2, mol_reactant_1]] - - # --- HELPERS --- - + + + # --- GENERAL HELPERS --- + def _is_consecutive(self, num_list: list[int]) -> bool: """ Checks if list contains consecutive integers with no duplicates. - + Args: num_list: List of integers to check - + Returns: True if list is consecutive and has no duplicates, False otherwise """ @@ -735,7 +748,7 @@ def _is_consecutive(self, num_list: list[int]) -> bool: ) # --- VISUALIZATION --- - + def reaction_templates_highlighted_image_grid( self, session: "Session", @@ -743,11 +756,11 @@ def reaction_templates_highlighted_image_grid( ) -> Image: """ Generates grid image of reactions with highlighted atoms based on type. - + Args: session: The Session object containing reaction metadata to visualize highlight_type: Type of atoms to highlight - "template", "edge", "initiators", or "delete" - + Returns: PIL Image containing 2-column grid of reactant-product pairs with highlighted atoms """ @@ -798,7 +811,7 @@ def reaction_templates_highlighted_image_grid( color_map[a] = (1.0, 0.0, 0.0) # red mols.extend([reactant, product]) - + # Build atom mappings for highlighting forward_map = metadata.reactant_to_product_mapping reactant_atoms = atoms @@ -828,4 +841,4 @@ def reaction_templates_highlighted_image_grid( subImgSize=(400, 400), useSVG=False, ) - return img \ No newline at end of file + return img From 1fa9111e45e0625213a624ccefc9f7ee47f4a505 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:09:19 -0400 Subject: [PATCH 017/104] Fix product idx remapping for multi-fragment products Refactors _get_product_index into _get_product_idxs, which now also returns the (possibly trimmed) product molecule. When a reaction produces multiple disconnected fragments, only the largest heavy-atom fragment is kept and product atom indices are remapped accordingly. Also fixes _clean_product to operate on a copy so the original molecule is not mutated, and updates _get_product_smiles to use the cleaned copy. Adds a glycine test example JSON. --- .../reaction_processor/prepare_reactions.py | 5 +- .../reaction_progression.py | 123 ++++++++++++++---- examples/test_glycine.json | 19 +++ 3 files changed, 121 insertions(+), 26 deletions(-) create mode 100644 examples/test_glycine.json diff --git a/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py b/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py index bce72c8..69eeed6 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py @@ -8,7 +8,7 @@ """ # WARNING: -# When modifying this file for dataframe or any other indexing variables use idx and idxs, do not use index or indices or similar. +# When modifying this file for dataframe or any other indexing variables use idx and idxs, do not use index or indices or similar. Mapping validation error from dataclasses import dataclass from functools import reduce @@ -570,9 +570,10 @@ def _validate_mapping(self, df: pd.DataFrame, reactant: Chem.Mol, product: Chem. # All atoms must be mapped (complete mapping) if len(r_idxs) != reactant.GetNumAtoms(): raise MappingError(f"Mapping validation error: incomplete mapping for reactant.") + # pass if len(p_idxs) != product.GetNumAtoms(): raise MappingError(f"Mapping validation error: incomplete mapping for product.") - + # pass # --- ATOM MAPPING --- def _assign_atom_map_numbers_and_set_isotopes(self, r1: Chem.Mol, r2: Chem.Mol) -> None: diff --git a/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py index 75ac270..fc832b5 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py @@ -129,14 +129,15 @@ def _prepare_products_for_idx_based_fg_detection( for reaction in reaction_metadata: product_mol = reaction.product_combined_RDmol - + indexes_in_template, product_mol = self._get_product_idxs( + reaction.template_reactant_to_product_mapping, + product_mol + ) monomer_roles_for_idx_based_fg_detection.append( MonomerRoleforIndexBasedFGDetection( smiles=self._get_product_smiles(product_mol), name=f"new_{reaction.reaction_id}", - indexes_in_template=self._get_product_index( - reaction.template_reactant_to_product_mapping - ), + indexes_in_template=indexes_in_template, is_monomer=False, is_looped=False, rdkit_mol=self._sanitize_molecule(product_mol), @@ -165,47 +166,121 @@ def _sanitize_molecule(self, mol: Chem.Mol) -> Chem.Mol | None: def _clean_product(self, mol: Chem.Mol) -> Chem.Mol: """ - Removes atom map numbers and isotope labels from a product molecule. + Return a copy of the molecule with atom-map numbers and isotope + labels removed. + + The input molecule is not modified. """ - for atom in mol.GetAtoms(): + cleaned_mol = Chem.Mol(mol) + + for atom in cleaned_mol.GetAtoms(): atom.SetAtomMapNum(0) atom.SetIsotope(0) - return mol + return cleaned_mol + def _get_product_smiles(self, mol: Chem.Mol) -> str: """ - Converts an RDKit molecule object to its corresponding SMILES string. + Convert a product molecule to SMILES without modifying the + original RDKit molecule. """ - self._clean_product(mol) + cleaned_mol = self._clean_product(mol) try: - return Chem.MolToSmiles(mol) + return Chem.MolToSmiles(cleaned_mol) except Exception: return "" - def _get_product_index( + + def _get_product_idxs( self, - template_reactant_to_product_mapping: dict, - ) -> list[int]: + template_reactant_to_product_mapping: dict[int, int], + mol: Chem.Mol, + ) -> tuple[list[int], Chem.Mol]: """ - Retrieves product indexes from the reactant-to-product mapping. - - Args: - template_reactant_to_product_mapping (dict): Mapping from reactant - indices to product indices. + Retrieve mapped product atom idxs and keep only the largest + molecular fragment when the product contains multiple fragments. Returns: - list[int]: Corresponding product indexes. + A tuple containing: + - Product atom idxs relative to the returned molecule. + - The complete product or its largest fragment. """ - product_indexes = [] + product = Chem.Mol(mol) - for product_idx in template_reactant_to_product_mapping.values(): - product_indexes.append(product_idx) + 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, + ) - print(f"Product indexes: {product_indexes}") # Debug print + print(f"Product idxs: {product_idxs}") - return product_indexes + return product_idxs, product + + + def _keep_largest_fragment( + self, + mol: Chem.Mol, + product_idxs: list[int], + ) -> tuple[Chem.Mol, list[int]]: + """ + Keep the fragment with the largest number of heavy atoms and + remap product atom idxs to the retained fragment. + + Args: + mol: + Molecule containing one or more disconnected fragments. + product_idxs: + Product atom idxs referring to the original molecule. + + Returns: + A tuple containing: + - The largest fragment. + - Product atom idxs remapped to the largest fragment. + """ + 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.") + + largest_fragment_position = max( + range(len(fragments)), + key=lambda position: fragments[position].GetNumHeavyAtoms(), + ) + + largest_fragment = fragments[largest_fragment_position] + + # The atom mapping stores: + # new fragment idx -> original molecule idx. + original_atom_idxs = fragment_atom_mappings[ + largest_fragment_position + ] + + 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_monomer_flag(self) -> int: """ diff --git a/examples/test_glycine.json b/examples/test_glycine.json new file mode 100644 index 0000000..33c5da9 --- /dev/null +++ b/examples/test_glycine.json @@ -0,0 +1,19 @@ +{ + "simulation_name": "Epoxy_Test_Primary_Diamine_Diepoxy", + "simulations": [ + { + "tag": "epoxy_test", + "temperature": 300, + "density": 1.0, + "monomer_counts": { + "di_epoxy": 200 + } + } + ], + "monomers": [ + { + "name": "di_epoxy", + "smiles": "NCC(=O)O" + } + ] +} \ No newline at end of file From 7f77e7e8f2b61c9b11786522ee14e66da13c6a35 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:16:58 -0400 Subject: [PATCH 018/104] Remove unused detectors and processor files Delete placeholder/incomplete files: empty functional_groups_rules.json, reaction_rules.json, detected_chemistry_filter.py (contained unfinished logic with syntax errors), and species_pool.py. --- .../detectors/functional_groups_rules.json | 0 AutoREACTER/detectors/reaction_rules.json | 15 --- .../detected_chemistry_filter.py | 107 ------------------ .../reaction_processor/species_pool.py | 41 ------- 4 files changed, 163 deletions(-) delete mode 100644 AutoREACTER/detectors/functional_groups_rules.json delete mode 100644 AutoREACTER/detectors/reaction_rules.json delete mode 100644 AutoREACTER/reaction_preparation/reaction_processor/detected_chemistry_filter.py delete mode 100644 AutoREACTER/reaction_preparation/reaction_processor/species_pool.py diff --git a/AutoREACTER/detectors/functional_groups_rules.json b/AutoREACTER/detectors/functional_groups_rules.json deleted file mode 100644 index e69de29..0000000 diff --git a/AutoREACTER/detectors/reaction_rules.json b/AutoREACTER/detectors/reaction_rules.json deleted file mode 100644 index 1493150..0000000 --- a/AutoREACTER/detectors/reaction_rules.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "multi_step_reactions": [ - { - "name": "epoxy_polymerization", - "if_reactions": "Amine Epoxy Addition First Stage", - "required_reactions": [ - "Amine Epoxy Addition Second Stage" - ] - , - "fg_additon": { - "primary_amine": "secondary_amine" - } - } - ] -} \ No newline at end of file diff --git a/AutoREACTER/reaction_preparation/reaction_processor/detected_chemistry_filter.py b/AutoREACTER/reaction_preparation/reaction_processor/detected_chemistry_filter.py deleted file mode 100644 index 6470042..0000000 --- a/AutoREACTER/reaction_preparation/reaction_processor/detected_chemistry_filter.py +++ /dev/null @@ -1,107 +0,0 @@ -from __future__ import annotations # 1. Must be the first line - -from dataclasses import dataclass -from typing import TYPE_CHECKING, Optional -import json -from importlib.resources import files - -if TYPE_CHECKING: - from AutoREACTER.session import Session - - -@dataclass(slots=True) -class DetectedChemistries: - functional_groups: list[str] # Unique functional group SMARTS detected in available reactions - reactions: dict[str, str] # reaction_name -> reaction_smarts - - - -class DetectedChemistryFilter: - """ - Collects available detected chemistry from reaction instances. - - This class extracts: - - unique functional group SMARTS used by detected reactions - - unique reaction names and their reaction SMARTS - """ - - def __init__(self, session: Session): - self.reaction_instances = session.reaction_instances or [] - - def _add_to_the_list(self, list_to_add: list[str], item: Optional[str]) -> None: - """ - Adds an item to the list if it is not None and not already present. - """ - if item is not None and item not in list_to_add: - list_to_add.append(item) - - def _add_to_the_dict(self, dict_to_add: dict[str, str], key: str, value: str) -> None: - """ - Adds a key-value pair to the dictionary if the key is not already present. - """ - if key not in dict_to_add: - dict_to_add[key] = value - - def filter(self) -> DetectedChemistries: - """ - Extracts available functional group SMARTS and reaction SMARTS - from detected reaction instances. - - Returns: - DetectedChemistries: Available functional groups and reactions. - """ - available_functional_groups: list[str] = [] - available_reactions: dict[str, str] = {} - - for reaction_instance in self.reaction_instances: - reaction_name = reaction_instance.reaction_name - reaction_smarts = reaction_instance.reaction_smarts - - self._add_to_the_dict( - available_reactions, - reaction_name, - reaction_smarts, - ) - - functional_group_1 = reaction_instance.functional_group_1 - self._add_to_the_list( - available_functional_groups, - functional_group_1.fg_smarts_1, - ) - self._add_to_the_list( - available_functional_groups, - functional_group_1.fg_smarts_2, - ) - - functional_group_2 = reaction_instance.functional_group_2 - if functional_group_2 is not None: - self._add_to_the_list( - available_functional_groups, - functional_group_2.fg_smarts_1, - ) - self._add_to_the_list( - available_functional_groups, - functional_group_2.fg_smarts_2, - ) - rules = self._add_progessive_chemistries() - for reaction in available_reactions: - for rule in rules: - print(f"yet to implement for reaction: {reaction}, rule: {rule}" - in here we need to add functional groups with meta data - and then add to the set. - ) - return DetectedChemistries( - functional_groups=available_functional_groups, - reactions=available_reactions, - ) - - - - - def _add_progessive_chemistries(): - rules_file = files("AutoREACTER.detectors").joinpath("reaction_rules.json") - - with rules_file.open("r", encoding="utf-8") as file: - reaction_rules = json.load(file) - rules = reaction_rules["multi_step_reactions"] - return rules \ No newline at end of file diff --git a/AutoREACTER/reaction_preparation/reaction_processor/species_pool.py b/AutoREACTER/reaction_preparation/reaction_processor/species_pool.py deleted file mode 100644 index 11768f2..0000000 --- a/AutoREACTER/reaction_preparation/reaction_processor/species_pool.py +++ /dev/null @@ -1,41 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from rdkit import Chem -from typing import Optional - -@dataclass(slots=True) -class PoolSpecies: - """ - Represents a species in the reaction pool. - Attributes: - species_id (str): The unique identifier of the species. - is_monomer (bool): Indicates whether the species is a monomer. - smiles (Optional[str]): The SMILES representation of the species. - mol (Optional[Chem.Mol]): The RDKit Mol object of the species. - template_idxes (Optional[list[int]]): The list of atom indices used as a template. - """ - species_id: str - is_monomer: bool - smiles: Optional[str] = None - mol: Optional[Chem.Mol] = None - template_idxes: Optional[list[int]] = None - - -def _populate_mols(pool: list[PoolSpecies]) -> list[PoolSpecies]: - """ - Populate the `mol` and `template_idxes` attributes of each monomer species in the pool based on its SMILES string. - Args: - pool (list[PoolSpecies]): The list of species in the pool. - - Returns: - list[PoolSpecies]: The updated list of species with populated `mol` and `template_idxes` attributes. - """ - for species in pool: - if species.smiles and species.mol is None and species.is_monomer: - species.mol = Chem.MolFromSmiles(species.smiles) - atoms = [] - for atom in species.mol.GetAtoms(): - atoms.append(atom.GetIdx()) - species.template_idxes = atoms - return pool From fcfe4a0ebf0cbd45293b613d41bf4a116cb9ff41 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:25:05 -0400 Subject: [PATCH 019/104] Add DeduplicationDetector for reactions Introduces DeduplicationDetector, which converts LAMMPS molecule-template files to NetworkX graphs and uses graph isomorphism (matching atom and bond types) to identify duplicate pre/post reaction pairs. --- .../deduplication_detector.py | 309 ++++++++++++++++++ 1 file changed, 309 insertions(+) create mode 100644 AutoREACTER/reaction_preparation/deduplication_detector.py diff --git a/AutoREACTER/reaction_preparation/deduplication_detector.py b/AutoREACTER/reaction_preparation/deduplication_detector.py new file mode 100644 index 0000000..6d73eb2 --- /dev/null +++ b/AutoREACTER/reaction_preparation/deduplication_detector.py @@ -0,0 +1,309 @@ +import os +from pathlib import Path + +import networkx as nx + + +class DeduplicationDetector: + def __init__(self): + # Graphs are not hashable, so store reaction graph pairs in a list. + self.seen_reactions: list[tuple[nx.Graph, nx.Graph]] = [] + + def is_duplicate( + self, + pre_template_graph: nx.Graph, + post_template_graph: nx.Graph, + ) -> bool: + """ + Check whether a pre/post reaction graph pair has already been seen. + + Atom types and bond types are included in the graph comparison. + Coordinates are intentionally ignored because the same molecular + topology can have different coordinates. + """ + node_match = nx.algorithms.isomorphism.categorical_node_match( + "atom_type", + None, + ) + edge_match = nx.algorithms.isomorphism.categorical_edge_match( + "bond_type", + None, + ) + + for seen_pre_graph, seen_post_graph in self.seen_reactions: + pre_matches = nx.is_isomorphic( + pre_template_graph, + seen_pre_graph, + node_match=node_match, + edge_match=edge_match, + ) + + if not pre_matches: + continue + + post_matches = nx.is_isomorphic( + post_template_graph, + seen_post_graph, + node_match=node_match, + edge_match=edge_match, + ) + + if post_matches: + return True + + self.seen_reactions.append( + ( + pre_template_graph.copy(), + post_template_graph.copy(), + ) + ) + + return False + + def lammps_molecule_to_networkx( + self, + file_path: str | Path, + ) -> nx.Graph: + """ + Convert a LAMMPS molecule-template file into a NetworkX graph. + + Supported sections: + Types + Coords + Bonds + """ + file_path = Path(file_path) + + if not file_path.exists(): + raise FileNotFoundError( + f"Molecule file does not exist: {file_path}" + ) + + sections = self._read_sections(file_path) + + atom_mapping: dict[int, str] = {} + coord_mapping: dict[int, tuple[float, float, float]] = {} + bond_mapping: dict[int, tuple[str, int, int]] = {} + + for line in sections.get("Types", []): + atom_data = line.split() + + if len(atom_data) < 2: + raise ValueError( + f"Invalid Types line in {file_path}: {line!r}" + ) + + atom_id = int(atom_data[0]) + atom_type = atom_data[1] + atom_mapping[atom_id] = atom_type + + for line in sections.get("Coords", []): + coord_data = line.split() + + if len(coord_data) < 4: + raise ValueError( + f"Invalid Coords line in {file_path}: {line!r}" + ) + + atom_id = int(coord_data[0]) + x = float(coord_data[1]) + y = float(coord_data[2]) + z = float(coord_data[3]) + + coord_mapping[atom_id] = (x, y, z) + + for line in sections.get("Bonds", []): + bond_data = line.split() + + if len(bond_data) < 4: + raise ValueError( + f"Invalid Bonds line in {file_path}: {line!r}" + ) + + bond_id = int(bond_data[0]) + bond_type = bond_data[1] + atom1_id = int(bond_data[2]) + atom2_id = int(bond_data[3]) + + bond_mapping[bond_id] = ( + bond_type, + atom1_id, + atom2_id, + ) + + graph = nx.Graph() + + for atom_id, atom_type in atom_mapping.items(): + if atom_id not in coord_mapping: + raise ValueError( + f"Atom {atom_id} has a type but no coordinates " + f"in {file_path}." + ) + + graph.add_node( + atom_id, + atom_type=atom_type, + coords=coord_mapping[atom_id], + ) + + for bond_id, bond_data in bond_mapping.items(): + bond_type, atom1_id, atom2_id = bond_data + + if atom1_id not in graph or atom2_id not in graph: + raise ValueError( + f"Bond {bond_id} references an undefined atom " + f"in {file_path}." + ) + + graph.add_edge( + atom1_id, + atom2_id, + bond_id=bond_id, + bond_type=bond_type, + ) + + print( + f"Graph created from {file_path} with " + f"{graph.number_of_nodes()} nodes and " + f"{graph.number_of_edges()} edges." + ) + + return graph + + def _read_sections( + self, + file_path: Path, + ) -> dict[str, list[str]]: + """ + Read relevant sections from a LAMMPS molecule-template file. + """ + supported_sections = { + "Types", + "Coords", + "Bonds", + "Charges", + "Molecules", + "Angles", + "Dihedrals", + "Impropers", + "Special Bond Counts", + "Special Bonds", + } + + 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: + # Remove comments but preserve the actual data. + line = raw_line.split("#", maxsplit=1)[0].strip() + + if not line: + continue + + if line in supported_sections: + current_section = line + sections.setdefault(current_section, []) + continue + + if current_section is not None: + # Stop collecting when another unsupported header/count + # line is encountered only through recognized sections. + sections[current_section].append(line) + + return sections + + def _couple_graphs( + self, + pre_template_graph: nx.Graph, + post_template_graph: nx.Graph, + ) -> dict[str, nx.Graph]: + """ + Store the pre- and post-template graphs together. + """ + return { + "pre_template_graph": pre_template_graph, + "post_template_graph": post_template_graph, + } + + def _compare_graphs( + self, + mol_file_paths: list[str | Path], + ) -> dict[str, bool]: + """ + Process pre-template files and report whether each reaction is + a duplicate of a previously processed pre/post pair. + + Returns: + Mapping from the pre-template file path to duplicate status. + """ + results: dict[str, bool] = {} + + for file_path_value in mol_file_paths: + file_path = Path(file_path_value) + + if "pre" not in file_path.name: + continue + + post_file_name = file_path.name.replace("pre", "post", 1) + post_file_path = file_path.with_name(post_file_name) + + if not post_file_path.exists(): + print( + f"Post-template file does not exist for " + f"{file_path}." + ) + continue + + pre_template_graph = self.lammps_molecule_to_networkx( + file_path + ) + post_template_graph = self.lammps_molecule_to_networkx( + post_file_path + ) + + is_duplicate = self.is_duplicate( + pre_template_graph, + post_template_graph, + ) + + results[str(file_path)] = is_duplicate + + if is_duplicate: + print( + f"Duplicate reaction: {file_path.name} and " + f"{post_file_path.name}" + ) + else: + print( + f"Unique reaction: {file_path.name} and " + f"{post_file_path.name}" + ) + + return results + + +if __name__ == "__main__": + deduplication_detector = DeduplicationDetector() + + folder_path = Path( + "/mnt/c/Users/Janitha/Documents/AutoREACTER/examples/" + "AutoREACTER_outputs/" + "Epoxy_Test_Primary_Diamine_Diepoxy" + ) + + pre_template_files = sorted( + file_path + for file_path in folder_path.glob("*.molecule") + if "pre" in file_path.name + ) + + results = deduplication_detector._compare_graphs( + pre_template_files + ) + + print("\nDeduplication results:") + + for file_path, is_duplicate in results.items(): + status = "duplicate" if is_duplicate else "unique" + print(f"{Path(file_path).name}: {status}") \ No newline at end of file From 33adcd3e203fa428e4578dbdf0aee9b0f6560ba7 Mon Sep 17 00:00:00 2001 From: Janitha Mahanthe <119646255+janitha-mahanthe@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:15:02 -0400 Subject: [PATCH 020/104] Add RDKit reaction dedup across progression Reworks deduplication to compare coupled pre/post graphs using normalized atom/bond labels, adds separate LAMMPS and RDKit comparison caches, and introduces RDKit molecule graph conversion for in-memory reaction metadata. Reaction progression now accumulates reactions across iterations, disables duplicates via dedup checks, skips inactive reactions downstream, and uses a session-level monotonic reaction ID counter so generated reaction CSV IDs remain globally unique across loop passes. Also updates LUNAR config to a concrete local root path and refreshes notebook debug output. --- .../deduplication_detector.py | 675 ++++++++++++++---- .../ff_wrapper/lunar_client/config.py | 2 +- .../reaction_processor/prepare_reactions.py | 24 +- .../reaction_progression.py | 52 +- test.ipynb | 11 +- 5 files changed, 585 insertions(+), 179 deletions(-) diff --git a/AutoREACTER/reaction_preparation/deduplication_detector.py b/AutoREACTER/reaction_preparation/deduplication_detector.py index 6d73eb2..e510aee 100644 --- a/AutoREACTER/reaction_preparation/deduplication_detector.py +++ b/AutoREACTER/reaction_preparation/deduplication_detector.py @@ -1,188 +1,409 @@ -import os +from __future__ import annotations + 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.reaction_progression import ( + ReactionMetadata, + ) class DeduplicationDetector: - def __init__(self): - # Graphs are not hashable, so store reaction graph pairs in a list. - self.seen_reactions: list[tuple[nx.Graph, nx.Graph]] = [] + """ + Detect duplicate pre/post reaction graph pairs. + + Coordinates, atom IDs, and bond IDs are ignored during graph + isomorphism comparison. + + A coupled graph is created for every reaction pair so that the same + atom mapping must be valid for both the reactant and product graphs. + """ + + NODE_ATTRIBUTE = "atom_label" + EDGE_ATTRIBUTE = "bond_label" + + LAMMPS_COMPARISON_GROUP = "lammps" + RDKIT_COMPARISON_GROUP = "rdkit" + + def __init__(self) -> None: + """ + Initialize independent deduplication caches. + """ + self.seen_reactions: dict[str, list[nx.Graph]] = { + self.LAMMPS_COMPARISON_GROUP: [], + self.RDKIT_COMPARISON_GROUP: [], + } def is_duplicate( self, pre_template_graph: nx.Graph, post_template_graph: nx.Graph, + comparison_group: str, ) -> bool: """ - Check whether a pre/post reaction graph pair has already been seen. + Check whether an equivalent pre/post reaction pair has been seen. + + Args: + pre_template_graph: + Graph representing the reactant state. + + post_template_graph: + Graph representing the product state. - Atom types and bond types are included in the graph comparison. - Coordinates are intentionally ignored because the same molecular - topology can have different coordinates. + comparison_group: + Cache group used for the comparison, such as ``lammps`` + or ``rdkit``. + + Returns: + True if an equivalent reaction pair has already been seen. + Otherwise, stores the reaction pair and returns False. """ + coupled_graph = self._couple_graphs( + pre_template_graph=pre_template_graph, + post_template_graph=post_template_graph, + ) + node_match = nx.algorithms.isomorphism.categorical_node_match( - "atom_type", - None, + ["phase", self.NODE_ATTRIBUTE], + [None, None], ) + edge_match = nx.algorithms.isomorphism.categorical_edge_match( - "bond_type", - None, + ["relationship", self.EDGE_ATTRIBUTE], + [None, None], ) - for seen_pre_graph, seen_post_graph in self.seen_reactions: - pre_matches = nx.is_isomorphic( - pre_template_graph, - seen_pre_graph, - node_match=node_match, - edge_match=edge_match, - ) + seen_graphs = self.seen_reactions.setdefault( + comparison_group, + [], + ) - if not pre_matches: + for seen_graph in seen_graphs: + # Cheap checks before running graph isomorphism. + if coupled_graph.number_of_nodes() != seen_graph.number_of_nodes(): continue - post_matches = nx.is_isomorphic( - post_template_graph, - seen_post_graph, + 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, - ) - - if post_matches: + ): return True - self.seen_reactions.append( - ( - pre_template_graph.copy(), - post_template_graph.copy(), - ) - ) + seen_graphs.append(coupled_graph.copy()) return False - def lammps_molecule_to_networkx( + def _couple_graphs( self, - file_path: str | Path, + pre_template_graph: nx.Graph, + post_template_graph: nx.Graph, ) -> nx.Graph: """ - Convert a LAMMPS molecule-template file into a NetworkX graph. + Combine the reactant and product graphs into one graph. - Supported sections: - Types - Coords - Bonds + Every atom is represented twice: + + ("pre", atom_id) + ("post", atom_id) + + A correspondence edge connects the same atom ID in the pre- and + post-reaction graphs. This requires one consistent atom mapping + across both reaction states. """ - file_path = Path(file_path) + pre_atom_ids = set(pre_template_graph.nodes) + post_atom_ids = set(post_template_graph.nodes) - if not file_path.exists(): - raise FileNotFoundError( - f"Molecule file does not exist: {file_path}" + 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 ) - sections = self._read_sections(file_path) + 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() + + self._add_phase_to_coupled_graph( + source_graph=pre_template_graph, + coupled_graph=coupled_graph, + phase="pre", + ) + + self._add_phase_to_coupled_graph( + source_graph=post_template_graph, + coupled_graph=coupled_graph, + phase="post", + ) - atom_mapping: dict[int, str] = {} - coord_mapping: dict[int, tuple[float, float, float]] = {} - bond_mapping: dict[int, tuple[str, int, int]] = {} + for atom_id in pre_atom_ids: + coupled_graph.add_edge( + ("pre", atom_id), + ("post", atom_id), + relationship="atom_correspondence", + **{self.EDGE_ATTRIBUTE: None}, + ) + + return coupled_graph - for line in sections.get("Types", []): - atom_data = line.split() + 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 pre/post graph. + """ + for atom_id, attributes in source_graph.nodes(data=True): + atom_label = attributes.get(self.NODE_ATTRIBUTE) - if len(atom_data) < 2: + if atom_label is None: raise ValueError( - f"Invalid Types line in {file_path}: {line!r}" + f"Node {atom_id} is missing the required " + f"{self.NODE_ATTRIBUTE!r} attribute." ) - atom_id = int(atom_data[0]) - atom_type = atom_data[1] - atom_mapping[atom_id] = atom_type + coupled_graph.add_node( + (phase, atom_id), + phase=phase, + **{self.NODE_ATTRIBUTE: atom_label}, + ) - for line in sections.get("Coords", []): - coord_data = line.split() + for atom1_id, atom2_id, attributes in source_graph.edges( + data=True + ): + bond_label = attributes.get(self.EDGE_ATTRIBUTE) - if len(coord_data) < 4: + if bond_label is None: raise ValueError( - f"Invalid Coords line in {file_path}: {line!r}" + f"Edge {atom1_id}-{atom2_id} is missing the required " + f"{self.EDGE_ATTRIBUTE!r} attribute." ) - atom_id = int(coord_data[0]) - x = float(coord_data[1]) - y = float(coord_data[2]) - z = float(coord_data[3]) + coupled_graph.add_edge( + (phase, atom1_id), + (phase, atom2_id), + relationship="bond", + **{self.EDGE_ATTRIBUTE: bond_label}, + ) - coord_mapping[atom_id] = (x, y, z) + def rdkit_mol_to_networkx( + self, + molecule: Chem.Mol, + ) -> nx.Graph: + """ + Convert an in-memory RDKit molecule into a NetworkX graph. - for line in sections.get("Bonds", []): - bond_data = line.split() + Coordinates are not read or stored. - if len(bond_data) < 4: - raise ValueError( - f"Invalid Bonds line in {file_path}: {line!r}" - ) + Node attribute: + atom_label: + Chemical element symbol. - bond_id = int(bond_data[0]) - bond_type = bond_data[1] - atom1_id = int(bond_data[2]) - atom2_id = int(bond_data[3]) + Edge attribute: + bond_label: + RDKit bond type. + """ + if molecule is None: + raise ValueError( + "Cannot create a graph from a None RDKit molecule." + ) - bond_mapping[bond_id] = ( - bond_type, + graph = nx.Graph() + + for atom in molecule.GetAtoms(): + atom_id = atom.GetIdx() + + graph.add_node( + atom_id, + **{ + self.NODE_ATTRIBUTE: atom.GetSymbol(), + }, + ) + + for bond in molecule.GetBonds(): + atom1_id = bond.GetBeginAtomIdx() + atom2_id = bond.GetEndAtomIdx() + + graph.add_edge( atom1_id, atom2_id, + **{ + self.EDGE_ATTRIBUTE: str( + bond.GetBondType() + ), + }, + ) + + return graph + + def lammps_molecule_to_networkx( + self, + file_path: str | Path, + ) -> nx.Graph: + """ + Convert a LAMMPS molecule-template file into a NetworkX graph. + + Only the following sections are used: + + Types + Bonds + + Coordinates, charges, angles, dihedrals, impropers, atom IDs, + and bond IDs are not used in graph isomorphism comparison. + """ + 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() - for atom_id, atom_type in atom_mapping.items(): - if atom_id not in coord_mapping: + 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, + ) + + print( + f"Graph created from {file_path.name}: " + f"{graph.number_of_nodes()} atoms and " + f"{graph.number_of_edges()} bonds." + ) + + return graph + + def _add_lammps_atoms( + self, + graph: nx.Graph, + type_lines: list[str], + file_path: Path, + ) -> None: + """ + Add atoms from a LAMMPS Types section to a graph. + """ + for line in type_lines: + parts = line.split() + + if len(parts) < 2: raise ValueError( - f"Atom {atom_id} has a type but no coordinates " - f"in {file_path}." + 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, - atom_type=atom_type, - coords=coord_mapping[atom_id], + **{ + self.NODE_ATTRIBUTE: atom_type, + }, ) - for bond_id, bond_data in bond_mapping.items(): - bond_type, atom1_id, atom2_id = bond_data + def _add_lammps_bonds( + self, + graph: nx.Graph, + bond_lines: list[str], + file_path: Path, + ) -> None: + """ + Add bonds from a LAMMPS Bonds section to a graph. + """ + for line in bond_lines: + parts = line.split() - if atom1_id not in graph or atom2_id not in graph: + if len(parts) < 4: raise ValueError( - f"Bond {bond_id} references an undefined atom " - f"in {file_path}." + 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, - bond_id=bond_id, - bond_type=bond_type, + **{ + self.EDGE_ATTRIBUTE: bond_type, + }, ) - print( - f"Graph created from {file_path} with " - f"{graph.number_of_nodes()} nodes and " - f"{graph.number_of_edges()} edges." - ) - - return graph - - def _read_sections( + def _read_lammps_sections( self, file_path: Path, ) -> dict[str, list[str]]: """ Read relevant sections from a LAMMPS molecule-template file. """ - supported_sections = { + relevant_sections = { "Types", - "Coords", "Bonds", + } + + all_section_headers = { + "Coords", + "Types", "Charges", "Molecules", + "Bonds", "Angles", "Dihedrals", "Impropers", @@ -193,117 +414,269 @@ def _read_sections( sections: dict[str, list[str]] = {} current_section: str | None = None - with file_path.open("r", encoding="utf-8") as file: + with file_path.open( + "r", + encoding="utf-8", + ) as file: for raw_line in file: - # Remove comments but preserve the actual data. - line = raw_line.split("#", maxsplit=1)[0].strip() + # Remove comments while preserving the actual data. + line = raw_line.split( + "#", + maxsplit=1, + )[0].strip() if not line: continue - if line in supported_sections: - current_section = line - sections.setdefault(current_section, []) + if line in all_section_headers: + if line in relevant_sections: + current_section = line + sections.setdefault( + current_section, + [], + ) + else: + current_section = None + continue if current_section is not None: - # Stop collecting when another unsupported header/count - # line is encountered only through recognized sections. sections[current_section].append(line) return sections - def _couple_graphs( + def _validate_bond_atoms( self, - pre_template_graph: nx.Graph, - post_template_graph: nx.Graph, - ) -> dict[str, nx.Graph]: + graph: nx.Graph, + bond_id: int, + atom1_id: int, + atom2_id: int, + source: Path | str, + ) -> None: """ - Store the pre- and post-template graphs together. + Ensure that both atoms referenced by a bond exist. """ - return { - "pre_template_graph": pre_template_graph, - "post_template_graph": post_template_graph, - } + 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}." + ) - def _compare_graphs( + def compare_graphs( self, - mol_file_paths: list[str | Path], + molecule_file_paths: list[str | Path], ) -> dict[str, bool]: """ - Process pre-template files and report whether each reaction is - a duplicate of a previously processed pre/post pair. + Compare LAMMPS pre/post molecule-template pairs. + + A pre-template filename must contain ``pre``. Its corresponding + post-template filename is found by replacing the first occurrence + of ``pre`` with ``post``. Returns: - Mapping from the pre-template file path to duplicate status. + Mapping from each pre-template path to its duplicate status. """ results: dict[str, bool] = {} - for file_path_value in mol_file_paths: - file_path = Path(file_path_value) + for file_path_value in molecule_file_paths: + pre_file_path = Path(file_path_value) - if "pre" not in file_path.name: + if "pre" not in pre_file_path.name: continue - post_file_name = file_path.name.replace("pre", "post", 1) - post_file_path = file_path.with_name(post_file_name) + post_file_path = pre_file_path.with_name( + pre_file_path.name.replace( + "pre", + "post", + 1, + ) + ) - if not post_file_path.exists(): + if not post_file_path.is_file(): print( - f"Post-template file does not exist for " - f"{file_path}." + "Skipping reaction because its post-template file " + f"does not exist: {post_file_path}" ) continue - pre_template_graph = self.lammps_molecule_to_networkx( - file_path + pre_graph = self.lammps_molecule_to_networkx( + pre_file_path ) - post_template_graph = self.lammps_molecule_to_networkx( + + post_graph = self.lammps_molecule_to_networkx( post_file_path ) - is_duplicate = self.is_duplicate( - pre_template_graph, - post_template_graph, + duplicate = self.is_duplicate( + pre_template_graph=pre_graph, + post_template_graph=post_graph, + comparison_group=self.LAMMPS_COMPARISON_GROUP, ) - results[str(file_path)] = is_duplicate + results[str(pre_file_path)] = duplicate + + status = ( + "Duplicate" + if duplicate + else "Unique" + ) + + print( + f"{status} reaction: " + f"{pre_file_path.name} -> " + f"{post_file_path.name}" + ) + + return results + + def compare_graphs_mol( + self, + reaction_metadata_items: list[ReactionMetadata], + ) -> list[ReactionMetadata]: + """ + Detect duplicate reactions using in-memory RDKit molecules. + + Reactions whose ``activity_stats`` value is already False are + skipped. + + Duplicate reactions are disabled by setting: + + reaction_metadata.activity_stats = False + + Args: + reaction_metadata_items: + Prepared reaction metadata objects. + + Returns: + The original metadata list with duplicate reactions disabled. + """ + for reaction_index, reaction_metadata in enumerate( + reaction_metadata_items, + start=1, + ): + if reaction_metadata.activity_stats is False: + continue + + reactant_mol = ( + reaction_metadata.reactant_combined_RDmol + ) + product_mol = ( + reaction_metadata.product_combined_RDmol + ) + template_reactant_to_product_mapping = ( + reaction_metadata.template_reactant_to_product_mapping + ) + 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." + ) + + pre_graph = self.rdkit_mol_to_networkx( + reactant_mol + ) + + post_graph = self.rdkit_mol_to_networkx( + product_mol + ) + + 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 - if is_duplicate: print( - f"Duplicate reaction: {file_path.name} and " - f"{post_file_path.name}" + f"Reaction {reaction_index}: " + "duplicate reaction detected and disabled." ) else: print( - f"Unique reaction: {file_path.name} and " - f"{post_file_path.name}" + f"Reaction {reaction_index}: " + "unique reaction retained." ) - return results + return reaction_metadata_items + def clear_cache( + self, + comparison_group: str | None = None, + ) -> None: + """ + Clear stored reaction graphs. -if __name__ == "__main__": - deduplication_detector = DeduplicationDetector() + Args: + comparison_group: + Clear only one cache group. When None, all cache groups + are cleared. + """ + if comparison_group is None: + for seen_graphs in self.seen_reactions.values(): + seen_graphs.clear() + return + + self.seen_reactions.setdefault( + comparison_group, + [], + ).clear() + + +if __name__ == "__main__": folder_path = Path( - "/mnt/c/Users/Janitha/Documents/AutoREACTER/examples/" - "AutoREACTER_outputs/" - "Epoxy_Test_Primary_Diamine_Diepoxy" + "/mnt/c/Users/janit/Documents/GitHub/AutoREACTER/" + "examples/AutoREACTER_outputs/" + "Epoxy_Test_Primary_Diamine_Diepoxy/" + "LAMMPS_input_files/" + "Epoxy_Test_Primary_Diamine_Diepoxy_epoxy_test" ) + 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 ) - results = deduplication_detector._compare_graphs( + 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, is_duplicate in results.items(): - status = "duplicate" if is_duplicate else "unique" - print(f"{Path(file_path).name}: {status}") \ No newline at end of file + for file_path, duplicate in results.items(): + status = ( + "duplicate" + if duplicate + else "unique" + ) + + print( + f"{Path(file_path).name}: {status}" + ) \ No newline at end of file diff --git a/AutoREACTER/reaction_preparation/ff_wrapper/lunar_client/config.py b/AutoREACTER/reaction_preparation/ff_wrapper/lunar_client/config.py index 96b9c6f..a9a571e 100644 --- a/AutoREACTER/reaction_preparation/ff_wrapper/lunar_client/config.py +++ b/AutoREACTER/reaction_preparation/ff_wrapper/lunar_client/config.py @@ -1 +1 @@ -LUNAR_ROOT_DIR = None +LUNAR_ROOT_DIR = '/mnt/c/Users/janit/Documents/GitHub/LUNAR' diff --git a/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py b/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py index 69eeed6..426e88e 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py @@ -103,6 +103,8 @@ def __init__(self, session: "Session"): self.staging_dir = Path(session.staging_dir) self.cache = self.staging_dir self.csv_cache = prepare_paths(self.cache, "csv_cache") + if not hasattr(session, "reaction_id_counter"): + session.reaction_id_counter = 0 # Initialize a counter for unique reaction IDs if not already present def prepare_reactions(self, session): prepared_reactions = self._prepare_reactions_stage(session) @@ -260,14 +262,14 @@ def _process_reaction_instances( return reaction_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, - forced_indexes_1: Optional[set] = None, - forced_indexes_2: Optional[set] = None, - ) -> list[ReactionMetadata]: + 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]: """ Runs reactions on reactant pairs and builds metadata for each product set. @@ -340,7 +342,11 @@ def _process_reaction_products(self, pd.Series(byproduct_reactant_idxs, name="byproduct_idx") ], axis=1).astype(pd.Int64Dtype()) - total_products = len(reaction_metadata) + 1 + # counter so every reaction across the whole run — including every + # pass of the progression loop — gets a distinct, ever-increasing id. + self.session.reaction_id_counter += 1 + total_products = self.session.reaction_id_counter + self._clear_isotopes(reactant_combined, product_combined) df_combined.to_csv(csv_cache / f"reaction_{total_products}.csv", index=False) diff --git a/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py index fc832b5..8d5e712 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py @@ -7,6 +7,7 @@ from AutoREACTER.detectors.functional_groups_detector import FunctionalGroupsDetector from AutoREACTER.detectors.reaction_detector import ReactionDetector +from AutoREACTER.reaction_preparation.deduplication_detector import DeduplicationDetector if TYPE_CHECKING: from AutoREACTER.session import Session @@ -54,12 +55,14 @@ def reaction_progression(self, max_loop: int = MAX_LOOP) -> list["ReactionMetada """ iteration = 0 monomer_roles_in_loop = self.session.monomer_roles.copy() - reaction_instances = self.session.reaction_instances.copy() + + # Accumulate prepared reactions across iterations to avoid reprocessing in each loop. + all_prepared_reactions: list["ReactionMetadata"] = [] while iteration < max_loop: iteration += 1 self.session.reaction_progression_session.iteration = iteration - + size_of_initial_reaction_pool = self._length_of_active_reactions() if iteration == 1: self._populate_monomer_roles() @@ -95,19 +98,39 @@ def reaction_progression(self, max_loop: int = MAX_LOOP) -> list["ReactionMetada rxns = self.rxn_detector.index_based_reaction_detector( monomer_roles_in_loop ) - reaction_instances.extend(rxns) - print(len(reaction_instances)) # Debug print + # Debug prints to trace the reaction progression loop. + if not rxns: + print( + f"No new reactions detected in iteration {iteration}. " + f"Ending the reaction progression loop." + ) + break + + print(len(rxns)) # Debug print (was len(reaction_instances); now just the new batch) print(monomer_roles_in_loop) # Debug print + + # prepared_reactions = self._index_based_reaction_preparation( - reaction_instances=reaction_instances + reaction_instances=rxns ) print(prepared_reactions) # Debug print + + # Accumulate prepared reactions across iterations. + all_prepared_reactions.extend(prepared_reactions) + print(size_of_initial_reaction_pool) + deduplication_detector = DeduplicationDetector() + all_prepared_reactions = deduplication_detector.compare_graphs_mol( + all_prepared_reactions + ) + if self._loop_break_condition( - size_before=size_of_pool, size_after=len(monomer_roles_in_loop) + size_before=size_of_initial_reaction_pool, size_after=len(all_prepared_reactions) ): - return prepared_reactions - return prepared_reactions + self.session.reaction_metadata = all_prepared_reactions + return all_prepared_reactions + self.session.reaction_metadata = all_prepared_reactions + return all_prepared_reactions def _index_based_reaction_preparation( self, reaction_instances: list["ReactionInstance"] @@ -128,6 +151,8 @@ def _prepare_products_for_idx_based_fg_detection( reaction_metadata = self.session.reaction_metadata for reaction in reaction_metadata: + if not reaction.activity_stats: + continue product_mol = reaction.product_combined_RDmol indexes_in_template, product_mol = self._get_product_idxs( reaction.template_reactant_to_product_mapping, @@ -321,4 +346,13 @@ def _loop_break_condition(self, size_before: int, size_after: int) -> bool: f"(before={size_before}, after={size_after})." ) return True - return False \ No newline at end of file + return False + + def _length_of_active_reactions(self) -> int: + """ + Returns the number of reactions in the session that have activity stats. + """ + return sum( + 1 for reaction in self.session.reaction_metadata + if reaction.activity_stats + ) \ No newline at end of file diff --git a/test.ipynb b/test.ipynb index 56f21e4..b37645b 100644 --- a/test.ipynb +++ b/test.ipynb @@ -12,20 +12,13 @@ "from importlib.resources import files\n", "\n", "\n", - "def _add_progessive_chemistries():\n", - " rules_file = files(\"AutoREACTER.detectors\").joinpath(\"reaction_rules.json\")\n", - "\n", - " with rules_file.open(\"r\", encoding=\"utf-8\") as file:\n", - " reaction_rules = json.load(file)\n", - " rules = reaction_rules[\"multi_step_reactions\"]\n", - " print(rules)\n", "\n", " return reaction_rules" ] }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 7, "id": "8af2ad56", "metadata": {}, "outputs": [ @@ -33,7 +26,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "{'multi_step_reactions': [{'name': 'epoxy_polymerization', 'if_reactions': 'Amine Epoxy Addition First Stage', 'required_reactions': ['Amine Epoxy Addition Second Stage'], 'fg_additon': {'primary_amine': 'secondary_amine'}}]}\n" + "[{'name': 'epoxy_polymerization', 'if_reactions': 'Amine Epoxy Addition First Stage', 'required_reactions': ['Amine Epoxy Addition Second Stage'], 'fg_additon': {'primary_amine': 'secondary_amine'}}]\n" ] } ], From 9259d0994e6c5ffd6ce2fb71102b5931d10ad247 Mon Sep 17 00:00:00 2001 From: Janitha Mahanthe <119646255+janitha-mahanthe@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:23:47 -0400 Subject: [PATCH 021/104] Update deduplication_detector.py --- .../deduplication_detector.py | 837 ++++++++++-------- 1 file changed, 476 insertions(+), 361 deletions(-) diff --git a/AutoREACTER/reaction_preparation/deduplication_detector.py b/AutoREACTER/reaction_preparation/deduplication_detector.py index e510aee..a067c71 100644 --- a/AutoREACTER/reaction_preparation/deduplication_detector.py +++ b/AutoREACTER/reaction_preparation/deduplication_detector.py @@ -12,15 +12,25 @@ ) +""" +In here we do not add any coordinations, atom IDs, or bond IDs to the graphs. This is because these +There tempalate can generate from different reactanats but can have same template +How ever the is a possible herdle in here that element can be same but atom types in +LAMMPS can be different. In future release we can add one more attribute to +the graph which is atom type in LAMMPS and bond type in LAMMPS. +This will make sure that we are comparing the same template and not different template with same element. +""" + class DeduplicationDetector: """ - Detect duplicate pre/post reaction graph pairs. + Detect duplicate pre/post-reaction graph pairs. Coordinates, atom IDs, and bond IDs are ignored during graph isomorphism comparison. - A coupled graph is created for every reaction pair so that the same - atom mapping must be valid for both the reactant and product graphs. + Each pre/post pair is combined into a coupled graph. Correspondence + edges connect the same atom across the reactant and product states, + ensuring that one consistent atom mapping must satisfy both graphs. """ NODE_ATTRIBUTE = "atom_label" @@ -29,15 +39,41 @@ class DeduplicationDetector: 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 deduplication caches. - """ + """Initialize independent deduplication caches.""" self.seen_reactions: dict[str, list[nx.Graph]] = { self.LAMMPS_COMPARISON_GROUP: [], self.RDKIT_COMPARISON_GROUP: [], } + # ------------------------------------------------------------------ + # Public duplicate-detection API + # ------------------------------------------------------------------ + def is_duplicate( self, pre_template_graph: nx.Graph, @@ -45,22 +81,20 @@ def is_duplicate( comparison_group: str, ) -> bool: """ - Check whether an equivalent pre/post reaction pair has been seen. + Check whether an equivalent pre/post-reaction pair was seen. Args: pre_template_graph: Graph representing the reactant state. - post_template_graph: Graph representing the product state. - comparison_group: Cache group used for the comparison, such as ``lammps`` or ``rdkit``. Returns: - True if an equivalent reaction pair has already been seen. - Otherwise, stores the reaction pair and returns False. + True when an equivalent reaction pair is already cached. + Otherwise, caches the reaction pair and returns False. """ coupled_graph = self._couple_graphs( pre_template_graph=pre_template_graph, @@ -83,7 +117,6 @@ def is_duplicate( ) for seen_graph in seen_graphs: - # Cheap checks before running graph isomorphism. if coupled_graph.number_of_nodes() != seen_graph.number_of_nodes(): continue @@ -99,151 +132,304 @@ def is_duplicate( return True seen_graphs.append(coupled_graph.copy()) - return False - def _couple_graphs( + def compare_graphs( self, - pre_template_graph: nx.Graph, - post_template_graph: nx.Graph, - ) -> nx.Graph: + molecule_file_paths: list[str | Path], + ) -> dict[str, bool]: """ - Combine the reactant and product graphs into one graph. + Compare LAMMPS pre/post molecule-template pairs. - Every atom is represented twice: + A pre-template filename must contain ``pre``. The corresponding + post-template filename is determined by replacing the first + occurrence of ``pre`` with ``post``. - ("pre", atom_id) - ("post", atom_id) + Args: + molecule_file_paths: + Candidate LAMMPS molecule-template file paths. - A correspondence edge connects the same atom ID in the pre- and - post-reaction graphs. This requires one consistent atom mapping - across both reaction states. + Returns: + A mapping from each processed pre-template path to its + duplicate status. """ - pre_atom_ids = set(pre_template_graph.nodes) - post_atom_ids = set(post_template_graph.nodes) + results: dict[str, bool] = {} - 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 + 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, + ) ) - 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}." + if not post_file_path.is_file(): + print( + "Skipping reaction because its post-template file " + f"does not exist: {post_file_path}" + ) + continue + + pre_graph = self.lammps_molecule_to_networkx( + pre_file_path + ) + post_graph = self.lammps_molecule_to_networkx( + post_file_path ) - coupled_graph = nx.Graph() + duplicate = self.is_duplicate( + pre_template_graph=pre_graph, + post_template_graph=post_graph, + comparison_group=self.LAMMPS_COMPARISON_GROUP, + ) - self._add_phase_to_coupled_graph( - source_graph=pre_template_graph, - coupled_graph=coupled_graph, - phase="pre", - ) + results[str(pre_file_path)] = duplicate - self._add_phase_to_coupled_graph( - source_graph=post_template_graph, - coupled_graph=coupled_graph, - phase="post", - ) + status = "Duplicate" if duplicate else "Unique" - for atom_id in pre_atom_ids: - coupled_graph.add_edge( - ("pre", atom_id), - ("post", atom_id), - relationship="atom_correspondence", - **{self.EDGE_ATTRIBUTE: None}, + print( + f"{status} reaction: " + f"{pre_file_path.name} -> {post_file_path.name}" ) - return coupled_graph + return results - def _add_phase_to_coupled_graph( + def compare_graphs_mol( self, - source_graph: nx.Graph, - coupled_graph: nx.Graph, - phase: str, - ) -> None: + reaction_metadata_items: list[ReactionMetadata], + ) -> list[ReactionMetadata]: """ - Add one reaction phase to a coupled pre/post graph. + Detect duplicate reactions using in-memory RDKit molecules. + + Reactions whose ``activity_stats`` value is already False are + skipped. Detected duplicates are disabled by setting + ``activity_stats`` to False. + + Only atoms included in + ``template_reactant_to_product_mapping`` participate in duplicate + detection. Product atom indices are relabeled into reactant-index + space before the pre/post graphs are coupled. + + Args: + reaction_metadata_items: + Prepared reaction metadata objects. + + Returns: + The original metadata list with duplicate reactions disabled. """ - for atom_id, attributes in source_graph.nodes(data=True): - atom_label = attributes.get(self.NODE_ATTRIBUTE) + for reaction_index, reaction_metadata in enumerate( + reaction_metadata_items, + start=1, + ): + if reaction_metadata.activity_stats is False: + continue - if atom_label is None: - raise ValueError( - f"Node {atom_id} is missing the required " - f"{self.NODE_ATTRIBUTE!r} attribute." - ) + reactant_mol = reaction_metadata.reactant_combined_RDmol + product_mol = reaction_metadata.product_combined_RDmol - coupled_graph.add_node( - (phase, atom_id), - phase=phase, - **{self.NODE_ATTRIBUTE: atom_label}, + reactant_to_product_mapping = ( + reaction_metadata.template_reactant_to_product_mapping ) - for atom1_id, atom2_id, attributes in source_graph.edges( - data=True - ): - bond_label = attributes.get(self.EDGE_ATTRIBUTE) + if reactant_mol is None: + raise ValueError( + f"Reaction {reaction_index} does not contain a " + "combined reactant RDKit molecule." + ) - if bond_label is None: + if product_mol is None: raise ValueError( - f"Edge {atom1_id}-{atom2_id} is missing the required " - f"{self.EDGE_ATTRIBUTE!r} attribute." + f"Reaction {reaction_index} does not contain a " + "combined product RDKit molecule." ) - coupled_graph.add_edge( - (phase, atom1_id), - (phase, atom2_id), - relationship="bond", - **{self.EDGE_ATTRIBUTE: bond_label}, + if not reactant_to_product_mapping: + raise ValueError( + f"Reaction {reaction_index} does not contain a " + "template_reactant_to_product_mapping; duplicate " + "comparison cannot be restricted to template atoms." + ) + + reactant_template_indices = set( + reactant_to_product_mapping + ) + + product_to_reactant_mapping = { + product_index: reactant_index + for reactant_index, product_index + in reactant_to_product_mapping.items() + } + + product_template_indices = set( + product_to_reactant_mapping + ) + + pre_graph = self.rdkit_mol_to_networkx( + molecule=reactant_mol, + atom_idxs=reactant_template_indices, ) + post_graph = self.rdkit_mol_to_networkx( + molecule=product_mol, + atom_idxs=product_template_indices, + idx_relabel=product_to_reactant_mapping, + ) + + 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 + + print( + f"Reaction {reaction_index}: " + "duplicate reaction detected and disabled." + ) + else: + print( + f"Reaction {reaction_index}: " + "unique reaction retained." + ) + + return reaction_metadata_items + + def clear_cache( + self, + comparison_group: str | None = None, + ) -> None: + """ + Clear stored reaction graphs. + + Args: + comparison_group: + Cache group to clear. When None, all cache groups are + cleared. + """ + if comparison_group is None: + for seen_graphs in self.seen_reactions.values(): + seen_graphs.clear() + + return + + self.seen_reactions.setdefault( + comparison_group, + [], + ).clear() + + # ------------------------------------------------------------------ + # Public graph-conversion API + # ------------------------------------------------------------------ + 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 in-memory RDKit molecule into a NetworkX graph. Coordinates are not read or stored. - Node attribute: + Node attributes: atom_label: Chemical element symbol. - Edge attribute: + Edge attributes: bond_label: - RDKit bond type. + RDKit bond type represented as a string. + + Args: + molecule: + RDKit molecule to convert. + atom_idxs: + Optional atom-index set defining the induced subgraph. + Atoms outside this set and bonds touching excluded atoms + are omitted. + idx_relabel: + Optional mapping from RDKit atom indices to graph node + IDs. This can be used to express a product graph in the + corresponding reactant-index space. + + Returns: + NetworkX representation of the selected molecule region. """ 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_id = atom.GetIdx() + 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, + ) graph.add_node( - atom_id, + node_id, **{ self.NODE_ATTRIBUTE: atom.GetSymbol(), }, ) for bond in molecule.GetBonds(): - atom1_id = bond.GetBeginAtomIdx() - atom2_id = bond.GetEndAtomIdx() + 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( - atom1_id, - atom2_id, + node1_id, + node2_id, **{ self.EDGE_ATTRIBUTE: str( bond.GetBondType() @@ -260,13 +446,16 @@ def lammps_molecule_to_networkx( """ Convert a LAMMPS molecule-template file into a NetworkX graph. - Only the following sections are used: + Only the ``Types`` and ``Bonds`` sections are used. Coordinates, + charges, angles, dihedrals, impropers, atom IDs, and bond IDs do + not participate in graph-isomorphism comparison. - Types - Bonds + Args: + file_path: + Path to the LAMMPS molecule-template file. - Coordinates, charges, angles, dihedrals, impropers, atom IDs, - and bond IDs are not used in graph isomorphism comparison. + Returns: + NetworkX representation of the molecule template. """ file_path = Path(file_path) @@ -304,113 +493,136 @@ def lammps_molecule_to_networkx( return graph - def _add_lammps_atoms( + # ------------------------------------------------------------------ + # Private coupled-graph helpers + # ------------------------------------------------------------------ + + def _couple_graphs( self, - graph: nx.Graph, - type_lines: list[str], - file_path: Path, - ) -> None: - """ - Add atoms from a LAMMPS Types section to a graph. + pre_template_graph: nx.Graph, + post_template_graph: nx.Graph, + ) -> nx.Graph: """ - for line in type_lines: - parts = line.split() - - if len(parts) < 2: - raise ValueError( - f"Invalid Types line in {file_path}: {line!r}" - ) + Combine reactant and product graphs into one coupled graph. - try: - atom_id = int(parts[0]) - except ValueError as error: - raise ValueError( - f"Invalid atom ID in {file_path}: {line!r}" - ) from error + Every atom is represented twice: - atom_type = parts[1] + ("pre", atom_id) + ("post", atom_id) - if atom_id in graph: - raise ValueError( - f"Duplicate atom ID {atom_id} in {file_path}." - ) + A correspondence edge connects matching atom IDs across the two + phases, requiring one consistent mapping for both states. + """ + pre_atom_ids = set(pre_template_graph.nodes) + post_atom_ids = set(post_template_graph.nodes) - graph.add_node( - atom_id, - **{ - self.NODE_ATTRIBUTE: atom_type, - }, + 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 ) - def _add_lammps_bonds( + 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() + + 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, - graph: nx.Graph, - bond_lines: list[str], - file_path: Path, + source_graph: nx.Graph, + coupled_graph: nx.Graph, + phase: str, ) -> None: - """ - Add bonds from a LAMMPS Bonds section to a graph. - """ - for line in bond_lines: - parts = line.split() + """Add one reaction phase to a coupled pre/post graph.""" + for atom_id, attributes in source_graph.nodes(data=True): + atom_label = attributes.get(self.NODE_ATTRIBUTE) - if len(parts) < 4: + if atom_label is None: raise ValueError( - f"Invalid Bonds line in {file_path}: {line!r}" + f"Node {atom_id} is missing the required " + f"{self.NODE_ATTRIBUTE!r} attribute." ) - 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 + coupled_graph.add_node( + (phase, atom_id), + phase=phase, + **{ + self.NODE_ATTRIBUTE: atom_label, + }, + ) - bond_type = parts[1] + for atom1_id, atom2_id, attributes in source_graph.edges( + data=True + ): + bond_label = attributes.get(self.EDGE_ATTRIBUTE) - self._validate_bond_atoms( - graph=graph, - bond_id=bond_id, - atom1_id=atom1_id, - atom2_id=atom2_id, - source=file_path, - ) + if bond_label is None: + raise ValueError( + f"Edge {atom1_id}-{atom2_id} is missing the required " + f"{self.EDGE_ATTRIBUTE!r} attribute." + ) - graph.add_edge( - atom1_id, - atom2_id, + coupled_graph.add_edge( + (phase, atom1_id), + (phase, atom2_id), + relationship=self._BOND_RELATIONSHIP, **{ - self.EDGE_ATTRIBUTE: bond_type, + 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 output graph node ID.""" + if idx_relabel is None: + return atom_index + + return idx_relabel[atom_index] + + # ------------------------------------------------------------------ + # Private LAMMPS parsing helpers + # ------------------------------------------------------------------ + def _read_lammps_sections( self, file_path: Path, ) -> dict[str, list[str]]: - """ - Read relevant sections from a LAMMPS molecule-template file. - """ - relevant_sections = { - "Types", - "Bonds", - } - - all_section_headers = { - "Coords", - "Types", - "Charges", - "Molecules", - "Bonds", - "Angles", - "Dihedrals", - "Impropers", - "Special Bond Counts", - "Special Bonds", - } - + """Read relevant sections from a LAMMPS molecule-template file.""" sections: dict[str, list[str]] = {} current_section: str | None = None @@ -419,7 +631,6 @@ def _read_lammps_sections( encoding="utf-8", ) as file: for raw_line in file: - # Remove comments while preserving the actual data. line = raw_line.split( "#", maxsplit=1, @@ -428,8 +639,8 @@ def _read_lammps_sections( if not line: continue - if line in all_section_headers: - if line in relevant_sections: + if line in self._LAMMPS_SECTION_HEADERS: + if line in self._LAMMPS_RELEVANT_SECTIONS: current_section = line sections.setdefault( current_section, @@ -445,205 +656,112 @@ def _read_lammps_sections( return sections - def _validate_bond_atoms( + def _add_lammps_atoms( self, graph: nx.Graph, - bond_id: int, - atom1_id: int, - atom2_id: int, - source: Path | str, + type_lines: list[str], + file_path: Path, ) -> None: - """ - Ensure that 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}." - ) - - 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 corresponding - post-template filename is found by replacing the first occurrence - of ``pre`` with ``post``. - - Returns: - Mapping from each pre-template path to its duplicate status. - """ - 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, - ) - ) + """Add atoms from a LAMMPS ``Types`` section.""" + for line in type_lines: + parts = line.split() - if not post_file_path.is_file(): - print( - "Skipping reaction because its post-template file " - f"does not exist: {post_file_path}" + if len(parts) < 2: + raise ValueError( + f"Invalid Types line in {file_path}: {line!r}" ) - continue - - pre_graph = self.lammps_molecule_to_networkx( - pre_file_path - ) - - post_graph = self.lammps_molecule_to_networkx( - post_file_path - ) - duplicate = self.is_duplicate( - pre_template_graph=pre_graph, - post_template_graph=post_graph, - comparison_group=self.LAMMPS_COMPARISON_GROUP, - ) + try: + atom_id = int(parts[0]) + except ValueError as error: + raise ValueError( + f"Invalid atom ID in {file_path}: {line!r}" + ) from error - results[str(pre_file_path)] = duplicate + atom_type = parts[1] - status = ( - "Duplicate" - if duplicate - else "Unique" - ) + if atom_id in graph: + raise ValueError( + f"Duplicate atom ID {atom_id} in {file_path}." + ) - print( - f"{status} reaction: " - f"{pre_file_path.name} -> " - f"{post_file_path.name}" + graph.add_node( + atom_id, + **{ + self.NODE_ATTRIBUTE: atom_type, + }, ) - return results - - def compare_graphs_mol( + def _add_lammps_bonds( self, - reaction_metadata_items: list[ReactionMetadata], - ) -> list[ReactionMetadata]: - """ - Detect duplicate reactions using in-memory RDKit molecules. - - Reactions whose ``activity_stats`` value is already False are - skipped. - - Duplicate reactions are disabled by setting: - - reaction_metadata.activity_stats = False - - Args: - reaction_metadata_items: - Prepared reaction metadata objects. - - Returns: - The original metadata list with duplicate reactions disabled. - """ - for reaction_index, reaction_metadata in enumerate( - reaction_metadata_items, - start=1, - ): - if reaction_metadata.activity_stats is False: - continue + 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() - reactant_mol = ( - reaction_metadata.reactant_combined_RDmol - ) - product_mol = ( - reaction_metadata.product_combined_RDmol - ) - template_reactant_to_product_mapping = ( - reaction_metadata.template_reactant_to_product_mapping - ) - if reactant_mol is None: + if len(parts) < 4: raise ValueError( - f"Reaction {reaction_index} does not contain a " - "combined reactant RDKit molecule." + f"Invalid Bonds line in {file_path}: {line!r}" ) - if product_mol is None: + try: + bond_id = int(parts[0]) + atom1_id = int(parts[2]) + atom2_id = int(parts[3]) + except ValueError as error: raise ValueError( - f"Reaction {reaction_index} does not contain a " - "combined product RDKit molecule." - ) + f"Invalid Bonds line in {file_path}: {line!r}" + ) from error - pre_graph = self.rdkit_mol_to_networkx( - reactant_mol - ) + bond_type = parts[1] - post_graph = self.rdkit_mol_to_networkx( - product_mol + self._validate_bond_atoms( + graph=graph, + bond_id=bond_id, + atom1_id=atom1_id, + atom2_id=atom2_id, + source=file_path, ) - duplicate = self.is_duplicate( - pre_template_graph=pre_graph, - post_template_graph=post_graph, - comparison_group=self.RDKIT_COMPARISON_GROUP, + graph.add_edge( + atom1_id, + atom2_id, + **{ + self.EDGE_ATTRIBUTE: bond_type, + }, ) - if duplicate: - reaction_metadata.activity_stats = False - - print( - f"Reaction {reaction_index}: " - "duplicate reaction detected and disabled." - ) - else: - print( - f"Reaction {reaction_index}: " - "unique reaction retained." - ) - - return reaction_metadata_items - - def clear_cache( - self, - comparison_group: str | None = None, + @staticmethod + def _validate_bond_atoms( + graph: nx.Graph, + bond_id: int, + atom1_id: int, + atom2_id: int, + source: Path | str, ) -> None: - """ - Clear stored reaction graphs. - - Args: - comparison_group: - Clear only one cache group. When None, all cache groups - are cleared. - """ - if comparison_group is None: - for seen_graphs in self.seen_reactions.values(): - seen_graphs.clear() - - return + """Ensure that 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 + ] - self.seen_reactions.setdefault( - comparison_group, - [], - ).clear() + if undefined_atoms: + raise ValueError( + f"Bond {bond_id} references undefined atom IDs " + f"{undefined_atoms} in {source}." + ) -if __name__ == "__main__": +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/" - "LAMMPS_input_files/" - "Epoxy_Test_Primary_Diamine_Diepoxy_epoxy_test" ) if not folder_path.is_dir(): @@ -663,20 +781,17 @@ def clear_cache( ) detector = DeduplicationDetector() - - results = detector.compare_graphs( - pre_template_files - ) + results = detector.compare_graphs(pre_template_files) print("\nDeduplication results:") for file_path, duplicate in results.items(): - status = ( - "duplicate" - if duplicate - else "unique" - ) + status = "duplicate" if duplicate else "unique" print( f"{Path(file_path).name}: {status}" - ) \ No newline at end of file + ) + + +if __name__ == "__main__": + _main() \ No newline at end of file From c262190275d2a19fd0db36dfa6d657fab64d7e53 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:45:40 -0400 Subject: [PATCH 022/104] Refactor reaction_progression for clarity Clean up imports, variable names, docstrings, and formatting throughout reaction_progression.py. Replace debug prints with informative log messages, improve inline comments, and apply consistent style (trailing newline, line-length wrapping). --- .../reaction_progression.py | 256 +++++++++++------- 1 file changed, 161 insertions(+), 95 deletions(-) diff --git a/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py index 8d5e712..391c269 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py @@ -1,26 +1,34 @@ -MAX_LOOP = 5 # Maximum number of iterations for the reaction progression loop. +MAX_LOOP = 5 # Maximum number of reaction progression iterations. 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.functional_groups_detector import ( + FunctionalGroupsDetector, +) from AutoREACTER.detectors.reaction_detector import ReactionDetector -from AutoREACTER.reaction_preparation.deduplication_detector import DeduplicationDetector +from AutoREACTER.reaction_preparation.deduplication_detector import ( + DeduplicationDetector, +) if TYPE_CHECKING: - from AutoREACTER.session import Session from AutoREACTER.detectors.functional_groups_detector import MonomerRole - from AutoREACTER.reaction_preparation.reaction_processor.prepare_reactions import ReactionInstance, ReactionMetadata + from AutoREACTER.reaction_preparation.reaction_processor.prepare_reactions import ( + ReactionInstance, + ReactionMetadata, + ) + from AutoREACTER.session import Session @dataclass(slots=True) class MonomerRoleforIndexBasedFGDetection: """ - Represents a monomer role with its associated properties for - index-based functional group detection. + Represents a monomer role used for index-based functional-group + detection. """ + smiles: str name: str indexes_in_template: list[int] @@ -32,8 +40,9 @@ class MonomerRoleforIndexBasedFGDetection: @dataclass(slots=True) class ReactionProgressionSession: """ - Placeholder for future reaction progression state. + Stores state associated with reaction progression. """ + monomer_roles: list["MonomerRole"] = field(default_factory=list) iteration: int = 0 @@ -41,124 +50,167 @@ class ReactionProgressionSession: class ReactionProgression: def __init__(self, session: "Session"): self.session = session - self.session.reaction_progression_session = ReactionProgressionSession() + self.session.reaction_progression_session = ( + ReactionProgressionSession() + ) + self.fg_detector = FunctionalGroupsDetector() self.rxn_detector = ReactionDetector() - def reaction_progression(self, max_loop: int = MAX_LOOP) -> list["ReactionMetadata"]: + def reaction_progression( + self, + max_loop: int = MAX_LOOP, + ) -> list["ReactionMetadata"]: """ - Progresses the reaction by iteratively applying detected chemistries - to the session's molecules. + Progress a reaction by repeatedly detecting functional groups, + detecting reactions, preparing reactions, and removing duplicates. Args: - max_loop (int): Maximum number of iterations for the reaction progression loop. + max_loop: + Maximum number of progression iterations. + + Returns: + Prepared reaction metadata accumulated across the progression + loop. """ iteration = 0 monomer_roles_in_loop = self.session.monomer_roles.copy() - - # Accumulate prepared reactions across iterations to avoid reprocessing in each loop. all_prepared_reactions: list["ReactionMetadata"] = [] while iteration < max_loop: iteration += 1 self.session.reaction_progression_session.iteration = iteration - size_of_initial_reaction_pool = self._length_of_active_reactions() + + size_of_initial_reaction_pool = ( + self._length_of_active_reactions() + ) + if iteration == 1: self._populate_monomer_roles() if iteration > 1: print( f"Starting iteration {iteration} " - f"of the reaction progression loop." + "of the reaction progression loop." ) - size_of_pool = self._set_is_monomer_flag() + self._set_is_monomer_flag() + + initial_reaction_pool_size = ( + self._length_of_active_reactions() + ) - print(self.session.monomer_roles) # Debug print + print( + f"Initial reaction pool size at iteration {iteration}: " + f"{initial_reaction_pool_size}" + ) - monomer_roles_for_idx_based_fg_detection = ( + roles_for_fg_detection = ( self._prepare_products_for_idx_based_fg_detection() ) fg_detection_results = ( self.fg_detector.index_based_functional_groups_detector( - monomer_roles_for_idx_based_fg_detection + roles_for_fg_detection ) ) if not fg_detection_results: print( - f"No new functional groups detected in iteration {iteration}. " - f"Ending the reaction progression loop." + f"No new functional groups detected in iteration " + f"{iteration}. Ending the reaction progression loop." ) break monomer_roles_in_loop.extend(fg_detection_results) - rxns = self.rxn_detector.index_based_reaction_detector( - monomer_roles_in_loop + reaction_instances = ( + self.rxn_detector.index_based_reaction_detector( + monomer_roles_in_loop + ) ) - # Debug prints to trace the reaction progression loop. - if not rxns: + if not reaction_instances: print( f"No new reactions detected in iteration {iteration}. " - f"Ending the reaction progression loop." + "Ending the reaction progression loop." ) break - print(len(rxns)) # Debug print (was len(reaction_instances); now just the new batch) - print(monomer_roles_in_loop) # Debug print + print(len(reaction_instances)) - # - prepared_reactions = self._index_based_reaction_preparation( - reaction_instances=rxns + prepared_reactions = ( + self._index_based_reaction_preparation( + reaction_instances=reaction_instances + ) ) - print(prepared_reactions) # Debug print - # Accumulate prepared reactions across iterations. + print(prepared_reactions) + all_prepared_reactions.extend(prepared_reactions) + + print( + f"Total prepared reactions after iteration {iteration}: " + f"{len(all_prepared_reactions)}" + ) print(size_of_initial_reaction_pool) + deduplication_detector = DeduplicationDetector() - all_prepared_reactions = deduplication_detector.compare_graphs_mol( - all_prepared_reactions + + all_prepared_reactions = ( + deduplication_detector.compare_graphs_mol( + all_prepared_reactions + ) ) - - if self._loop_break_condition( - size_before=size_of_initial_reaction_pool, size_after=len(all_prepared_reactions) - ): + + should_break = self._loop_break_condition( + size_before=size_of_initial_reaction_pool, + size_after=len(all_prepared_reactions), + ) + + if should_break: self.session.reaction_metadata = all_prepared_reactions return all_prepared_reactions + self.session.reaction_metadata = all_prepared_reactions return all_prepared_reactions def _index_based_reaction_preparation( - self, reaction_instances: list["ReactionInstance"] + self, + reaction_instances: list["ReactionInstance"], ) -> list["ReactionMetadata"]: - from AutoREACTER.reaction_preparation.reaction_processor.prepare_reactions import PrepareReactions + from AutoREACTER.reaction_preparation.reaction_processor.prepare_reactions import ( + PrepareReactions, + ) + + reaction_preparer = PrepareReactions(self.session) + + return reaction_preparer._prepare_reactions_stage( + reaction_instances + ) - prepare_reactions = PrepareReactions(self.session) - prepared_reactions = prepare_reactions._prepare_reactions_stage(reaction_instances) - return prepared_reactions - def _prepare_products_for_idx_based_fg_detection( self, ) -> list[MonomerRoleforIndexBasedFGDetection]: """ - Prepares generated reaction products for index-based functional group detection. + Prepare generated products for index-based functional-group + detection. """ - monomer_roles_for_idx_based_fg_detection = [] + prepared_monomer_roles = [] reaction_metadata = self.session.reaction_metadata for reaction in reaction_metadata: if not reaction.activity_stats: continue + product_mol = reaction.product_combined_RDmol + indexes_in_template, product_mol = self._get_product_idxs( - reaction.template_reactant_to_product_mapping, - product_mol - ) - monomer_roles_for_idx_based_fg_detection.append( + reaction.template_reactant_to_product_mapping, + product_mol, + ) + + prepared_monomer_roles.append( MonomerRoleforIndexBasedFGDetection( smiles=self._get_product_smiles(product_mol), name=f"new_{reaction.reaction_id}", @@ -169,17 +221,21 @@ def _prepare_products_for_idx_based_fg_detection( ) ) - return monomer_roles_for_idx_based_fg_detection + return prepared_monomer_roles - def _sanitize_molecule(self, mol: Chem.Mol) -> Chem.Mol | None: + def _sanitize_molecule( + self, + mol: Chem.Mol, + ) -> Chem.Mol | None: """ - Sanitizes an RDKit molecule object. + Sanitize an RDKit molecule. Args: - mol (Chem.Mol): RDKit molecule object. + mol: + RDKit molecule to sanitize. Returns: - Chem.Mol | None: Sanitized molecule, or None if sanitization fails. + The sanitized molecule, or ``None`` when sanitization fails. """ self._clean_product(mol) @@ -191,7 +247,7 @@ def _sanitize_molecule(self, mol: Chem.Mol) -> Chem.Mol | None: def _clean_product(self, mol: Chem.Mol) -> Chem.Mol: """ - Return a copy of the molecule with atom-map numbers and isotope + Return a copy of a molecule with atom-map numbers and isotope labels removed. The input molecule is not modified. @@ -204,11 +260,10 @@ def _clean_product(self, mol: Chem.Mol) -> Chem.Mol: return cleaned_mol - def _get_product_smiles(self, mol: Chem.Mol) -> str: """ Convert a product molecule to SMILES without modifying the - original RDKit molecule. + original molecule. """ cleaned_mol = self._clean_product(mol) @@ -217,20 +272,21 @@ def _get_product_smiles(self, mol: Chem.Mol) -> str: except Exception: return "" - def _get_product_idxs( self, template_reactant_to_product_mapping: dict[int, int], mol: Chem.Mol, ) -> tuple[list[int], Chem.Mol]: """ - Retrieve mapped product atom idxs and keep only the largest - molecular fragment when the product contains multiple fragments. + Retrieve mapped product atom indexes. + + When the product contains disconnected fragments, retain only + the fragment with the largest number of heavy atoms and remap + the product indexes to that fragment. Returns: - A tuple containing: - - Product atom idxs relative to the returned molecule. - - The complete product or its largest fragment. + A tuple containing the product atom indexes and the retained + product molecule. """ product = Chem.Mol(mol) @@ -248,26 +304,24 @@ def _get_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 fragment with the largest number of heavy atoms and - remap product atom idxs to the retained fragment. + Retain the fragment with the largest number of heavy atoms and + remap product atom indexes to the retained fragment. Args: mol: Molecule containing one or more disconnected fragments. product_idxs: - Product atom idxs referring to the original molecule. + Product atom indexes referring to the original molecule. Returns: - A tuple containing: - - The largest fragment. - - Product atom idxs remapped to the largest fragment. + A tuple containing the largest fragment and the remapped + product atom indexes. """ fragment_atom_mappings: list[tuple[int, ...]] = [] @@ -279,17 +333,21 @@ def _keep_largest_fragment( ) if not fragments: - raise ValueError("No fragments found in the product molecule.") + raise ValueError( + "No fragments found in the product molecule." + ) largest_fragment_position = max( range(len(fragments)), - key=lambda position: fragments[position].GetNumHeavyAtoms(), + key=lambda position: ( + fragments[position].GetNumHeavyAtoms() + ), ) largest_fragment = fragments[largest_fragment_position] - # The atom mapping stores: - # new fragment idx -> original molecule idx. + # Mapping direction: + # fragment atom index -> original molecule atom index original_atom_idxs = fragment_atom_mappings[ largest_fragment_position ] @@ -309,10 +367,10 @@ def _keep_largest_fragment( def _set_is_monomer_flag(self) -> int: """ - Sets the is_looped flag for each monomer role in the session. + Mark every session monomer role as looped. Returns: - int: Number of monomer roles before functional group detection. + Number of monomer roles in the session. """ for monomer_role in self.session.monomer_roles: monomer_role.is_looped = True @@ -321,38 +379,46 @@ def _set_is_monomer_flag(self) -> int: def _populate_monomer_roles(self) -> None: """ - Populates RDKit molecule objects for monomers marked as monomers. + Populate RDKit molecules for roles marked as monomers. """ for monomer in self.session.monomer_roles: if monomer.is_monomer: - monomer.rdkit_mol = self._smiles_to_rdkit_mol(monomer.smiles) + monomer.rdkit_mol = self._smiles_to_rdkit_mol( + monomer.smiles + ) - def _smiles_to_rdkit_mol(self, smiles: str) -> Chem.Mol | None: + def _smiles_to_rdkit_mol( + self, + smiles: str, + ) -> Chem.Mol | None: """ - Converts a SMILES string to an RDKit molecule object. - - Args: - smiles (str): SMILES string. - - Returns: - Chem.Mol | None: RDKit molecule object. + Convert a SMILES string to an RDKit molecule. """ return Chem.MolFromSmiles(smiles) - def _loop_break_condition(self, size_before: int, size_after: int) -> bool: + def _loop_break_condition( + self, + size_before: int, + size_after: int, + ) -> bool: + """ + Determine whether progression should stop based on pool growth. + """ if size_after <= size_before: print( - f"Breaking the loop as the pool did not grow " + "Breaking the loop as the pool did not grow " f"(before={size_before}, after={size_after})." ) return True + return False - + def _length_of_active_reactions(self) -> int: """ - Returns the number of reactions in the session that have activity stats. + Return the number of session reactions with activity statistics. """ return sum( - 1 for reaction in self.session.reaction_metadata + 1 + for reaction in self.session.reaction_metadata if reaction.activity_stats ) \ No newline at end of file From 4fb669d2d2123721157069e22b27f758fe7fd2df Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:19:13 -0400 Subject: [PATCH 023/104] Refactor reaction progression logic - Move MAX_LOOP constant below imports for clarity - Add MolSanitizeException import and improve sanitization error logging - Instantiate DeduplicationDetector once in __init__ instead of per-loop - Initialize all_prepared_reactions from session state to support resumption - Rename _set_is_monomer_flag to _set_is_looped_flag with proper scoping - Rename _length_of_active_reactions to _count_active_reactions with explicit parameter - Add _store_reactions helper to reduce duplication - Sync session.monomer_roles and session.reaction_metadata incrementally - Fix loop break condition to use pre-iteration pool size - Condense docstrings throughout --- .../reaction_progression.py | 186 ++++++++---------- 1 file changed, 86 insertions(+), 100 deletions(-) diff --git a/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py index 391c269..8c64f88 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py @@ -1,9 +1,8 @@ -MAX_LOOP = 5 # Maximum number of reaction progression iterations. - from dataclasses import dataclass, field from typing import TYPE_CHECKING from rdkit import Chem +from rdkit.Chem.rdchem import MolSanitizeException from AutoREACTER.detectors.functional_groups_detector import ( FunctionalGroupsDetector, @@ -22,12 +21,12 @@ from AutoREACTER.session import Session +MAX_LOOP = 5 + + @dataclass(slots=True) class MonomerRoleforIndexBasedFGDetection: - """ - Represents a monomer role used for index-based functional-group - detection. - """ + """Monomer role used for index-based functional-group detection.""" smiles: str name: str @@ -39,9 +38,7 @@ class MonomerRoleforIndexBasedFGDetection: @dataclass(slots=True) class ReactionProgressionSession: - """ - Stores state associated with reaction progression. - """ + """State associated with reaction progression.""" monomer_roles: list["MonomerRole"] = field(default_factory=list) iteration: int = 0 @@ -56,48 +53,49 @@ def __init__(self, session: "Session"): self.fg_detector = FunctionalGroupsDetector() self.rxn_detector = ReactionDetector() + self.deduplication_detector = DeduplicationDetector() def reaction_progression( self, max_loop: int = MAX_LOOP, ) -> list["ReactionMetadata"]: """ - Progress a reaction by repeatedly detecting functional groups, - detecting reactions, preparing reactions, and removing duplicates. + Repeatedly detect functional groups, detect reactions, prepare + reactions, and remove duplicate products. Args: max_loop: Maximum number of progression iterations. Returns: - Prepared reaction metadata accumulated across the progression - loop. + Reaction metadata generated during the progression loop. """ iteration = 0 - monomer_roles_in_loop = self.session.monomer_roles.copy() - all_prepared_reactions: list["ReactionMetadata"] = [] + monomer_roles_in_loop = list["MonomerRole"]( + self.session.monomer_roles + ) + all_prepared_reactions: list["ReactionMetadata"] = list( + self.session.reaction_metadata + ) while iteration < max_loop: iteration += 1 self.session.reaction_progression_session.iteration = iteration - size_of_initial_reaction_pool = ( - self._length_of_active_reactions() - ) - if iteration == 1: self._populate_monomer_roles() - - if iteration > 1: + else: print( f"Starting iteration {iteration} " "of the reaction progression loop." ) - self._set_is_monomer_flag() + self._set_is_looped_flag(monomer_roles_in_loop) initial_reaction_pool_size = ( - self._length_of_active_reactions() + self._length_of_active_reactions( + self.session.reaction_metadata + ) ) print( @@ -123,6 +121,7 @@ def reaction_progression( break monomer_roles_in_loop.extend(fg_detection_results) + self.session.monomer_roles = monomer_roles_in_loop reaction_instances = ( self.rxn_detector.index_based_reaction_detector( @@ -137,40 +136,35 @@ def reaction_progression( ) break - print(len(reaction_instances)) - prepared_reactions = ( self._index_based_reaction_preparation( reaction_instances=reaction_instances ) ) - - print(prepared_reactions) - + all_prepared_reactions.extend(prepared_reactions) + self.session.reaction_metadata = all_prepared_reactions - print( - f"Total prepared reactions after iteration {iteration}: " - f"{len(all_prepared_reactions)}" + all_prepared_reactions = ( + self.deduplication_detector.compare_graphs_mol( + all_prepared_reactions + ) ) - print(size_of_initial_reaction_pool) + self.session.reaction_metadata = all_prepared_reactions - deduplication_detector = DeduplicationDetector() - - all_prepared_reactions = ( - deduplication_detector.compare_graphs_mol( + deduplicated_reaction_count = ( + self._length_of_active_reactions( all_prepared_reactions ) ) should_break = self._loop_break_condition( - size_before=size_of_initial_reaction_pool, - size_after=len(all_prepared_reactions), + size_before=initial_reaction_pool_size, + size_after=deduplicated_reaction_count, ) if should_break: - self.session.reaction_metadata = all_prepared_reactions - return all_prepared_reactions + return self._store_reactions(all_prepared_reactions) self.session.reaction_metadata = all_prepared_reactions return all_prepared_reactions @@ -192,14 +186,12 @@ def _index_based_reaction_preparation( def _prepare_products_for_idx_based_fg_detection( self, ) -> list[MonomerRoleforIndexBasedFGDetection]: - """ - Prepare generated products for index-based functional-group - detection. - """ - prepared_monomer_roles = [] - reaction_metadata = self.session.reaction_metadata + """Prepare generated products for functional-group detection.""" + prepared_monomer_roles: list[ + MonomerRoleforIndexBasedFGDetection + ] = [] - for reaction in reaction_metadata: + for reaction in self.session.reaction_metadata: if not reaction.activity_stats: continue @@ -215,40 +207,45 @@ def _prepare_products_for_idx_based_fg_detection( smiles=self._get_product_smiles(product_mol), name=f"new_{reaction.reaction_id}", indexes_in_template=indexes_in_template, - is_monomer=False, - is_looped=False, rdkit_mol=self._sanitize_molecule(product_mol), ) ) return prepared_monomer_roles + + def _store_reactions( + self, + reactions: list["ReactionMetadata"], + ) -> list["ReactionMetadata"]: + self.session.reaction_metadata = reactions + return reactions def _sanitize_molecule( self, mol: Chem.Mol, ) -> Chem.Mol | None: """ - Sanitize an RDKit molecule. + Sanitize an RDKit molecule by cleaning it and applying RDKit sanitization. Args: - mol: - RDKit molecule to sanitize. + mol: The RDKit molecule to sanitize. Returns: - The sanitized molecule, or ``None`` when sanitization fails. + The sanitized molecule, or ``None`` if sanitization fails. """ - self._clean_product(mol) + cleaned_mol = self._clean_product(mol) try: - Chem.SanitizeMol(mol) - return mol - except Exception: + Chem.SanitizeMol(cleaned_mol) + except Exception as error: + print(f"Could not sanitize product molecule: {error}") return None + return cleaned_mol + def _clean_product(self, mol: Chem.Mol) -> Chem.Mol: """ - Return a copy of a molecule with atom-map numbers and isotope - labels removed. + Return a molecule copy without atom-map numbers or isotope labels. The input molecule is not modified. """ @@ -261,10 +258,7 @@ def _clean_product(self, mol: Chem.Mol) -> Chem.Mol: return cleaned_mol def _get_product_smiles(self, mol: Chem.Mol) -> str: - """ - Convert a product molecule to SMILES without modifying the - original molecule. - """ + """Convert a cleaned product molecule to SMILES.""" cleaned_mol = self._clean_product(mol) try: @@ -280,16 +274,11 @@ def _get_product_idxs( """ Retrieve mapped product atom indexes. - When the product contains disconnected fragments, retain only - the fragment with the largest number of heavy atoms and remap - the product indexes to that fragment. - - Returns: - A tuple containing the product atom indexes and the retained - product molecule. + When the product contains multiple fragments, retain the fragment + with the largest number of heavy atoms and remap the product + indexes to that fragment. """ product = Chem.Mol(mol) - product_idxs = list( template_reactant_to_product_mapping.values() ) @@ -311,17 +300,16 @@ def _keep_largest_fragment( ) -> tuple[Chem.Mol, list[int]]: """ Retain the fragment with the largest number of heavy atoms and - remap product atom indexes to the retained fragment. + remap the product indexes to the retained fragment. Args: mol: Molecule containing one or more disconnected fragments. product_idxs: - Product atom indexes referring to the original molecule. + Product indexes referring to the original molecule. Returns: - A tuple containing the largest fragment and the remapped - product atom indexes. + The largest fragment and its remapped product indexes. """ fragment_atom_mappings: list[tuple[int, ...]] = [] @@ -365,22 +353,16 @@ def _keep_largest_fragment( return largest_fragment, remapped_product_idxs - def _set_is_monomer_flag(self) -> int: - """ - Mark every session monomer role as looped. - - Returns: - Number of monomer roles in the session. - """ - for monomer_role in self.session.monomer_roles: + def _set_is_looped_flag( + self, + monomer_roles: list["MonomerRole"], + ) -> None: + """Mark the supplied monomer roles as already processed.""" + for monomer_role in monomer_roles: monomer_role.is_looped = True - return len(self.session.monomer_roles) - def _populate_monomer_roles(self) -> None: - """ - Populate RDKit molecules for roles marked as monomers. - """ + """Create RDKit molecules for roles marked as monomers.""" for monomer in self.session.monomer_roles: if monomer.is_monomer: monomer.rdkit_mol = self._smiles_to_rdkit_mol( @@ -391,9 +373,7 @@ def _smiles_to_rdkit_mol( self, smiles: str, ) -> Chem.Mol | None: - """ - Convert a SMILES string to an RDKit molecule. - """ + """Convert a SMILES string to an RDKit molecule.""" return Chem.MolFromSmiles(smiles) def _loop_break_condition( @@ -401,9 +381,7 @@ def _loop_break_condition( size_before: int, size_after: int, ) -> bool: - """ - Determine whether progression should stop based on pool growth. - """ + """Return whether the active reaction pool failed to grow.""" if size_after <= size_before: print( "Breaking the loop as the pool did not grow " @@ -413,12 +391,20 @@ def _loop_break_condition( return False - def _length_of_active_reactions(self) -> int: + def _count_active_reactions( + self, + reactions: list["ReactionMetadata"], + ) -> int: """ - Return the number of session reactions with activity statistics. + Count the number of active reactions in the supplied list. + + Args: + reactions: A list of ``ReactionMetadata`` objects to check for activity. + + Returns: + The number of reactions that have activity statistics. """ return sum( - 1 - for reaction in self.session.reaction_metadata - if reaction.activity_stats - ) \ No newline at end of file + bool(reaction.activity_stats) + for reaction in reactions + ) \ No newline at end of file From fb1d5573bfb58a69e603af321214068cfaf5efe1 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:20:40 -0400 Subject: [PATCH 024/104] Refine reaction progression loop state handling Updates the progression loop to track reactions through a single `all_prepared_reactions` list, keep `session.reaction_metadata` synchronized before and after deduplication, and base loop-break checks on the updated pool. It also simplifies list initialization typing, rewrites the method docstring for clarity, adds per-iteration reaction count logging, and replaces early return with a clean loop break followed by a single final return. --- .../reaction_progression.py | 45 +++++++++---------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py index 8c64f88..b26c26f 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py @@ -60,23 +60,15 @@ def reaction_progression( max_loop: int = MAX_LOOP, ) -> list["ReactionMetadata"]: """ - Repeatedly detect functional groups, detect reactions, prepare - reactions, and remove duplicate products. - - Args: - max_loop: - Maximum number of progression iterations. - - Returns: - Reaction metadata generated during the progression loop. + Progresses the reaction process iteratively, detecting functional groups and reactions, + preparing reactions, and deduplicating them until no new reactions are found or the maximum + number of iterations is reached. + + Returns a list of all prepared reaction metadata. """ iteration = 0 - monomer_roles_in_loop = list["MonomerRole"]( - self.session.monomer_roles - ) - all_prepared_reactions: list["ReactionMetadata"] = list( - self.session.reaction_metadata - ) + monomer_roles_in_loop = list(self.session.monomer_roles) + all_prepared_reactions = list(self.session.reaction_metadata) while iteration < max_loop: iteration += 1 @@ -94,7 +86,7 @@ def reaction_progression( initial_reaction_pool_size = ( self._length_of_active_reactions( - self.session.reaction_metadata + all_prepared_reactions ) ) @@ -141,15 +133,24 @@ def reaction_progression( reaction_instances=reaction_instances ) ) - + all_prepared_reactions.extend(prepared_reactions) + + # Required for internal reaction-state modifications. self.session.reaction_metadata = all_prepared_reactions + print( + f"Total prepared reactions after iteration {iteration}: " + f"{len(all_prepared_reactions)}" + ) + all_prepared_reactions = ( self.deduplication_detector.compare_graphs_mol( - all_prepared_reactions + self.session.reaction_metadata ) ) + + # Update the session with the deduplicated reactions. self.session.reaction_metadata = all_prepared_reactions deduplicated_reaction_count = ( @@ -158,13 +159,11 @@ def reaction_progression( ) ) - should_break = self._loop_break_condition( + if self._loop_break_condition( size_before=initial_reaction_pool_size, size_after=deduplicated_reaction_count, - ) - - if should_break: - return self._store_reactions(all_prepared_reactions) + ): + break self.session.reaction_metadata = all_prepared_reactions return all_prepared_reactions From 5f350a7a7d208ac1a58ac786845b41f00bd31cd8 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:20:59 -0400 Subject: [PATCH 025/104] Add uncoupled pair-based duplicate detection Introduces is_duplicate_pair() for topology-only comparison of (reactant, product) graph pairs without coupling them into a single graph or requiring cross-phase atom mapping. Updates compare_graphs_mol() to use this simpler approach and adds index_source parameter supporting 'template' or 'first_shell' atom-index selection. Also clears the new seen_reaction_pairs cache in reset_seen_reactions(). --- .../deduplication_detector.py | 195 +++++++++++++++--- 1 file changed, 164 insertions(+), 31 deletions(-) diff --git a/AutoREACTER/reaction_preparation/deduplication_detector.py b/AutoREACTER/reaction_preparation/deduplication_detector.py index a067c71..27ef363 100644 --- a/AutoREACTER/reaction_preparation/deduplication_detector.py +++ b/AutoREACTER/reaction_preparation/deduplication_detector.py @@ -69,6 +69,9 @@ def __init__(self) -> None: self.LAMMPS_COMPARISON_GROUP: [], self.RDKIT_COMPARISON_GROUP: [], } + self.seen_reaction_pairs: dict[str, list[tuple[nx.Graph, nx.Graph]]] = { + self.RDKIT_COMPARISON_GROUP: [], + } # ------------------------------------------------------------------ # Public duplicate-detection API @@ -134,6 +137,79 @@ def is_duplicate( seen_graphs.append(coupled_graph.copy()) return False + def is_duplicate_pair( + self, + pre_graph: nx.Graph, + post_graph: nx.Graph, + comparison_group: str, + ) -> bool: + """ + # NEW METHOD + Simple, uncoupled duplicate check for a (reactant graph, product graph) + pair. + + Unlike is_duplicate, this does NOT combine the two graphs into one + coupled graph and does NOT require a single consistent atom mapping + across both phases. It only checks graph topology and atom/bond + labels — no coordinates, no atom IDs, no bond IDs, no cross-phase + atom correspondence. + + A pair is a duplicate only if BOTH: + - pre_graph is isomorphic to some cached pre_graph, AND + - post_graph is isomorphic to that SAME cached entry's post_graph. + + Args: + pre_graph: + Reactant-side graph, already restricted to the desired atom + indices (e.g. via rdkit_mol_to_networkx(atom_idxs=...)). + post_graph: + Product-side graph, already restricted to the desired atom + indices. + comparison_group: + Cache group used for the comparison. + + Returns: + True when an equivalent (reactant, product) pair was already + cached. Otherwise, caches the pair and returns False. + """ + 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, seen_post in seen_pairs: + if pre_graph.number_of_nodes() != seen_pre.number_of_nodes(): + continue + if pre_graph.number_of_edges() != seen_pre.number_of_edges(): + continue + if post_graph.number_of_nodes() != seen_post.number_of_nodes(): + continue + if post_graph.number_of_edges() != seen_post.number_of_edges(): + continue + + if not nx.is_isomorphic( + pre_graph, seen_pre, node_match=node_match, edge_match=edge_match + ): + continue + + if nx.is_isomorphic( + post_graph, seen_post, node_match=node_match, edge_match=edge_match + ): + return True + + seen_pairs.append((pre_graph.copy(), post_graph.copy())) + return False + def compare_graphs( self, molecule_file_paths: list[str | Path], @@ -183,9 +259,9 @@ def compare_graphs( post_file_path ) - duplicate = self.is_duplicate( - pre_template_graph=pre_graph, - post_template_graph=post_graph, + duplicate = self.is_duplicate_pair( + pre_graph=pre_graph, + post_graph=post_graph, comparison_group=self.LAMMPS_COMPARISON_GROUP, ) @@ -203,6 +279,10 @@ def compare_graphs( def compare_graphs_mol( self, reaction_metadata_items: list[ReactionMetadata], + index_source: str = "template", # which ReactionMetadata index attribute + # restricts the graph build. Defaults to + # "template". Other supported value: + # "first_shell". ) -> list[ReactionMetadata]: """ Detect duplicate reactions using in-memory RDKit molecules. @@ -211,14 +291,27 @@ def compare_graphs_mol( skipped. Detected duplicates are disabled by setting ``activity_stats`` to False. - Only atoms included in - ``template_reactant_to_product_mapping`` participate in duplicate - detection. Product atom indices are relabeled into reactant-index - space before the pre/post graphs are coupled. + # NEW: Comparison is now a simple, uncoupled topology check. The + # reactant graph is built restricted to the selected reactant indices; + # the product graph is built restricted to the corresponding product + # indices. These two graphs form one "set" for this reaction. Each new + # set is compared against previously seen sets using is_duplicate_pair: + # if both the reactant graph and the product graph independently match + # a previously seen pair's reactant/product graphs (same atom/bond + # topology and labels — no coordinates, no atom IDs, no bond IDs, no + # cross-phase atom correspondence), the reaction is marked as a + # duplicate and disabled. Args: reaction_metadata_items: Prepared reaction metadata objects. + index_source: + Selects which ReactionMetadata attribute defines the + restricted atom-index set used to build the pre/post graphs. + "template" (default) uses template_reactant_to_product_mapping. + "first_shell" restricts the reactant side to + reaction_metadata.first_shell (mapped through + reactant_to_product_mapping for the product side) instead. Returns: The original metadata list with duplicate reactions disabled. @@ -233,10 +326,6 @@ def compare_graphs_mol( reactant_mol = reaction_metadata.reactant_combined_RDmol product_mol = reaction_metadata.product_combined_RDmol - reactant_to_product_mapping = ( - reaction_metadata.template_reactant_to_product_mapping - ) - if reactant_mol is None: raise ValueError( f"Reaction {reaction_index} does not contain a " @@ -249,27 +338,60 @@ def compare_graphs_mol( "combined product RDKit molecule." ) - if not reactant_to_product_mapping: - raise ValueError( - f"Reaction {reaction_index} does not contain a " - "template_reactant_to_product_mapping; duplicate " - "comparison cannot be restricted to template atoms." + # index_source selection block: picks which reactant->product + # index mapping restricts the graph build. + if index_source == "template": + reactant_to_product_mapping = ( + reaction_metadata.template_reactant_to_product_mapping ) - reactant_template_indices = set( - reactant_to_product_mapping - ) + if not reactant_to_product_mapping: + raise ValueError( + f"Reaction {reaction_index} does not contain a " + "template_reactant_to_product_mapping; duplicate " + "comparison cannot be restricted to template atoms." + ) + + elif index_source == "first_shell": + # Build the restriction from first_shell reactant indices, + # mapped to product indices via the full reactant_to_product_mapping. + first_shell_idxs = reaction_metadata.first_shell + full_mapping = reaction_metadata.reactant_to_product_mapping + + if not first_shell_idxs: + raise ValueError( + f"Reaction {reaction_index} does not contain " + "first_shell indices; duplicate comparison cannot " + "be restricted to first-shell atoms." + ) + + if not full_mapping: + raise ValueError( + f"Reaction {reaction_index} does not contain a " + "reactant_to_product_mapping; duplicate comparison " + "cannot be restricted to first-shell atoms." + ) + + reactant_to_product_mapping = { + r_idx: full_mapping[r_idx] + for r_idx in first_shell_idxs + if r_idx in full_mapping + } - product_to_reactant_mapping = { - product_index: reactant_index - for reactant_index, product_index - in reactant_to_product_mapping.items() - } + else: + raise ValueError( + f"Unsupported index_source {index_source!r}. " + "Expected 'template' or 'first_shell'." + ) - product_template_indices = set( - product_to_reactant_mapping - ) + reactant_template_indices = set(reactant_to_product_mapping) + product_template_indices = set(reactant_to_product_mapping.values()) + # NEW: no idx_relabel — each graph keeps its own native atom + # indices. Isomorphism comparison doesn't care about absolute + # index/label values, only structure + atom_label/bond_label + # attributes, so relabeling into a shared index space is + # unnecessary for this simple pairwise check. pre_graph = self.rdkit_mol_to_networkx( molecule=reactant_mol, atom_idxs=reactant_template_indices, @@ -278,12 +400,13 @@ def compare_graphs_mol( post_graph = self.rdkit_mol_to_networkx( molecule=product_mol, atom_idxs=product_template_indices, - idx_relabel=product_to_reactant_mapping, ) - duplicate = self.is_duplicate( - pre_template_graph=pre_graph, - post_template_graph=post_graph, + # NEW: simple uncoupled comparison instead of is_duplicate's + # coupled-graph approach. + duplicate = self.is_duplicate_pair( + pre_graph=pre_graph, + post_graph=post_graph, comparison_group=self.RDKIT_COMPARISON_GROUP, ) @@ -318,6 +441,10 @@ def clear_cache( for seen_graphs in self.seen_reactions.values(): seen_graphs.clear() + # NEW: also clear the pairwise cache used by compare_graphs_mol. + for seen_pairs in self.seen_reaction_pairs.values(): + seen_pairs.clear() + return self.seen_reactions.setdefault( @@ -325,6 +452,12 @@ def clear_cache( [], ).clear() + # NEW: mirror the clear on the pairwise cache. + self.seen_reaction_pairs.setdefault( + comparison_group, + [], + ).clear() + # ------------------------------------------------------------------ # Public graph-conversion API # ------------------------------------------------------------------ From 7d5027675a602447b2ca74b03539ede3da310eaa Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:21:16 -0400 Subject: [PATCH 026/104] Fix forced-reaction mode for same reactants Remove redundant AddHs calls and mol copies for mol_reactant_1/2 in forced-reaction mode, and add swapped ordering for same-reactant cases so both orientations are tried. --- .../reaction_processor/prepare_reactions.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py b/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py index 426e88e..8444289 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py @@ -223,24 +223,15 @@ def _process_reaction_instances( if loop: # FORCED-REACTION MODE: use the actual index-scoped rdkit_mol (not a # fresh SMILES reparse) so atom indices line up with fg_*_indexes. - # AddHs only appends new explicit H atoms at the end, so heavy-atom - # indices — the ones that matter for the FG match indices — are preserved. - mol_reactant_1 = Chem.AddHs(Chem.Mol(reaction.monomer_1.rdkit_mol)) forced_indexes_1 = self._flatten_fg_indexes(reaction.functional_group_1) - # Handle case where both reactants are identical - if same_reactants: - mol_reactant_2 = Chem.AddHs(Chem.Mol(reaction.monomer_1.rdkit_mol)) - else: - mol_reactant_2 = Chem.AddHs(Chem.Mol(reaction.monomer_2.rdkit_mol)) - if reaction.functional_group_2 is not None: forced_indexes_2 = self._flatten_fg_indexes(reaction.functional_group_2) # Direction is already known from the ReactionInstance (monomer_1 -> # reactant slot 1, monomer_2 -> reactant slot 2), so unlike the normal # path we do NOT also try the swapped ordering. - reaction_tuple = [[mol_reactant_1, mol_reactant_2]] + reaction_tuple = [[mol_reactant_1, mol_reactant_2], [mol_reactant_2, mol_reactant_1]] if same_reactants else [[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 From 1b4f5747ace00fe0fea102001574d95c1d8a2e24 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:21:29 -0400 Subject: [PATCH 027/104] Update test_epoxy.json --- examples/test_epoxy.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/test_epoxy.json b/examples/test_epoxy.json index 00fb094..5558925 100644 --- a/examples/test_epoxy.json +++ b/examples/test_epoxy.json @@ -18,7 +18,7 @@ }, { "name": "primary_diamine", - "smiles": "NCCN" + "smiles": "NCCCCCN" } ] } \ No newline at end of file From b9f4ae774de8f233e97bd2f50d0d602ab9afa627 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:52:41 -0400 Subject: [PATCH 028/104] Update functional_groups_detector.py --- AutoREACTER/detectors/functional_groups_detector.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AutoREACTER/detectors/functional_groups_detector.py b/AutoREACTER/detectors/functional_groups_detector.py index d14395f..c29099f 100644 --- a/AutoREACTER/detectors/functional_groups_detector.py +++ b/AutoREACTER/detectors/functional_groups_detector.py @@ -557,7 +557,7 @@ def index_based_functional_groups_detector( 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: From fb47faa88a28170a6d390145ff68e2f5dfe65a5e Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:52:55 -0400 Subject: [PATCH 029/104] Refine deduplication cache behavior Improve `DeduplicationDetector` clarity and consistency by tightening docstrings, reorganizing helper sections, and cleaning formatting. Functional updates include initializing pair caches for both comparison groups, clearing the RDKit pair cache at the start of each `compare_graphs_mol` pass, and extracting index-source mapping logic into a dedicated helper for clearer validation and error messages. --- .../deduplication_detector.py | 429 +++++++++--------- 1 file changed, 209 insertions(+), 220 deletions(-) diff --git a/AutoREACTER/reaction_preparation/deduplication_detector.py b/AutoREACTER/reaction_preparation/deduplication_detector.py index 27ef363..ee7c76d 100644 --- a/AutoREACTER/reaction_preparation/deduplication_detector.py +++ b/AutoREACTER/reaction_preparation/deduplication_detector.py @@ -1,3 +1,12 @@ +""" +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 and bond type. LAMMPS comparisons +use atom type and bond type. +""" + from __future__ import annotations from pathlib import Path @@ -7,31 +16,13 @@ from rdkit import Chem if TYPE_CHECKING: - from AutoREACTER.reaction_preparation.reaction_processor.reaction_progression import ( + from AutoREACTER.reaction_preparation.reaction_processor.prepare_reactions import ( ReactionMetadata, ) -""" -In here we do not add any coordinations, atom IDs, or bond IDs to the graphs. This is because these -There tempalate can generate from different reactanats but can have same template -How ever the is a possible herdle in here that element can be same but atom types in -LAMMPS can be different. In future release we can add one more attribute to -the graph which is atom type in LAMMPS and bond type in LAMMPS. -This will make sure that we are comparing the same template and not different template with same element. -""" - class DeduplicationDetector: - """ - Detect duplicate pre/post-reaction graph pairs. - - Coordinates, atom IDs, and bond IDs are ignored during graph - isomorphism comparison. - - Each pre/post pair is combined into a coupled graph. Correspondence - edges connect the same atom across the reactant and product states, - ensuring that one consistent atom mapping must satisfy both graphs. - """ + """Detect duplicate pre/post-reaction graph pairs.""" NODE_ATTRIBUTE = "atom_label" EDGE_ATTRIBUTE = "bond_label" @@ -64,17 +55,22 @@ class DeduplicationDetector: } def __init__(self) -> None: - """Initialize independent deduplication caches.""" + """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.seen_reaction_pairs: dict[ + str, + list[tuple[nx.Graph, nx.Graph]], + ] = { + self.LAMMPS_COMPARISON_GROUP: [], self.RDKIT_COMPARISON_GROUP: [], } # ------------------------------------------------------------------ - # Public duplicate-detection API + # Duplicate-detection API # ------------------------------------------------------------------ def is_duplicate( @@ -84,20 +80,12 @@ def is_duplicate( comparison_group: str, ) -> bool: """ - Check whether an equivalent pre/post-reaction pair was seen. + Check whether an equivalent coupled pre/post pair was previously + cached. - Args: - pre_template_graph: - Graph representing the reactant state. - post_template_graph: - Graph representing the product state. - comparison_group: - Cache group used for the comparison, such as ``lammps`` - or ``rdkit``. - - Returns: - True when an equivalent reaction pair is already cached. - Otherwise, caches the reaction pair and returns False. + The pre- and post-reaction graphs are coupled using atom + correspondence edges. This requires one consistent atom mapping + to satisfy both reaction phases. """ coupled_graph = self._couple_graphs( pre_template_graph=pre_template_graph, @@ -120,10 +108,16 @@ def is_duplicate( ) for seen_graph in seen_graphs: - if coupled_graph.number_of_nodes() != seen_graph.number_of_nodes(): + if ( + coupled_graph.number_of_nodes() + != seen_graph.number_of_nodes() + ): continue - if coupled_graph.number_of_edges() != seen_graph.number_of_edges(): + if ( + coupled_graph.number_of_edges() + != seen_graph.number_of_edges() + ): continue if nx.is_isomorphic( @@ -144,33 +138,11 @@ def is_duplicate_pair( comparison_group: str, ) -> bool: """ - # NEW METHOD - Simple, uncoupled duplicate check for a (reactant graph, product graph) - pair. + Check whether an equivalent uncoupled pre/post graph pair was + previously cached. - Unlike is_duplicate, this does NOT combine the two graphs into one - coupled graph and does NOT require a single consistent atom mapping - across both phases. It only checks graph topology and atom/bond - labels — no coordinates, no atom IDs, no bond IDs, no cross-phase - atom correspondence. - - A pair is a duplicate only if BOTH: - - pre_graph is isomorphic to some cached pre_graph, AND - - post_graph is isomorphic to that SAME cached entry's post_graph. - - Args: - pre_graph: - Reactant-side graph, already restricted to the desired atom - indices (e.g. via rdkit_mol_to_networkx(atom_idxs=...)). - post_graph: - Product-side graph, already restricted to the desired atom - indices. - comparison_group: - Cache group used for the comparison. - - Returns: - True when an equivalent (reactant, product) pair was already - cached. Otherwise, caches the pair and returns False. + 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, @@ -187,27 +159,58 @@ def is_duplicate_pair( [], ) - for seen_pre, seen_post in seen_pairs: - if pre_graph.number_of_nodes() != seen_pre.number_of_nodes(): - continue - if pre_graph.number_of_edges() != seen_pre.number_of_edges(): - continue - if post_graph.number_of_nodes() != seen_post.number_of_nodes(): + for seen_pre_graph, seen_post_graph in seen_pairs: + if ( + pre_graph.number_of_nodes() + != seen_pre_graph.number_of_nodes() + ): continue - if post_graph.number_of_edges() != seen_post.number_of_edges(): + + if ( + pre_graph.number_of_edges() + != seen_pre_graph.number_of_edges() + ): continue - if not nx.is_isomorphic( - pre_graph, seen_pre, node_match=node_match, edge_match=edge_match + if ( + post_graph.number_of_nodes() + != seen_post_graph.number_of_nodes() ): continue - if nx.is_isomorphic( - post_graph, seen_post, node_match=node_match, edge_match=edge_match + 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())) + seen_pairs.append( + ( + pre_graph.copy(), + post_graph.copy(), + ) + ) + return False def compare_graphs( @@ -217,17 +220,9 @@ def compare_graphs( """ Compare LAMMPS pre/post molecule-template pairs. - A pre-template filename must contain ``pre``. The corresponding - post-template filename is determined by replacing the first - occurrence of ``pre`` with ``post``. - - Args: - molecule_file_paths: - Candidate LAMMPS molecule-template file paths. - - Returns: - A mapping from each processed pre-template path to its - duplicate status. + 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] = {} @@ -255,6 +250,7 @@ def compare_graphs( pre_graph = self.lammps_molecule_to_networkx( pre_file_path ) + post_graph = self.lammps_molecule_to_networkx( post_file_path ) @@ -278,44 +274,41 @@ def compare_graphs( def compare_graphs_mol( self, - reaction_metadata_items: list[ReactionMetadata], - index_source: str = "template", # which ReactionMetadata index attribute - # restricts the graph build. Defaults to - # "template". Other supported value: - # "first_shell". - ) -> list[ReactionMetadata]: + reaction_metadata_items: list["ReactionMetadata"], + index_source: str = "template", + ) -> list["ReactionMetadata"]: """ Detect duplicate reactions using in-memory RDKit molecules. - Reactions whose ``activity_stats`` value is already False are - skipped. Detected duplicates are disabled by setting - ``activity_stats`` to False. - - # NEW: Comparison is now a simple, uncoupled topology check. The - # reactant graph is built restricted to the selected reactant indices; - # the product graph is built restricted to the corresponding product - # indices. These two graphs form one "set" for this reaction. Each new - # set is compared against previously seen sets using is_duplicate_pair: - # if both the reactant graph and the product graph independently match - # a previously seen pair's reactant/product graphs (same atom/bond - # topology and labels — no coordinates, no atom IDs, no bond IDs, no - # cross-phase atom correspondence), the reaction is marked as a - # duplicate and disabled. + The RDKit pair cache is cleared at the start of every call. This + makes each invocation one independent deduplication pass over the + supplied accumulated reaction pool. + + Reactions already marked inactive are skipped. When multiple active + reactions have equivalent reactant and product graphs, the first is + retained and later matches are disabled. Args: reaction_metadata_items: - Prepared reaction metadata objects. + Accumulated reaction metadata pool. + index_source: - Selects which ReactionMetadata attribute defines the - restricted atom-index set used to build the pre/post graphs. - "template" (default) uses template_reactant_to_product_mapping. - "first_shell" restricts the reactant side to - reaction_metadata.first_shell (mapped through - reactant_to_product_mapping for the product side) instead. + Determines which atom indexes restrict the graph comparison. + + ``template``: + Use ``template_reactant_to_product_mapping``. + + ``first_shell``: + Use ``first_shell`` reactant indexes mapped through + ``reactant_to_product_mapping``. Returns: The original metadata list with duplicate reactions disabled. """ + # Each call represents a new full-pool deduplication pass. + # Do not retain RDKit graph pairs from previous progression loops. + self._clear_pair_cache(self.RDKIT_COMPARISON_GROUP) + for reaction_index, reaction_metadata in enumerate( reaction_metadata_items, start=1, @@ -338,60 +331,22 @@ def compare_graphs_mol( "combined product RDKit molecule." ) - # index_source selection block: picks which reactant->product - # index mapping restricts the graph build. - if index_source == "template": - reactant_to_product_mapping = ( - reaction_metadata.template_reactant_to_product_mapping + reactant_to_product_mapping = ( + self._select_reactant_to_product_mapping( + reaction_metadata=reaction_metadata, + reaction_index=reaction_index, + index_source=index_source, ) + ) - if not reactant_to_product_mapping: - raise ValueError( - f"Reaction {reaction_index} does not contain a " - "template_reactant_to_product_mapping; duplicate " - "comparison cannot be restricted to template atoms." - ) - - elif index_source == "first_shell": - # Build the restriction from first_shell reactant indices, - # mapped to product indices via the full reactant_to_product_mapping. - first_shell_idxs = reaction_metadata.first_shell - full_mapping = reaction_metadata.reactant_to_product_mapping - - if not first_shell_idxs: - raise ValueError( - f"Reaction {reaction_index} does not contain " - "first_shell indices; duplicate comparison cannot " - "be restricted to first-shell atoms." - ) - - if not full_mapping: - raise ValueError( - f"Reaction {reaction_index} does not contain a " - "reactant_to_product_mapping; duplicate comparison " - "cannot be restricted to first-shell atoms." - ) - - reactant_to_product_mapping = { - r_idx: full_mapping[r_idx] - for r_idx in first_shell_idxs - if r_idx in full_mapping - } - - else: - raise ValueError( - f"Unsupported index_source {index_source!r}. " - "Expected 'template' or 'first_shell'." - ) + reactant_template_indices = set( + reactant_to_product_mapping + ) - reactant_template_indices = set(reactant_to_product_mapping) - product_template_indices = set(reactant_to_product_mapping.values()) + product_template_indices = set( + reactant_to_product_mapping.values() + ) - # NEW: no idx_relabel — each graph keeps its own native atom - # indices. Isomorphism comparison doesn't care about absolute - # index/label values, only structure + atom_label/bond_label - # attributes, so relabeling into a shared index space is - # unnecessary for this simple pairwise check. pre_graph = self.rdkit_mol_to_networkx( molecule=reactant_mol, atom_idxs=reactant_template_indices, @@ -402,8 +357,6 @@ def compare_graphs_mol( atom_idxs=product_template_indices, ) - # NEW: simple uncoupled comparison instead of is_duplicate's - # coupled-graph approach. duplicate = self.is_duplicate_pair( pre_graph=pre_graph, post_graph=post_graph, @@ -430,18 +383,17 @@ def clear_cache( comparison_group: str | None = None, ) -> None: """ - Clear stored reaction graphs. + Clear graph-comparison caches. Args: comparison_group: - Cache group to clear. When None, all cache groups are - cleared. + 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() - # NEW: also clear the pairwise cache used by compare_graphs_mol. for seen_pairs in self.seen_reaction_pairs.values(): seen_pairs.clear() @@ -452,14 +404,13 @@ def clear_cache( [], ).clear() - # NEW: mirror the clear on the pairwise cache. self.seen_reaction_pairs.setdefault( comparison_group, [], ).clear() # ------------------------------------------------------------------ - # Public graph-conversion API + # RDKit graph conversion # ------------------------------------------------------------------ def rdkit_mol_to_networkx( @@ -469,32 +420,15 @@ def rdkit_mol_to_networkx( idx_relabel: dict[int, int] | None = None, ) -> nx.Graph: """ - Convert an in-memory RDKit molecule into a NetworkX graph. + Convert an RDKit molecule into a NetworkX graph. Coordinates are not read or stored. Node attributes: - atom_label: - Chemical element symbol. + ``atom_label`` contains the chemical element symbol. Edge attributes: - bond_label: - RDKit bond type represented as a string. - - Args: - molecule: - RDKit molecule to convert. - atom_idxs: - Optional atom-index set defining the induced subgraph. - Atoms outside this set and bonds touching excluded atoms - are omitted. - idx_relabel: - Optional mapping from RDKit atom indices to graph node - IDs. This can be used to express a product graph in the - corresponding reactant-index space. - - Returns: - NetworkX representation of the selected molecule region. + ``bond_label`` contains the RDKit bond type. """ if molecule is None: raise ValueError( @@ -555,6 +489,7 @@ def rdkit_mol_to_networkx( atom_index=atom1_index, idx_relabel=idx_relabel, ) + node2_id = self._resolve_node_id( atom_index=atom2_index, idx_relabel=idx_relabel, @@ -572,6 +507,10 @@ def rdkit_mol_to_networkx( return graph + # ------------------------------------------------------------------ + # LAMMPS graph conversion + # ------------------------------------------------------------------ + def lammps_molecule_to_networkx( self, file_path: str | Path, @@ -579,16 +518,7 @@ def lammps_molecule_to_networkx( """ Convert a LAMMPS molecule-template file into a NetworkX graph. - Only the ``Types`` and ``Bonds`` sections are used. Coordinates, - charges, angles, dihedrals, impropers, atom IDs, and bond IDs do - not participate in graph-isomorphism comparison. - - Args: - file_path: - Path to the LAMMPS molecule-template file. - - Returns: - NetworkX representation of the molecule template. + Only the ``Types`` and ``Bonds`` sections are included. """ file_path = Path(file_path) @@ -627,7 +557,74 @@ def lammps_molecule_to_networkx( return graph # ------------------------------------------------------------------ - # Private coupled-graph helpers + # 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'." + ) + + # ------------------------------------------------------------------ + # Cache helpers + # ------------------------------------------------------------------ + + def _clear_pair_cache( + self, + comparison_group: str, + ) -> None: + """Clear only the uncoupled pre/post pair cache for one group.""" + self.seen_reaction_pairs.setdefault( + comparison_group, + [], + ).clear() + + # ------------------------------------------------------------------ + # Coupled-graph helpers # ------------------------------------------------------------------ def _couple_graphs( @@ -636,15 +633,8 @@ def _couple_graphs( post_template_graph: nx.Graph, ) -> nx.Graph: """ - Combine reactant and product graphs into one coupled graph. - - Every atom is represented twice: - - ("pre", atom_id) - ("post", atom_id) - - A correspondence edge connects matching atom IDs across the two - phases, requiring one consistent mapping for both states. + Combine reactant and product graphs using atom-correspondence + edges. """ pre_atom_ids = set(pre_template_graph.nodes) post_atom_ids = set(post_template_graph.nodes) @@ -653,6 +643,7 @@ def _couple_graphs( missing_from_post = sorted( pre_atom_ids - post_atom_ids ) + missing_from_pre = sorted( post_atom_ids - pre_atom_ids ) @@ -698,7 +689,7 @@ def _add_phase_to_coupled_graph( coupled_graph: nx.Graph, phase: str, ) -> None: - """Add one reaction phase to a coupled pre/post graph.""" + """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) @@ -741,21 +732,21 @@ def _resolve_node_id( atom_index: int, idx_relabel: dict[int, int] | None, ) -> int: - """Resolve an RDKit atom index to its output graph node ID.""" + """Resolve an RDKit atom index to its graph node ID.""" if idx_relabel is None: return atom_index return idx_relabel[atom_index] # ------------------------------------------------------------------ - # Private LAMMPS parsing helpers + # LAMMPS parsing helpers # ------------------------------------------------------------------ def _read_lammps_sections( self, file_path: Path, ) -> dict[str, list[str]]: - """Read relevant sections from a LAMMPS molecule-template file.""" + """Read relevant sections from a LAMMPS molecule file.""" sections: dict[str, list[str]] = {} current_section: str | None = None @@ -875,7 +866,7 @@ def _validate_bond_atoms( atom2_id: int, source: Path | str, ) -> None: - """Ensure that both atoms referenced by a bond exist.""" + """Ensure both atoms referenced by a bond exist.""" undefined_atoms = [ atom_id for atom_id in (atom1_id, atom2_id) @@ -921,9 +912,7 @@ def _main() -> None: for file_path, duplicate in results.items(): status = "duplicate" if duplicate else "unique" - print( - f"{Path(file_path).name}: {status}" - ) + print(f"{Path(file_path).name}: {status}") if __name__ == "__main__": From 2c8e153b5c197ad86dba220a13a56b180c27af14 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:53:09 -0400 Subject: [PATCH 030/104] Delete fragment_comparison.py --- .../reaction_processor/fragment_comparison.py | 238 ------------------ 1 file changed, 238 deletions(-) delete mode 100644 AutoREACTER/reaction_preparation/reaction_processor/fragment_comparison.py 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 From 03b9d070e1e9cdf4a2f276795bbf3e659ca7fd4b Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:53:27 -0400 Subject: [PATCH 031/104] Raise error when no reactions are active Add a dedicated `ZeroActiveReactionsError` in reaction preparation and fail after building reaction metadata when none of the reactions include activity stats. This makes empty or invalid datasets surface as an explicit AutoREACTER error instead of continuing silently. --- .../reaction_processor/prepare_reactions.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py b/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py index 8444289..b4e9ab7 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py @@ -40,6 +40,8 @@ class MappingError(Exception): class SMARTSParsingError(Exception): """Custom exception raised when parsing SMILES or reaction SMARTS fails.""" +class ZeroActiveReactionsError(Exception): + """Custom exception raised when no active reactions are found in the dataset.""" @dataclass(slots=True) class ReactionMetadata: @@ -115,7 +117,28 @@ def prepare_reactions(self, session): prepared_reactions.extend(added_reaction_progression) session.reaction_metadata = prepared_reactions # update with full final list + self._zero_active_reactions_error(prepared_reactions) return session + + def _zero_active_reactions_error(self, reaction_metadata: list[ReactionMetadata]): + """ + Checks if there are any active reactions in the provided reaction metadata list. + + Args: + reaction_metadata: List of ReactionMetadata objects to check for active reactions. + + Raises: + ZeroActiveReactionsError: If no active reactions are found in the list. + """ + active_reactions = False + for reaction in reaction_metadata: + if reaction.activity_stats: + active_reactions = True + break + if not active_reactions: + 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]: """ From 66bd8cfb7b3f2e7922a5feb62c0995a4b1c4fd5e Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:53:41 -0400 Subject: [PATCH 032/104] Refine reaction progression loop Clarify `ReactionProgression.progress_reaction_process` with a more focused docstring and explicit typing for the working reaction lists. The loop now counts active reactions from the current session state, deduplicates in-place without extra logging, and returns through `_store_reactions(...)` as soon as the break condition is met so the final reaction set is persisted consistently. --- .../reaction_progression.py | 49 ++++++++++--------- 1 file changed, 25 insertions(+), 24 deletions(-) diff --git a/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py index b26c26f..4b2aba5 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py @@ -60,15 +60,23 @@ def reaction_progression( max_loop: int = MAX_LOOP, ) -> list["ReactionMetadata"]: """ - Progresses the reaction process iteratively, detecting functional groups and reactions, - preparing reactions, and deduplicating them until no new reactions are found or the maximum - number of iterations is reached. - - Returns a list of all prepared reaction metadata. + Repeatedly detect functional groups, detect reactions, prepare + reactions, and remove duplicate products. + + Args: + max_loop: + Maximum number of progression iterations. + + Returns: + Reaction metadata generated during the progression loop. """ iteration = 0 - monomer_roles_in_loop = list(self.session.monomer_roles) - all_prepared_reactions = list(self.session.reaction_metadata) + monomer_roles_in_loop = list["MonomerRole"]( + self.session.monomer_roles + ) + all_prepared_reactions: list["ReactionMetadata"] = list( + self.session.reaction_metadata + ) while iteration < max_loop: iteration += 1 @@ -85,8 +93,8 @@ def reaction_progression( self._set_is_looped_flag(monomer_roles_in_loop) initial_reaction_pool_size = ( - self._length_of_active_reactions( - all_prepared_reactions + self._count_active_reactions( + self.session.reaction_metadata ) ) @@ -133,37 +141,30 @@ def reaction_progression( reaction_instances=reaction_instances ) ) - + all_prepared_reactions.extend(prepared_reactions) - - # Required for internal reaction-state modifications. self.session.reaction_metadata = all_prepared_reactions - print( - f"Total prepared reactions after iteration {iteration}: " - f"{len(all_prepared_reactions)}" - ) - all_prepared_reactions = ( self.deduplication_detector.compare_graphs_mol( - self.session.reaction_metadata + all_prepared_reactions ) ) - - # Update the session with the deduplicated reactions. self.session.reaction_metadata = all_prepared_reactions deduplicated_reaction_count = ( - self._length_of_active_reactions( + self._count_active_reactions( all_prepared_reactions ) ) - if self._loop_break_condition( + should_break = self._loop_break_condition( size_before=initial_reaction_pool_size, size_after=deduplicated_reaction_count, - ): - break + ) + + if should_break: + return self._store_reactions(all_prepared_reactions) self.session.reaction_metadata = all_prepared_reactions return all_prepared_reactions From 61c0df217c0c056ac4188ca8cd433d8e930e2f61 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:38:26 -0400 Subject: [PATCH 033/104] Fix progression deduplication handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update reaction progression to keep the session’s final deduplicated reaction list instead of extending it with newly generated reactions. Deduplication now clears the full comparison cache per pass, drops repeated references to the same ReactionMetadata object without disabling it, and compares relabeled product graphs in reactant index space. The change also refreshes inline documentation around progression flow and product handling. --- .../deduplication_detector.py | 110 +++++----- .../reaction_processor/prepare_reactions.py | 18 +- .../reaction_progression.py | 188 ++++++++++++------ 3 files changed, 206 insertions(+), 110 deletions(-) diff --git a/AutoREACTER/reaction_preparation/deduplication_detector.py b/AutoREACTER/reaction_preparation/deduplication_detector.py index ee7c76d..c7f91d6 100644 --- a/AutoREACTER/reaction_preparation/deduplication_detector.py +++ b/AutoREACTER/reaction_preparation/deduplication_detector.py @@ -280,13 +280,19 @@ def compare_graphs_mol( """ Detect duplicate reactions using in-memory RDKit molecules. - The RDKit pair cache is cleared at the start of every call. This - makes each invocation one independent deduplication pass over the - supplied accumulated reaction pool. + 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 already marked inactive are skipped. When multiple active - reactions have equivalent reactant and product graphs, the first is - retained and later matches are disabled. + 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. Args: reaction_metadata_items: @@ -303,17 +309,31 @@ def compare_graphs_mol( ``reactant_to_product_mapping``. Returns: - The original metadata list with duplicate reactions disabled. + A new list containing one active representative of each unique + reaction. """ - # Each call represents a new full-pool deduplication pass. - # Do not retain RDKit graph pairs from previous progression loops. - self._clear_pair_cache(self.RDKIT_COMPARISON_GROUP) + # Each call is a complete deduplication pass over the current pool. + # Cached graphs from an earlier progression iteration must not be + # reused, or retained reactions will match their own old cache entry. + 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 reaction_metadata.activity_stats is False: + # Inactive reactions do not participate in the active pool. + if not reaction_metadata.activity_stats: + continue + + reaction_object_id = id(reaction_metadata) + + # The accumulated progression pool can contain the exact same + # metadata object more than once. Do not mark that object inactive; + # simply keep its first occurrence. + if reaction_object_id in retained_object_ids: continue reactant_mol = reaction_metadata.reactant_combined_RDmol @@ -339,44 +359,58 @@ def compare_graphs_mol( ) ) - reactant_template_indices = set( - reactant_to_product_mapping - ) - - product_template_indices = set( + reactant_indices = set(reactant_to_product_mapping) + product_indices = set( reactant_to_product_mapping.values() ) + # Relabel the product graph into reactant-index space so the + # coupled graph preserves atom correspondence across the + # pre- and post-reaction states. + 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_template_indices, + atom_idxs=reactant_indices, ) post_graph = self.rdkit_mol_to_networkx( molecule=product_mol, - atom_idxs=product_template_indices, + atom_idxs=product_indices, + idx_relabel=product_to_reactant_mapping, ) - duplicate = self.is_duplicate_pair( - pre_graph=pre_graph, - post_graph=post_graph, + 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 - print( - f"Reaction {reaction_index}: " - "duplicate reaction detected and disabled." - ) - else: - print( - f"Reaction {reaction_index}: " - "unique reaction retained." - ) + retained_object_ids.add(reaction_object_id) + unique_reactions.append(reaction_metadata) - return reaction_metadata_items + print( + f"Reaction {reaction_index}: " + "unique reaction retained." + ) + + return unique_reactions def clear_cache( self, @@ -609,20 +643,6 @@ def _select_reactant_to_product_mapping( "Expected 'template' or 'first_shell'." ) - # ------------------------------------------------------------------ - # Cache helpers - # ------------------------------------------------------------------ - - def _clear_pair_cache( - self, - comparison_group: str, - ) -> None: - """Clear only the uncoupled pre/post pair cache for one group.""" - self.seen_reaction_pairs.setdefault( - comparison_group, - [], - ).clear() - # ------------------------------------------------------------------ # Coupled-graph helpers # ------------------------------------------------------------------ diff --git a/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py b/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py index b4e9ab7..2d04e0a 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py @@ -109,16 +109,22 @@ def __init__(self, session: "Session"): session.reaction_id_counter = 0 # Initialize a counter for unique reaction IDs if not already present def prepare_reactions(self, session): + # 1. Get initial reactions prepared_reactions = self._prepare_reactions_stage(session) - session.reaction_metadata = prepared_reactions # set BEFORE progression needs it + session.reaction_metadata = prepared_reactions + # 2. Run progression loop (this returns the FULL, deduplicated list) reaction_progression = ReactionProgression(session) - added_reaction_progression = reaction_progression.reaction_progression() - - prepared_reactions.extend(added_reaction_progression) - session.reaction_metadata = prepared_reactions # update with full final list - self._zero_active_reactions_error(prepared_reactions) + final_reactions = reaction_progression.reaction_progression() + + # 3. Overwrite the session metadata with the final deduplicated list + session.reaction_metadata = final_reactions + + # 4. Error check + self._zero_active_reactions_error(final_reactions) + return session + def _zero_active_reactions_error(self, reaction_metadata: list[ReactionMetadata]): """ diff --git a/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py index 4b2aba5..fb0d011 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py @@ -21,12 +21,18 @@ from AutoREACTER.session import Session +# Prevent the progression process from continuing indefinitely when reactions +# keep producing additional detectable functional groups. MAX_LOOP = 5 @dataclass(slots=True) class MonomerRoleforIndexBasedFGDetection: - """Monomer role used for index-based functional-group detection.""" + """Describe a molecule prepared for index-based functional-group detection. + + The stored atom indexes refer to positions in the reaction template and + allow functional groups to be associated with their original reactants. + """ smiles: str name: str @@ -38,14 +44,27 @@ class MonomerRoleforIndexBasedFGDetection: @dataclass(slots=True) class ReactionProgressionSession: - """State associated with reaction progression.""" + """Track state that is shared across reaction-progression iterations.""" monomer_roles: list["MonomerRole"] = field(default_factory=list) iteration: int = 0 class ReactionProgression: + """Coordinate iterative functional-group detection and reaction generation. + + Each iteration uses products from previously prepared reactions as potential + monomers. Newly detected functional groups are converted into reaction + instances, prepared into reaction metadata, and deduplicated before the + next iteration begins. + """ + def __init__(self, session: "Session"): + """Initialize detectors and attach progression state to a session. + + Args: + session: Session containing monomer roles and reaction metadata. + """ self.session = session self.session.reaction_progression_session = ( ReactionProgressionSession() @@ -59,16 +78,18 @@ def reaction_progression( self, max_loop: int = MAX_LOOP, ) -> list["ReactionMetadata"]: - """ - Repeatedly detect functional groups, detect reactions, prepare - reactions, and remove duplicate products. + """Run the reaction-progression loop until no progress is possible. + + Each iteration detects functional groups in generated products, finds + compatible reactions, prepares the reactions, and removes duplicates. + The loop stops when no new functional groups or reactions are found, + when the reaction pool does not grow, or when ``max_loop`` is reached. Args: - max_loop: - Maximum number of progression iterations. + max_loop: Maximum number of progression iterations to execute. Returns: - Reaction metadata generated during the progression loop. + The prepared and deduplicated reaction metadata. """ iteration = 0 monomer_roles_in_loop = list["MonomerRole"]( @@ -83,6 +104,8 @@ def reaction_progression( self.session.reaction_progression_session.iteration = iteration if iteration == 1: + # Convert the initial monomer SMILES strings into RDKit + # molecules before the first functional-group search. self._populate_monomer_roles() else: print( @@ -90,12 +113,12 @@ def reaction_progression( "of the reaction progression loop." ) + # Roles seen in earlier iterations are marked so detectors can + # distinguish already-processed molecules from newly added ones. self._set_is_looped_flag(monomer_roles_in_loop) - initial_reaction_pool_size = ( - self._count_active_reactions( - self.session.reaction_metadata - ) + initial_reaction_pool_size = self._count_active_reactions( + self.session.reaction_metadata ) print( @@ -136,15 +159,15 @@ def reaction_progression( ) break - prepared_reactions = ( - self._index_based_reaction_preparation( - reaction_instances=reaction_instances - ) + prepared_reactions = self._index_based_reaction_preparation( + reaction_instances=reaction_instances ) - + all_prepared_reactions.extend(prepared_reactions) self.session.reaction_metadata = all_prepared_reactions + # Deduplication occurs after preparation because equivalent + # products may be generated through different reaction paths. all_prepared_reactions = ( self.deduplication_detector.compare_graphs_mol( all_prepared_reactions @@ -152,10 +175,8 @@ def reaction_progression( ) self.session.reaction_metadata = all_prepared_reactions - deduplicated_reaction_count = ( - self._count_active_reactions( - all_prepared_reactions - ) + deduplicated_reaction_count = self._count_active_reactions( + all_prepared_reactions ) should_break = self._loop_break_condition( @@ -166,13 +187,17 @@ def reaction_progression( if should_break: return self._store_reactions(all_prepared_reactions) - self.session.reaction_metadata = all_prepared_reactions return all_prepared_reactions def _index_based_reaction_preparation( self, reaction_instances: list["ReactionInstance"], ) -> list["ReactionMetadata"]: + """Convert detected reaction instances into prepared reaction metadata. + + The import is local to avoid importing the reaction-preparation module + during module initialization, which also helps prevent circular imports. + """ from AutoREACTER.reaction_preparation.reaction_processor.prepare_reactions import ( PrepareReactions, ) @@ -186,12 +211,19 @@ def _index_based_reaction_preparation( def _prepare_products_for_idx_based_fg_detection( self, ) -> list[MonomerRoleforIndexBasedFGDetection]: - """Prepare generated products for functional-group detection.""" + """Prepare active reaction products for index-based FG detection. + + Products are converted into cleaned SMILES strings and RDKit molecules. + Their template atom indexes are retained so newly detected functional + groups can be traced back to the reaction that produced them. + """ prepared_monomer_roles: list[ MonomerRoleforIndexBasedFGDetection ] = [] for reaction in self.session.reaction_metadata: + # Inactive reactions do not contain products that can participate + # in a subsequent progression iteration. if not reaction.activity_stats: continue @@ -212,11 +244,12 @@ def _prepare_products_for_idx_based_fg_detection( ) return prepared_monomer_roles - + def _store_reactions( self, reactions: list["ReactionMetadata"], ) -> list["ReactionMetadata"]: + """Save reaction metadata to the session and return it.""" self.session.reaction_metadata = reactions return reactions @@ -224,11 +257,14 @@ def _sanitize_molecule( self, mol: Chem.Mol, ) -> Chem.Mol | None: - """ - Sanitize an RDKit molecule by cleaning it and applying RDKit sanitization. + """Clean and sanitize an RDKit molecule. + + Atom-map numbers and isotope labels are removed before sanitization. + Products that RDKit cannot sanitize are discarded and represented by + ``None``. Args: - mol: The RDKit molecule to sanitize. + mol: Molecule to clean and sanitize. Returns: The sanitized molecule, or ``None`` if sanitization fails. @@ -244,10 +280,15 @@ def _sanitize_molecule( return cleaned_mol def _clean_product(self, mol: Chem.Mol) -> Chem.Mol: - """ - Return a molecule copy without atom-map numbers or isotope labels. + """Return a copy of ``mol`` without atom maps or isotope labels. + + The input molecule is copied and therefore remains unchanged. + + Args: + mol: Molecule whose atom annotations should be removed. - The input molecule is not modified. + Returns: + A cleaned copy of the input molecule. """ cleaned_mol = Chem.Mol(mol) @@ -258,7 +299,14 @@ def _clean_product(self, mol: Chem.Mol) -> Chem.Mol: return cleaned_mol def _get_product_smiles(self, mol: Chem.Mol) -> str: - """Convert a cleaned product molecule to SMILES.""" + """Convert a cleaned product molecule to canonical SMILES. + + Args: + mol: Product molecule to serialize. + + Returns: + The product SMILES, or an empty string if conversion fails. + """ cleaned_mol = self._clean_product(mol) try: @@ -271,12 +319,20 @@ def _get_product_idxs( template_reactant_to_product_mapping: dict[int, int], mol: Chem.Mol, ) -> tuple[list[int], Chem.Mol]: - """ - Retrieve mapped product atom indexes. + """Return product indexes and the molecule containing those indexes. + + If the product contains multiple disconnected fragments, only the + fragment with the greatest number of heavy atoms is retained. Indexes + are remapped to match the retained fragment. - When the product contains multiple fragments, retain the fragment - with the largest number of heavy atoms and remap the product - indexes to that fragment. + Args: + template_reactant_to_product_mapping: + Mapping from template reactant atom indexes to product indexes. + mol: Combined product molecule. + + Returns: + A tuple containing remapped product indexes and the selected + product molecule. """ product = Chem.Mol(mol) product_idxs = list( @@ -298,18 +354,17 @@ def _keep_largest_fragment( mol: Chem.Mol, product_idxs: list[int], ) -> tuple[Chem.Mol, list[int]]: - """ - Retain the fragment with the largest number of heavy atoms and - remap the product indexes to the retained fragment. + """Keep the largest disconnected fragment and remap atom indexes. Args: - mol: - Molecule containing one or more disconnected fragments. - product_idxs: - Product indexes referring to the original molecule. + mol: Molecule containing one or more disconnected fragments. + product_idxs: Atom indexes referring to the original molecule. Returns: - The largest fragment and its remapped product indexes. + The largest fragment and the indexes remapped to that fragment. + + Raises: + ValueError: If the molecule contains no fragments. """ fragment_atom_mappings: list[tuple[int, ...]] = [] @@ -327,24 +382,22 @@ def _keep_largest_fragment( largest_fragment_position = max( range(len(fragments)), - key=lambda position: ( - fragments[position].GetNumHeavyAtoms() - ), + key=lambda position: fragments[position].GetNumHeavyAtoms(), ) largest_fragment = fragments[largest_fragment_position] - # Mapping direction: - # fragment atom index -> original molecule atom index + # RDKit provides each retained fragment's original atom indexes. + # Build the inverse mapping to translate indexes into fragment space. original_atom_idxs = fragment_atom_mappings[ largest_fragment_position ] - original_to_new_idx = { original_idx: new_idx for new_idx, original_idx in enumerate(original_atom_idxs) } + # Ignore mapped indexes that belong to discarded fragments. remapped_product_idxs = [ original_to_new_idx[product_idx] for product_idx in product_idxs @@ -357,12 +410,12 @@ def _set_is_looped_flag( self, monomer_roles: list["MonomerRole"], ) -> None: - """Mark the supplied monomer roles as already processed.""" + """Mark supplied monomer roles as processed by the current loop.""" for monomer_role in monomer_roles: monomer_role.is_looped = True def _populate_monomer_roles(self) -> None: - """Create RDKit molecules for roles marked as monomers.""" + """Create RDKit molecules for all roles identified as monomers.""" for monomer in self.session.monomer_roles: if monomer.is_monomer: monomer.rdkit_mol = self._smiles_to_rdkit_mol( @@ -373,7 +426,14 @@ def _smiles_to_rdkit_mol( self, smiles: str, ) -> Chem.Mol | None: - """Convert a SMILES string to an RDKit molecule.""" + """Parse a SMILES string into an RDKit molecule. + + Args: + smiles: SMILES representation of the molecule. + + Returns: + The parsed molecule, or ``None`` if RDKit cannot parse the string. + """ return Chem.MolFromSmiles(smiles) def _loop_break_condition( @@ -381,7 +441,18 @@ def _loop_break_condition( size_before: int, size_after: int, ) -> bool: - """Return whether the active reaction pool failed to grow.""" + """Return whether the active reaction pool failed to grow. + + A non-growing pool indicates that the latest iteration did not add + useful reaction products and further progression is unlikely to help. + + Args: + size_before: Active reaction count before the iteration. + size_after: Active reaction count after deduplication. + + Returns: + ``True`` when the pool stayed the same size or became smaller. + """ if size_after <= size_before: print( "Breaking the loop as the pool did not grow " @@ -395,16 +466,15 @@ def _count_active_reactions( self, reactions: list["ReactionMetadata"], ) -> int: - """ - Count the number of active reactions in the supplied list. + """Count reactions that contain activity statistics. Args: - reactions: A list of ``ReactionMetadata`` objects to check for activity. + reactions: Reaction metadata objects to inspect. Returns: - The number of reactions that have activity statistics. + The number of active reactions. """ return sum( bool(reaction.activity_stats) for reaction in reactions - ) \ No newline at end of file + ) From b25fe077cfaaabc75fd56568157f9c3381a41171 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:50:08 -0400 Subject: [PATCH 034/104] Remove noisy debug output Silences leftover print statements in functional group detection, reaction deduplication, and reaction progression. Also clears a few functional group library comments that were only carrying debug-style guidance. --- AutoREACTER/detectors/functional_groups_detector.py | 3 +-- AutoREACTER/detectors/functional_groups_library.py | 6 +++--- .../reaction_preparation/deduplication_detector.py | 9 +++++---- .../reaction_processor/reaction_progression.py | 2 -- 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/AutoREACTER/detectors/functional_groups_detector.py b/AutoREACTER/detectors/functional_groups_detector.py index c29099f..803c2f6 100644 --- a/AutoREACTER/detectors/functional_groups_detector.py +++ b/AutoREACTER/detectors/functional_groups_detector.py @@ -474,7 +474,6 @@ def index_based_functional_groups_detector( continue # Skip already processed monomers mol = monomer.rdkit_mol - print(f"Processing monomer: {monomer.name} with SMILES: {monomer.smiles}") target_indices = set(monomer.indexes_in_template or []) detected_functionalities = [] @@ -521,7 +520,7 @@ def index_based_functional_groups_detector( all_matches.extend(functional_matches) # Log detected functionality for debugging/user feedback. - print(f"{monomer.smiles} has functionality: {functional_group['group_name']}") + # print(f"{monomer.smiles} has functionality: {functional_group['group_name']}") if functional_group.get("comments"): print(f"Note: {monomer.smiles} - {functional_group['comments']}") diff --git a/AutoREACTER/detectors/functional_groups_library.py b/AutoREACTER/detectors/functional_groups_library.py index 3f38c5c..3dd4a54 100644 --- a/AutoREACTER/detectors/functional_groups_library.py +++ b/AutoREACTER/detectors/functional_groups_library.py @@ -159,21 +159,21 @@ def __init__(self): "functionality_type": "di_identical", "smarts_1": "[CX4;R1]1[OX2;R1][CX4;R1]1", "group_name": "di_epoxide", - "comments": "Difunctional epoxide monomer. Required on the epoxy side for epoxy-amine polymerization.", + "comments": None, }, "primary_amine_monomer": { "functionality_type": "mono", "smarts_1": "[NX3H2;!$(NC=O);!$(NC=[N,O,S])]", "group_name": "primary_amine", - "comments": "Mono primary amine. One -NH2 group has two active hydrogens and can react with two epoxide groups.", + "comments": None, }, "secondary_amine_monomer": { "functionality_type": "mono", "smarts_1": "[NX3H1;!$(NC=O);!$(NC=[N,O,S])]", "group_name": "secondary_amine", - "comments": "Mono secondary amine. Represents the second-stage reactive amine after primary amine reacts once with epoxide. By itself, a mono secondary amine reacts only once with epoxide and is not a true polymer-forming monomer.", + "comments": None, }, # ============================================================ diff --git a/AutoREACTER/reaction_preparation/deduplication_detector.py b/AutoREACTER/reaction_preparation/deduplication_detector.py index c7f91d6..8602df2 100644 --- a/AutoREACTER/reaction_preparation/deduplication_detector.py +++ b/AutoREACTER/reaction_preparation/deduplication_detector.py @@ -405,10 +405,11 @@ def compare_graphs_mol( retained_object_ids.add(reaction_object_id) unique_reactions.append(reaction_metadata) - print( - f"Reaction {reaction_index}: " - "unique reaction retained." - ) + # Debugging: print unique reaction retention information. + # print( + # f"Reaction {reaction_index}: " + # "unique reaction retained." + # ) return unique_reactions diff --git a/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py index fb0d011..0a14fde 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py @@ -345,8 +345,6 @@ def _get_product_idxs( product_idxs, ) - print(f"Product idxs: {product_idxs}") - return product_idxs, product def _keep_largest_fragment( From 339638c605d634498c2e4db9f0424fe177de7b07 Mon Sep 17 00:00:00 2001 From: Janitha Mahanthe <119646255+janitha-mahanthe@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:31:54 -0400 Subject: [PATCH 035/104] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- examples/test_glycine.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/test_glycine.json b/examples/test_glycine.json index 33c5da9..cb53b3a 100644 --- a/examples/test_glycine.json +++ b/examples/test_glycine.json @@ -1,18 +1,18 @@ { - "simulation_name": "Epoxy_Test_Primary_Diamine_Diepoxy", + "simulation_name": "Glycine_Test", "simulations": [ { - "tag": "epoxy_test", + "tag": "glycine_test", "temperature": 300, "density": 1.0, "monomer_counts": { - "di_epoxy": 200 + "glycine": 200 } } ], "monomers": [ { - "name": "di_epoxy", + "name": "glycine", "smiles": "NCC(=O)O" } ] From 070cc31682146870926738b3f46b227dcc65a0fe Mon Sep 17 00:00:00 2001 From: Janitha Mahanthe <119646255+janitha-mahanthe@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:32:39 -0400 Subject: [PATCH 036/104] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- AutoREACTER/detectors/functional_groups_detector.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/AutoREACTER/detectors/functional_groups_detector.py b/AutoREACTER/detectors/functional_groups_detector.py index 803c2f6..dd74bd5 100644 --- a/AutoREACTER/detectors/functional_groups_detector.py +++ b/AutoREACTER/detectors/functional_groups_detector.py @@ -441,9 +441,9 @@ def _detect_functional_groups_by_index(self, mol: Chem.Mol, smarts: str, indices return bool(matching_hits) def index_based_functional_groups_detector( - self, monomer_roles_in: list[MonomerRoleforIndexBasedFGDetection] - ) -> None: - """ + 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. From 8497c3837867e991b5151f16fb636ee86d77c186 Mon Sep 17 00:00:00 2001 From: Janitha Mahanthe <119646255+janitha-mahanthe@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:33:43 -0400 Subject: [PATCH 037/104] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../reaction_processor/prepare_reactions.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py b/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py index 2d04e0a..0faa52d 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py @@ -142,9 +142,11 @@ def _zero_active_reactions_error(self, reaction_metadata: list[ReactionMetadata] active_reactions = True break if not active_reactions: - 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") + 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]: """ From 0a52366ed6f5747b8d005f4485f4eabd1dc00d20 Mon Sep 17 00:00:00 2001 From: Janitha Mahanthe <119646255+janitha-mahanthe@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:34:01 -0400 Subject: [PATCH 038/104] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../reaction_processor/warning_asci.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/AutoREACTER/reaction_preparation/reaction_processor/warning_asci.py b/AutoREACTER/reaction_preparation/reaction_processor/warning_asci.py index 629be0c..2979cef 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/warning_asci.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/warning_asci.py @@ -2,15 +2,17 @@ def ascii_art(message: str) -> None: message = message.upper() print(f"WARNING: {message}") - """ _ _ _ _ + print( + """ _ _ _ _ | | | | (_) | | | | | | | | __ _ _ __ _ __ _ _ __ __ _| | | | | |/\| |/ _` | '__| '_ \| | '_ \ / _` | | | | \ /\ / (_| | | | | | | | | | | (_| |_|_|_| \/ \/ \__,_|_| |_| |_|_|_| |_|\__, (_|_|_) - __/ | - |___/ + __/ | + |___/ """ + ) def print_warning() -> None: From bbfa638b625dcba697fcebafedabc206b08c29cd Mon Sep 17 00:00:00 2001 From: Janitha Mahanthe <119646255+janitha-mahanthe@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:34:35 -0400 Subject: [PATCH 039/104] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- AutoREACTER/detectors/functional_groups_detector.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/AutoREACTER/detectors/functional_groups_detector.py b/AutoREACTER/detectors/functional_groups_detector.py index dd74bd5..704cdf0 100644 --- a/AutoREACTER/detectors/functional_groups_detector.py +++ b/AutoREACTER/detectors/functional_groups_detector.py @@ -118,8 +118,9 @@ logger = logging.getLogger(__name__) # Module-level logger for future diagnostics. if TYPE_CHECKING: from AutoREACTER.session import Session - from AutoREACTER.detectors.functional_groups_detector import MonomerRoleforIndexBasedFGDetection - + from AutoREACTER.reaction_preparation.reaction_processor.reaction_progression import ( + MonomerRoleforIndexBasedFGDetection, + ) @dataclass(slots=True) class FunctionalGroupInfo: From 0450d2fbb632c7929a3d5f0466bd9d7536dc99df Mon Sep 17 00:00:00 2001 From: Janitha Mahanthe <119646255+janitha-mahanthe@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:34:48 -0400 Subject: [PATCH 040/104] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../reaction_processor/warning_asci.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/AutoREACTER/reaction_preparation/reaction_processor/warning_asci.py b/AutoREACTER/reaction_preparation/reaction_processor/warning_asci.py index 2979cef..cdf3d8a 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/warning_asci.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/warning_asci.py @@ -16,8 +16,9 @@ def ascii_art(message: str) -> None: def print_warning() -> None: - message = "Warning " \ - "Entering to the reaction progression Loop still in the Beta phase" \ - "Caution: Can be chemically inaccurate" + message = ( + "Entering the reaction progression loop is still in the beta phase. " + "Caution: results can be chemically inaccurate." + ) ascii_art(message) From 7ff392f7d79b5160b43de598757d4700eaa72ef0 Mon Sep 17 00:00:00 2001 From: Janitha Mahanthe <119646255+janitha-mahanthe@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:35:06 -0400 Subject: [PATCH 041/104] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../reaction_preparation/ff_wrapper/lunar_client/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AutoREACTER/reaction_preparation/ff_wrapper/lunar_client/config.py b/AutoREACTER/reaction_preparation/ff_wrapper/lunar_client/config.py index a9a571e..96b9c6f 100644 --- a/AutoREACTER/reaction_preparation/ff_wrapper/lunar_client/config.py +++ b/AutoREACTER/reaction_preparation/ff_wrapper/lunar_client/config.py @@ -1 +1 @@ -LUNAR_ROOT_DIR = '/mnt/c/Users/janit/Documents/GitHub/LUNAR' +LUNAR_ROOT_DIR = None From fe390775ad57aaf01125b14c245bd5c399c425b9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:35:33 +0000 Subject: [PATCH 042/104] Remove unused numpy indices import --- AutoREACTER/detectors/functional_groups_detector.py | 1 - 1 file changed, 1 deletion(-) diff --git a/AutoREACTER/detectors/functional_groups_detector.py b/AutoREACTER/detectors/functional_groups_detector.py index 704cdf0..c82fe1e 100644 --- a/AutoREACTER/detectors/functional_groups_detector.py +++ b/AutoREACTER/detectors/functional_groups_detector.py @@ -1,7 +1,6 @@ from __future__ import annotations from typing import TYPE_CHECKING, List -from numpy import indices """ * Monomer Functionality Detection Module -------------------------------------- From e865c75ed436ea327e66a1226662f8f807c01a43 Mon Sep 17 00:00:00 2001 From: Janitha Mahanthe <119646255+janitha-mahanthe@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:37:14 -0400 Subject: [PATCH 043/104] Refactor forced index handling in prepare_reactions.py --- .../reaction_processor/prepare_reactions.py | 50 ++++++++++++++----- 1 file changed, 37 insertions(+), 13 deletions(-) diff --git a/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py b/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py index 0faa52d..ca17045 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py @@ -252,22 +252,46 @@ def _process_reaction_instances( forced_indexes_2 = None if loop: - # FORCED-REACTION MODE: use the actual index-scoped rdkit_mol (not a - # fresh SMILES reparse) so atom indices line up with fg_*_indexes. - forced_indexes_1 = self._flatten_fg_indexes(reaction.functional_group_1) - + forced_indexes_1 = self._flatten_fg_indexes( + reaction.functional_group_1 + ) + if reaction.functional_group_2 is not None: - forced_indexes_2 = self._flatten_fg_indexes(reaction.functional_group_2) - - # Direction is already known from the ReactionInstance (monomer_1 -> - # reactant slot 1, monomer_2 -> reactant slot 2), so unlike the normal - # path we do NOT also try the swapped ordering. - reaction_tuple = [[mol_reactant_1, mol_reactant_2], [mol_reactant_2, mol_reactant_1]] if same_reactants else [[mol_reactant_1, mol_reactant_2]] + forced_indexes_2 = self._flatten_fg_indexes( + reaction.functional_group_2 + ) + + mol_reactant_1 = Chem.AddHs( + Chem.Mol(reaction.monomer_1.rdkit_mol) + ) + + monomer_2 = ( + reaction.monomer_1 + if same_reactants + else reaction.monomer_2 + ) + mol_reactant_2 = Chem.AddHs( + Chem.Mol(monomer_2.rdkit_mol) + ) + + # ReactionInstance already defines slot direction. + 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) + 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, + ) rxn = self._build_reaction(rxn_smarts) From f828e695cd6279a3325e4311d4dedb637c6813bd Mon Sep 17 00:00:00 2001 From: Janitha Mahanthe <119646255+janitha-mahanthe@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:37:47 -0400 Subject: [PATCH 044/104] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../detectors/functional_groups_detector.py | 33 +++++++------------ 1 file changed, 12 insertions(+), 21 deletions(-) diff --git a/AutoREACTER/detectors/functional_groups_detector.py b/AutoREACTER/detectors/functional_groups_detector.py index c82fe1e..ad4fbad 100644 --- a/AutoREACTER/detectors/functional_groups_detector.py +++ b/AutoREACTER/detectors/functional_groups_detector.py @@ -414,31 +414,22 @@ def _functional_groups_detector_for_visualization( ) return monomer_roles_visualization - def _detect_functional_groups_by_index(self, mol: Chem.Mol, smarts: str, indices: list[int]) -> bool: - """ - mol: RDKit Mol object - smarts: SMARTS pattern string - indices: iterable of atom indices you care about - - Returns: bool - True if any of the specified indices match the SMARTS pattern, False otherwise - """ - target_indices = set(indices) - results = {} + 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: - # invalid SMARTS - results["present"] = False - results["error"] = "invalid SMARTS" - return results - - matches = mol.GetSubstructMatches(patt, uniquify=True) # tuple of tuples of atom idx - - # check if ANY match shares AT LEAST ONE atom with your target indices - matching_hits = [m for m in matches if target_indices.intersection(m)] + logger.warning("Invalid SMARTS pattern: %s", smarts) + return False - return bool(matching_hits) + matches = mol.GetSubstructMatches(patt, uniquify=True) + return any(target_indices.intersection(match) for match in matches) def index_based_functional_groups_detector( self, From f425265adc6564176acb62019756d1894e25a44f Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:32:55 -0400 Subject: [PATCH 045/104] Add vinyl polymerization reaction support Introduces vinyl-specific functional groups (`vinyl` and `vinyl_chain_radical`) and adds initiation/propagation entries for vinyl addition polymerization in the reaction library. Reaction progression now sanitizes product molecules before reuse, skips failed sanitizations with a clear message, and uses the sanitized molecule for both SMILES extraction and downstream functional-group detection. Also fixes a missing docstring delimiter in `functional_groups_detector.py`. --- .../detectors/functional_groups_detector.py | 1 + .../detectors/functional_groups_library.py | 17 +++++++ AutoREACTER/detectors/reactions_library.py | 50 +++++++++++++++++++ .../reaction_progression.py | 15 ++++-- 4 files changed, 79 insertions(+), 4 deletions(-) diff --git a/AutoREACTER/detectors/functional_groups_detector.py b/AutoREACTER/detectors/functional_groups_detector.py index ad4fbad..6f1a9e5 100644 --- a/AutoREACTER/detectors/functional_groups_detector.py +++ b/AutoREACTER/detectors/functional_groups_detector.py @@ -435,6 +435,7 @@ 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. diff --git a/AutoREACTER/detectors/functional_groups_library.py b/AutoREACTER/detectors/functional_groups_library.py index 3dd4a54..0207c82 100644 --- a/AutoREACTER/detectors/functional_groups_library.py +++ b/AutoREACTER/detectors/functional_groups_library.py @@ -175,6 +175,23 @@ def __init__(self): "group_name": "secondary_amine", "comments": None, }, + # ============================================================ + # Vinyl Addition Polymerization + # ============================================================ + + "vinyl_monomer": { + "functionality_type": "vinyl", + "smarts_1": "[CH2]=[C;!R]", + "group_name": "vinyl", + "comments": None, + }, + + "vinyl_chain_radical_monomer": { + "functionality_type": "mono", + "smarts_1": "[C]-[*]", + "group_name": "vinyl_chain_radical", + "comments": "Reactive chain-end group used for vinyl propagation.", + }, # ============================================================ # Commented functional groups diff --git a/AutoREACTER/detectors/reactions_library.py b/AutoREACTER/detectors/reactions_library.py index 25f7fb9..29cc753 100644 --- a/AutoREACTER/detectors/reactions_library.py +++ b/AutoREACTER/detectors/reactions_library.py @@ -34,7 +34,15 @@ - Polymerization in Supercritical Carbon Dioxide - Thiophene Polymerizations: Oxidative Polymerization of Thiophenes """ +rxn_prop_smarts = ( + "[C:1]-[*:5].[CH2:3]=[CH;!R:4]-[*:6]" + ">>[C:1]([*:5])-[CH2:3]-[CH:4]-[*:6]" +) +rxn_init_smarts = ( + "[CH2:1]=[CH;!R:2].[CH2:3]=[C;!R:4]-[*:5]" + ">>[C:1]-[C:2]-[C:3]-[C:4]-[*:5]" +) class ReactionLibrary: def __init__(self): @@ -366,7 +374,49 @@ def __init__(self): }, "comments": "Secondary amine's remaining N-H opens a second epoxide ring. Nitrogen becomes a fully substituted tertiary amine (network crosslink point); no reactive N-H remains on this nitrogen." }, + + "Vinyl Addition Polymerization Initiation": { + "same_reactants": True, + "reactant_1": "vinyl", + "product": "vinyl_chain_radical", + "delete_atom": False, + "reaction": ( + "[CH2:1]=[CH;!R:3]." + "[CH2:2]=[C;!R:4]-[*:5]" + ">>" + "[C:1]-[C:3]-[C:2]-[C:4]-[*:5]" + ), + "reference": { + "smarts": None, + "reaction_and_mechanism": None, + }, + "comments": ( + "Vinyl initiation reaction. Atom maps 1 and 2 identify the " + "reacting atoms from the first and second vinyl molecules." + ), + }, + "Vinyl Addition Polymerization Propagation": { + "same_reactants": False, + "reactant_1": "vinyl_chain_radical", + "reactant_2": "vinyl", + "product": "vinyl_chain_radical", + "delete_atom": False, + "reaction": ( + "[C:1]-[*:5]." + "[CH2:2]=[CH;!R:3]-[*:6]" + ">>" + "[C:1]([*:5])-[CH2:2]-[CH:3]-[*:6]" + ), + "reference": { + "smarts": None, + "reaction_and_mechanism": None, + }, + "comments": ( + "Vinyl propagation reaction. Atom map 1 is the reacting chain-end " + "atom, and atom map 2 is the reacting atom of the incoming vinyl molecule." + ), + }, # ============================================================ # Commented reactions # ============================================================ diff --git a/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py index 0a14fde..38b096d 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py @@ -222,8 +222,6 @@ def _prepare_products_for_idx_based_fg_detection( ] = [] for reaction in self.session.reaction_metadata: - # Inactive reactions do not contain products that can participate - # in a subsequent progression iteration. if not reaction.activity_stats: continue @@ -234,12 +232,21 @@ def _prepare_products_for_idx_based_fg_detection( product_mol, ) + sanitized_mol = self._sanitize_molecule(product_mol) + + if sanitized_mol is None: + print( + f"Skipping reaction product {reaction.reaction_id}: " + "RDKit molecule sanitization failed." + ) + continue + prepared_monomer_roles.append( MonomerRoleforIndexBasedFGDetection( - smiles=self._get_product_smiles(product_mol), + smiles=self._get_product_smiles(sanitized_mol), name=f"new_{reaction.reaction_id}", indexes_in_template=indexes_in_template, - rdkit_mol=self._sanitize_molecule(product_mol), + rdkit_mol=sanitized_mol, ) ) From 8f9c279f2ff22d01a243de593b34fb42aab2d8ca Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:09:44 -0400 Subject: [PATCH 046/104] Fix vinyl polymerization SMARTS and radical handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Overhaul vinyl addition polymerization detection and reaction logic: - Rename `vinyl_chain_radical_monomer` → `vinyl_chain_end_radical` with a corrected SMARTS that matches secondary carbon radicals without false-positives on unreacted monomers - Fix initiation and propagation reaction SMARTS/atom maps to correctly track radical chain ends - Add reverse-order retry when RDKit RunReactants fails - Rewrite `_sanitize_molecule` to patch radical electrons on newly formed chain ends and strip them from saturated carbons post-reaction - Add `_fix_radical_and_sanitize` helper for under-valent radical carbon recovery - Clear RDKit ghost atom properties (`old_mapno`, `react_atom_idx`) in `_clean_product` to prevent stale atom tracking - Add debug print statements for molecule inspection and substructure matching --- .../detectors/functional_groups_detector.py | 1 + .../detectors/functional_groups_library.py | 11 +- AutoREACTER/detectors/reactions_library.py | 32 ++-- .../reaction_processor/prepare_reactions.py | 15 +- .../reaction_progression.py | 152 +++++++++++++----- 5 files changed, 153 insertions(+), 58 deletions(-) diff --git a/AutoREACTER/detectors/functional_groups_detector.py b/AutoREACTER/detectors/functional_groups_detector.py index 6f1a9e5..b0d9377 100644 --- a/AutoREACTER/detectors/functional_groups_detector.py +++ b/AutoREACTER/detectors/functional_groups_detector.py @@ -532,6 +532,7 @@ def index_based_functional_groups_detector( # Add to roles if any functionalities detected. if detected_functionalities: + print(f"Detected functionalities for monomer {detected_functionalities}") monomer_roles_out.append( MonomerRole( smiles=monomer.smiles, diff --git a/AutoREACTER/detectors/functional_groups_library.py b/AutoREACTER/detectors/functional_groups_library.py index 0207c82..155a8f1 100644 --- a/AutoREACTER/detectors/functional_groups_library.py +++ b/AutoREACTER/detectors/functional_groups_library.py @@ -186,11 +186,14 @@ def __init__(self): "comments": None, }, - "vinyl_chain_radical_monomer": { + "vinyl_chain_end_radical": { "functionality_type": "mono", - "smarts_1": "[C]-[*]", - "group_name": "vinyl_chain_radical", - "comments": "Reactive chain-end group used for vinyl propagation.", + "smarts_1": "[C;!R;D3](-[H])(-[!#1])-[!#1]", + "group_name": "vinyl_chain_end_radical", + "comments": ( + "Secondary carbon-centered radical attached through two carbon bonds. " + "Does not match the valence-4 alkene carbon of an unreacted monomer." + ), }, # ============================================================ diff --git a/AutoREACTER/detectors/reactions_library.py b/AutoREACTER/detectors/reactions_library.py index 29cc753..4f9f426 100644 --- a/AutoREACTER/detectors/reactions_library.py +++ b/AutoREACTER/detectors/reactions_library.py @@ -378,43 +378,43 @@ def __init__(self): "Vinyl Addition Polymerization Initiation": { "same_reactants": True, "reactant_1": "vinyl", - "product": "vinyl_chain_radical", + "product": "vinyl_chain_end_radical", "delete_atom": False, "reaction": ( - "[CH2:1]=[CH;!R:3]." - "[CH2:2]=[C;!R:4]-[*:5]" + "[CH2:1]=[CH:3]-[*:5]." + "[CH2:2]=[CH:4]-[*:6]" ">>" - "[C:1]-[C:3]-[C:2]-[C:4]-[*:5]" + "[CH2:1](-[C:3]-[*:5])-[CH2:2]-[C:4]-[*:6]" ), "reference": { "smarts": None, "reaction_and_mechanism": None, }, "comments": ( - "Vinyl initiation reaction. Atom maps 1 and 2 identify the " - "reacting atoms from the first and second vinyl molecules." + "Atom maps 1 and 2 are the initiating atoms from the two vinyl " + "molecules. Atom 4 becomes the new carbon-centered radical." ), }, - "Vinyl Addition Polymerization Propagation": { "same_reactants": False, - "reactant_1": "vinyl_chain_radical", - "reactant_2": "vinyl", - "product": "vinyl_chain_radical", + "reactant_1": "vinyl", + "reactant_2": "vinyl_chain_end_radical", + "product": "vinyl_chain_end_radical", "delete_atom": False, - "reaction": ( - "[C:1]-[*:5]." - "[CH2:2]=[CH;!R:3]-[*:6]" + "reaction":( + "[CH2:2]=[CH:3]-[*:6]." + "[*:5]-[C;!R;H1:1]-[*:7]" ">>" - "[C:1]([*:5])-[CH2:2]-[CH:3]-[*:6]" + "[*:5]-[C:1](-[*:7])-[CH2:2]-[C:3]-[*:6]" ), "reference": { "smarts": None, "reaction_and_mechanism": None, }, "comments": ( - "Vinyl propagation reaction. Atom map 1 is the reacting chain-end " - "atom, and atom map 2 is the reacting atom of the incoming vinyl molecule." + "Atom 1 is the existing radical chain-end carbon. Atom 2 is the " + "reacting atom of the incoming vinyl monomer. Atom 3 becomes the " + "new radical chain end." ), }, # ============================================================ diff --git a/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py b/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py index ca17045..e0fd248 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py @@ -338,13 +338,24 @@ def _process_reaction_products(self, # Assign atom map numbers and isotopes to reactants for tracking through the reaction self._assign_atom_map_numbers_and_set_isotopes(r1, r2) - # Run the reaction and get product sets + # 1. Run the reaction in default order (A + B) products = rxn.RunReactants((r1, r2)) - # Skip if no products were generated + # 2. If it fails due to order mismatch, try the reverse (B + A) if not products: + print ("Reaction failed in default order, trying reverse order...") + products = rxn.RunReactants((r2, r1)) + + # Skip if no products were generated in either direction + if not products: + print ("Reaction failed in both orders, skipping this reactant pair.") + print(f"\n[ERROR] RDKit failed to react {r1.GetNumAtoms()} atoms with {r2.GetNumAtoms()} atoms.") + print(f"Reactant 1 SMILES: {Chem.MolToSmiles(r1)}") + print(f"Reactant 2 SMILES: {Chem.MolToSmiles(r2)}") + print(f"Reaction SMARTS: \n") continue + # Process each product set generated by the reaction for product_set in products: df = pd.DataFrame(columns=["reactant_idx", "product_idx"]) diff --git a/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py index 38b096d..2b94304 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py @@ -145,13 +145,14 @@ def reaction_progression( monomer_roles_in_loop.extend(fg_detection_results) self.session.monomer_roles = monomer_roles_in_loop + print(monomer_roles_in_loop) reaction_instances = ( self.rxn_detector.index_based_reaction_detector( monomer_roles_in_loop ) ) - + print(reaction_instances) if not reaction_instances: print( f"No new reactions detected in iteration {iteration}. " @@ -193,11 +194,7 @@ def _index_based_reaction_preparation( self, reaction_instances: list["ReactionInstance"], ) -> list["ReactionMetadata"]: - """Convert detected reaction instances into prepared reaction metadata. - - The import is local to avoid importing the reaction-preparation module - during module initialization, which also helps prevent circular imports. - """ + """Convert detected reaction instances into prepared reaction metadata.""" from AutoREACTER.reaction_preparation.reaction_processor.prepare_reactions import ( PrepareReactions, ) @@ -205,7 +202,7 @@ def _index_based_reaction_preparation( reaction_preparer = PrepareReactions(self.session) return reaction_preparer._prepare_reactions_stage( - reaction_instances + reaction_instances, loop=True ) def _prepare_products_for_idx_based_fg_detection( @@ -232,15 +229,33 @@ def _prepare_products_for_idx_based_fg_detection( product_mol, ) - sanitized_mol = self._sanitize_molecule(product_mol) - - if sanitized_mol is None: + sanitized_mol, success = self._sanitize_molecule(product_mol) + + # THIS IS A DEBUG BLOCK --- + if success: + print("\n--- DEBUG MOLECULE ---") + print("SMILES:", Chem.MolToSmiles(sanitized_mol)) + for a in sanitized_mol.GetAtoms(): + if a.GetDegree() == 2 and a.GetAtomicNum() == 6: + print(f"Atom {a.GetIdx()}: Symbol={a.GetSymbol()}, Heavy Neighbors={a.GetDegree()}, Total Hs={a.GetTotalNumHs()}, Radicals={a.GetNumRadicalElectrons()}") + print("----------------------\n") + # ---------------------------- + + matches = sanitized_mol.GetSubstructMatches(Chem.MolFromSmarts("[C;!R;D3](-[H])(-[!#1])-[!#1]")) + print(f"Substructure matches for radical carbon: {matches}") + print( + f"Sanitizing reaction product {reaction.reaction_id}... " + f"result {success}" + ) + if not success: print( f"Skipping reaction product {reaction.reaction_id}: " "RDKit molecule sanitization failed." ) - continue - + else: + print( + f"Reaction product {reaction.reaction_id} sanitized successfully." + ) prepared_monomer_roles.append( MonomerRoleforIndexBasedFGDetection( smiles=self._get_product_smiles(sanitized_mol), @@ -263,46 +278,111 @@ def _store_reactions( def _sanitize_molecule( self, mol: Chem.Mol, - ) -> Chem.Mol | None: - """Clean and sanitize an RDKit molecule. - - Atom-map numbers and isotope labels are removed before sanitization. - Products that RDKit cannot sanitize are discarded and represented by - ``None``. - - Args: - mol: Molecule to clean and sanitize. - - Returns: - The sanitized molecule, or ``None`` if sanitization fails. - """ + ) -> tuple[Chem.Mol | None, bool]: cleaned_mol = self._clean_product(mol) + patched_mol = Chem.RWMol(cleaned_mol) + patched_mol.UpdatePropertyCache(strict=False) + Chem.FastFindRings(patched_mol) + + for atom in patched_mol.GetAtoms(): + if atom.GetAtomicNum() == 6: + atom.SetNoImplicit(True) + + heavy_val = int(sum(bond.GetValenceContrib(atom) for bond in atom.GetBonds())) + explicit_hs = atom.GetNumExplicitHs() + rads = atom.GetNumRadicalElectrons() + + # 1. STRIP radical electrons ONLY from the OLD chain end! + # If it just formed a new bond, its heavy + Hs equals 4. It is no longer a radical. + if heavy_val + explicit_hs >= 4 and rads > 0: + atom.SetNumRadicalElectrons(0) + rads = 0 + + # 2. Fix over-valent carbons (if RunReactants forces too many Hs) + if heavy_val + explicit_hs + rads > 4: + allowed_hs = max(0, 4 - heavy_val - rads) + atom.SetNumExplicitHs(allowed_hs) + explicit_hs = allowed_hs + + # 3. PROTECT the NEW chain end! + # If it has 3 bonds, it's the new radical. Give it the electron back + # so Chem.AddHs() doesn't accidentally quench it with a fake hydrogen! + if heavy_val + explicit_hs == 3 and atom.GetFormalCharge() == 0: + atom.SetNumRadicalElectrons(1) + + patched_mol = patched_mol.GetMol() + patched_mol.ClearComputedProps() try: - Chem.SanitizeMol(cleaned_mol) - except Exception as error: - print(f"Could not sanitize product molecule: {error}") - return None - - return cleaned_mol + Chem.SanitizeMol(patched_mol) + return patched_mol, True + except Exception: + pass - def _clean_product(self, mol: Chem.Mol) -> Chem.Mol: - """Return a copy of ``mol`` without atom maps or isotope labels. + radical_fixed_mol = self._fix_radical_and_sanitize(patched_mol) - The input molecule is copied and therefore remains unchanged. + try: + Chem.SanitizeMol(radical_fixed_mol) + return radical_fixed_mol, True + + except Exception as error2: + 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: + """ + RunReactants() output is unsanitized. Our radical carbon is deliberately + under-valent (v3 instead of v4) to mark it in SMARTS, but that's not a + real chemical species RDKit can sanitize or round-trip through SMILES. + This finds that atom and gives it an actual radical electron, so the + missing valence is accounted for and the mol becomes fully sanitizable. Args: - mol: Molecule whose atom annotations should be removed. + raw_mol: Molecule that may contain an under-valent radical carbon. + query: SMARTS identifying the deliberately under-valent radical atom. Returns: - A cleaned copy of the input molecule. + A new Chem.Mol with the radical atom's valence properly accounted + for via NumRadicalElectrons, ready for Chem.SanitizeMol(). """ + mol = Chem.RWMol(raw_mol) + mol.UpdatePropertyCache(strict=False) # need valence to even run the query + Chem.FastFindRings(mol) + + query_mol = Chem.MolFromSmarts(query) + hits = mol.GetSubstructMatches(query_mol) + + for match in hits: + atom = mol.GetAtomWithIdx(match[0]) # first atom = the radical carbon + atom.SetNoImplicit(True) + if atom.GetTotalNumHs() != 1: + atom.SetNumExplicitHs(1) + atom.SetNumRadicalElectrons(1) + + return mol.GetMol() + + def _clean_product(self, mol: Chem.Mol) -> Chem.Mol: + """Return a copy of ``mol`` without atom maps, isotopes, or ghost properties.""" cleaned_mol = Chem.Mol(mol) for atom in cleaned_mol.GetAtoms(): + # Clean standard tracking labels atom.SetAtomMapNum(0) atom.SetIsotope(0) + # EXORCISE THE GHOSTS: + # RDKit hides tracking data like 'old_mapno' deep in the atom properties. + # We MUST clear them so AutoREACTER doesn't mistake old atoms for new initiators! + 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: From 8c3f4ad920684973539ac0b64ad3a46a85480659 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:12:02 -0400 Subject: [PATCH 047/104] Create test_styrene.json --- examples/test_styrene.json | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 examples/test_styrene.json diff --git a/examples/test_styrene.json b/examples/test_styrene.json new file mode 100644 index 0000000..d8721b8 --- /dev/null +++ b/examples/test_styrene.json @@ -0,0 +1,19 @@ +{ + "simulation_name": "Epoxy_Test_Primary_Diamine_Diepoxy", + "simulations": [ + { + "tag": "vinyl_test", + "temperature": 300, + "density": 1.0, + "monomer_counts": { + "styrene": 200 + } + } + ], + "monomers": [ + { + "name": "styrene", + "smiles": "c1ccccc1C=C" + } + ] +} \ No newline at end of file From ff253874591e4f9312aad92669768c1ab29f52fb Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:17:30 -0400 Subject: [PATCH 048/104] Improve 3D molecule prep and add repair step --- .../ff_wrapper/molecule_3d_preparation.py | 170 +++++++++++++----- 1 file changed, 127 insertions(+), 43 deletions(-) diff --git a/AutoREACTER/reaction_preparation/ff_wrapper/molecule_3d_preparation.py b/AutoREACTER/reaction_preparation/ff_wrapper/molecule_3d_preparation.py index 6500e37..648d9b0 100644 --- a/AutoREACTER/reaction_preparation/ff_wrapper/molecule_3d_preparation.py +++ b/AutoREACTER/reaction_preparation/ff_wrapper/molecule_3d_preparation.py @@ -219,69 +219,153 @@ 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 - # 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 From 735c20a931053d561c51aeed5ee583040c036bcb Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:17:50 -0400 Subject: [PATCH 049/104] Generalize vinyl radical SMARTS patterns --- .../detectors/functional_groups_library.py | 7 +-- AutoREACTER/detectors/reactions_library.py | 45 ++++++++++++++----- examples/test_ethelene.json | 19 ++++++++ 3 files changed, 56 insertions(+), 15 deletions(-) create mode 100644 examples/test_ethelene.json diff --git a/AutoREACTER/detectors/functional_groups_library.py b/AutoREACTER/detectors/functional_groups_library.py index 155a8f1..72f6069 100644 --- a/AutoREACTER/detectors/functional_groups_library.py +++ b/AutoREACTER/detectors/functional_groups_library.py @@ -188,11 +188,12 @@ def __init__(self): "vinyl_chain_end_radical": { "functionality_type": "mono", - "smarts_1": "[C;!R;D3](-[H])(-[!#1])-[!#1]", + "smarts_1": "[C;!R;D3;v3]", "group_name": "vinyl_chain_end_radical", "comments": ( - "Secondary carbon-centered radical attached through two carbon bonds. " - "Does not match the valence-4 alkene carbon of an unreacted monomer." + "Neutral non-ring carbon-centered radical with degree 3 and " + "valence 3. Supports primary, secondary, and tertiary vinyl " + "polymer chain ends." ), }, diff --git a/AutoREACTER/detectors/reactions_library.py b/AutoREACTER/detectors/reactions_library.py index 4f9f426..6d28592 100644 --- a/AutoREACTER/detectors/reactions_library.py +++ b/AutoREACTER/detectors/reactions_library.py @@ -381,18 +381,18 @@ def __init__(self): "product": "vinyl_chain_end_radical", "delete_atom": False, "reaction": ( - "[CH2:1]=[CH:3]-[*:5]." - "[CH2:2]=[CH:4]-[*:6]" + "[CH2:1]=[C;!R:3]." + "[CH2:2]=[C;!R:4]" ">>" - "[CH2:1](-[C:3]-[*:5])-[CH2:2]-[C:4]-[*:6]" + "[CH2:1](-[C:3])-[CH2:2]-[C:4]" ), "reference": { "smarts": None, "reaction_and_mechanism": None, }, "comments": ( - "Atom maps 1 and 2 are the initiating atoms from the two vinyl " - "molecules. Atom 4 becomes the new carbon-centered radical." + "Joins two terminal vinyl groups. The two alkene carbons that do " + "not form the new intermolecular bond become radical chain ends." ), }, "Vinyl Addition Polymerization Propagation": { @@ -401,20 +401,41 @@ def __init__(self): "reactant_2": "vinyl_chain_end_radical", "product": "vinyl_chain_end_radical", "delete_atom": False, - "reaction":( - "[CH2:2]=[CH:3]-[*:6]." - "[*:5]-[C;!R;H1:1]-[*:7]" + "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": ( + "The existing carbon radical forms a bond with the terminal CH2 " + "of the incoming vinyl monomer. The other alkene carbon becomes " + "the new radical center." + ), + }, + "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]" ">>" - "[*:5]-[C:1](-[*:7])-[CH2:2]-[C:3]-[*:6]" + "[C:1]-[C:2]" ), "reference": { "smarts": None, "reaction_and_mechanism": None, }, "comments": ( - "Atom 1 is the existing radical chain-end carbon. Atom 2 is the " - "reacting atom of the incoming vinyl monomer. Atom 3 becomes the " - "new radical chain end." + "Termination by combination of two neutral carbon-centered " + "vinyl chain-end radicals. A new carbon-carbon single bond " + "is formed and both radical centers are consumed." ), }, # ============================================================ diff --git a/examples/test_ethelene.json b/examples/test_ethelene.json new file mode 100644 index 0000000..d7f10d7 --- /dev/null +++ b/examples/test_ethelene.json @@ -0,0 +1,19 @@ +{ + "simulation_name": "Epoxy_Test_Primary_Diamine_Diepoxy", + "simulations": [ + { + "tag": "vinyl_test", + "temperature": 300, + "density": 1.0, + "monomer_counts": { + "styrene": 200 + } + } + ], + "monomers": [ + { + "name": "styrene", + "smiles": "C=C" + } + ] +} \ No newline at end of file From e6cd09c657fb3aa3f268a97feb8d8dc407728ba0 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:10:04 -0400 Subject: [PATCH 050/104] Make RDKit deduplication radical-aware Update reaction graph comparison to include radical state in RDKit atom labels and attach a full-molecule radical signature to coupled graphs before isomorphism checks. This prevents false duplicate matches when restricted templates miss radical centers, and adds robust radical detection/counting logic for unsanitized RunReactants products. --- .../deduplication_detector.py | 158 +++++++++++++++++- 1 file changed, 154 insertions(+), 4 deletions(-) diff --git a/AutoREACTER/reaction_preparation/deduplication_detector.py b/AutoREACTER/reaction_preparation/deduplication_detector.py index 8602df2..70c35c5 100644 --- a/AutoREACTER/reaction_preparation/deduplication_detector.py +++ b/AutoREACTER/reaction_preparation/deduplication_detector.py @@ -3,8 +3,8 @@ molecule templates. The comparison intentionally ignores coordinates, atom IDs, and bond IDs. -RDKit comparisons use chemical element and bond type. LAMMPS comparisons -use atom type and bond type. +RDKit comparisons use chemical element, radical state, and bond type. +LAMMPS comparisons use atom type and bond type. """ from __future__ import annotations @@ -27,6 +27,10 @@ class DeduplicationDetector: 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" @@ -108,6 +112,19 @@ def is_duplicate( ) for seen_graph in seen_graphs: + # The restricted template graph may not include every radical + # center in the complete molecule. Compare the full-molecule + # pre/post radical signature before checking graph isomorphism. + 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() @@ -392,6 +409,33 @@ def compare_graphs_mol( idx_relabel=product_to_reactant_mapping, ) + # Keep full-molecule radical information in addition to the + # atom-level radical labels inside the restricted template graph. + # Progression may explicitly mark the product as radical even when + # the original unsanitized RDKit product does not retain the + # radical-electron property cleanly. + 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, @@ -460,11 +504,14 @@ def rdkit_mol_to_networkx( Coordinates are not read or stored. Node attributes: - ``atom_label`` contains the chemical element symbol. + ``atom_label`` contains a tuple of: + - chemical element symbol + - whether the atom is a radical center Edge attributes: ``bond_label`` contains the RDKit bond type. """ + if molecule is None: raise ValueError( "Cannot create a graph from a None RDKit molecule." @@ -503,10 +550,17 @@ def rdkit_mol_to_networkx( idx_relabel=idx_relabel, ) + is_radical = self._is_radical_atom(atom) + + atom_label = ( + atom.GetSymbol(), + is_radical, + ) + graph.add_node( node_id, **{ - self.NODE_ATTRIBUTE: atom.GetSymbol(), + self.NODE_ATTRIBUTE: atom_label, }, ) @@ -657,6 +711,7 @@ def _couple_graphs( Combine reactant and product graphs using atom-correspondence edges. """ + pre_atom_ids = set(pre_template_graph.nodes) post_atom_ids = set(post_template_graph.nodes) @@ -678,6 +733,25 @@ def _couple_graphs( 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, @@ -899,6 +973,82 @@ def _validate_bond_atoms( 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. + """ + + # Best source when progression or RDKit has explicitly assigned the + # radical electron. + if atom.GetNumRadicalElectrons() > 0: + return True + + # Current vinyl implementation only needs neutral carbon radicals. + if atom.GetAtomicNum() != 6: + return False + + if atom.GetFormalCharge() != 0: + return False + + if atom.GetIsAromatic(): + return False + + # A normal carbon with an implicit hydrogen can have only three visible + # graph bonds. Do not classify it as a radical. + try: + if atom.GetNumImplicitHs() > 0: + return False + except RuntimeError: + # Unsanitized reaction products may not have a complete property + # cache. Continue with the graph-based valence calculation. + pass + + # Count actual graph bonds, including explicit hydrogen atoms. + # + # Do not blindly add GetNumExplicitHs(): RunReactants may retain both + # real hydrogen atoms and a duplicate product-SMARTS hydrogen count. + graph_bond_valence = sum( + bond.GetBondTypeAsDouble() + for bond in atom.GetBonds() + ) + + explicit_hydrogen_neighbors = sum( + neighbor.GetAtomicNum() == 1 + for neighbor in atom.GetNeighbors() + ) + + # When no real hydrogen atoms are attached, the explicit-H property is + # part of the atom's valence description and must be included. When + # real H atoms are already present, ignore the property to avoid + # double-counting the same hydrogens. + effective_valence = graph_bond_valence + + if explicit_hydrogen_neighbors == 0: + effective_valence += atom.GetNumExplicitHs() + + # Neutral carbon with effective valence three and no available implicit + # hydrogen is the carbon-centered radical used by vinyl progression. + return abs(effective_valence - 3.0) < 1.0e-6 def _main() -> None: From 584e6aaf0d8b872c6c352987af7eafe4f3fc1fdf Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:10:36 -0400 Subject: [PATCH 051/104] Track radical atoms before reaction dedup Adds radical metadata to `ReactionMetadata` (`is_radical` and `radical_atom_idxs`) and populates it by sanitizing product molecules before NetworkX deduplication. Radical atom indices are mapped from product space into reactant-index space so identity survives relabeling/comparison. The in-loop debug print block was replaced with structured metadata assignment and explicit non-radical defaults when sanitization or product data is unavailable. --- .../reaction_processor/prepare_reactions.py | 2 + .../reaction_progression.py | 81 ++++++++++++++++--- 2 files changed, 73 insertions(+), 10 deletions(-) diff --git a/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py b/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py index e0fd248..4cfa7a0 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py @@ -88,6 +88,8 @@ 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 diff --git a/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py index 2b94304..71294c3 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py @@ -164,6 +164,11 @@ def reaction_progression( reaction_instances=reaction_instances ) + # Radical identity must exist before NetworkX deduplication. + self._annotate_radicals_before_deduplication( + prepared_reactions + ) + all_prepared_reactions.extend(prepared_reactions) self.session.reaction_metadata = all_prepared_reactions @@ -186,6 +191,7 @@ def reaction_progression( ) if should_break: + continue return self._store_reactions(all_prepared_reactions) return all_prepared_reactions @@ -230,16 +236,14 @@ def _prepare_products_for_idx_based_fg_detection( ) sanitized_mol, success = self._sanitize_molecule(product_mol) - - # THIS IS A DEBUG BLOCK --- - if success: - print("\n--- DEBUG MOLECULE ---") - print("SMILES:", Chem.MolToSmiles(sanitized_mol)) - for a in sanitized_mol.GetAtoms(): - if a.GetDegree() == 2 and a.GetAtomicNum() == 6: - print(f"Atom {a.GetIdx()}: Symbol={a.GetSymbol()}, Heavy Neighbors={a.GetDegree()}, Total Hs={a.GetTotalNumHs()}, Radicals={a.GetNumRadicalElectrons()}") - print("----------------------\n") - # ---------------------------- + if success and sanitized_mol is not None: + self._set_reaction_radical_metadata( + reaction, + sanitized_mol, + ) + else: + reaction.is_radical = False + reaction.radical_atom_idxs = () matches = sanitized_mol.GetSubstructMatches(Chem.MolFromSmarts("[C;!R;D3](-[H])(-[!#1])-[!#1]")) print(f"Substructure matches for radical carbon: {matches}") @@ -563,3 +567,60 @@ def _count_active_reactions( 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 using product_to_reactant_mapping. + """ + 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 new products and record radical atoms before NetworkX comparison.""" + 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, + ) \ No newline at end of file From e52f82f8e6cd446a743ed78775a152b30ac12a22 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:15:47 -0400 Subject: [PATCH 052/104] Add loop controls for reaction progression This change introduces input-level loop configuration so users can disable progression entirely or provide a positive integer to cap loop iterations. The parser now validates `loop` and stores `loop`/`max_loop_count` in `SimulationSetup`, and reaction preparation conditionally runs progression based on that flag while still enforcing zero-active-reaction checks. The styrene example was also updated (name and monomer count) to reflect current test usage. --- AutoREACTER/input_parser.py | 30 +++++++++++++++++++ .../reaction_processor/prepare_reactions.py | 21 ++++++++----- .../reaction_progression.py | 2 ++ examples/test_styrene.json | 4 +-- 4 files changed, 47 insertions(+), 10 deletions(-) diff --git a/AutoREACTER/input_parser.py b/AutoREACTER/input_parser.py index be05278..d6e2365 100644 --- a/AutoREACTER/input_parser.py +++ b/AutoREACTER/input_parser.py @@ -149,6 +149,8 @@ class SimulationSetup: density: list[float] force_field: str | None monomers: list[MonomerEntry] + loop : bool = True + max_loop_count: int | None = None simulations: list[Simulation] | None = None composition_method: CompositionMethodType | None = None composition: dict[str, Any] | None = None @@ -203,6 +205,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 +216,8 @@ 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 ) def molecule_representation_of_initial_molecules( @@ -939,6 +945,30 @@ def _validate_simulations( "systems": systems, "simulations": simulations, } + + def _validate_loop(self, inputs: dict) -> tuple[bool, int | None]: + """ + Validates the 'loop' parameter in the input dictionary. + + Returns a tuple (loop, max_loop_count) where 'loop' is a boolean indicating + whether looping is enabled, and 'max_loop_count' is an integer specifying + the maximum number of loop iterations if 'loop' is an integer, or None otherwise. + """ + loop_variables = ["loop", "repeat", "iterations", "do_loop"] + loop = inputs.get("loop", True) + if not isinstance(loop, bool): + if isinstance(loop, int): + if loop <= 0: + raise InputSchemaError( + "'loop' must be a positive integer." + ) + return True, loop + if loop in loop_variables: + return True, None + raise InputSchemaError( + "'loop' must be a boolean value or a positive integer." + ) + return loop, None if __name__ == "__main__": diff --git a/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py b/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py index 4cfa7a0..6f2573c 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py @@ -115,15 +115,20 @@ def prepare_reactions(self, session): prepared_reactions = self._prepare_reactions_stage(session) session.reaction_metadata = prepared_reactions - # 2. Run progression loop (this returns the FULL, deduplicated list) - reaction_progression = ReactionProgression(session) - final_reactions = reaction_progression.reaction_progression() + if session.inputs.loop: + # 2. Run progression loop (this returns the FULL, deduplicated list) + reaction_progression = ReactionProgression(session) + + final_reactions = reaction_progression.reaction_progression() - # 3. Overwrite the session metadata with the final deduplicated list - session.reaction_metadata = final_reactions - - # 4. Error check - self._zero_active_reactions_error(final_reactions) + # 3. Overwrite the session metadata with the final deduplicated list + session.reaction_metadata = final_reactions + + # 4. Check weather is there avaible reaction present + self._zero_active_reactions_error(final_reactions) + + else: + self._zero_active_reactions_error(prepared_reactions) return session diff --git a/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py index 71294c3..d2283dd 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py @@ -91,6 +91,8 @@ def reaction_progression( Returns: The prepared and deduplicated reaction metadata. """ + if self.session.inputs.max_loop_count is not None: + max_loop = self.session.inputs.max_loop_count iteration = 0 monomer_roles_in_loop = list["MonomerRole"]( self.session.monomer_roles diff --git a/examples/test_styrene.json b/examples/test_styrene.json index d8721b8..6cdcfb7 100644 --- a/examples/test_styrene.json +++ b/examples/test_styrene.json @@ -1,12 +1,12 @@ { - "simulation_name": "Epoxy_Test_Primary_Diamine_Diepoxy", + "simulation_name": "Styrene_Test", "simulations": [ { "tag": "vinyl_test", "temperature": 300, "density": 1.0, "monomer_counts": { - "styrene": 200 + "styrene": 2000 } } ], From 3602aaaea4ab1d0cf32993867776eb0d624132cc Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:39:51 -0400 Subject: [PATCH 053/104] Refine loop parsing and trim debug output Updated input loop validation to explicitly handle booleans, positive integers, and supported loop keywords with clearer error messaging. Integer loop values now announce the max iteration setting before execution. Reaction progression now shows the warning ASCII banner at initialization, and several ad-hoc debug prints were removed from functional group and reaction progression paths to reduce noisy console output. The ethene example input was also refreshed to use the new simulation name and a fixed loop count. --- .../detectors/functional_groups_detector.py | 4 -- AutoREACTER/input_parser.py | 51 ++++++++++++------- .../reaction_progression.py | 19 ++----- .../reaction_processor/warning_asci.py | 7 ++- examples/test_ethelene.json | 7 +-- 5 files changed, 48 insertions(+), 40 deletions(-) diff --git a/AutoREACTER/detectors/functional_groups_detector.py b/AutoREACTER/detectors/functional_groups_detector.py index b0d9377..34aed96 100644 --- a/AutoREACTER/detectors/functional_groups_detector.py +++ b/AutoREACTER/detectors/functional_groups_detector.py @@ -514,9 +514,6 @@ def index_based_functional_groups_detector( # Log detected functionality for debugging/user feedback. # print(f"{monomer.smiles} has functionality: {functional_group['group_name']}") - if functional_group.get("comments"): - print(f"Note: {monomer.smiles} - {functional_group['comments']}") - detected_functionalities.append( FunctionalGroupInfo( functionality_type=ftype, @@ -532,7 +529,6 @@ def index_based_functional_groups_detector( # Add to roles if any functionalities detected. if detected_functionalities: - print(f"Detected functionalities for monomer {detected_functionalities}") monomer_roles_out.append( MonomerRole( smiles=monomer.smiles, diff --git a/AutoREACTER/input_parser.py b/AutoREACTER/input_parser.py index d6e2365..9d9a8f7 100644 --- a/AutoREACTER/input_parser.py +++ b/AutoREACTER/input_parser.py @@ -2,6 +2,8 @@ import logging from dataclasses import dataclass from pathlib import Path +import time + from typing import Any, Literal, Optional from PIL.Image import Image @@ -948,27 +950,40 @@ def _validate_simulations( def _validate_loop(self, inputs: dict) -> tuple[bool, int | None]: """ - Validates the 'loop' parameter in the input dictionary. + Validate the ``loop`` input. + + Returns: + A tuple containing: + - Whether looping is enabled. + - The maximum iteration count, or ``None`` when no limit is specified. - Returns a tuple (loop, max_loop_count) where 'loop' is a boolean indicating - whether looping is enabled, and 'max_loop_count' is an integer specifying - the maximum number of loop iterations if 'loop' is an integer, or None otherwise. + Raises: + InputSchemaError: If ``loop`` is not a boolean, a positive integer, + or a supported loop keyword. """ - loop_variables = ["loop", "repeat", "iterations", "do_loop"] - loop = inputs.get("loop", True) - if not isinstance(loop, bool): - if isinstance(loop, int): - if loop <= 0: - raise InputSchemaError( - "'loop' must be a positive integer." - ) - return True, loop - if loop in loop_variables: - return True, None - raise InputSchemaError( - "'loop' must be a boolean value or a positive integer." + 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.") + + print( + f"Reaction will be looped and maximum iterations set to {loop_value}" ) - return loop, None + time.sleep(5) + 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/reaction_processor/reaction_progression.py b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py index d2283dd..1dfd6f6 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py @@ -11,6 +11,9 @@ 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 @@ -73,6 +76,7 @@ def __init__(self, session: "Session"): self.fg_detector = FunctionalGroupsDetector() self.rxn_detector = ReactionDetector() self.deduplication_detector = DeduplicationDetector() + print_warning() def reaction_progression( self, @@ -147,14 +151,12 @@ def reaction_progression( monomer_roles_in_loop.extend(fg_detection_results) self.session.monomer_roles = monomer_roles_in_loop - print(monomer_roles_in_loop) reaction_instances = ( self.rxn_detector.index_based_reaction_detector( monomer_roles_in_loop ) ) - print(reaction_instances) if not reaction_instances: print( f"No new reactions detected in iteration {iteration}. " @@ -193,7 +195,6 @@ def reaction_progression( ) if should_break: - continue return self._store_reactions(all_prepared_reactions) return all_prepared_reactions @@ -246,22 +247,12 @@ def _prepare_products_for_idx_based_fg_detection( else: reaction.is_radical = False reaction.radical_atom_idxs = () - - matches = sanitized_mol.GetSubstructMatches(Chem.MolFromSmarts("[C;!R;D3](-[H])(-[!#1])-[!#1]")) - print(f"Substructure matches for radical carbon: {matches}") - print( - f"Sanitizing reaction product {reaction.reaction_id}... " - f"result {success}" - ) if not success: print( f"Skipping reaction product {reaction.reaction_id}: " "RDKit molecule sanitization failed." ) - else: - print( - f"Reaction product {reaction.reaction_id} sanitized successfully." - ) + prepared_monomer_roles.append( MonomerRoleforIndexBasedFGDetection( smiles=self._get_product_smiles(sanitized_mol), diff --git a/AutoREACTER/reaction_preparation/reaction_processor/warning_asci.py b/AutoREACTER/reaction_preparation/reaction_processor/warning_asci.py index cdf3d8a..667c5d1 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/warning_asci.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/warning_asci.py @@ -1,9 +1,13 @@ +import time + + def ascii_art(message: str) -> None: message = message.upper() print(f"WARNING: {message}") print( - """ _ _ _ _ +""" + _ _ _ _ | | | | (_) | | | | | | | | __ _ _ __ _ __ _ _ __ __ _| | | | | |/\| |/ _` | '__| '_ \| | '_ \ / _` | | | | @@ -21,4 +25,5 @@ def print_warning() -> None: "Caution: results can be chemically inaccurate." ) ascii_art(message) + time.sleep(5) diff --git a/examples/test_ethelene.json b/examples/test_ethelene.json index d7f10d7..14958a8 100644 --- a/examples/test_ethelene.json +++ b/examples/test_ethelene.json @@ -1,18 +1,19 @@ { - "simulation_name": "Epoxy_Test_Primary_Diamine_Diepoxy", + "simulation_name": "Epoxy_ethene", + "loop": 9, "simulations": [ { "tag": "vinyl_test", "temperature": 300, "density": 1.0, "monomer_counts": { - "styrene": 200 + "ethene": 200 } } ], "monomers": [ { - "name": "styrene", + "name": "ethene", "smiles": "C=C" } ] From 80532a8d80260ebf8af50987958d27608001e523 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:49:06 -0400 Subject: [PATCH 054/104] Fix vinyl radical type and expand parser tests Updates `vinyl_chain_end_radical` in the functional group library to use `functionality_type: "vinyl"` instead of `"mono"`, aligning classification with its intended chemistry and downstream handling. Also replaces `tests/test_input_parser.py` with a comprehensive test suite covering format/schema checks, counts vs ratio mode detection, numeric and force-field validation, SMILES canonicalization and duplicate handling, monomer/simulation normalization, loop settings, end-to-end `validate_inputs` behavior, and molecule representation integration points. --- .../detectors/functional_groups_library.py | 2 +- tests/test_input_parser.py | 1152 +++++++++++++---- 2 files changed, 878 insertions(+), 276 deletions(-) diff --git a/AutoREACTER/detectors/functional_groups_library.py b/AutoREACTER/detectors/functional_groups_library.py index 72f6069..92e0061 100644 --- a/AutoREACTER/detectors/functional_groups_library.py +++ b/AutoREACTER/detectors/functional_groups_library.py @@ -187,7 +187,7 @@ def __init__(self): }, "vinyl_chain_end_radical": { - "functionality_type": "mono", + "functionality_type": "vinyl", "smarts_1": "[C;!R;D3;v3]", "group_name": "vinyl_chain_end_radical", "comments": ( diff --git a/tests/test_input_parser.py b/tests/test_input_parser.py index f1df078..db20f96 100644 --- a/tests/test_input_parser.py +++ b/tests/test_input_parser.py @@ -1,364 +1,927 @@ +""" +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), + ) + + @patch("AutoREACTER.input_parser.time.sleep") + def test_validate_loop_accepts_positive_integer( + self, + sleep_mock, + ) -> None: + self.assertEqual( + self.parser._validate_loop({"loop": 4}), + (True, 4), + ) + sleep_mock.assert_called_once_with(5) + + 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"] = 3 + + result = self.parser.validate_inputs(inputs) + + self.assertTrue(result.loop) + self.assertEqual(result.max_loop_count, 3) + 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 +937,7 @@ def test_validate_inputs_duplicate_monomer_smiles_raises_error(self): }, { "name": "ethanol_b", - "smiles": "CCO", + "smiles": "C(C)O", }, ], } @@ -383,5 +946,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 From 6960e2726dfbabcd5dd846c52cb2b12d9bebed18 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:09:03 -0400 Subject: [PATCH 055/104] Remove legacy compatibility shim Deletes the obsolete `_compat.py` patch helper and removes unused reaction SMARTS constants from `reactions_library.py`. --- AutoREACTER/_compat.py | 60 ---------------------- AutoREACTER/detectors/reactions_library.py | 9 ---- 2 files changed, 69 deletions(-) delete mode 100644 AutoREACTER/_compat.py 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/detectors/reactions_library.py b/AutoREACTER/detectors/reactions_library.py index 6d28592..67a6739 100644 --- a/AutoREACTER/detectors/reactions_library.py +++ b/AutoREACTER/detectors/reactions_library.py @@ -34,15 +34,6 @@ - Polymerization in Supercritical Carbon Dioxide - Thiophene Polymerizations: Oxidative Polymerization of Thiophenes """ -rxn_prop_smarts = ( - "[C:1]-[*:5].[CH2:3]=[CH;!R:4]-[*:6]" - ">>[C:1]([*:5])-[CH2:3]-[CH:4]-[*:6]" -) - -rxn_init_smarts = ( - "[CH2:1]=[CH;!R:2].[CH2:3]=[C;!R:4]-[*:5]" - ">>[C:1]-[C:2]-[C:3]-[C:4]-[*:5]" -) class ReactionLibrary: def __init__(self): From 7f97a23dc757c78a5006bb0ca0f94b92513d011a Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:57:12 -0400 Subject: [PATCH 056/104] Refresh warning banner ASCII art Replace the warning output art in `warning_asci.py` with a new, wider banner style to make warning messages more visually prominent in terminal output. --- .../reaction_processor/warning_asci.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/AutoREACTER/reaction_preparation/reaction_processor/warning_asci.py b/AutoREACTER/reaction_preparation/reaction_processor/warning_asci.py index 667c5d1..80c82cf 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/warning_asci.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/warning_asci.py @@ -7,14 +7,13 @@ def ascii_art(message: str) -> None: print(f"WARNING: {message}") print( """ - _ _ _ _ -| | | | (_) | | | | -| | | | __ _ _ __ _ __ _ _ __ __ _| | | | -| |/\| |/ _` | '__| '_ \| | '_ \ / _` | | | | -\ /\ / (_| | | | | | | | | | | (_| |_|_|_| - \/ \/ \__,_|_| |_| |_|_|_| |_|\__, (_|_|_) - __/ | - |___/ + ____ ____ _ _______ ____ _____ _____ ____ _____ ______ _ _ _ +|_ _| |_ _|/ \ |_ __ \ |_ \|_ _||_ _||_ \|_ _|.' ___ | | | | | | | + \ \ /\ / / / _ \ | |__) | | \ | | | | | \ | | / .' \_| | | | | | | + \ \/ \/ / / ___ \ | __ / | |\ \| | | | | |\ \| | | | ____ | | | | | | + \ /\ /_/ / \ \_ _| | \ \_ _| |_\ |_ _| |_ _| |_\ |_\ `.___] | |_| |_| |_| + \/ \/|____| |____||____| |___||_____|\____||_____||_____|\____|`._____.' (_) (_) (_) + """ ) From 91a5b7f1db248d146ce9c8c77e774f3993d4a147 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:54:27 -0400 Subject: [PATCH 057/104] Refactor reaction progression and docs --- .../reaction_progression.py | 437 ++++++++++++------ 1 file changed, 302 insertions(+), 135 deletions(-) diff --git a/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py index 1dfd6f6..7724718 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py @@ -1,8 +1,19 @@ +""" +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 +import time from typing import TYPE_CHECKING from rdkit import Chem -from rdkit.Chem.rdchem import MolSanitizeException from AutoREACTER.detectors.functional_groups_detector import ( FunctionalGroupsDetector, @@ -12,7 +23,7 @@ DeduplicationDetector, ) from AutoREACTER.reaction_preparation.reaction_processor.warning_asci import ( - print_warning + print_warning, ) if TYPE_CHECKING: @@ -24,8 +35,8 @@ from AutoREACTER.session import Session -# Prevent the progression process from continuing indefinitely when reactions -# keep producing additional detectable functional groups. +# Prevent progression from continuing indefinitely when newly generated +# products keep exposing additional detectable functional groups. MAX_LOOP = 5 @@ -33,8 +44,20 @@ class MonomerRoleforIndexBasedFGDetection: """Describe a molecule prepared for index-based functional-group detection. - The stored atom indexes refer to positions in the reaction template and - allow functional groups to be associated with their original reactants. + 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 @@ -47,7 +70,16 @@ class MonomerRoleforIndexBasedFGDetection: @dataclass(slots=True) class ReactionProgressionSession: - """Track state that is shared across reaction-progression iterations.""" + """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 @@ -56,19 +88,36 @@ class ReactionProgressionSession: class ReactionProgression: """Coordinate iterative functional-group detection and reaction generation. - Each iteration uses products from previously prepared reactions as potential - monomers. Newly detected functional groups are converted into reaction - instances, prepared into reaction metadata, and deduplicated before the - next iteration begins. + 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"): + def __init__(self, session: "Session", preparer=None): """Initialize detectors and attach progression state to a session. Args: - session: Session containing monomer roles and reaction metadata. + 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() ) @@ -76,42 +125,51 @@ def __init__(self, session: "Session"): 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 the reaction-progression loop until no progress is possible. + """Run progression until no further useful reactions are generated. Each iteration detects functional groups in generated products, finds - compatible reactions, prepares the reactions, and removes duplicates. - The loop stops when no new functional groups or reactions are found, - when the reaction pool does not grow, or when ``max_loop`` is reached. + 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 to execute. + max_loop: Maximum number of progression iterations. Returns: - The prepared and deduplicated reaction metadata. + 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}." + ) + time.sleep(1) + iteration = 0 - monomer_roles_in_loop = list["MonomerRole"]( - self.session.monomer_roles - ) - all_prepared_reactions: list["ReactionMetadata"] = list( - self.session.reaction_metadata - ) + # 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: - # Convert the initial monomer SMILES strings into RDKit - # molecules before the first functional-group search. + # On the first pass, convert input monomer SMILES into + # explicit RDKit molecules so detectors can work on them. self._populate_monomer_roles() else: print( @@ -119,29 +177,32 @@ def reaction_progression( "of the reaction progression loop." ) - # Roles seen in earlier iterations are marked so detectors can - # distinguish already-processed molecules from newly added ones. + # 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 " @@ -149,14 +210,18 @@ def reaction_progression( ) 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}. " @@ -164,11 +229,17 @@ def reaction_progression( ) 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 exist before NetworkX deduplication. + # 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 ) @@ -176,8 +247,8 @@ def reaction_progression( all_prepared_reactions.extend(prepared_reactions) self.session.reaction_metadata = all_prepared_reactions - # Deduplication occurs after preparation because equivalent - # products may be generated through different reaction paths. + # 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 @@ -189,12 +260,12 @@ def reaction_progression( all_prepared_reactions ) - should_break = self._loop_break_condition( + # 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, - ) - - if should_break: + ): return self._store_reactions(all_prepared_reactions) return all_prepared_reactions @@ -203,42 +274,55 @@ def _index_based_reaction_preparation( self, reaction_instances: list["ReactionInstance"], ) -> list["ReactionMetadata"]: - """Convert detected reaction instances into prepared reaction metadata.""" - from AutoREACTER.reaction_preparation.reaction_processor.prepare_reactions import ( - PrepareReactions, - ) + """Convert detected reaction instances into reaction metadata. - reaction_preparer = PrepareReactions(self.session) + 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. - return reaction_preparer._prepare_reactions_stage( - reaction_instances, loop=True + 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 reaction products for index-based FG detection. + """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. - Products are converted into cleaned SMILES strings and RDKit molecules. - Their template atom indexes are retained so newly detected functional - groups 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 - 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: self._set_reaction_radical_metadata( reaction, @@ -247,12 +331,13 @@ def _prepare_products_for_idx_based_fg_detection( 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), @@ -268,7 +353,14 @@ def _store_reactions( self, reactions: list["ReactionMetadata"], ) -> list["ReactionMetadata"]: - """Save reaction metadata to the session and return it.""" + """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 @@ -276,53 +368,97 @@ 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(): - if atom.GetAtomicNum() == 6: - atom.SetNoImplicit(True) - - heavy_val = int(sum(bond.GetValenceContrib(atom) for bond in atom.GetBonds())) - explicit_hs = atom.GetNumExplicitHs() - rads = atom.GetNumRadicalElectrons() - - # 1. STRIP radical electrons ONLY from the OLD chain end! - # If it just formed a new bond, its heavy + Hs equals 4. It is no longer a radical. - if heavy_val + explicit_hs >= 4 and rads > 0: - atom.SetNumRadicalElectrons(0) - rads = 0 - - # 2. Fix over-valent carbons (if RunReactants forces too many Hs) - if heavy_val + explicit_hs + rads > 4: - allowed_hs = max(0, 4 - heavy_val - rads) - atom.SetNumExplicitHs(allowed_hs) - explicit_hs = allowed_hs - - # 3. PROTECT the NEW chain end! - # If it has 3 bonds, it's the new radical. Give it the electron back - # so Chem.AddHs() doesn't accidentally quench it with a fake hydrogen! - if heavy_val + explicit_hs == 3 and atom.GetFormalCharge() == 0: - atom.SetNumRadicalElectrons(1) + # 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 - radical_fixed_mol = self._fix_radical_and_sanitize(patched_mol) + # 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 as error2: + 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 @@ -332,49 +468,63 @@ def _fix_radical_and_sanitize( raw_mol: Chem.Mol, query: str = "[CH;X3;v3]", ) -> Chem.Mol: - """ - RunReactants() output is unsanitized. Our radical carbon is deliberately - under-valent (v3 instead of v4) to mark it in SMARTS, but that's not a - real chemical species RDKit can sanitize or round-trip through SMILES. - This finds that atom and gives it an actual radical electron, so the - missing valence is accounted for and the mol becomes fully sanitizable. + """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 identifying the deliberately under-valent radical atom. + query: SMARTS used to identify the radical carbon. Defaults to a + neutral carbon with one hydrogen, three explicit connections, + and total valence three. Returns: - A new Chem.Mol with the radical atom's valence properly accounted - for via NumRadicalElectrons, ready for Chem.SanitizeMol(). + Molecule with radical valence represented explicitly. """ mol = Chem.RWMol(raw_mol) - mol.UpdatePropertyCache(strict=False) # need valence to even run the query + 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]) # first atom = the radical carbon + 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 ``mol`` without atom maps, isotopes, or ghost properties.""" + """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(): - # Clean standard tracking labels atom.SetAtomMapNum(0) atom.SetIsotope(0) - # EXORCISE THE GHOSTS: - # RDKit hides tracking data like 'old_mapno' deep in the atom properties. - # We MUST clear them so AutoREACTER doesn't mistake old atoms for new initiators! if atom.HasProp("old_mapno"): atom.ClearProp("old_mapno") if atom.HasProp("react_atom_idx"): @@ -386,10 +536,10 @@ def _get_product_smiles(self, mol: Chem.Mol) -> str: """Convert a cleaned product molecule to canonical SMILES. Args: - mol: Product molecule to serialize. + mol: Product molecule (may be ``None`` if sanitization failed). Returns: - The product SMILES, or an empty string if conversion fails. + Canonical SMILES string, or an empty string if conversion fails. """ cleaned_mol = self._clean_product(mol) @@ -405,18 +555,17 @@ def _get_product_idxs( ) -> tuple[list[int], Chem.Mol]: """Return product indexes and the molecule containing those indexes. - If the product contains multiple disconnected fragments, only the - fragment with the greatest number of heavy atoms is retained. Indexes - are remapped to match the retained fragment. + 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 template reactant atom indexes to product indexes. - mol: Combined product molecule. + 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 containing remapped product indexes and the selected - product molecule. + A tuple of (list of remapped product indexes, retained fragment). """ product = Chem.Mol(mol) product_idxs = list( @@ -436,20 +585,20 @@ def _keep_largest_fragment( mol: Chem.Mol, product_idxs: list[int], ) -> tuple[Chem.Mol, list[int]]: - """Keep the largest disconnected fragment and remap atom indexes. + """Keep the largest heavy-atom fragment and remap its atom indexes. Args: - mol: Molecule containing one or more disconnected fragments. - product_idxs: Atom indexes referring to the original molecule. + mol: Multi-fragment product molecule. + product_idxs: Product-side atom indexes to retain. Returns: - The largest fragment and the indexes remapped to that fragment. + A tuple of (largest fragment molecule, product indexes remapped + into that fragment). Raises: - ValueError: If the molecule contains no fragments. + ValueError: If no fragments could be extracted from the molecule. """ fragment_atom_mappings: list[tuple[int, ...]] = [] - fragments = Chem.GetMolFrags( mol, asMols=True, @@ -462,24 +611,24 @@ def _keep_largest_fragment( "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(), + key=lambda position: ( + fragments[position].GetNumHeavyAtoms() + ), ) - largest_fragment = fragments[largest_fragment_position] - - # RDKit provides each retained fragment's original atom indexes. - # Build the inverse mapping to translate indexes into fragment space. 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) } - - # Ignore mapped indexes that belong to discarded fragments. remapped_product_idxs = [ original_to_new_idx[product_idx] for product_idx in product_idxs @@ -492,12 +641,20 @@ def _set_is_looped_flag( self, monomer_roles: list["MonomerRole"], ) -> None: - """Mark supplied monomer roles as processed by the current loop.""" + """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 all roles identified as monomers.""" + """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( @@ -511,10 +668,10 @@ def _smiles_to_rdkit_mol( """Parse a SMILES string into an RDKit molecule. Args: - smiles: SMILES representation of the molecule. + smiles: SMILES string to parse. Returns: - The parsed molecule, or ``None`` if RDKit cannot parse the string. + The parsed RDKit molecule, or ``None`` if parsing fails. """ return Chem.MolFromSmiles(smiles) @@ -525,15 +682,12 @@ def _loop_break_condition( ) -> bool: """Return whether the active reaction pool failed to grow. - A non-growing pool indicates that the latest iteration did not add - useful reaction products and further progression is unlikely to help. - Args: - size_before: Active reaction count before the iteration. - size_after: Active reaction count after deduplication. + size_before: Number of active reactions before deduplication. + size_after: Number of active reactions after deduplication. Returns: - ``True`` when the pool stayed the same size or became smaller. + ``True`` if the pool did not grow and the loop should stop. """ if size_after <= size_before: print( @@ -548,13 +702,13 @@ def _count_active_reactions( self, reactions: list["ReactionMetadata"], ) -> int: - """Count reactions that contain activity statistics. + """Count reactions included in activity statistics. Args: - reactions: Reaction metadata objects to inspect. + reactions: Reaction metadata to inspect. Returns: - The number of active reactions. + Number of reactions with truthy ``activity_stats``. """ return sum( bool(reaction.activity_stats) @@ -569,14 +723,19 @@ def _set_reaction_radical_metadata( """Store product radical atoms in reactant-index space. Deduplication relabels product atoms into reactant-index space, so - radical indexes are converted using product_to_reactant_mapping. + 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 @@ -592,7 +751,15 @@ def _annotate_radicals_before_deduplication( self, reactions: list["ReactionMetadata"], ) -> None: - """Sanitize new products and record radical atoms before NetworkX comparison.""" + """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 @@ -616,4 +783,4 @@ def _annotate_radicals_before_deduplication( self._set_reaction_radical_metadata( reaction, sanitized_mol, - ) \ No newline at end of file + ) From ef489e12885bb0b973512a393fd7a5b7f41d1e7f Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:01:53 -0400 Subject: [PATCH 058/104] Refactor reaction preparation workflow --- .../reaction_processor/prepare_reactions.py | 1212 ++++++++++------- 1 file changed, 756 insertions(+), 456 deletions(-) diff --git a/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py b/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py index 6f2573c..f4a754d 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py @@ -1,74 +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. Mapping validation error +# 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 -from AutoREACTER.reaction_preparation.ff_wrapper.REACTER_files_builder import logger +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 FunctionalGroupInfo, 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 -from AutoREACTER.reaction_preparation.reaction_processor.reaction_progression import ReactionProgression 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): - """Custom exception raised when no active reactions are found in the dataset.""" + """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 @@ -94,184 +128,237 @@ class ReactionMetadata: class PrepareReactions: - """Processes chemical reactions: builds atom mappings, identifies reaction centers, and detects byproducts.""" - - # --- INITIALIZATION AND PUBLIC WORKFLOW --- + """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") + if not hasattr(session, "reaction_id_counter"): - session.reaction_id_counter = 0 # Initialize a counter for unique reaction IDs if not already present + session.reaction_id_counter = 0 def prepare_reactions(self, session): - # 1. Get initial reactions + """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: - # 2. Run progression loop (this returns the FULL, deduplicated list) - reaction_progression = ReactionProgression(session) - - final_reactions = reaction_progression.reaction_progression() - - # 3. Overwrite the session metadata with the final deduplicated list - session.reaction_metadata = final_reactions - - # 4. Check weather is there avaible reaction present + 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]): - """ - Checks if there are any active reactions in the provided reaction metadata list. + 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 check for active reactions. + reaction_metadata: List of ``ReactionMetadata`` objects to inspect. Raises: - ZeroActiveReactionsError: If no active reactions are found in the list. + ZeroActiveReactionsError: If none of the reactions have + ``activity_stats`` set to True. """ - active_reactions = False - for reaction in reaction_metadata: - if reaction.activity_stats: - active_reactions = True - break - if not active_reactions: - 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" - ) + 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]: - """ - Main pipeline: processes reaction instances, detects duplicates, and enriches metadata with template mappings. + 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 - loop: Boolean indicating whether this is a looped call (default: False) + 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. """ try: reaction_instances = session.reaction_instances except AttributeError: reaction_instances = session - # Process and filter reactions - reactions_metadata = self._process_reaction_instances(reaction_instances, loop=loop) - unique_reaction_metadata = self._detect_duplicates(reactions_metadata) + 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 return unique_reaction_metadata - - # --- REACTION INSTANCE AND PRODUCT PROCESSING --- - def _process_reaction_instances( - self, detected_reactions: list[ReactionInstance], loop: bool = False + self, + detected_reactions: list[ReactionInstance], + loop: bool = False, ) -> list[ReactionMetadata]: - """ - Converts ReactionInstance objects into ReactionMetadata by building molecules and running reactions. + """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: - # SMARTS template associated with this detected reaction. - rxn_smarts = reaction.reaction_smarts - - 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)): + 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( - f"Skipping reaction {reaction.reaction_name}: monomer rdkit_mol is None " - f"(likely failed sanitization upstream)." + "Skipping reaction %s: monomer rdkit_mol is None " + "(likely failed sanitization upstream).", + reaction.reaction_name, ) continue - same_reactants = reaction.same_reactants - delete_atoms = reaction.delete_atom - forced_indexes_1 = None - forced_indexes_2 = None + same_reactants = reaction.same_reactants + forced_idxs_1 = None + forced_idxs_2 = None if loop: - forced_indexes_1 = self._flatten_fg_indexes( + # 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_indexes_2 = self._flatten_fg_indexes( + forced_idxs_2 = self._flatten_fg_indexes( reaction.functional_group_2 ) - + mol_reactant_1 = Chem.AddHs( Chem.Mol(reaction.monomer_1.rdkit_mol) ) - monomer_2 = ( reaction.monomer_1 if same_reactants @@ -280,8 +367,8 @@ def _process_reaction_instances( mol_reactant_2 = Chem.AddHs( Chem.Mol(monomer_2.rdkit_mol) ) - - # ReactionInstance already defines slot direction. + + # The ReactionInstance already defines reactant-slot order. reaction_tuple = [[mol_reactant_1, mol_reactant_2]] else: reactant_smiles_1 = reaction.monomer_1.smiles @@ -300,123 +387,163 @@ def _process_reaction_instances( mol_reactant_2, ) - rxn = self._build_reaction(rxn_smarts) - + rxn = self._build_reaction(reaction.reaction_smarts) reaction_metadata = self._process_reaction_products( - rxn, - csv_cache, - reaction_tuple, - delete_atoms, - reaction_metadata, - forced_indexes_1=forced_indexes_1, - forced_indexes_2=forced_indexes_2, + 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 _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]: - """ - Runs reactions on reactant pairs and builds metadata for each product set. + 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 atom map numbers and isotopes to reactants for tracking through the reaction + r1 = Chem.Mol(pair[0]) + r2 = Chem.Mol(pair[1]) self._assign_atom_map_numbers_and_set_isotopes(r1, r2) - # 1. Run the reaction in default order (A + B) products = rxn.RunReactants((r1, r2)) - - # 2. If it fails due to order mismatch, try the reverse (B + A) if not products: - print ("Reaction failed in default order, trying reverse order...") + print( + "Reaction failed in default order, " + "trying reverse order..." + ) products = rxn.RunReactants((r2, r1)) - # Skip if no products were generated in either direction if not products: - print ("Reaction failed in both orders, skipping this reactant pair.") - print(f"\n[ERROR] RDKit failed to react {r1.GetNumAtoms()} atoms with {r2.GetNumAtoms()} atoms.") + 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(f"Reaction SMARTS: \n") + print("Reaction SMARTS: \n") continue - - # Process each product set generated by the reaction for product_set in products: - df = pd.DataFrame(columns=["reactant_idx", "product_idx"]) - reactant_combined = Chem.CombineMols(r1, r2) - if len(product_set) == 1: - product_combined = product_set[0] - else: - product_combined = reduce(Chem.CombineMols, product_set) + product_combined = ( + product_set[0] + if len(product_set) == 1 + else reduce(Chem.CombineMols, product_set) + ) - # Reassign atom map numbers based on isotopes to recover original reactant identities - self._reassign_atom_map_numbers_by_isotope(product_combined) - mapping_dict, df = self._build_atom_index_mapping(reactant_combined, product_combined) - reverse_mapping = {v: k for k, v in mapping_dict.items()} + self._reassign_atom_map_numbers_by_isotope( + product_combined + ) + mapping_dict, mapping_df = self._build_atom_index_mapping( + reactant_combined, + product_combined, + ) + reverse_mapping = { + product_idx: reactant_idx + for reactant_idx, product_idx in mapping_dict.items() + } - # Reveal original template map numbers for visualization and validation self._reveal_template_map_numbers(product_combined) + self._validate_mapping( + mapping_df, + reactant_combined, + product_combined, + ) - # Validate mapping consistency - self._validate_mapping(df, reactant_combined, product_combined) + first_shell, initiator_idxs = ( + self._assign_first_shell_and_initiators( + reactant_combined, + product_combined, + reverse_mapping, + ) + ) - # Identify atoms involved in reaction center and initiators - first_shell, initiator_idxs = self._assign_first_shell_and_initiators( - reactant_combined, product_combined, reverse_mapping + # 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, ) - # FORCED-INDEX CHECK: discard this product if the atom that actually - # reacted isn't inside the given index set for its side. - if forced_indexes_1 is not None or forced_indexes_2 is not None: - if 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) - - 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()) - - # counter so every reaction across the whole run — including every - # pass of the progression loop — gets a distinct, ever-increasing id. + 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 - total_products = self.session.reaction_id_counter + reaction_id = self.session.reaction_id_counter + csv_path = csv_cache / f"reaction_{reaction_id}.csv" - self._clear_isotopes(reactant_combined, product_combined) - df_combined.to_csv(csv_cache / f"reaction_{total_products}.csv", index=False) + self._clear_isotopes( + reactant_combined, + product_combined, + ) + reaction_df.to_csv(csv_path, index=False) 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, @@ -424,61 +551,84 @@ 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 + def _detect_duplicates( + self, + reaction_metadata_list: list[ReactionMetadata], + ) -> list[ReactionMetadata]: + """Return unique reaction metadata based on reactants and products. - def _detect_duplicates(self, reaction_metadata_list: list[ReactionMetadata]) -> list[ReactionMetadata]: - """ - Filters duplicate reactions based on reactant and product molecules. + 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 reaction metadata to filter + reaction_metadata_list: List of ``ReactionMetadata`` objects to + deduplicate. Returns: - List of unique reactions; duplicates marked with activity_stats=False + The same list, with duplicate reactions flagged as inactive. """ 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 - - # Keep reaction if it's unique, otherwise mark as duplicate - if compare_set(unique_metadata, reactants, products): + 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. - # --- PROGRESSION AND FORCED-INDEX HANDLING --- + 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. - def _flatten_fg_indexes(self, fg: Optional["FunctionalGroupInfo"]) -> Optional[set]: - """ - Flattens a FunctionalGroupInfo's recorded match indexes (fg_1_indexes and, - if present, fg_2_indexes for di_different groups) into a single allowed - atom-index set for forced-reaction checking. + Args: + fg: ``FunctionalGroupInfo`` object, or None. - Returns None only if the FG has no recorded indexes at all (no forcing applied). + Returns: + A set of atom indices, or None if no matches are available. """ 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) + 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) + combined.update( + atom_idx + for match in fg.fg_2_indexes + for atom_idx in match + ) return combined or None @@ -489,326 +639,446 @@ def _initiators_within_forced_indexes( forced_indexes_1: Optional[set], forced_indexes_2: Optional[set], ) -> bool: - """ - Checks whether the atoms that actually reacted (the two 'initiators') fall - within the forced index constraint for their respective side. + """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. - Combined-mol index ranges follow Chem.CombineMols(r1, r2) ordering: - [0, r1_atom_count) belongs to r1, [r1_atom_count, ...) belongs to r2. + 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: + 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: + if ( + forced_indexes_2 is not None + and local_idx not in forced_indexes_2 + ): return False - return True + return True - def _index_based_reaction_preparation(self, reaction_instances): - from AutoREACTER.reaction_preparation.reaction_processor.prepare_reactions import PrepareReactions - prepare_reactions = PrepareReactions(self.session) - prepared_reactions = prepare_reactions._prepare_reactions_stage(reaction_instances, loop=True) - return prepared_reactions + 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. - # --- REACTION-CENTER ANALYSIS AND VALIDATION --- + Args: + reaction_instances: Collection of ``ReactionInstance`` objects. - 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]]: + Returns: + List of ``ReactionMetadata`` objects produced in loop mode. """ - 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). + 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() - - if p_idx not in reversed_mapping_dict: - raise ValueError(f"Mapping error: product atom {p_idx} not found in mapping_dict") + for product_atom in product_combined.GetAtoms(): + map_num = product_atom.GetAtomMapNum() + if map_num >= 999: + continue - r_idx = reversed_mapping_dict[p_idx] - atom = reactant_combined.GetAtomWithIdx(r_idx) - atom.SetAtomMapNum(p_atom.GetAtomMapNum()) + 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" + ) - first_shell.append(r_idx) + reactant_idx = reversed_mapping_dict[product_idx] + reactant_atom = reactant_combined.GetAtomWithIdx( + reactant_idx + ) + reactant_atom.SetAtomMapNum(map_num) + first_shell.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 map_num in (1, 2): + initiator_idxs.append(reactant_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]: + """Map atoms in the smallest product fragment to reactant 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. + 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) + fragment_idxs = rdmolops.GetMolFrags(product_combined) + smallest_fragment_idxs = min(fragment_idxs, key=len) - # Find the tuple with the smallest number of atoms - smallest_frag_indices = min(frags_indices, key=len) - - byproduct_reactant_indices = [] - - # 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]) - - return byproduct_reactant_indices + return [ + reversed_mapping_dict[product_idx] + for product_idx in smallest_fragment_idxs + if product_idx in reversed_mapping_dict + ] + def _validate_mapping( + self, + df: pd.DataFrame, + reactant: Chem.Mol, + product: Chem.Mol, + ) -> None: + """Validate mapping columns, uniqueness, bounds, and completeness. - 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. + 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. 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.") - # pass + 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.") - # pass - # --- ATOM MAPPING --- + 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: - """ - 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. + 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. 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 + atom.SetIsotope(idx) + def _reassign_atom_map_numbers_by_isotope( + self, + mol: Chem.Mol, + ) -> None: + """Restore product atom map numbers from tracking isotopes. - 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. + 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]: - """ - Builds bidirectional atom index mapping between reactants and products using map numbers. + 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. 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: - """ - Clears isotope values from molecules to restore normal chemistry after using isotopes for atom tracking. + def _clear_isotopes( + self, + mol_1: Chem.Mol, + mol_2: Chem.Mol, + ) -> None: + """Remove tracking isotopes from two molecules. 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) - # --- REACTION AND REACTANT 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_reaction( + self, + rxn_smarts: str, + ) -> Chem.rdChemReactions.ChemicalReaction: + """Build an RDKit reaction from a SMARTS string. + Args: + rxn_smarts: Reaction SMARTS describing the transformation. - def _build_reactants(self, reactant_smiles_1: str, reactant_smiles_2: str) -> tuple[Chem.Mol, Chem.Mol]: + 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) + raise SMARTSParsingError( + "Failed to parse second reactant SMILES: " + f"{reactant_smiles_2!r}" + ) - return mol_reactant_1, mol_reactant_2 + 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. - 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. + 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]] - - - # --- GENERAL 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 @@ -818,22 +1088,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: @@ -847,63 +1124,87 @@ 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 + # 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 - 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) + 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, @@ -912,4 +1213,3 @@ def reaction_templates_highlighted_image_grid( subImgSize=(400, 400), useSVG=False, ) - return img From 50e1c6735bb8897019dec05ff4d16428231ec2b0 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:36:16 -0400 Subject: [PATCH 059/104] Fix epoxy polymer reaction rules Update the functional groups detector import to use the registry module and add epoxy polymer reaction definitions with corrected SN2 regioselectivity for primary and secondary amine additions. --- .../detectors/functional_groups_detector.py | 2 +- .../reactions_library/epoxy_polymers.py | 34 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 AutoREACTER/detectors/reactions_library/epoxy_polymers.py diff --git a/AutoREACTER/detectors/functional_groups_detector.py b/AutoREACTER/detectors/functional_groups_detector.py index 34aed96..c17f09e 100644 --- a/AutoREACTER/detectors/functional_groups_detector.py +++ b/AutoREACTER/detectors/functional_groups_detector.py @@ -112,7 +112,7 @@ 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: diff --git a/AutoREACTER/detectors/reactions_library/epoxy_polymers.py b/AutoREACTER/detectors/reactions_library/epoxy_polymers.py new file mode 100644 index 0000000..24f6d12 --- /dev/null +++ b/AutoREACTER/detectors/reactions_library/epoxy_polymers.py @@ -0,0 +1,34 @@ +""" +Reactions organized under the epoxy polymers polymer family. +Tested and Passed - 7/21/2026 +""" +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 + }, + 'Notes': 'Corrected for SN2 regioselectivity (attacks less hindered carbon)'}, + + '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}, + 'Notes': 'Corrected for SN2 regioselectivity (attacks less hindered carbon)'} + , + } \ No newline at end of file From 19abeb7a1d660a27d1fcf65a0f769e5405c028e2 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:52:03 -0400 Subject: [PATCH 060/104] Add registries for groups and reactions Introduces new registry modules for functional groups and reaction libraries that aggregate all submodules into single dictionaries, validate duplicates, and keep backward-compatible wrapper classes (`FunctionalGroupsLibrary` and `ReactionLibrary`). Also cleans up `epoxy_polymers.py` structure/formatting so the REACTIONS mapping is consistently closed. --- .../functional_groups_library/registry.py | 62 ++++++++++++++++++ .../reactions_library/epoxy_polymers.py | 9 +-- .../detectors/reactions_library/registry.py | 64 +++++++++++++++++++ 3 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 AutoREACTER/detectors/functional_groups_library/registry.py create mode 100644 AutoREACTER/detectors/reactions_library/registry.py diff --git a/AutoREACTER/detectors/functional_groups_library/registry.py b/AutoREACTER/detectors/functional_groups_library/registry.py new file mode 100644 index 0000000..fc7e207 --- /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/reactions_library/epoxy_polymers.py b/AutoREACTER/detectors/reactions_library/epoxy_polymers.py index 24f6d12..26cfa9a 100644 --- a/AutoREACTER/detectors/reactions_library/epoxy_polymers.py +++ b/AutoREACTER/detectors/reactions_library/epoxy_polymers.py @@ -16,7 +16,8 @@ 'smarts': None, 'reaction_and_mechanism': None }, - 'Notes': 'Corrected for SN2 regioselectivity (attacks less hindered carbon)'}, + 'Notes': 'Corrected for SN2 regioselectivity (attacks less hindered carbon)' + }, 'Secondary Amine and Epoxide Polyaddition (Epoxy-Amine, Second Addition / Crosslink)': { @@ -29,6 +30,6 @@ '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}, - 'Notes': 'Corrected for SN2 regioselectivity (attacks less hindered carbon)'} - , - } \ No newline at end of file + 'Notes': 'Corrected for SN2 regioselectivity (attacks less hindered carbon)' + } + } \ 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..15b2968 --- /dev/null +++ b/AutoREACTER/detectors/reactions_library/registry.py @@ -0,0 +1,64 @@ +"""Aggregate and validate all polymer-family reaction modules.""" + +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 + +_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, +] + + +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 + + return merged + + +REACTIONS = load_reactions() + + +class ReactionLibrary: + """Backward-compatible class exposing ``self.reactions``.""" + + def __init__(self): + self.reactions = load_reactions() From 8f85eaef62fa47b58d4a0c653fe7101c610c4f35 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:56:45 -0400 Subject: [PATCH 061/104] Add package exports for detector libraries Create `__init__.py` modules for `functional_groups_library` and `reactions_library` to expose their registry APIs (`load_*`, library classes, and constants) through clean package-level imports. --- AutoREACTER/detectors/functional_groups_library/__init__.py | 5 +++++ AutoREACTER/detectors/reactions_library/__init__.py | 5 +++++ 2 files changed, 10 insertions(+) create mode 100644 AutoREACTER/detectors/functional_groups_library/__init__.py create mode 100644 AutoREACTER/detectors/reactions_library/__init__.py 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/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"] From 31e15b483bbad5f731dd14ea0b7968f5439f7bdf Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:15:14 -0400 Subject: [PATCH 062/104] Add polyamide reaction library Adds a new `polyamides` reaction library with polyamidation definitions for amino acid, diamine/diacid, and diacid halide routes, plus hydrolytic caprolactam initiation. Includes source references and notes for untested or later-implemented reactions. --- .../detectors/reactions_library/polyamides.py | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 AutoREACTER/detectors/reactions_library/polyamides.py diff --git a/AutoREACTER/detectors/reactions_library/polyamides.py b/AutoREACTER/detectors/reactions_library/polyamides.py new file mode 100644 index 0000000..80e56de --- /dev/null +++ b/AutoREACTER/detectors/reactions_library/polyamides.py @@ -0,0 +1,130 @@ +"""Reactions organized under the polyamides polymer family. + +Uncommented reactions from the supplied working library retain their original dictionary values. Formerly commented and newly added reactions remain marked UNTESTED in comments.""" + +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" + }, + } + +### LATER IMPLEMENTATION: Consider adding more detailed reaction mechanisms and validation steps for hydrolytic initiation of caprolactam. + +# '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': +# } +# } From 13b70d95bbd785c73167775c4015578a23ae65f1 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:26:13 -0400 Subject: [PATCH 063/104] Add mixed AB groups and polymer reaction libraries Introduces a new `mixed_ab_groups` functional-group library with hydroxy/carboxylic, hydroxy/acid-halide, amino-acid, acid/acid-halide, and hydroxy/thiol monomer patterns. Adds new polymer reaction library modules for cycloaddition, metathesis, phenolic resins, and polyanhydrides; the polyanhydride pathways are defined as active reactions, while the others are scaffolded as commented UNTESTED templates for future implementation. --- .../mixed_ab_groups.py | 43 +++++++++++++++ .../cycloaddition_polymers.py | 29 +++++++++++ .../reactions_library/metathesis_polymers.py | 22 ++++++++ .../reactions_library/phenolic_resins.py | 52 +++++++++++++++++++ .../reactions_library/polyanhydrides.py | 31 +++++++++++ 5 files changed, 177 insertions(+) create mode 100644 AutoREACTER/detectors/functional_groups_library/mixed_ab_groups.py create mode 100644 AutoREACTER/detectors/reactions_library/cycloaddition_polymers.py create mode 100644 AutoREACTER/detectors/reactions_library/metathesis_polymers.py create mode 100644 AutoREACTER/detectors/reactions_library/phenolic_resins.py create mode 100644 AutoREACTER/detectors/reactions_library/polyanhydrides.py 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..aee41c5 --- /dev/null +++ b/AutoREACTER/detectors/functional_groups_library/mixed_ab_groups.py @@ -0,0 +1,43 @@ + +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 + } +} diff --git a/AutoREACTER/detectors/reactions_library/cycloaddition_polymers.py b/AutoREACTER/detectors/reactions_library/cycloaddition_polymers.py new file mode 100644 index 0000000..5d48677 --- /dev/null +++ b/AutoREACTER/detectors/reactions_library/cycloaddition_polymers.py @@ -0,0 +1,29 @@ +# """Reactions organized under the cycloaddition polymers polymer family.""" + +# Yet to be tested and implemented +# 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': 'UNTESTED new chemistry; generic [2+2] ' +# 'four-center cycloaddition.' +# }, +# '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': 'UNTESTED: separate false instance for two ' +# 'different bis-alkenes.' +# } +# } diff --git a/AutoREACTER/detectors/reactions_library/metathesis_polymers.py b/AutoREACTER/detectors/reactions_library/metathesis_polymers.py new file mode 100644 index 0000000..17cab81 --- /dev/null +++ b/AutoREACTER/detectors/reactions_library/metathesis_polymers.py @@ -0,0 +1,22 @@ +# """Reactions organized under the metathesis polymers polymer family. + +# Uncommented reactions from the supplied working library retain their original dictionary values. Formerly commented and newly added reactions remain marked UNTESTED in comments.""" + +# 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': 'UNTESTED new chemistry. Initiator Ru/alkylidene carbon are maps 1 and 2.'}, +# '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': 'UNTESTED new chemistry.'}} diff --git a/AutoREACTER/detectors/reactions_library/phenolic_resins.py b/AutoREACTER/detectors/reactions_library/phenolic_resins.py new file mode 100644 index 0000000..09761f2 --- /dev/null +++ b/AutoREACTER/detectors/reactions_library/phenolic_resins.py @@ -0,0 +1,52 @@ +# """Reactions organized under the phenolic resins polymer family. + +# Uncommented reactions from the supplied working library retain their original dictionary values. Formerly commented and newly added reactions remain marked UNTESTED in comments.""" + +# 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": ( +# "UNTESTED new chemistry; first Bakelite-forming step. " +# "Generic aromatic C-H matching is not restricted to " +# "ortho/para substitution." +# ), +# }, + +# "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": ( +# "UNTESTED. Produces a methylene bridge and water. " +# "Generic aromatic C-H matching is not restricted to " +# "ortho/para substitution." +# ), +# }, +# } \ 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..513cd9a --- /dev/null +++ b/AutoREACTER/detectors/reactions_library/polyanhydrides.py @@ -0,0 +1,31 @@ +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:2](=[O:5])[OX2: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 + }, + 'Carboxylic Acid and Acid Halide Polycondensation(Polyanhydride Formation) - Different Reactants': + { + '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:2](=[O:5])[OX2: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 + } + } From 90e9792fbba92ea6b6b8dc01b971119e1b28956a Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:33:09 -0400 Subject: [PATCH 064/104] Add nitrogen functional groups library Add definitions for nitrogen-based functional groups (primary amine, secondary amine, di-amine, di-primary amine) to support amine-based polymer reactions. Also stage polybenzimidazole reaction definitions for future work, currently disabled pending validation. --- .../nitrogen_groups.py | 41 +++++++++++++++++++ .../reactions_library/polybenzimidazoles.py | 17 ++++++++ .../detectors/reactions_library/registry.py | 2 +- 3 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 AutoREACTER/detectors/functional_groups_library/nitrogen_groups.py create mode 100644 AutoREACTER/detectors/reactions_library/polybenzimidazoles.py 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..c21ced1 --- /dev/null +++ b/AutoREACTER/detectors/functional_groups_library/nitrogen_groups.py @@ -0,0 +1,41 @@ +"""Functional-group definitions organized by the nitrogen groups motif. + +Each entry is defined once in the entire functional-group registry. Reaction libraries reference the entry's group_name value.""" + +FUNCTIONAL_GROUPS = { + 'primary_amine_monomer': + { + 'functionality_type': 'mono', + 'smarts_1': '[NX3H2;!$(NC=O);!$(NC=[N,O,S])]', + 'group_name': 'primary_amine', + 'comments': None + }, # Tested - Passed + '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 + # } +} 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/registry.py b/AutoREACTER/detectors/reactions_library/registry.py index 15b2968..b1d87c8 100644 --- a/AutoREACTER/detectors/reactions_library/registry.py +++ b/AutoREACTER/detectors/reactions_library/registry.py @@ -11,7 +11,7 @@ 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 .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 59c8200b28c916767b48de0eab5ea098353732ae Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:33:25 -0400 Subject: [PATCH 065/104] Update registry.py --- AutoREACTER/detectors/reactions_library/registry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AutoREACTER/detectors/reactions_library/registry.py b/AutoREACTER/detectors/reactions_library/registry.py index b1d87c8..81f589a 100644 --- a/AutoREACTER/detectors/reactions_library/registry.py +++ b/AutoREACTER/detectors/reactions_library/registry.py @@ -31,7 +31,7 @@ VINYL_POLYMERS, POLYCARBONATES, POLYIMIDES, - POLYBENZIMIDAZOLES, + #POLYBENZIMIDAZOLES, # PHENOLIC_RESINS, POLYSILOXANES, POLYSULFIDES, From 92ad7932d134a1c2eca7c43f40f0af0cbd201d69 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:54:47 -0400 Subject: [PATCH 066/104] Add polycarbonate synthesis pathways Add functional group definitions for carboxyl and carbonyl groups, and reaction pathways for polycarbonate formation via diol with phosgene or diphenyl carbonate. --- .../carboxyl_and_carbonyl_groups.py | 40 +++++++++++++++++++ .../reactions_library/polycarbonates.py | 32 +++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 AutoREACTER/detectors/functional_groups_library/carboxyl_and_carbonyl_groups.py create mode 100644 AutoREACTER/detectors/reactions_library/polycarbonates.py 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..af4e6f6 --- /dev/null +++ b/AutoREACTER/detectors/functional_groups_library/carboxyl_and_carbonyl_groups.py @@ -0,0 +1,40 @@ +"""Functional-group definitions organized by the carboxyl and carbonyl groups motif. + +Each entry is defined once in the entire functional-group registry. Reaction libraries reference the entry's group_name value.""" + +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': 'di_identical', + 'smarts_1': '[CX3](=[OX1])[Cl]', + '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/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 From 3fa18ed72e1ea694657d0ed17ba4a96413a32ff1 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:28:20 -0400 Subject: [PATCH 067/104] Fix phosgene SMARTS to prevent partial matches Changed phosgene_monomer from di_identical to mono functionality type and updated the SMARTS pattern to strictly require both chlorines on the carbonyl carbon, preventing false matches with standard acid chlorides. --- .../carboxyl_and_carbonyl_groups.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/AutoREACTER/detectors/functional_groups_library/carboxyl_and_carbonyl_groups.py b/AutoREACTER/detectors/functional_groups_library/carboxyl_and_carbonyl_groups.py index af4e6f6..8a1fb1c 100644 --- a/AutoREACTER/detectors/functional_groups_library/carboxyl_and_carbonyl_groups.py +++ b/AutoREACTER/detectors/functional_groups_library/carboxyl_and_carbonyl_groups.py @@ -22,8 +22,9 @@ 'comments': None }, 'phosgene_monomer': { - 'functionality_type': 'di_identical', - 'smarts_1': '[CX3](=[OX1])[Cl]', + '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, }, From 126dedc82299d0e89398f3871e9d7c18e0d831b6 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:59:53 -0400 Subject: [PATCH 068/104] Add polyester polycondensation reaction library --- .../detectors/reactions_library/polyesters.py | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 AutoREACTER/detectors/reactions_library/polyesters.py diff --git a/AutoREACTER/detectors/reactions_library/polyesters.py b/AutoREACTER/detectors/reactions_library/polyesters.py new file mode 100644 index 0000000..c29deb8 --- /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 From 0f8f49c8a6f9f4bd8720d3c81c35beb69d1df361 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:31:33 -0400 Subject: [PATCH 069/104] Add polysiloxane reactions, disable draft modules --- .../detectors/reactions_library/polyethers.py | 92 +++++++++++++++++++ .../detectors/reactions_library/polyimides.py | 18 ++++ .../reactions_library/polysiloxanes.py | 46 ++++++++++ .../detectors/reactions_library/registry.py | 10 +- 4 files changed, 161 insertions(+), 5 deletions(-) create mode 100644 AutoREACTER/detectors/reactions_library/polyethers.py create mode 100644 AutoREACTER/detectors/reactions_library/polyimides.py create mode 100644 AutoREACTER/detectors/reactions_library/polysiloxanes.py 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..d3176ea --- /dev/null +++ b/AutoREACTER/detectors/reactions_library/polyimides.py @@ -0,0 +1,18 @@ +# This reaction also needs to further studied and validated +# REACTIONS = { +# 'Di-cyclic Anhydride and Di-Primary Amine Polycondensation (Polyimidation)': +# { +# 'same_reactants': False, +# 'reactant_1': 'di_cyclic_anhydride', +# 'reactant_2': 'di_primary_amine', +# 'product': 'polyimide_chain', +# 'delete_atom': True, +# 'reaction': '[NX3H2:1](-[H:6])-[H:8].[CX3;R:2](=[OX1:4])[OX2;R:5][CX3;R:3](=[OX1:7])>>[NX3:1]([CX3:2](=[OX1:4]))[CX3:3](=[OX1:7]).[OX2:5](-[H:6])-[H:8]', +# 'reference': +# { +# 'smarts': None, +# 'reaction_and_mechanism': None +# }, +# 'comments': None +# } +# } diff --git a/AutoREACTER/detectors/reactions_library/polysiloxanes.py b/AutoREACTER/detectors/reactions_library/polysiloxanes.py new file mode 100644 index 0000000..4afe7f3 --- /dev/null +++ b/AutoREACTER/detectors/reactions_library/polysiloxanes.py @@ -0,0 +1,46 @@ +REACTIONS = { + 'Dichlorosilane Hydrolysis to Silanol': + { + 'same_reactants': False, + 'reactant_1': 'dichlorosilane', + 'reactant_2': 'water', + 'product': 'silanediol', + 'delete_atom': True, + 'reaction': '[Si:1]-[Cl:2].[OX2H2:3](-[H:4])-[H:5]>>[Si:1]-[OX2:3]-[H:4].[Cl:2]-[H:5]', + 'reference': + { + 'smarts': None, + 'reaction_and_mechanism': None + }, + "Note": "Tested, but LUNAR cannot parameterize silicone chemistry" + }, + '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 + }, + "Note": "Tested, but LUNAR cannot parameterize silicone chemistry" + }, + '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 + }, + "Note": "Tested, but LUNAR cannot parameterize silicone chemistry" + } +} diff --git a/AutoREACTER/detectors/reactions_library/registry.py b/AutoREACTER/detectors/reactions_library/registry.py index 81f589a..15ba12c 100644 --- a/AutoREACTER/detectors/reactions_library/registry.py +++ b/AutoREACTER/detectors/reactions_library/registry.py @@ -1,7 +1,7 @@ """Aggregate and validate all polymer-family reaction modules.""" from .polyesters import REACTIONS as POLYESTERS -from .polyethers import REACTIONS as POLYETHERS +# from .polyethers import REACTIONS as POLYETHERS from .polyamides import REACTIONS as POLYAMIDES from .polyanhydrides import REACTIONS as POLYANHYDRIDES from .polythioesters import REACTIONS as POLYTHIOESTERS @@ -10,7 +10,7 @@ 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 .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 @@ -21,7 +21,7 @@ _REACTION_MODULES = [ POLYESTERS, - POLYETHERS, + # POLYETHERS, POLYAMIDES, POLYANHYDRIDES, POLYTHIOESTERS, @@ -30,8 +30,8 @@ EPOXY_POLYMERS, VINYL_POLYMERS, POLYCARBONATES, - POLYIMIDES, - #POLYBENZIMIDAZOLES, + # POLYIMIDES, + # POLYBENZIMIDAZOLES, # PHENOLIC_RESINS, POLYSILOXANES, POLYSULFIDES, From 9f82c8d3a7124f3f355a2eb7b01a39e7894923dc Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:24:52 -0400 Subject: [PATCH 070/104] Disable untested halide and polysulfide entries Comments out halide functional-group registration and polysulfide reaction registration so untested chemistry is not loaded by default. Adds a new `polysulfides.py` scaffold with the planned reaction definition kept fully commented for future activation once validated. --- .../functional_groups_library/registry.py | 4 ++-- .../detectors/reactions_library/polysulfides.py | 17 +++++++++++++++++ .../detectors/reactions_library/registry.py | 4 ++-- 3 files changed, 21 insertions(+), 4 deletions(-) create mode 100644 AutoREACTER/detectors/reactions_library/polysulfides.py diff --git a/AutoREACTER/detectors/functional_groups_library/registry.py b/AutoREACTER/detectors/functional_groups_library/registry.py index fc7e207..79d3a7d 100644 --- a/AutoREACTER/detectors/functional_groups_library/registry.py +++ b/AutoREACTER/detectors/functional_groups_library/registry.py @@ -9,7 +9,7 @@ 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 .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 @@ -23,7 +23,7 @@ VINYL_AND_ALKENE_GROUPS, AROMATIC_GROUPS, SILICON_GROUPS, - HALIDE_GROUPS, + # HALIDE_GROUPS, HETEROCUMULENE_GROUPS, ACTIVE_CENTERS, ] diff --git a/AutoREACTER/detectors/reactions_library/polysulfides.py b/AutoREACTER/detectors/reactions_library/polysulfides.py new file mode 100644 index 0000000..65732c0 --- /dev/null +++ b/AutoREACTER/detectors/reactions_library/polysulfides.py @@ -0,0 +1,17 @@ +# Polysulfide formation reactions are currently commented out as they are untested. +# 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': 'UNTESTED new chemistry; ' +# 'one substitution event ' +# 'leaves a thiolate chain ' +# 'end for continued ' +# 'growth.'}} diff --git a/AutoREACTER/detectors/reactions_library/registry.py b/AutoREACTER/detectors/reactions_library/registry.py index 15ba12c..30eb35b 100644 --- a/AutoREACTER/detectors/reactions_library/registry.py +++ b/AutoREACTER/detectors/reactions_library/registry.py @@ -14,7 +14,7 @@ # 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 .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 @@ -34,7 +34,7 @@ # POLYBENZIMIDAZOLES, # PHENOLIC_RESINS, POLYSILOXANES, - POLYSULFIDES, + # POLYSULFIDES, THIOL_ENE_POLYMERS, # METATHESIS_POLYMERS, # CYCLOADDITION_POLYMERS, From ead2824156743c4eb86f8341162a73aec56fb976 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:25:05 -0400 Subject: [PATCH 071/104] Add polythioester reaction library entries --- .../reactions_library/polythioesters.py | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 AutoREACTER/detectors/reactions_library/polythioesters.py diff --git a/AutoREACTER/detectors/reactions_library/polythioesters.py b/AutoREACTER/detectors/reactions_library/polythioesters.py new file mode 100644 index 0000000..43dd6dc --- /dev/null +++ b/AutoREACTER/detectors/reactions_library/polythioesters.py @@ -0,0 +1,57 @@ +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': 'Possible thioesterification with water elimination, but generally less straightforward than acid-halide route.' + }, + + '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 From 38052b96540187b7bdab24ad986ffa163f8b360a Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:31:14 -0400 Subject: [PATCH 072/104] Add functional groups and polyurea reaction --- .../active_centers.py | 21 +++++++++++++++++++ .../halide_groups.py | 8 +++++++ .../sulfur_groups.py | 17 +++++++++++++++ .../detectors/reactions_library/polyureas.py | 15 +++++++++++++ 4 files changed, 61 insertions(+) create mode 100644 AutoREACTER/detectors/functional_groups_library/active_centers.py create mode 100644 AutoREACTER/detectors/functional_groups_library/halide_groups.py create mode 100644 AutoREACTER/detectors/functional_groups_library/sulfur_groups.py create mode 100644 AutoREACTER/detectors/reactions_library/polyureas.py 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..f1cf6ad --- /dev/null +++ b/AutoREACTER/detectors/functional_groups_library/active_centers.py @@ -0,0 +1,21 @@ +"""Functional-group definitions organized by the active centers motif. + +Each entry is defined once in the entire functional-group registry. Reaction libraries reference the entry's group_name value.""" + +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': 'UNTESTED simplified ruthenium alkylidene motif used for both ROMP initiation ' + # 'and propagation. Atom maps 1 and 2 are assigned only in the reaction SMARTS.' + # } + } 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/sulfur_groups.py b/AutoREACTER/detectors/functional_groups_library/sulfur_groups.py new file mode 100644 index 0000000..d688600 --- /dev/null +++ b/AutoREACTER/detectors/functional_groups_library/sulfur_groups.py @@ -0,0 +1,17 @@ +"""Functional-group definitions organized by the sulfur groups motif. + +Each entry is defined once in the entire functional-group registry. Reaction libraries reference the entry's group_name value.""" + +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': 'Sulfide dianion; sodium counterions are included in the reaction SMARTS.'} +} diff --git a/AutoREACTER/detectors/reactions_library/polyureas.py b/AutoREACTER/detectors/reactions_library/polyureas.py new file mode 100644 index 0000000..5b631cd --- /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,H1;!$([N][C,S]=*):1]-[H:3].[NX2:4]=[CX2:2]=[OX1:5]>>[NX3:1]-[CX3:2](=[OX1:5])-[NX3:4]-[H:3]', + 'reference': { + 'smarts': None, + 'reaction_and_mechanism': None + }, + 'comments': None + } +} \ No newline at end of file From 49bb79a9780b99d9508812c82bb34cd8a9a3a4b3 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:36:26 -0400 Subject: [PATCH 073/104] Add polyurethanes reactions library module --- .../reactions_library/polyurethanes.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 AutoREACTER/detectors/reactions_library/polyurethanes.py diff --git a/AutoREACTER/detectors/reactions_library/polyurethanes.py b/AutoREACTER/detectors/reactions_library/polyurethanes.py new file mode 100644 index 0000000..d898ef0 --- /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': 'UNTESTED: chemically consistent replacement for the commented entry whose label said epoxide/isocyanate but whose SMARTS was isocyanate + O/S-H.' + } +} \ No newline at end of file From a6fd6a27fca8a11abeed92fde8a17b6603df6d69 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:36:50 -0400 Subject: [PATCH 074/104] Update polyurethanes.py --- AutoREACTER/detectors/reactions_library/polyurethanes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AutoREACTER/detectors/reactions_library/polyurethanes.py b/AutoREACTER/detectors/reactions_library/polyurethanes.py index d898ef0..3d908a6 100644 --- a/AutoREACTER/detectors/reactions_library/polyurethanes.py +++ b/AutoREACTER/detectors/reactions_library/polyurethanes.py @@ -24,6 +24,6 @@ 'smarts': None, 'reaction_and_mechanism': None }, - 'comments': 'UNTESTED: chemically consistent replacement for the commented entry whose label said epoxide/isocyanate but whose SMARTS was isocyanate + O/S-H.' + 'comments': None } } \ No newline at end of file From c4de1ed60702b9a97787ffd749b9918f4bd8dc46 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:15:55 -0400 Subject: [PATCH 075/104] Improve embedding for congested polymers --- .../ff_wrapper/molecule_3d_preparation.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/AutoREACTER/reaction_preparation/ff_wrapper/molecule_3d_preparation.py b/AutoREACTER/reaction_preparation/ff_wrapper/molecule_3d_preparation.py index 648d9b0..a9316f9 100644 --- a/AutoREACTER/reaction_preparation/ff_wrapper/molecule_3d_preparation.py +++ b/AutoREACTER/reaction_preparation/ff_wrapper/molecule_3d_preparation.py @@ -239,6 +239,13 @@ def _optimization( 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 + # ---------------------------------------------------------- embed_result = AllChem.EmbedMolecule(mol, params) From 29da8c967ca531fc31c6c2e4f80f1f2248b994ef Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:16:35 -0400 Subject: [PATCH 076/104] Add vinyl polymer reaction library definitions --- .../reactions_library/vinyl_polymers.py | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 AutoREACTER/detectors/reactions_library/vinyl_polymers.py diff --git a/AutoREACTER/detectors/reactions_library/vinyl_polymers.py b/AutoREACTER/detectors/reactions_library/vinyl_polymers.py new file mode 100644 index 0000000..bca2d62 --- /dev/null +++ b/AutoREACTER/detectors/reactions_library/vinyl_polymers.py @@ -0,0 +1,79 @@ +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 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 + # } +} \ No newline at end of file From b12183f9539ac7cc14b1dd0f209744625775d9da Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:51:29 -0400 Subject: [PATCH 077/104] Add new functional group library modules --- .../heterocumulene_groups.py | 11 ++++++++ .../oxygen_groups.py | 27 +++++++++++++++++++ .../vinyl_and_alkene_groups.py | 24 +++++++++++++++++ 3 files changed, 62 insertions(+) create mode 100644 AutoREACTER/detectors/functional_groups_library/heterocumulene_groups.py create mode 100644 AutoREACTER/detectors/functional_groups_library/oxygen_groups.py create mode 100644 AutoREACTER/detectors/functional_groups_library/vinyl_and_alkene_groups.py 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/oxygen_groups.py b/AutoREACTER/detectors/functional_groups_library/oxygen_groups.py new file mode 100644 index 0000000..0fa3a94 --- /dev/null +++ b/AutoREACTER/detectors/functional_groups_library/oxygen_groups.py @@ -0,0 +1,27 @@ +"""Functional-group definitions organized by the oxygen groups motif. + +Each entry is defined once in the entire functional-group registry. Reaction libraries reference the entry's group_name value.""" + +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 + } +} 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..b76eb3c --- /dev/null +++ b/AutoREACTER/detectors/functional_groups_library/vinyl_and_alkene_groups.py @@ -0,0 +1,24 @@ +"""Functional-group definitions organized by the vinyl and alkene groups motif. + +Each entry is defined once in the entire functional-group registry. Reaction libraries reference the entry's group_name value.""" + +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': 'One alkene site; a bis-alkene supplies two identical matches.'}} From f49b0c3a89e8c0da6cb2d434859c0e4f196ebd7a Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:09:37 -0400 Subject: [PATCH 078/104] Refactor reaction libraries into package modules --- .../detectors/functional_groups_library.py | 290 --------- AutoREACTER/detectors/reaction_detector.py | 2 +- AutoREACTER/detectors/reactions_library.py | 566 ------------------ 3 files changed, 1 insertion(+), 857 deletions(-) delete mode 100644 AutoREACTER/detectors/functional_groups_library.py delete mode 100644 AutoREACTER/detectors/reactions_library.py diff --git a/AutoREACTER/detectors/functional_groups_library.py b/AutoREACTER/detectors/functional_groups_library.py deleted file mode 100644 index 92e0061..0000000 --- a/AutoREACTER/detectors/functional_groups_library.py +++ /dev/null @@ -1,290 +0,0 @@ -""" -Functional group library for epoxy polymerization chemistry. - -This library focuses on monomers and curing-agent functional groups relevant to -epoxy polymerization / epoxy curing systems. It includes epoxides and common -epoxy-reactive groups such as primary amines, secondary amines, thiols, alcohols, -carboxylic acids, and cyclic anhydrides. - -Each entry defines: - - functionality_type: - "mono" : one reactive functional group - "di_identical" : two identical reactive functional groups - "di_different" : two different reactive functional groups - - - smarts_1 / smarts_2: - SMARTS patterns used for substructure matching - - - group_name: - functional group label used by the detector - - - comments: - optional chemistry notes -""" - - - -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, - }, - - # ============================================================ - # Epoxy / Amine Functional Monomers - # Relevant for epoxy-amine polymerization - # - # Polymer-forming epoxy monomer: - # must contain two epoxide groups, i.e. diepoxy - # - # Primary monoamine: - # one -NH2 group has two active hydrogens - # can react with two epoxide groups in two stages - # - # Stage 1: - # primary amine + epoxide -> secondary amine - # - # Stage 2: - # secondary amine + epoxide -> tertiary amine - # ============================================================ - - "di_epoxy_monomer": { - "functionality_type": "di_identical", - "smarts_1": "[CX4;R1]1[OX2;R1][CX4;R1]1", - "group_name": "di_epoxide", - "comments": None, - }, - - "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, - }, - # ============================================================ - # Vinyl Addition Polymerization - # ============================================================ - - "vinyl_monomer": { - "functionality_type": "vinyl", - "smarts_1": "[CH2]=[C;!R]", - "group_name": "vinyl", - "comments": None, - }, - - "vinyl_chain_end_radical": { - "functionality_type": "vinyl", - "smarts_1": "[C;!R;D3;v3]", - "group_name": "vinyl_chain_end_radical", - "comments": ( - "Neutral non-ring carbon-centered radical with degree 3 and " - "valence 3. Supports primary, secondary, and tertiary vinyl " - "polymer chain ends." - ), - }, - - # ============================================================ - # 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/reaction_detector.py b/AutoREACTER/detectors/reaction_detector.py index 73b6911..1bfe070 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 diff --git a/AutoREACTER/detectors/reactions_library.py b/AutoREACTER/detectors/reactions_library.py deleted file mode 100644 index 67a6739..0000000 --- a/AutoREACTER/detectors/reactions_library.py +++ /dev/null @@ -1,566 +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 - }, - "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, - "reaction": "[NX3H2:1]-[H:6].[CX4:2]1[OX2:5][CX4:3]1>>[NX3:1][C:2][C:3][OX2:5][H:6]", - "reference": { - "smarts": None, - "reaction_and_mechanism": None - }, - "comments": "Primary amine (-NH2) opens one epoxide ring. One N-H is consumed and transferred to the epoxide oxygen as -OH; nitrogen becomes a secondary amine with one remaining N-H, still reactive toward a second epoxide." - }, - - "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, - "reaction": "[NX3H1:1]-[H:6].[CX4:2]1[OX2:5][CX4:3]1>>[NX3:1][C:2][C:3][OX2:5][H:6]", - "reference": { - "smarts": None, - "reaction_and_mechanism": None - }, - "comments": "Secondary amine's remaining N-H opens a second epoxide ring. Nitrogen becomes a fully substituted tertiary amine (network crosslink point); no reactive N-H remains on this nitrogen." - }, - - "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": ( - "Joins two terminal vinyl groups. The two alkene carbons that do " - "not form the new intermolecular bond become radical chain ends." - ), - }, - "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": ( - "The existing carbon radical forms a bond with the terminal CH2 " - "of the incoming vinyl monomer. The other alkene carbon becomes " - "the new radical center." - ), - }, - "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": ( - "Termination by combination of two neutral carbon-centered " - "vinyl chain-end radicals. A new carbon-carbon single bond " - "is formed and both radical centers are consumed." - ), - }, - # ============================================================ - # 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 From 6c418ee1f90ba224906d0abdc1e182d5aa62714b Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:15:24 -0400 Subject: [PATCH 079/104] Disable aromatic group registration --- .../active_centers.py | 33 ++++++++----------- .../aromatic_groups.py | 32 ++++++++++++++++++ .../carboxyl_and_carbonyl_groups.py | 18 +++++----- .../functional_groups_library/registry.py | 4 +-- 4 files changed, 55 insertions(+), 32 deletions(-) create mode 100644 AutoREACTER/detectors/functional_groups_library/aromatic_groups.py diff --git a/AutoREACTER/detectors/functional_groups_library/active_centers.py b/AutoREACTER/detectors/functional_groups_library/active_centers.py index f1cf6ad..033e706 100644 --- a/AutoREACTER/detectors/functional_groups_library/active_centers.py +++ b/AutoREACTER/detectors/functional_groups_library/active_centers.py @@ -1,21 +1,14 @@ -"""Functional-group definitions organized by the active centers motif. - -Each entry is defined once in the entire functional-group registry. Reaction libraries reference the entry's group_name value.""" - 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': 'UNTESTED simplified ruthenium alkylidene motif used for both ROMP initiation ' - # 'and propagation. Atom maps 1 and 2 are assigned only in the reaction SMARTS.' - # } - } + '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 index 8a1fb1c..99f5eb2 100644 --- a/AutoREACTER/detectors/functional_groups_library/carboxyl_and_carbonyl_groups.py +++ b/AutoREACTER/detectors/functional_groups_library/carboxyl_and_carbonyl_groups.py @@ -1,14 +1,10 @@ -"""Functional-group definitions organized by the carboxyl and carbonyl groups motif. - -Each entry is defined once in the entire functional-group registry. Reaction libraries reference the entry's group_name value.""" - 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]', @@ -26,7 +22,7 @@ # 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, + 'comments': None }, 'diphenyl_carbonate_monomer': { 'functionality_type': 'di_identical', @@ -34,8 +30,10 @@ 'group_name': 'diphenyl_carbonate', 'comments': None } -# 'formaldehyde_monomer': {'functionality_type': 'mono', -# 'smarts_1': '[CH2]=[OX1]', -# 'group_name': 'formaldehyde', -# '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/registry.py b/AutoREACTER/detectors/functional_groups_library/registry.py index 79d3a7d..98c1391 100644 --- a/AutoREACTER/detectors/functional_groups_library/registry.py +++ b/AutoREACTER/detectors/functional_groups_library/registry.py @@ -7,7 +7,7 @@ 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 .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 @@ -21,7 +21,7 @@ MIXED_AB_GROUPS, RING_GROUPS, VINYL_AND_ALKENE_GROUPS, - AROMATIC_GROUPS, + # AROMATIC_GROUPS, SILICON_GROUPS, # HALIDE_GROUPS, HETEROCUMULENE_GROUPS, From ab4281f525db52d3f5aa1cce173bf2448317fb79 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:19:16 -0400 Subject: [PATCH 080/104] Add ring/silicon groups and normalize defs --- .../mixed_ab_groups.py | 78 +++++++++---------- .../nitrogen_groups.py | 71 ++++++++--------- .../oxygen_groups.py | 45 +++++------ .../functional_groups_library/ring_groups.py | 44 +++++++++++ .../silicon_groups.py | 14 ++++ .../sulfur_groups.py | 29 ++++--- .../vinyl_and_alkene_groups.py | 42 +++++----- 7 files changed, 176 insertions(+), 147 deletions(-) create mode 100644 AutoREACTER/detectors/functional_groups_library/ring_groups.py create mode 100644 AutoREACTER/detectors/functional_groups_library/silicon_groups.py diff --git a/AutoREACTER/detectors/functional_groups_library/mixed_ab_groups.py b/AutoREACTER/detectors/functional_groups_library/mixed_ab_groups.py index aee41c5..8679d04 100644 --- a/AutoREACTER/detectors/functional_groups_library/mixed_ab_groups.py +++ b/AutoREACTER/detectors/functional_groups_library/mixed_ab_groups.py @@ -1,43 +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 - } -} + '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 index c21ced1..36aa22b 100644 --- a/AutoREACTER/detectors/functional_groups_library/nitrogen_groups.py +++ b/AutoREACTER/detectors/functional_groups_library/nitrogen_groups.py @@ -1,41 +1,32 @@ -"""Functional-group definitions organized by the nitrogen groups motif. - -Each entry is defined once in the entire functional-group registry. Reaction libraries reference the entry's group_name value.""" - FUNCTIONAL_GROUPS = { - 'primary_amine_monomer': - { - 'functionality_type': 'mono', - 'smarts_1': '[NX3H2;!$(NC=O);!$(NC=[N,O,S])]', - 'group_name': 'primary_amine', - 'comments': None - }, # Tested - Passed - '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 - # } -} + '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 index 0fa3a94..d87891e 100644 --- a/AutoREACTER/detectors/functional_groups_library/oxygen_groups.py +++ b/AutoREACTER/detectors/functional_groups_library/oxygen_groups.py @@ -1,27 +1,20 @@ -"""Functional-group definitions organized by the oxygen groups motif. - -Each entry is defined once in the entire functional-group registry. Reaction libraries reference the entry's group_name value.""" - 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 - } -} + '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/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 index d688600..fbc6b95 100644 --- a/AutoREACTER/detectors/functional_groups_library/sulfur_groups.py +++ b/AutoREACTER/detectors/functional_groups_library/sulfur_groups.py @@ -1,17 +1,14 @@ -"""Functional-group definitions organized by the sulfur groups motif. - -Each entry is defined once in the entire functional-group registry. Reaction libraries reference the entry's group_name value.""" - 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': 'Sulfide dianion; sodium counterions are included in the reaction SMARTS.'} -} + '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 index b76eb3c..1641eda 100644 --- a/AutoREACTER/detectors/functional_groups_library/vinyl_and_alkene_groups.py +++ b/AutoREACTER/detectors/functional_groups_library/vinyl_and_alkene_groups.py @@ -1,24 +1,20 @@ -"""Functional-group definitions organized by the vinyl and alkene groups motif. - -Each entry is defined once in the entire functional-group registry. Reaction libraries reference the entry's group_name value.""" - 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': 'One alkene site; a bis-alkene supplies two identical matches.'}} + '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 + # } +} \ No newline at end of file From 99fc3985e0bd21167c2af2d5c340d26c996cbd81 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:26:08 -0400 Subject: [PATCH 081/104] Normalize polymer reaction library entries --- .../cycloaddition_polymers.py | 46 ++-- .../reactions_library/epoxy_polymers.py | 61 +++-- .../reactions_library/metathesis_polymers.py | 34 +-- .../reactions_library/phenolic_resins.py | 70 ++---- .../detectors/reactions_library/polyamides.py | 226 ++++++++---------- .../reactions_library/polyanhydrides.py | 52 ++-- .../detectors/reactions_library/polyimides.py | 29 +-- 7 files changed, 227 insertions(+), 291 deletions(-) diff --git a/AutoREACTER/detectors/reactions_library/cycloaddition_polymers.py b/AutoREACTER/detectors/reactions_library/cycloaddition_polymers.py index 5d48677..3d2a79f 100644 --- a/AutoREACTER/detectors/reactions_library/cycloaddition_polymers.py +++ b/AutoREACTER/detectors/reactions_library/cycloaddition_polymers.py @@ -1,29 +1,21 @@ -# """Reactions organized under the cycloaddition polymers polymer family.""" - -# Yet to be tested and implemented # 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': 'UNTESTED new chemistry; generic [2+2] ' -# 'four-center cycloaddition.' -# }, -# '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': 'UNTESTED: separate false instance for two ' -# 'different bis-alkenes.' -# } +# '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 index 26cfa9a..dac56bc 100644 --- a/AutoREACTER/detectors/reactions_library/epoxy_polymers.py +++ b/AutoREACTER/detectors/reactions_library/epoxy_polymers.py @@ -1,35 +1,30 @@ -""" -Reactions organized under the epoxy polymers polymer family. -Tested and Passed - 7/21/2026 -""" 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 - }, - 'Notes': 'Corrected for SN2 regioselectivity (attacks less hindered carbon)' + '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 }, - - '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}, - 'Notes': 'Corrected for SN2 regioselectivity (attacks less hindered carbon)' - } - } \ No newline at end of file + '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 index 17cab81..78fa98d 100644 --- a/AutoREACTER/detectors/reactions_library/metathesis_polymers.py +++ b/AutoREACTER/detectors/reactions_library/metathesis_polymers.py @@ -1,7 +1,3 @@ -# """Reactions organized under the metathesis polymers polymer family. - -# Uncommented reactions from the supplied working library retain their original dictionary values. Formerly commented and newly added reactions remain marked UNTESTED in comments.""" - # REACTIONS = { # 'ROMP Initiation': { # 'same_reactants': False, @@ -10,13 +6,23 @@ # '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': 'UNTESTED new chemistry. Initiator Ru/alkylidene carbon are maps 1 and 2.'}, -# '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': 'UNTESTED new chemistry.'}} +# '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 index 09761f2..ff71d19 100644 --- a/AutoREACTER/detectors/reactions_library/phenolic_resins.py +++ b/AutoREACTER/detectors/reactions_library/phenolic_resins.py @@ -1,52 +1,28 @@ -# """Reactions organized under the phenolic resins polymer family. - -# Uncommented reactions from the supplied working library retain their original dictionary values. Formerly commented and newly added reactions remain marked UNTESTED in comments.""" - # 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, +# '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": ( -# "UNTESTED new chemistry; first Bakelite-forming step. " -# "Generic aromatic C-H matching is not restricted to " -# "ortho/para substitution." -# ), +# '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, +# '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": ( -# "UNTESTED. Produces a methylene bridge and water. " -# "Generic aromatic C-H matching is not restricted to " -# "ortho/para substitution." -# ), -# }, +# 'comments': None +# } # } \ No newline at end of file diff --git a/AutoREACTER/detectors/reactions_library/polyamides.py b/AutoREACTER/detectors/reactions_library/polyamides.py index 80e56de..29b9e05 100644 --- a/AutoREACTER/detectors/reactions_library/polyamides.py +++ b/AutoREACTER/detectors/reactions_library/polyamides.py @@ -1,130 +1,104 @@ -"""Reactions organized under the polyamides polymer family. - -Uncommented reactions from the supplied working library retain their original dictionary values. Formerly commented and newly added reactions remain marked UNTESTED in comments.""" - 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 + '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' + ] }, - '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 + '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' + ] }, - '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" + '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' + ] }, - } - -### LATER IMPLEMENTATION: Consider adding more detailed reaction mechanisms and validation steps for hydrolytic initiation of caprolactam. - -# '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': -# } -# } + '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 index 513cd9a..9bb3346 100644 --- a/AutoREACTER/detectors/reactions_library/polyanhydrides.py +++ b/AutoREACTER/detectors/reactions_library/polyanhydrides.py @@ -1,31 +1,27 @@ 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:2](=[O:5])[OX2: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 + '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])[OX2: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 }, - 'Carboxylic Acid and Acid Halide Polycondensation(Polyanhydride Formation) - Different Reactants': - { - '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:2](=[O:5])[OX2: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 - } + 'comments': None + }, + '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:2](=[O:5])[OX2: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 } +} \ No newline at end of file diff --git a/AutoREACTER/detectors/reactions_library/polyimides.py b/AutoREACTER/detectors/reactions_library/polyimides.py index d3176ea..d407f39 100644 --- a/AutoREACTER/detectors/reactions_library/polyimides.py +++ b/AutoREACTER/detectors/reactions_library/polyimides.py @@ -1,18 +1,15 @@ -# This reaction also needs to further studied and validated # REACTIONS = { -# 'Di-cyclic Anhydride and Di-Primary Amine Polycondensation (Polyimidation)': -# { -# 'same_reactants': False, -# 'reactant_1': 'di_cyclic_anhydride', -# 'reactant_2': 'di_primary_amine', -# 'product': 'polyimide_chain', -# 'delete_atom': True, -# 'reaction': '[NX3H2:1](-[H:6])-[H:8].[CX3;R:2](=[OX1:4])[OX2;R:5][CX3;R:3](=[OX1:7])>>[NX3:1]([CX3:2](=[OX1:4]))[CX3:3](=[OX1:7]).[OX2:5](-[H:6])-[H:8]', -# 'reference': -# { -# 'smarts': None, -# 'reaction_and_mechanism': None -# }, -# 'comments': None -# } +# '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 From f2fb2f60c44a705ccff80ef47efddb519c060149 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:29:11 -0400 Subject: [PATCH 082/104] Normalization --- .../reactions_library/polysiloxanes.py | 80 +++++++++---------- .../reactions_library/polysulfides.py | 30 ++++--- .../reactions_library/polythioesters.py | 5 +- 3 files changed, 52 insertions(+), 63 deletions(-) diff --git a/AutoREACTER/detectors/reactions_library/polysiloxanes.py b/AutoREACTER/detectors/reactions_library/polysiloxanes.py index 4afe7f3..f7f3e6a 100644 --- a/AutoREACTER/detectors/reactions_library/polysiloxanes.py +++ b/AutoREACTER/detectors/reactions_library/polysiloxanes.py @@ -1,46 +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:2].[OX2H2:3](-[H:4])-[H:5]>>[Si:1]-[OX2:3]-[H:4].[Cl:2]-[H:5]', - 'reference': - { - 'smarts': None, - 'reaction_and_mechanism': None - }, - "Note": "Tested, but LUNAR cannot parameterize silicone chemistry" + 'Dichlorosilane Hydrolysis to Silanol': { + 'same_reactants': False, + 'reactant_1': 'dichlorosilane', + 'reactant_2': 'water', + 'product': 'silanediol', + 'delete_atom': True, + 'reaction': '[Si:1]-[Cl:2].[OX2H2:3](-[H:4])-[H:5]>>[Si:1]-[OX2:3]-[H:4].[Cl:2]-[H:5]', + 'reference': { + 'smarts': None, + 'reaction_and_mechanism': None }, - '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 - }, - "Note": "Tested, but LUNAR cannot parameterize silicone chemistry" + 'comments': None + }, + '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 }, - '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 - }, - "Note": "Tested, but LUNAR cannot parameterize silicone chemistry" - } -} + 'comments': 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 + }, + 'comments': None + } +} \ No newline at end of file diff --git a/AutoREACTER/detectors/reactions_library/polysulfides.py b/AutoREACTER/detectors/reactions_library/polysulfides.py index 65732c0..4b0482b 100644 --- a/AutoREACTER/detectors/reactions_library/polysulfides.py +++ b/AutoREACTER/detectors/reactions_library/polysulfides.py @@ -1,17 +1,15 @@ -# Polysulfide formation reactions are currently commented out as they are untested. # 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': 'UNTESTED new chemistry; ' -# 'one substitution event ' -# 'leaves a thiolate chain ' -# 'end for continued ' -# 'growth.'}} +# '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 index 43dd6dc..830a8a5 100644 --- a/AutoREACTER/detectors/reactions_library/polythioesters.py +++ b/AutoREACTER/detectors/reactions_library/polythioesters.py @@ -12,7 +12,6 @@ }, 'comments': None }, - 'Dithiol and Di-Carboxylic Acid Polycondensation(Polythioesterification)': { 'same_reactants': False, 'reactant_1': 'dithiol', @@ -24,9 +23,8 @@ 'smarts': None, 'reaction_and_mechanism': None }, - 'comments': 'Possible thioesterification with water elimination, but generally less straightforward than acid-halide route.' + 'comments': None }, - 'Hydroxy-Thiol and Di-Carboxylic Acid Halide Polycondensation through Hydroxy Group': { 'same_reactants': False, 'reactant_1': 'hydroxy_thiol', @@ -40,7 +38,6 @@ }, 'comments': None }, - 'Hydroxy-Thiol and Di-Carboxylic Acid Halide Polycondensation through Thiol Group': { 'same_reactants': False, 'reactant_1': 'hydroxy_thiol', From d48858b7a3bc455c1d816b0fe0e16584b0133097 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:29:17 -0400 Subject: [PATCH 083/104] Create thiol_ene_polymers.py --- .../reactions_library/thiol_ene_polymers.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 AutoREACTER/detectors/reactions_library/thiol_ene_polymers.py 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 From cd2f3c8e6c5e6f6facdfd15795dea2cdda2e510d Mon Sep 17 00:00:00 2001 From: Janitha Mahanthe <119646255+janitha-mahanthe@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:45:46 -0400 Subject: [PATCH 084/104] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- AutoREACTER/input_parser.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/AutoREACTER/input_parser.py b/AutoREACTER/input_parser.py index 9d9a8f7..8bfedb2 100644 --- a/AutoREACTER/input_parser.py +++ b/AutoREACTER/input_parser.py @@ -971,10 +971,10 @@ def _validate_loop(self, inputs: dict) -> tuple[bool, int | None]: if loop_value <= 0: raise InputSchemaError("'loop' must be a positive integer.") - print( - f"Reaction will be looped and maximum iterations set to {loop_value}" + logger.info( + "Looping enabled with maximum iterations set to %s", + loop_value, ) - time.sleep(5) return True, loop_value if isinstance(loop_value, str) and loop_value in loop_keywords: From adc3739e6d55cbb3c641b666e0728051706cfe85 Mon Sep 17 00:00:00 2001 From: Janitha Mahanthe <119646255+janitha-mahanthe@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:45:57 -0400 Subject: [PATCH 085/104] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../reaction_preparation/reaction_processor/warning_asci.py | 1 - 1 file changed, 1 deletion(-) diff --git a/AutoREACTER/reaction_preparation/reaction_processor/warning_asci.py b/AutoREACTER/reaction_preparation/reaction_processor/warning_asci.py index 80c82cf..8bfa6d4 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/warning_asci.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/warning_asci.py @@ -24,5 +24,4 @@ def print_warning() -> None: "Caution: results can be chemically inaccurate." ) ascii_art(message) - time.sleep(5) From 8ae3698273727969027458670969749dac296bd8 Mon Sep 17 00:00:00 2001 From: Janitha Mahanthe <119646255+janitha-mahanthe@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:46:06 -0400 Subject: [PATCH 086/104] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../reaction_processor/prepare_reactions.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py b/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py index f4a754d..9c67725 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py @@ -460,8 +460,10 @@ def _process_reaction_products( ) print(f"Reactant 1 SMILES: {Chem.MolToSmiles(r1)}") print(f"Reactant 2 SMILES: {Chem.MolToSmiles(r2)}") - print("Reaction SMARTS: \n") - continue + print( + "Reaction SMARTS: " + f"{Chem.rdChemReactions.ReactionToSmarts(rxn)}\n" + ) for product_set in products: reactant_combined = Chem.CombineMols(r1, r2) From dc12f88b90c1735549ed8dedb369257e93bff60d Mon Sep 17 00:00:00 2001 From: Janitha Mahanthe <119646255+janitha-mahanthe@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:46:14 -0400 Subject: [PATCH 087/104] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- examples/test_ethelene.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/test_ethelene.json b/examples/test_ethelene.json index 14958a8..622948a 100644 --- a/examples/test_ethelene.json +++ b/examples/test_ethelene.json @@ -1,5 +1,5 @@ { - "simulation_name": "Epoxy_ethene", + "simulation_name": "Ethene_Test", "loop": 9, "simulations": [ { From 188d51a94fea1b0e0313f30f7fc8cac9ffe95072 Mon Sep 17 00:00:00 2001 From: Janitha Mahanthe <119646255+janitha-mahanthe@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:46:23 -0400 Subject: [PATCH 088/104] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../reaction_processor/reaction_progression.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py index 7724718..b2d1d6b 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py @@ -155,7 +155,7 @@ def reaction_progression( f"Overriding default max_loop of {MAX_LOOP} with " f"user-specified max_loop_count of {max_loop}." ) - time.sleep(1) + # Avoid sleeping in library code; callers control pacing. iteration = 0 # Start from the monomer roles already present in the session. From b16c63f98d7f32599536282b8a9309a6fead4125 Mon Sep 17 00:00:00 2001 From: Janitha Mahanthe <119646255+janitha-mahanthe@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:46:33 -0400 Subject: [PATCH 089/104] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tests/test_input_parser.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/test_input_parser.py b/tests/test_input_parser.py index db20f96..18257d6 100644 --- a/tests/test_input_parser.py +++ b/tests/test_input_parser.py @@ -794,16 +794,13 @@ def test_validate_loop_accepts_booleans(self) -> None: (False, None), ) - @patch("AutoREACTER.input_parser.time.sleep") def test_validate_loop_accepts_positive_integer( self, - sleep_mock, ) -> None: self.assertEqual( self.parser._validate_loop({"loop": 4}), (True, 4), ) - sleep_mock.assert_called_once_with(5) def test_validate_loop_accepts_supported_keywords(self) -> None: for keyword in ("loop", "repeat", "iterations", "do_loop"): From 5f7d61218df2818f584255d4ed15b614b2d06e85 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:15:10 -0400 Subject: [PATCH 090/104] Add TFE vinyl polymerization support --- .../vinyl_and_alkene_groups.py | 6 ++ .../detectors/reactions_library/registry.py | 57 +++++++++++++------ .../reactions_library/vinyl_polymers.py | 20 +++++++ examples/test.json | 19 +++++++ 4 files changed, 84 insertions(+), 18 deletions(-) create mode 100644 examples/test.json diff --git a/AutoREACTER/detectors/functional_groups_library/vinyl_and_alkene_groups.py b/AutoREACTER/detectors/functional_groups_library/vinyl_and_alkene_groups.py index 1641eda..569a003 100644 --- a/AutoREACTER/detectors/functional_groups_library/vinyl_and_alkene_groups.py +++ b/AutoREACTER/detectors/functional_groups_library/vinyl_and_alkene_groups.py @@ -17,4 +17,10 @@ # '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/reactions_library/registry.py b/AutoREACTER/detectors/reactions_library/registry.py index 30eb35b..65b3511 100644 --- a/AutoREACTER/detectors/reactions_library/registry.py +++ b/AutoREACTER/detectors/reactions_library/registry.py @@ -1,23 +1,36 @@ """Aggregate and validate all polymer-family reaction modules.""" +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 -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, @@ -62,3 +75,11 @@ class ReactionLibrary: def __init__(self): self.reactions = load_reactions() + + +if __name__ == "__main__": + REACTIONS = load_reactions() + num = 0 + for reaction_name, reaction in REACTIONS.items(): + num += 1 + print(f"{num:3}. AutoREACTER can support the reaction: {reaction_name}") \ No newline at end of file diff --git a/AutoREACTER/detectors/reactions_library/vinyl_polymers.py b/AutoREACTER/detectors/reactions_library/vinyl_polymers.py index bca2d62..faec21f 100644 --- a/AutoREACTER/detectors/reactions_library/vinyl_polymers.py +++ b/AutoREACTER/detectors/reactions_library/vinyl_polymers.py @@ -76,4 +76,24 @@ # '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/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" + } + ] +} From 71b2a3b70cf64e43acf3397c27b76531cfc8df07 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:36:02 -0400 Subject: [PATCH 091/104] Bump to v0.3 and update reaction workflows --- AutoREACTER/__init__.py | 2 +- .../detectors/reactions_library/registry.py | 11 +- .../deduplication_detector.py | 2 +- .../reaction_progression.py | 1 - .../writers/rxn_first_stage_writer.py | 24 ++- docs/source/supported-reactions.md | 184 ++++++++++++++---- 6 files changed, 174 insertions(+), 50 deletions(-) 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/detectors/reactions_library/registry.py b/AutoREACTER/detectors/reactions_library/registry.py index 65b3511..48a7097 100644 --- a/AutoREACTER/detectors/reactions_library/registry.py +++ b/AutoREACTER/detectors/reactions_library/registry.py @@ -80,6 +80,11 @@ def __init__(self): if __name__ == "__main__": REACTIONS = load_reactions() num = 0 - for reaction_name, reaction in REACTIONS.items(): - num += 1 - print(f"{num:3}. AutoREACTER can support the reaction: {reaction_name}") \ No newline at end of file + + 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}, num reactions: {reaction_len}") \ No newline at end of file diff --git a/AutoREACTER/reaction_preparation/deduplication_detector.py b/AutoREACTER/reaction_preparation/deduplication_detector.py index 70c35c5..e37ec32 100644 --- a/AutoREACTER/reaction_preparation/deduplication_detector.py +++ b/AutoREACTER/reaction_preparation/deduplication_detector.py @@ -8,7 +8,6 @@ """ from __future__ import annotations - from pathlib import Path from typing import TYPE_CHECKING @@ -1016,6 +1015,7 @@ def _is_radical_atom(atom: Chem.Atom) -> bool: # A normal carbon with an implicit hydrogen can have only three visible # graph bonds. Do not classify it as a radical. try: + atom.GetOwningMol().UpdatePropertyCache(strict=False) if atom.GetNumImplicitHs() > 0: return False except RuntimeError: diff --git a/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py index b2d1d6b..7d04787 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py @@ -10,7 +10,6 @@ """ from dataclasses import dataclass, field -import time from typing import TYPE_CHECKING from rdkit import Chem 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/supported-reactions.md b/docs/source/supported-reactions.md index bc2ff2f..5d10cd3 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 and Hydroxy Carboxylic 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 From 014e3e0cd827ee54f1dbb33a97a81d947d754095 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:31:27 -0400 Subject: [PATCH 092/104] Add LAMMPS template deduplication step --- .../deduplication_detector.py | 77 +++++++++++++++++-- .../ff_wrapper/REACTER_files_builder.py | 12 ++- 2 files changed, 82 insertions(+), 7 deletions(-) diff --git a/AutoREACTER/reaction_preparation/deduplication_detector.py b/AutoREACTER/reaction_preparation/deduplication_detector.py index e37ec32..644a6ad 100644 --- a/AutoREACTER/reaction_preparation/deduplication_detector.py +++ b/AutoREACTER/reaction_preparation/deduplication_detector.py @@ -18,6 +18,9 @@ from AutoREACTER.reaction_preparation.reaction_processor.prepare_reactions import ( ReactionMetadata, ) + from AutoREACTER.reaction_preparation.ff_wrapper.REACTER_files_builder import ( + TemplateFile, + ) class DeduplicationDetector: @@ -636,14 +639,78 @@ def lammps_molecule_to_networkx( file_path=file_path, ) - print( - f"Graph created from {file_path.name}: " - f"{graph.number_of_nodes()} atoms and " - f"{graph.number_of_edges()} bonds." - ) + # print( + # f"Graph created from {file_path.name}: " + # f"{graph.number_of_nodes()} atoms and " + # f"{graph.number_of_edges()} bonds." + # ) 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. + """ + # Clear the cache for a fresh deduplication pass + self.clear_cache(self.LAMMPS_COMPARISON_GROUP) + + unique_templates: list["TemplateFile"] = [] + + for template in template_files: + # Safely check if the template has both required 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 + + # Convert to graphs + pre_graph = self.lammps_molecule_to_networkx(pre_file_path) + post_graph = self.lammps_molecule_to_networkx(post_file_path) + + # Check if this pair has been seen before + duplicate = self.is_duplicate_pair( + pre_graph=pre_graph, + post_graph=post_graph, + comparison_group=self.LAMMPS_COMPARISON_GROUP, + ) + + # status = "Duplicate" if duplicate else "Unique" + # print( + # f"{status} template ID {template.reaction_id}: " + # f"{pre_file_path.name} -> {post_file_path.name}" + # ) + + if not duplicate: + unique_templates.append(template) + + return unique_templates + # ------------------------------------------------------------------ # Reaction-index selection # ------------------------------------------------------------------ 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 From 8cce4dd55c3b406389b8ce9fd54b65746e3e953d Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:08:42 -0400 Subject: [PATCH 093/104] Add s_m sulfone params to PCFF force field --- .../ff_wrapper/FF_files/pcff.frc | 112 +++++++++++++++++- 1 file changed, 107 insertions(+), 5 deletions(-) diff --git a/AutoREACTER/reaction_preparation/ff_wrapper/FF_files/pcff.frc b/AutoREACTER/reaction_preparation/ff_wrapper/FF_files/pcff.frc index 07ecac8..50875e2 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,12 +1816,15 @@ 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 @@ -2133,7 +2175,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 +2205,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 +2244,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 +2452,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 +2490,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 +2767,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 +2794,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 +2817,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 +2829,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 @@ -2918,13 +2985,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 +3042,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 +3259,24 @@ 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 + + #wilson_out_of_plane cff91 > E = K * (Chi - Chi0)^2 @@ -3374,6 +3475,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 From a5f5f80a6e0f52c969e0c18614cc1d9ee5fcb518 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:51:17 -0400 Subject: [PATCH 094/104] Improve reaction/merge handling and PCFF params --- AutoREACTER/arx_cli.py | 8 +++- AutoREACTER/detectors/reaction_detector.py | 12 +++++- .../detectors/reactions_library/polyureas.py | 2 +- .../ff_wrapper/FF_files/pcff.frc | 41 ++++++++++++++++++- .../ff_wrapper/lunar_client/lunar_executor.py | 4 +- .../ff_wrapper/lunar_client/merge_builder.py | 15 ++++--- 6 files changed, 70 insertions(+), 12 deletions(-) diff --git a/AutoREACTER/arx_cli.py b/AutoREACTER/arx_cli.py index c2683c4..94cb625 100644 --- a/AutoREACTER/arx_cli.py +++ b/AutoREACTER/arx_cli.py @@ -30,6 +30,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: """ @@ -384,7 +387,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/reaction_detector.py b/AutoREACTER/detectors/reaction_detector.py index 1bfe070..6b7ad8c 100644 --- a/AutoREACTER/detectors/reaction_detector.py +++ b/AutoREACTER/detectors/reaction_detector.py @@ -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,16 @@ 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( diff --git a/AutoREACTER/detectors/reactions_library/polyureas.py b/AutoREACTER/detectors/reactions_library/polyureas.py index 5b631cd..854b672 100644 --- a/AutoREACTER/detectors/reactions_library/polyureas.py +++ b/AutoREACTER/detectors/reactions_library/polyureas.py @@ -5,7 +5,7 @@ 'reactant_2': 'di_isocyanate', 'product': 'polyurea_chain', 'delete_atom': False, - 'reaction': '[NX3;H2,H1;!$([N][C,S]=*):1]-[H:3].[NX2:4]=[CX2:2]=[OX1:5]>>[NX3:1]-[CX3:2](=[OX1:5])-[NX3:4]-[H:3]', + '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 diff --git a/AutoREACTER/reaction_preparation/ff_wrapper/FF_files/pcff.frc b/AutoREACTER/reaction_preparation/ff_wrapper/FF_files/pcff.frc index 50875e2..3ac98dd 100644 --- a/AutoREACTER/reaction_preparation/ff_wrapper/FF_files/pcff.frc +++ b/AutoREACTER/reaction_preparation/ff_wrapper/FF_files/pcff.frc @@ -1826,6 +1826,7 @@ 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 @@ -3275,7 +3276,45 @@ 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 #wilson_out_of_plane cff91 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/lunar_client/merge_builder.py b/AutoREACTER/reaction_preparation/ff_wrapper/lunar_client/merge_builder.py index b398f8d..ad430e1 100644 --- a/AutoREACTER/reaction_preparation/ff_wrapper/lunar_client/merge_builder.py +++ b/AutoREACTER/reaction_preparation/ff_wrapper/lunar_client/merge_builder.py @@ -81,8 +81,9 @@ def write_bond_react_merge_input( reaction_pairs[rid]["post"] = r # Write pre/post pairs in order - rxn_counter = 1 + for rid in sorted(reaction_pairs): + pair = reaction_pairs[rid] pre = pair.get("pre") @@ -93,11 +94,13 @@ def write_bond_react_merge_input( pre_path = normalize_path(Path(cache_all2lmp) / pre.all2lmp_data_file) post_path = normalize_path(Path(cache_all2lmp) / post.all2lmp_data_file) - - merge_files += f"{f'pre{rxn_counter}':<10}{pre_path:<150}# for rxn{rxn_counter}\n" - merge_files += f"{f'post{rxn_counter}':<10}{post_path:<150}# for rxn{rxn_counter}\n" - - rxn_counter += 1 + data_counter += 1 + tag = f"data{data_counter}" + merge_files += f"{tag:<10}{pre_path:<150}{comment}\n" + data_counter += 1 + tag = f"data{data_counter}" + merge_files += f"{tag:<10}{post_path:<150}{comment}\n" + data_counter += 1 merge_files += f"\n# Specify the parent_directory of where to write results (optional)\n" From 90f4a5db1b1f4024de08499de9e15b13bc50f347 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:15:35 -0400 Subject: [PATCH 095/104] Add changelog entry for v0.3 --- docs/source/change_log.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) 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 From 5a330b89838147aebcdf7e0cb7143de228f6f574 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:27:16 -0400 Subject: [PATCH 096/104] Use full atom count (H included) for num_atoms --- .../sim_setup/system_property_calculations.py | 23 +++++++++++++++---- tests/test_input_parser.py | 4 ++-- 2 files changed, 20 insertions(+), 7 deletions(-) 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/tests/test_input_parser.py b/tests/test_input_parser.py index 18257d6..3c85c00 100644 --- a/tests/test_input_parser.py +++ b/tests/test_input_parser.py @@ -866,12 +866,12 @@ def test_validate_inputs_stores_integer_loop_limit( sleep_mock, ) -> None: inputs = self.counts_input() - inputs["loop"] = 3 + 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, 3) + 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: From 7d37cffdaf06b201d2aaad81580003351d8bae21 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:18:08 -0400 Subject: [PATCH 097/104] Add input snapshot and polymerization updates --- AutoREACTER/arx_cli.py | 8 +- .../reactions_library/vinyl_polymers.py | 68 ++++++++------- AutoREACTER/input_parser.py | 4 +- .../deduplication_detector.py | 83 +++++++++++++++++-- .../ff_wrapper/FF_files/pcff.frc | 2 + 5 files changed, 129 insertions(+), 36 deletions(-) diff --git a/AutoREACTER/arx_cli.py b/AutoREACTER/arx_cli.py index 94cb625..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 @@ -101,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), @@ -317,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): """ diff --git a/AutoREACTER/detectors/reactions_library/vinyl_polymers.py b/AutoREACTER/detectors/reactions_library/vinyl_polymers.py index faec21f..e7f2c27 100644 --- a/AutoREACTER/detectors/reactions_library/vinyl_polymers.py +++ b/AutoREACTER/detectors/reactions_library/vinyl_polymers.py @@ -1,23 +1,35 @@ 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 - }, + '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', @@ -36,16 +48,16 @@ # '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 - }, + # '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, diff --git a/AutoREACTER/input_parser.py b/AutoREACTER/input_parser.py index 8bfedb2..0e159d0 100644 --- a/AutoREACTER/input_parser.py +++ b/AutoREACTER/input_parser.py @@ -152,6 +152,7 @@ class SimulationSetup: 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 @@ -219,7 +220,8 @@ def validate_inputs(self, inputs: dict) -> SimulationSetup: composition=validated_simulations, force_field=force_field, loop=loop, - max_loop_count=max_loop_count + max_loop_count=max_loop_count, + input_json=inputs, ) def molecule_representation_of_initial_molecules( diff --git a/AutoREACTER/reaction_preparation/deduplication_detector.py b/AutoREACTER/reaction_preparation/deduplication_detector.py index 644a6ad..234341d 100644 --- a/AutoREACTER/reaction_preparation/deduplication_detector.py +++ b/AutoREACTER/reaction_preparation/deduplication_detector.py @@ -25,7 +25,7 @@ class DeduplicationDetector: """Detect duplicate pre/post-reaction graph pairs.""" - + DEEP_CHECK = False NODE_ATTRIBUTE = "atom_label" EDGE_ATTRIBUTE = "bond_label" @@ -459,6 +459,68 @@ def compare_graphs_mol( 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. + + Each external-neighbor signature contains: + - neighbor element + - neighbor formal charge + - neighbor aromatic state + - neighbor hybridization + - neighbor total hydrogen count + - neighbor radical state + - bond type connecting edge atom to neighbor + """ + 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, @@ -553,11 +615,20 @@ def rdkit_mol_to_networkx( ) is_radical = self._is_radical_atom(atom) - - atom_label = ( - atom.GetSymbol(), - is_radical, - ) + if not 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, diff --git a/AutoREACTER/reaction_preparation/ff_wrapper/FF_files/pcff.frc b/AutoREACTER/reaction_preparation/ff_wrapper/FF_files/pcff.frc index 3ac98dd..dcf5e25 100644 --- a/AutoREACTER/reaction_preparation/ff_wrapper/FF_files/pcff.frc +++ b/AutoREACTER/reaction_preparation/ff_wrapper/FF_files/pcff.frc @@ -2917,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 @@ -3315,6 +3316,7 @@ 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 From 5e2a15354990a72baced022320642a75d7c127fd Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:06:43 -0400 Subject: [PATCH 098/104] Disable transesterification and tweak equilibration --- .../detectors/reactions_library/polyesters.py | 28 +++++++++---------- .../deduplication_detector.py | 7 ++++- .../sim_setup/writers/pre_eq_writer.py | 20 ++++++------- 3 files changed, 30 insertions(+), 25 deletions(-) diff --git a/AutoREACTER/detectors/reactions_library/polyesters.py b/AutoREACTER/detectors/reactions_library/polyesters.py index c29deb8..2c77c63 100644 --- a/AutoREACTER/detectors/reactions_library/polyesters.py +++ b/AutoREACTER/detectors/reactions_library/polyesters.py @@ -93,20 +93,20 @@ }, '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 - }, + # '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': # { diff --git a/AutoREACTER/reaction_preparation/deduplication_detector.py b/AutoREACTER/reaction_preparation/deduplication_detector.py index 234341d..2b5fd68 100644 --- a/AutoREACTER/reaction_preparation/deduplication_detector.py +++ b/AutoREACTER/reaction_preparation/deduplication_detector.py @@ -615,11 +615,16 @@ def rdkit_mol_to_networkx( ) is_radical = self._is_radical_atom(atom) - if not DEEP_CHECK: + + # Determine the atom label based on the DEEP_CHECK setting + # DEEP_CHECK should be able to change from input script + if not self.DEEP_CHECK: atom_label = ( atom.GetSymbol(), is_radical, ) + + # If DEEP_CHECK is enabled, include the one-neighbor edge environment signature in the atom label. else: atom_label = ( atom.GetSymbol(), 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", From 7a310fe496140168484db29c4ee4b7ab2c03c68d Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:28:18 -0400 Subject: [PATCH 099/104] Validate reaction initiator atom maps Add registry-level validation for reaction SMARTS to ensure the reserved AutoREACTER initiator atom maps are present and bonded in products. Update polyanhydride and polysiloxane templates to use the reserved maps consistently and replace legacy comments with notes. --- .../reactions_library/polyanhydrides.py | 9 +- .../reactions_library/polysiloxanes.py | 8 +- .../detectors/reactions_library/registry.py | 174 +++++++++++++++++- 3 files changed, 181 insertions(+), 10 deletions(-) diff --git a/AutoREACTER/detectors/reactions_library/polyanhydrides.py b/AutoREACTER/detectors/reactions_library/polyanhydrides.py index 9bb3346..cf8672c 100644 --- a/AutoREACTER/detectors/reactions_library/polyanhydrides.py +++ b/AutoREACTER/detectors/reactions_library/polyanhydrides.py @@ -4,24 +4,25 @@ '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])[OX2:6]-[H:7]>>[CX3:1](=[O:3])-[OX2:6]-[CX3:2](=[O:5]).[Cl,Br,I:4]-[H:7]', + '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 }, - 'comments': 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:2](=[O:5])[OX2:6]-[H:7]>>[CX3:1](=[O:3])-[OX2:6]-[CX3:2](=[O:5]).[Cl,Br,I:4]-[H:7]', + '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 }, - 'comments': 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/polysiloxanes.py b/AutoREACTER/detectors/reactions_library/polysiloxanes.py index f7f3e6a..83441ff 100644 --- a/AutoREACTER/detectors/reactions_library/polysiloxanes.py +++ b/AutoREACTER/detectors/reactions_library/polysiloxanes.py @@ -5,12 +5,12 @@ 'reactant_2': 'water', 'product': 'silanediol', 'delete_atom': True, - 'reaction': '[Si:1]-[Cl:2].[OX2H2:3](-[H:4])-[H:5]>>[Si:1]-[OX2:3]-[H:4].[Cl:2]-[H:5]', + '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 }, - 'comments': None + 'notes': 'Atom maps 1 and 2 are reserved as AutoREACTER/LAMMPS bond/react initiator atoms.' }, 'Silanediol Polycondensation(Polysiloxane Formation)': { 'same_reactants': True, @@ -22,7 +22,7 @@ 'smarts': None, 'reaction_and_mechanism': None }, - 'comments': None + 'notes': None }, 'Silanediol and Silanediol Copolycondensation(Polysiloxane Formation)': { 'same_reactants': False, @@ -35,6 +35,6 @@ 'smarts': None, 'reaction_and_mechanism': None }, - 'comments': None + 'notes': None } } \ No newline at end of file diff --git a/AutoREACTER/detectors/reactions_library/registry.py b/AutoREACTER/detectors/reactions_library/registry.py index 48a7097..957eead 100644 --- a/AutoREACTER/detectors/reactions_library/registry.py +++ b/AutoREACTER/detectors/reactions_library/registry.py @@ -1,4 +1,13 @@ """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 @@ -32,6 +41,7 @@ from polysiloxanes import REACTIONS as POLYSILOXANES from thiol_ene_polymers import REACTIONS as THIOL_ENE_POLYMERS + _REACTION_MODULES = [ POLYESTERS, # POLYETHERS, @@ -54,6 +64,158 @@ ] +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 = {} @@ -64,6 +226,8 @@ def load_reactions() -> dict: raise ValueError(f"Duplicate reaction name: {reaction_name}") merged[reaction_name] = reaction + validate_reactions(merged) + return merged @@ -80,11 +244,17 @@ def __init__(self): 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}, num reactions: {reaction_len}") \ No newline at end of file + print( + f"reactions.txt has been written to {file_abs_path}, " + f"num reactions: {reaction_len}" + ) \ No newline at end of file From 18c338689c673d760e6fe1496fac76bf734919a8 Mon Sep 17 00:00:00 2001 From: janitha1996 <119646255+janitha-mahanthe@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:30:48 -0400 Subject: [PATCH 100/104] Add LAMMPS map-based template deduplication --- .../deduplication_detector.py | 397 ++++++++++++------ .../ff_wrapper/lunar_client/merge_builder.py | 15 +- .../reaction_processor/prepare_reactions.py | 33 +- .../reaction_progression.py | 16 + 4 files changed, 324 insertions(+), 137 deletions(-) diff --git a/AutoREACTER/reaction_preparation/deduplication_detector.py b/AutoREACTER/reaction_preparation/deduplication_detector.py index 2b5fd68..a01d2c3 100644 --- a/AutoREACTER/reaction_preparation/deduplication_detector.py +++ b/AutoREACTER/reaction_preparation/deduplication_detector.py @@ -8,6 +8,8 @@ """ from __future__ import annotations + +import re from pathlib import Path from typing import TYPE_CHECKING @@ -25,7 +27,9 @@ class DeduplicationDetector: """Detect duplicate pre/post-reaction graph pairs.""" - DEEP_CHECK = False + + DEEP_CHECK = True + NODE_ATTRIBUTE = "atom_label" EDGE_ATTRIBUTE = "bond_label" @@ -92,12 +96,31 @@ def is_duplicate( 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], @@ -114,9 +137,6 @@ def is_duplicate( ) for seen_graph in seen_graphs: - # The restricted template graph may not include every radical - # center in the complete molecule. Compare the full-molecule - # pre/post radical signature before checking graph isomorphism. if ( coupled_graph.graph.get( self.RADICAL_SIGNATURE_ATTRIBUTE @@ -150,6 +170,241 @@ def is_duplicate( 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, @@ -266,17 +521,9 @@ def compare_graphs( ) continue - pre_graph = self.lammps_molecule_to_networkx( - pre_file_path - ) - - post_graph = self.lammps_molecule_to_networkx( - post_file_path - ) - - duplicate = self.is_duplicate_pair( - pre_graph=pre_graph, - post_graph=post_graph, + 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, ) @@ -312,28 +559,7 @@ def compare_graphs_mol( 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. - - Args: - reaction_metadata_items: - Accumulated reaction metadata pool. - - index_source: - Determines which atom indexes restrict the graph comparison. - - ``template``: - Use ``template_reactant_to_product_mapping``. - - ``first_shell``: - Use ``first_shell`` reactant indexes mapped through - ``reactant_to_product_mapping``. - - Returns: - A new list containing one active representative of each unique - reaction. """ - # Each call is a complete deduplication pass over the current pool. - # Cached graphs from an earlier progression iteration must not be - # reused, or retained reactions will match their own old cache entry. self.clear_cache(self.RDKIT_COMPARISON_GROUP) unique_reactions: list["ReactionMetadata"] = [] @@ -343,15 +569,11 @@ def compare_graphs_mol( reaction_metadata_items, start=1, ): - # Inactive reactions do not participate in the active pool. if not reaction_metadata.activity_stats: continue reaction_object_id = id(reaction_metadata) - # The accumulated progression pool can contain the exact same - # metadata object more than once. Do not mark that object inactive; - # simply keep its first occurrence. if reaction_object_id in retained_object_ids: continue @@ -383,9 +605,6 @@ def compare_graphs_mol( reactant_to_product_mapping.values() ) - # Relabel the product graph into reactant-index space so the - # coupled graph preserves atom correspondence across the - # pre- and post-reaction states. product_to_reactant_mapping = { product_idx: reactant_idx for reactant_idx, product_idx @@ -411,11 +630,6 @@ def compare_graphs_mol( idx_relabel=product_to_reactant_mapping, ) - # Keep full-molecule radical information in addition to the - # atom-level radical labels inside the restricted template graph. - # Progression may explicitly mark the product as radical even when - # the original unsanitized RDKit product does not retain the - # radical-electron property cleanly. reactant_radical_count = self._count_radical_atoms( reactant_mol ) @@ -451,12 +665,6 @@ def compare_graphs_mol( retained_object_ids.add(reaction_object_id) unique_reactions.append(reaction_metadata) - # Debugging: print unique reaction retention information. - # print( - # f"Reaction {reaction_index}: " - # "unique reaction retained." - # ) - return unique_reactions @classmethod @@ -472,15 +680,6 @@ def _one_neighbor_edge_environment_signature( 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. - - Each external-neighbor signature contains: - - neighbor element - - neighbor formal charge - - neighbor aromatic state - - neighbor hybridization - - neighbor total hydrogen count - - neighbor radical state - - bond type connecting edge atom to neighbor """ external_neighbor_signatures = [] @@ -520,7 +719,7 @@ def _safe_total_hydrogen_count(atom: Chem.Atom) -> int: for neighbor in atom.GetNeighbors() ) return explicit_h_neighbors + atom.GetNumExplicitHs() - + def clear_cache( self, comparison_group: str | None = None, @@ -566,16 +765,7 @@ def rdkit_mol_to_networkx( Convert an RDKit molecule into a NetworkX graph. Coordinates are not read or stored. - - Node attributes: - ``atom_label`` contains a tuple of: - - chemical element symbol - - whether the atom is a radical center - - Edge attributes: - ``bond_label`` contains the RDKit bond type. """ - if molecule is None: raise ValueError( "Cannot create a graph from a None RDKit molecule." @@ -616,15 +806,11 @@ def rdkit_mol_to_networkx( is_radical = self._is_radical_atom(atom) - # Determine the atom label based on the DEEP_CHECK setting - # DEEP_CHECK should be able to change from input script if not self.DEEP_CHECK: atom_label = ( atom.GetSymbol(), is_radical, ) - - # If DEEP_CHECK is enabled, include the one-neighbor edge environment signature in the atom label. else: atom_label = ( atom.GetSymbol(), @@ -715,12 +901,6 @@ def lammps_molecule_to_networkx( file_path=file_path, ) - # print( - # f"Graph created from {file_path.name}: " - # f"{graph.number_of_nodes()} atoms and " - # f"{graph.number_of_edges()} bonds." - # ) - return graph def compare_lammps_templates( @@ -734,14 +914,15 @@ def compare_lammps_templates( for their pre and post reaction files, detecting duplicates, and returning a list containing only unique templates. """ - # Clear the cache for a fresh deduplication pass self.clear_cache(self.LAMMPS_COMPARISON_GROUP) unique_templates: list["TemplateFile"] = [] for template in template_files: - # Safely check if the template has both required files - if template.pre_reaction_file is None or template.post_reaction_file is None: + 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." @@ -765,23 +946,12 @@ def compare_lammps_templates( ) continue - # Convert to graphs - pre_graph = self.lammps_molecule_to_networkx(pre_file_path) - post_graph = self.lammps_molecule_to_networkx(post_file_path) - - # Check if this pair has been seen before - duplicate = self.is_duplicate_pair( - pre_graph=pre_graph, - post_graph=post_graph, + 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, ) - # status = "Duplicate" if duplicate else "Unique" - # print( - # f"{status} template ID {template.reaction_id}: " - # f"{pre_file_path.name} -> {post_file_path.name}" - # ) - if not duplicate: unique_templates.append(template) @@ -852,8 +1022,9 @@ def _couple_graphs( """ 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) @@ -910,9 +1081,7 @@ def _couple_graphs( coupled_graph.add_edge( (self._PRE_PHASE, atom_id), (self._POST_PHASE, atom_id), - relationship=( - self._ATOM_CORRESPONDENCE_RELATIONSHIP - ), + relationship=self._ATOM_CORRESPONDENCE_RELATIONSHIP, **{ self.EDGE_ATTRIBUTE: None, }, @@ -1115,7 +1284,7 @@ def _validate_bond_atoms( f"Bond {bond_id} references undefined atom IDs " f"{undefined_atoms} in {source}." ) - + @classmethod def _count_radical_atoms( cls, @@ -1139,13 +1308,9 @@ def _is_radical_atom(atom: Chem.Atom) -> bool: The fallback is intentionally limited to neutral, non-aromatic carbon atoms used by the current vinyl-radical implementation. """ - - # Best source when progression or RDKit has explicitly assigned the - # radical electron. if atom.GetNumRadicalElectrons() > 0: return True - # Current vinyl implementation only needs neutral carbon radicals. if atom.GetAtomicNum() != 6: return False @@ -1155,21 +1320,13 @@ def _is_radical_atom(atom: Chem.Atom) -> bool: if atom.GetIsAromatic(): return False - # A normal carbon with an implicit hydrogen can have only three visible - # graph bonds. Do not classify it as a radical. try: atom.GetOwningMol().UpdatePropertyCache(strict=False) if atom.GetNumImplicitHs() > 0: return False except RuntimeError: - # Unsanitized reaction products may not have a complete property - # cache. Continue with the graph-based valence calculation. pass - # Count actual graph bonds, including explicit hydrogen atoms. - # - # Do not blindly add GetNumExplicitHs(): RunReactants may retain both - # real hydrogen atoms and a duplicate product-SMARTS hydrogen count. graph_bond_valence = sum( bond.GetBondTypeAsDouble() for bond in atom.GetBonds() @@ -1180,17 +1337,11 @@ def _is_radical_atom(atom: Chem.Atom) -> bool: for neighbor in atom.GetNeighbors() ) - # When no real hydrogen atoms are attached, the explicit-H property is - # part of the atom's valence description and must be included. When - # real H atoms are already present, ignore the property to avoid - # double-counting the same hydrogens. effective_valence = graph_bond_valence if explicit_hydrogen_neighbors == 0: effective_valence += atom.GetNumExplicitHs() - # Neutral carbon with effective valence three and no available implicit - # hydrogen is the carbon-centered radical used by vinyl progression. return abs(effective_valence - 3.0) < 1.0e-6 diff --git a/AutoREACTER/reaction_preparation/ff_wrapper/lunar_client/merge_builder.py b/AutoREACTER/reaction_preparation/ff_wrapper/lunar_client/merge_builder.py index ad430e1..b398f8d 100644 --- a/AutoREACTER/reaction_preparation/ff_wrapper/lunar_client/merge_builder.py +++ b/AutoREACTER/reaction_preparation/ff_wrapper/lunar_client/merge_builder.py @@ -81,9 +81,8 @@ def write_bond_react_merge_input( reaction_pairs[rid]["post"] = r # Write pre/post pairs in order - + rxn_counter = 1 for rid in sorted(reaction_pairs): - pair = reaction_pairs[rid] pre = pair.get("pre") @@ -94,13 +93,11 @@ def write_bond_react_merge_input( pre_path = normalize_path(Path(cache_all2lmp) / pre.all2lmp_data_file) post_path = normalize_path(Path(cache_all2lmp) / post.all2lmp_data_file) - data_counter += 1 - tag = f"data{data_counter}" - merge_files += f"{tag:<10}{pre_path:<150}{comment}\n" - data_counter += 1 - tag = f"data{data_counter}" - merge_files += f"{tag:<10}{post_path:<150}{comment}\n" - data_counter += 1 + + merge_files += f"{f'pre{rxn_counter}':<10}{pre_path:<150}# for rxn{rxn_counter}\n" + merge_files += f"{f'post{rxn_counter}':<10}{post_path:<150}# for rxn{rxn_counter}\n" + + rxn_counter += 1 merge_files += f"\n# Specify the parent_directory of where to write results (optional)\n" diff --git a/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py b/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py index 9c67725..0b140e5 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py @@ -356,16 +356,16 @@ def _process_reaction_instances( reaction.functional_group_2 ) - mol_reactant_1 = Chem.AddHs( - Chem.Mol(reaction.monomer_1.rdkit_mol) + 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 = Chem.AddHs( - Chem.Mol(monomer_2.rdkit_mol) + mol_reactant_2 = self._copy_loop_reactant_mol( + monomer_2 ) # The ReactionInstance already defines reactant-slot order. @@ -400,6 +400,29 @@ def _process_reaction_instances( return reaction_metadata + def _copy_loop_reactant_mol(self, monomer_role) -> Chem.Mol: + """Copy a loop-mode reactant without changing generated products. + + 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. + """ + 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, @@ -1212,6 +1235,6 @@ def reaction_templates_highlighted_image_grid( molsPerRow=2, highlightAtomLists=highlight_lists, highlightAtomColors=highlight_colors, - subImgSize=(400, 400), + subImgSize=(1000, 1000), useSVG=False, ) diff --git a/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py index 7d04787..c6f0882 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py @@ -313,6 +313,9 @@ def _prepare_products_for_idx_based_fg_detection( 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, @@ -323,6 +326,19 @@ def _prepare_products_for_idx_based_fg_detection( # 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, From f1ca90a4a94d35f26cb06dbdf3e51ef3f99fae97 Mon Sep 17 00:00:00 2001 From: Janitha Mahanthe <119646255+janitha-mahanthe@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:31:45 -0400 Subject: [PATCH 101/104] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- AutoREACTER/detectors/functional_groups_detector.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AutoREACTER/detectors/functional_groups_detector.py b/AutoREACTER/detectors/functional_groups_detector.py index c17f09e..bff0448 100644 --- a/AutoREACTER/detectors/functional_groups_detector.py +++ b/AutoREACTER/detectors/functional_groups_detector.py @@ -140,10 +140,10 @@ class FunctionalGroupInfo: fg_name: str fg_smarts_1: str fg_count_1: int - fg_1_indexes: Optional[Tuple[int, ...]] = None + 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[int, ...]] = None + fg_2_indexes: Optional[Tuple[Tuple[int, ...], ...]] = None @dataclass(slots=True) From bb12d911660976b425bc6c0bcb7f815c9a9f85f9 Mon Sep 17 00:00:00 2001 From: Janitha Mahanthe <119646255+janitha-mahanthe@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:31:53 -0400 Subject: [PATCH 102/104] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- AutoREACTER/input_parser.py | 1 - 1 file changed, 1 deletion(-) diff --git a/AutoREACTER/input_parser.py b/AutoREACTER/input_parser.py index 0e159d0..7ae674b 100644 --- a/AutoREACTER/input_parser.py +++ b/AutoREACTER/input_parser.py @@ -2,7 +2,6 @@ import logging from dataclasses import dataclass from pathlib import Path -import time from typing import Any, Literal, Optional From 73ea96af8a6251b1d37d1db5a208107595e13021 Mon Sep 17 00:00:00 2001 From: Janitha Mahanthe <119646255+janitha-mahanthe@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:32:03 -0400 Subject: [PATCH 103/104] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../reaction_preparation/reaction_processor/warning_asci.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/AutoREACTER/reaction_preparation/reaction_processor/warning_asci.py b/AutoREACTER/reaction_preparation/reaction_processor/warning_asci.py index 8bfa6d4..a1fbf33 100644 --- a/AutoREACTER/reaction_preparation/reaction_processor/warning_asci.py +++ b/AutoREACTER/reaction_preparation/reaction_processor/warning_asci.py @@ -1,6 +1,5 @@ -import time - +# (unused import removed) def ascii_art(message: str) -> None: message = message.upper() From 24837c0c8ef7cb65e3246ba2c5224810d08d90e3 Mon Sep 17 00:00:00 2001 From: Janitha Mahanthe <119646255+janitha-mahanthe@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:32:20 -0400 Subject: [PATCH 104/104] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/source/supported-reactions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/supported-reactions.md b/docs/source/supported-reactions.md index 5d10cd3..3971263 100644 --- a/docs/source/supported-reactions.md +++ b/docs/source/supported-reactions.md @@ -18,7 +18,7 @@ These reactions form ester linkages (`-COO-`) and typically release water (`H₂ * *Reactants:* `-OH` + `-COOH` -* **Hydroxy Carboxylic and Hydroxy Carboxylic Polycondensation** +* **Hydroxy Carboxylic Acid and Hydroxy Carboxylic Acid Polycondensation** * *Reactants:* `-OH` + `-COOH` (Intermolecular)