diff --git a/AutoREACTER/__init__.py b/AutoREACTER/__init__.py
index 7c18550a..a8c6fd58 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__ = "1.0.0"
__title__ = "AutoREACTER"
__author__ = "Janitha Mahanthe, Jacob Gissinger"
@@ -304,6 +304,7 @@ def process() -> None:
"__authors__",
"__license__",
"run",
+ "session",
"show_molecules",
"show_functional_groups",
"show_reactions",
@@ -313,4 +314,4 @@ def process() -> None:
"prepare_reactions",
"show_reaction_templates",
"process",
-]
+]
\ No newline at end of file
diff --git a/AutoREACTER/_compat.py b/AutoREACTER/_compat.py
deleted file mode 100644
index 28bf209d..00000000
--- a/AutoREACTER/_compat.py
+++ /dev/null
@@ -1,60 +0,0 @@
-import sys
-import collections
-import collections.abc
-import numpy as np
-
-def apply_legacy_patches():
- """
- Injects removed aliases back into numpy and collections at runtime,
- and bridges the OpenMM simtk namespace for older versions of Foyer/mBuild.
- """
- # 1. Restore removed Collections aliases
- _missing_classes = [
- "MutableSet", "MutableMapping", "Mapping", "MutableSequence",
- "Sequence", "Set", "Iterable", "Iterator", "Callable",
- "Container", "Hashable", "ItemsView", "KeysView", "ValuesView"
- ]
- for _name in _missing_classes:
- if not hasattr(collections, _name) and hasattr(collections.abc, _name):
- setattr(collections, _name, getattr(collections.abc, _name))
-
- # 2. Restore removed NumPy aliases
- if not hasattr(np, "float"): np.float = float
- if not hasattr(np, "int"): np.int = int
- if not hasattr(np, "complex"): np.complex = complex
- if not hasattr(np, "bool"): np.bool = np.bool_
- if not hasattr(np, "object"): np.object = np.object_
- if not hasattr(np, "str"): np.str = np.str_
-
- # 3. Robust OpenMM 'simtk' shim for Python 3.12+
- try:
- import sys
- import types
- import openmm
- import openmm.app
- import openmm.app.element
- import openmm.unit
-
- # Create pure, fake modules to bypass strict filesystem import checks
- simtk = types.ModuleType("simtk")
- simtk_openmm = types.ModuleType("simtk.openmm")
- simtk_openmm_app = types.ModuleType("simtk.openmm.app")
-
- # Copy the contents of the real modules into our fake ones
- simtk_openmm.__dict__.update(openmm.__dict__)
- simtk_openmm_app.__dict__.update(openmm.app.__dict__)
-
- # Manually wire the internal tree together
- simtk.openmm = simtk_openmm
- simtk.unit = openmm.unit
- simtk_openmm.app = simtk_openmm_app
- simtk_openmm_app.element = openmm.app.element
-
- # Register them in sys.modules so the 'import' statements find them instantly
- sys.modules["simtk"] = simtk
- sys.modules["simtk.openmm"] = simtk_openmm
- sys.modules["simtk.openmm.app"] = simtk_openmm_app
- sys.modules["simtk.openmm.app.element"] = openmm.app.element
- sys.modules["simtk.unit"] = openmm.unit
- except ImportError:
- pass
\ No newline at end of file
diff --git a/AutoREACTER/arx_cli.py b/AutoREACTER/arx_cli.py
index c2683c46..569ad171 100644
--- a/AutoREACTER/arx_cli.py
+++ b/AutoREACTER/arx_cli.py
@@ -14,6 +14,7 @@
from contextlib import contextmanager
import os
from pathlib import Path
+import shutil
import sys
import threading
from PIL import Image
@@ -30,6 +31,9 @@
from AutoREACTER.reaction_preparation.ff_wrapper.REACTER_files_builder import REACTERFilesBuilder
from AutoREACTER.sim_setup.simulation_setup import SimulationSetupManager
+class NoReactionGenerated(Exception):
+ """Custom exception raised when no reaction is generated in the pipeline."""
+ pass
class ErrorHandler:
"""
@@ -98,7 +102,8 @@ def __init__(self, input: Path) -> None:
self.img_dir = self.session.images_dir
# with open(self.session.output_dir / "AutoREACTER.log", 'w') as f:
# f.write("--- Starting AutoREACTER Session ---\n")
-
+ # Save a copy of the input JSON to the output directory
+ self._save_input_json(abs_path)
# Save an initial grid image of all monomers
self._save_rdkit_img(
InputParser().initial_molecules_image_grid(self.session),
@@ -241,14 +246,6 @@ def prepare_reactions(self) -> None:
"""
PrepareReactions(self.session).prepare_reactions(self.session)
self.error_handler["process"] = True
- highlight_types = ["template", "edge", "initiators", "delete"]
- for highlight_type in highlight_types:
- img = PrepareReactions(self.session).reaction_templates_highlighted_image_grid(
- self.session, highlight_type=highlight_type
- )
- self._save_rdkit_img(
- img, self.img_dir / f"templates_{highlight_type}.png"
- )
return None
def show_reaction_templates(self, highlight_type: str = "template") -> Image:
@@ -311,9 +308,24 @@ def process(self):
SimulationSetupManager().setup_and_write_simulation(self.session)
+ highlight_types = ["template", "edge", "initiators", "delete"]
+ for highlight_type in highlight_types:
+ img = PrepareReactions(self.session).reaction_templates_highlighted_image_grid(
+ self.session, highlight_type=highlight_type
+ )
+ self._save_rdkit_img(
+ img, self.img_dir / f"templates_{highlight_type}.png"
+ )
+
+ self.error_handler["process"] = True
+
# ------------------------------------------------------------------
# Internal helpers – lazy detection & image saving
# ------------------------------------------------------------------
+ def _save_input_json(self, abs_path: Path):
+ destination_file = "input.json"
+ destination_path = self.session.output_dir / destination_file
+ shutil.copy(abs_path, destination_path)
def _ensure_fg_detected(self):
"""
@@ -384,7 +396,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"):
@@ -430,59 +445,160 @@ def _writer(self, filename="AutoREACTER.log"):
self.session.output_dir.mkdir(parents=True, exist_ok=True)
log_path = self.session.output_dir / filename
- # 1. Save the original OS-level terminal output
+ # Save both Python's stdout object and the original OS-level stdout.
+ #
+ # These are not always the same destination. For example, pytest,
+ # Jupyter, and other environments may replace sys.stdout with their
+ # own wrapper while file descriptor 1 still exists separately.
+ original_stdout = sys.stdout
original_stdout_fd = os.dup(1)
- # 2. Create an OS-level pipe (a temporary tunnel for our data)
+ # Create an OS-level pipe. Both Python print() output and raw fd-1
+ # output will be redirected into this pipe.
pipe_read_fd, pipe_write_fd = os.pipe()
+ redirected_stdout = None
+
+ def write_to_original_stdout(data: bytes) -> None:
+ """
+ Forward captured output back to the original visible stdout.
+
+ If the original Python stdout directly represents fd 1, write to
+ the saved duplicate of fd 1. Otherwise, use the original Python
+ stdout object so capture systems such as pytest/Jupyter still see
+ the output.
+ """
+ try:
+ original_fileno = original_stdout.fileno()
+ except (AttributeError, OSError, ValueError):
+ original_fileno = None
+
+ if original_fileno == 1:
+ os.write(original_stdout_fd, data)
+ return
+
+ text = data.decode(
+ "utf-8",
+ errors="replace",
+ )
+
+ original_stdout.write(text)
+ original_stdout.flush()
+
def tee_thread():
- """Background worker that reads the pipe and writes to both destinations."""
- with open(log_path, 'a') as log_file:
+ """
+ Background worker that reads the pipe and writes to both
+ the original output destination and the log file.
+ """
+ with open(
+ log_path,
+ "a",
+ encoding="utf-8",
+ ) as log_file:
while True:
# Read incoming data from the pipe
- data = os.read(pipe_read_fd, 1024)
-
+ data = os.read(
+ pipe_read_fd,
+ 1024,
+ )
+
# If the pipe is closed, stop the thread
if not data:
break
-
- # Write to the actual terminal
- os.write(original_stdout_fd, data)
-
+
+ # Write to the original terminal / stdout capture
+ write_to_original_stdout(
+ data
+ )
+
# Write to the log file
- log_file.write(data.decode('utf-8', errors='replace'))
+ log_file.write(
+ data.decode(
+ "utf-8",
+ errors="replace",
+ )
+ )
log_file.flush()
- # 3. Start the background thread
- thread = threading.Thread(target=tee_thread)
+ # Start the background tee worker
+ thread = threading.Thread(
+ target=tee_thread
+ )
thread.start()
- # Flush Python's buffers before we switch the tracks
- sys.stdout.flush()
+ # Flush Python's current stdout before changing destinations
+ original_stdout.flush()
try:
- # 4. Redirect all OS-level output to the write-end of our pipe
- os.dup2(pipe_write_fd, 1)
+ # Redirect OS-level stdout (fd 1) into the pipe.
+ #
+ # This captures subprocess output, os.write(1, ...), native
+ # library output, and anything else that writes directly to
+ # standard output.
+ os.dup2(
+ pipe_write_fd,
+ 1,
+ )
+
+ # Redirect Python's sys.stdout explicitly as well.
+ #
+ # This is necessary because environments such as pytest and
+ # Jupyter can replace sys.stdout with an object that does not
+ # automatically follow changes made to file descriptor 1.
+ redirected_stdout = os.fdopen(
+ os.dup(1),
+ "w",
+ buffering=1,
+ encoding=getattr(
+ original_stdout,
+ "encoding",
+ None,
+ )
+ or "utf-8",
+ errors="replace",
+ )
+
+ sys.stdout = redirected_stdout
+
yield
-
+
finally:
- # Flush Python buffers one last time
- sys.stdout.flush()
-
- # 5. Restore the original terminal output
- os.dup2(original_stdout_fd, 1)
-
- # 6. Close the write end of the pipe (this tells the thread to stop)
- os.close(pipe_write_fd)
-
- # 7. Wait for the thread to finish processing the last bits of data
+ # Flush all pending Python output into the pipe
+ if redirected_stdout is not None:
+ redirected_stdout.flush()
+
+ # Restore Python's original stdout object first
+ sys.stdout = original_stdout
+
+ # Close the duplicated Python pipe writer
+ if redirected_stdout is not None:
+ redirected_stdout.close()
+
+ # Restore OS-level stdout
+ os.dup2(
+ original_stdout_fd,
+ 1,
+ )
+
+ # Close the original pipe write descriptor.
+ #
+ # Once every write descriptor pointing at the pipe is closed,
+ # the background thread receives EOF and exits.
+ os.close(
+ pipe_write_fd
+ )
+
+ # Wait until the final buffered output has been copied
thread.join()
-
- # 8. Clean up remaining file descriptors
- os.close(pipe_read_fd)
- os.close(original_stdout_fd)
+ # Clean up remaining file descriptors
+ os.close(
+ pipe_read_fd
+ )
+
+ os.close(
+ original_stdout_fd
+ )
# ------------------------------------------------------------------
# Magic Methods
# ------------------------------------------------------------------
diff --git a/AutoREACTER/cache.py b/AutoREACTER/cache.py
index 2dcdec93..ed1a0b5b 100644
--- a/AutoREACTER/cache.py
+++ b/AutoREACTER/cache.py
@@ -21,7 +21,9 @@ def __init__(self, clear_staging: bool = True):
# generate a unique staging directory for this run to prevent concurrent conflicts
self.staging_dir = Path(tempfile.gettempdir()) / f"AutoREACTER_staging"
self.staging_dir.mkdir(parents=True, exist_ok=True)
- self.clear_staging_dir()
+
+ if clear_staging:
+ self.clear_staging_dir()
def clear_staging_dir(self) -> None:
"""
diff --git a/AutoREACTER/detectors/detector.py b/AutoREACTER/detectors/detector.py
deleted file mode 100644
index 1f101470..00000000
--- a/AutoREACTER/detectors/detector.py
+++ /dev/null
@@ -1,259 +0,0 @@
-"""
-Module for detecting reactions and handling non-reactant monomers.
-
-This module provides functionality to analyze a set of input monomers, detect
-potential chemical reactions based on functional groups, and identify monomers
-that do not participate in any detected reactions. It also includes an interactive
-workflow to allow users to decide whether to retain non-reactant molecules in
-the simulation.
-"""
-
-import warnings
-import os, json
-
-if os.environ.get("AUTOREACTER_SHOW_DEPRECATION", "").lower() in {"1", "true", "yes", "on"}:
- warnings.warn(
- """This script is deprecated and will be modified in future versions. Within v0.2, the whole package will primaraliy
- support on jupyter notebook and the CLI is removed. Please use the notebook version for now and refer to the README for how to use the package.""",
- DeprecationWarning,
- stacklevel=2
- )
-
-# Attempt to import detector modules. Handles different import paths depending on
-# whether the script is run as a module, part of a package, or standalone.
-try:
- from functional_groups_detector import FunctionalGroupsDetector
- from reaction_detector import ReactionDetector
-except (ImportError, ModuleNotFoundError):
- from .functional_groups_detector import FunctionalGroupsDetector
- from .reaction_detector import ReactionDetector
-
-from AutoREACTER.input_parser import MonomerEntry
-
-class Detector:
- """
- A class to encapsulate the reaction detection workflow.
-
- This class provides methods to detect reactions based on input monomers and
- to identify non-reactant monomers. It serves as a structured way to organize
- the detection logic and can be extended in the future for additional functionality.
- """
- def __init__(self, input_dict: dict, interactive: bool = True):
- """
- Initializes the Detector with the given input dictionary.
-
- Args:
- input_dict (dict): A dictionary containing the 'monomers' key, which maps
- monomer IDs (int/str) to SMILES strings (str).
- interactive (bool): If False, automatically retain all non-reactants without prompting.
- """
- self.input_dict = input_dict
- self.reactions = {}
- self.non_reactants_list = []
- self.functional_groups_detector = FunctionalGroupsDetector()
- self.reactions_detector = ReactionDetector()
- self.reactions_dict, self.non_reactants_list, self.input_dict = self.detect_reactions(self.input_dict)
-
- def find_non_reactant_monomers(self, reactions_dict, input_dict) -> list:
- """
- Identifies monomers from the input that are not participating in any detected reactions
- and prompts the user to decide whether to retain them in the simulation.
- Args:
- reactions_dict (dict): A dictionary containing detected reaction data. Expected keys
- include 'monomer_1', 'monomer_2', and 'smiles' for each reaction entry.
- input_dict (dict): The original input dictionary containing a 'monomers' key
- mapping monomer IDs to SMILES strings.
- interactive (bool): If False, automatically retain all non-reactants without prompting.
-
- Returns:
- list: A list of SMILES strings representing the non-reactant monomers selected
- by the user to be retained. Returns an empty list if the user chooses
- to proceed with reactants only.
- """
- # 1) Collect all unique SMILES strings that participate in detected reactions
- reactant_smiles = set()
-
- for reaction_data in reactions_dict.values():
- # Extract SMILES for monomer 1, monomer 2, and the reaction itself
- for k, v in reaction_data.items():
- if not str(k).isdigit():
- continue
- if not isinstance(v, dict):
- continue
-
- m1 = v.get("monomer_1", {}).get("smiles")
- m2 = v.get("monomer_2", {}).get("smiles")
-
- if isinstance(m1, str):
- reactant_smiles.add(m1)
- if isinstance(m2, str):
- reactant_smiles.add(m2)
-
- # 2) Build a dictionary of monomers that are not found in the reactant set
- monomers = input_dict.get("monomers", {})
- self.non_reactants = {}
-
- # Re-index non-reactants starting from ID 1
- new_id = 1
- for _, smi in monomers.items():
- if smi not in reactant_smiles:
- self.non_reactants[new_id] = smi
- new_id += 1
-
- # 3) Handle user interaction if non-reactant monomers are found
- if self.non_reactants:
- print(
- "\nThere are non-reactant monomers/molecules in the input.\n"
- "There may be reactions possible with the given monomers in the user inputs,\n"
- "but some of them are not detected for a reaction.\n"
- "You can choose to retain some or all of these non-reactant molecules in the simulation.\n"
- "Non-reactant molecules:"
- )
- # Display the list of non-reactant molecules to the user
- for mid, molecule in self.non_reactants.items():
- print(f"{mid}. {molecule}")
-
- # Ask user if they want to exclude all non-reactants
- select = input(
- "Do you want to proceed with monomers only (no non-monomer molecules)? (y/n): "
- ).strip().lower()
-
- if select == "y":
- print("Proceeding with monomers only. No non-monomer molecules will be retained.")
- self.non_reactants_list = [] # Return empty list if user declines non-reactants
- else:
- # Ask user to specify which non-reactants to keep by ID
- self.selected_non_reactants = input(
- "Please specify the monomer IDs (comma-separated) you wish to retain as non-monomer molecules: "
- ).strip()
-
- # Parse the comma-separated input into a set of IDs
- selected_ids = {s.strip() for s in self.selected_non_reactants.split(",") if s.strip()}
-
- # Filter the non-reactants dictionary based on user selection
- # strings only (SMILES)
- self.non_reactants_list = [
- smi
- for mid, smi in self.non_reactants.items()
- if str(mid) in selected_ids
- ]
-
- return self.non_reactants_list
-
- # Return empty list if no non-reactants were found
- return []
-
- def filter_simulation_molecules(self, input_dict, non_reactants_list, reactions_dict):
- """
- Filters the input monomers to include only those that are part of detected reactions
- and any non-reactant monomers that the user has chosen to retain.
-
- Args:
- input_dict (dict): The original input dictionary containing a 'monomers' key mapping monomer IDs to SMILES strings.
- non_reactants_list (list): A list of SMILES strings for non-reactant monomers that the user has chosen to retain.
- reactions_dict (dict): A dictionary containing detected reaction data.
- Returns:
- dict: A filtered dictionary of monomers that includes only those participating in reactions and the selected non-reactants.
- """
- # Collect SMILES of reactants from detected reactions
- reactant_smiles = set()
- for reaction_data in reactions_dict.values():
- for k, v in reaction_data.items():
- if not str(k).isdigit():
- continue
- if not isinstance(v, dict):
- continue
-
- m1 = v.get("monomer_1", {}).get("smiles")
- m2 = v.get("monomer_2", {}).get("smiles")
-
- if isinstance(m1, str):
- reactant_smiles.add(m1)
- if isinstance(m2, str):
- reactant_smiles.add(m2)
-
- # Combine reactant SMILES with the selected non-reactant SMILES
- combined = reactant_smiles | set(non_reactants_list)
-
- # Filter input monomers to include only reactants and selected non-reactants
- monomers_dict_from_input = input_dict.get("monomers", {})
- for k in list(monomers_dict_from_input.keys()):
- if monomers_dict_from_input[k] not in combined:
- del monomers_dict_from_input[k]
-
- # Update the input dictionary with the filtered monomers
- input_dict["monomers"] = monomers_dict_from_input # Update the input dictionary with the filtered monomers
-
- return input_dict
-
-
- def detect_reactions(self, monomer_entry: MonomerEntry, interactive=True) -> tuple:
- """
- Detects chemical reactions and identifies non-reactant monomers based on the provided input.
-
- This function serves as the main workflow controller. It validates the input,
- detects functional groups, selects appropriate reactions, and identifies any
- monomers that did not participate in the detected reactions.
-
- Args:
- monomer_entry (MonomerEntry): A MonomerEntry object containing the monomers data.
-
- Returns:
- tuple: A tuple containing:
- - dict: The detected reactions.
- - list: A list of SMILES strings for non-reactant monomers to be retained.
-
- Raises:
- ValueError: If the input dictionary is missing the 'monomers' key or if it is empty.
- """
-
- # Step 1: Detect functional groups within the monomers
- fg_results = self.functional_groups_detector.functional_groups_detector(monomer_entry)
-
- # Debug: Print detected functional groups
- # print("Detected Functional Groups:", json.dumps(fg_results, indent=2))
-
- # Step 2: Select reactions based on the detected functional groups
- reactions = self.reactions_detector.reaction_detector(fg_results)
-
- # Debug: Print detected reactions
- # print("Detected Reactions:", json.dumps(reactions, indent=2))
-
- # Print detected reactions
- print("\nDetected Reactions:")
- for reaction_name, reaction_data in reactions.items():
- print(f"\n{reaction_name}:")
- monomer_1 = reaction_data.get("reactant_1", {})
- monomer_2 = reaction_data.get("reactant_2", {})
- if monomer_2:
- print(f"Reaction between {monomer_1} and {monomer_2}")
- else:
- print(f"Reaction involving {monomer_1}")
-
- # Step 3: Identify and handle monomers that are not part of any detected reaction
- non_reactants_list = self.find_non_reactant_monomers(reactions, self.input_dict)
-
- # Update the input dictionary to include only reactants and selected non-reactants
- self.input_dict = self.filter_simulation_molecules(self.input_dict, non_reactants_list, reactions)
-
- return reactions, non_reactants_list , self.input_dict
-
-
-if __name__ == "__main__":
- # Example usage of the module with sample monomer data
- sample_inputs = {
- "monomers": {
- 1: "ClC(=O)c1cc(cc(c1)C(Cl)=O)C(Cl)=O", # Example monomer 1 - Trimesoyl chloride (TMC)
- 2: "C1=CC(=CC(=C1)N)N", # Example monomer 2 - m-Phenylenediamine (MPD)
- 3: "CCO", # Example Non - monomer - Ethanol
- }
- }
-
- # Run the detection workflow
- detector = Detector(sample_inputs)
- print("Detected Reactions:", json.dumps(detector.reactions_dict, indent=2))
- # Output results
- print("Detected Reactions:", json.dumps(detector.reactions_dict, indent=2))
- if detector.non_reactants_list:
- print("Non-monomer molecules to retain:", detector.non_reactants_list)
- print("Filtered Input Dictionary for Simulation:", json.dumps(detector.input_dict, indent=2))
diff --git a/AutoREACTER/detectors/functional_groups_detector.py b/AutoREACTER/detectors/functional_groups_detector.py
index c9bed736..1bbef474 100644
--- a/AutoREACTER/detectors/functional_groups_detector.py
+++ b/AutoREACTER/detectors/functional_groups_detector.py
@@ -1,5 +1,6 @@
from __future__ import annotations
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, List
+
"""
* Monomer Functionality Detection Module
--------------------------------------
@@ -111,12 +112,14 @@
from AutoREACTER.input_parser import MonomerEntry
# Conditional import for FunctionalGroupsLibrary to support both installed and local usage.
-from .functional_groups_library import FunctionalGroupsLibrary
+from AutoREACTER.detectors.functional_groups_library.registry import FunctionalGroupsLibrary
logger = logging.getLogger(__name__) # Module-level logger for future diagnostics.
if TYPE_CHECKING:
from AutoREACTER.session import Session
-
+ from AutoREACTER.reaction_preparation.reaction_processor.reaction_progression import (
+ MonomerRoleforIndexBasedFGDetection,
+ )
@dataclass(slots=True)
class FunctionalGroupInfo:
@@ -127,19 +130,23 @@ class FunctionalGroupInfo:
functionality_type (str): Type of functionality (e.g., 'vinyl', 'mono', 'di_identical', 'di_different').
fg_name (str): Name of the functional group (e.g., 'acrylate').
fg_smarts_1 (str): Primary SMARTS pattern for matching.
+ fg_1_indexes (Optional[Tuple[int, ...]]): Atom indices for matches of fg_smarts_1.
fg_count_1 (int): Number of matches for fg_smarts_1.
fg_smarts_2 (Optional[str]): Secondary SMARTS pattern (for 'di_different' types).
+ fg_2_indexes (Optional[Tuple[int, ...]]): Atom indices for matches of fg_smarts_2.
fg_count_2 (Optional[int]): Number of matches for fg_smarts_2.
"""
functionality_type: str
fg_name: str
fg_smarts_1: str
fg_count_1: int
+ fg_1_indexes: Optional[Tuple[Tuple[int, ...], ...]] = None
fg_smarts_2: Optional[str] = None
fg_count_2: Optional[int] = None
+ fg_2_indexes: Optional[Tuple[Tuple[int, ...], ...]] = None
-@dataclass(slots=True, frozen=True)
+@dataclass(slots=True)
class MonomerRole:
"""
Immutable dataclass representing a monomer with its detected functional groups.
@@ -152,6 +159,10 @@ class MonomerRole:
smiles: str
name: str
functionalities: Tuple[FunctionalGroupInfo, ...] # Tuple of detected functionalities for the monomer
+ rdkit_mol: Optional[rdchem.Mol] = None # Optional RDKit molecule object for the monomer
+ indexes_in_template: List[int] = None # Optional list of atom indices in the template
+ is_monomer: bool = False # Flag indicating if the monomer is eligible for polymerization
+ is_looped: bool = False
@dataclass(slots=True)
class FunctionalGroupVisualization:
@@ -218,7 +229,7 @@ def detect_monomer_functionality(
# Convert SMILES to RDKit molecule object for substructure matching.
if mol is None:
- logger.warning(f"Invalid SMILES: {smiles}")
+ logger.warning("Invalid or missing RDKit molecule.")
return 0, None, None, None
# Create pattern from primary SMARTS and validate.
@@ -349,6 +360,7 @@ def functional_groups_detector(
smiles=smiles,
name=monomer.name,
functionalities=tuple(detected_functionalities),
+ is_monomer=True
)
)
@@ -401,6 +413,140 @@ def _functional_groups_detector_for_visualization(
)
)
return monomer_roles_visualization
+
+ def _detect_functional_groups_by_index(
+ self,
+ mol: Chem.Mol,
+ smarts: str,
+ atom_indices: list[int],
+ ) -> bool:
+ """Return True when any SMARTS match overlaps the supplied atom indices."""
+ target_indices = set(atom_indices)
+
+ patt = Chem.MolFromSmarts(smarts)
+ if patt is None:
+ logger.warning("Invalid SMARTS pattern: %s", smarts)
+ return False
+
+ matches = mol.GetSubstructMatches(patt, uniquify=True)
+ return any(target_indices.intersection(match) for match in matches)
+
+ def index_based_functional_groups_detector(
+ self,
+ monomer_roles_in: list[MonomerRoleforIndexBasedFGDetection],
+ ) -> list[MonomerRole] | bool:
+ """
+ Detect functional groups across a list of monomers and categorize them into roles,
+ restricted to a given set of atom indices per monomer.
+
+ Iterates over predefined monomer_types, matches each against the monomer's
+ rdkit_mol, and keeps only matches that overlap with the monomer's
+ `indexes_in_template`. Prints matches for debugging/user feedback.
+
+ Args:
+ monomer_roles_in (list[MonomerRoleforIndexBasedFGDetection]): List of monomer
+ roles to process, each carrying the atom indices of interest.
+
+ Returns:
+ list[MonomerRole] | bool: List of MonomerRole objects with index-filtered
+ functionalities, or False if none detected.
+
+ Notes:
+ - Index-based rule: at least ONE match overlapping the given indices is
+ enough to qualify, regardless of functionality_type. This intentionally
+ breaks the whole-molecule 'di_identical' (>=2 matches) rule, since here
+ we only care whether the given index sits inside a valid functional group,
+ not how many total sites exist on the monomer.
+ """
+
+ monomer_roles_out = []
+
+ for monomer in monomer_roles_in:
+ if monomer.is_looped:
+ continue # Skip already processed monomers
+
+ mol = monomer.rdkit_mol
+
+ target_indices = set(monomer.indexes_in_template or [])
+ detected_functionalities = []
+ all_matches = []
+
+ # Check against each predefined functional group type.
+ for functional_group in self.monomer_types.values():
+ ftype = functional_group["functionality_type"]
+ smarts_1 = functional_group["smarts_1"]
+ smarts_2 = functional_group.get("smarts_2")
+
+ patt1 = Chem.MolFromSmarts(smarts_1)
+ if patt1 is None:
+ logger.warning(f"Invalid primary SMARTS: {smarts_1}")
+ continue
+
+ matches1 = mol.GetSubstructMatches(patt1, uniquify=True)
+ # Index-based filter: keep only matches touching at least one target index.
+ matches1_hit = [m for m in matches1 if target_indices.intersection(m)]
+ count_1 = len(matches1_hit)
+
+ count_2 = None
+ matches2_hit = []
+
+ if smarts_2:
+ patt2 = Chem.MolFromSmarts(smarts_2)
+ if patt2 is None:
+ logger.warning(f"Invalid secondary SMARTS: {smarts_2}")
+ continue
+
+ matches2 = mol.GetSubstructMatches(patt2, uniquify=True)
+ matches2_hit = [m for m in matches2 if target_indices.intersection(m)]
+ count_2 = len(matches2_hit)
+
+ # di_different: still need one overlapping hit on EACH pattern.
+ functionality_count = 2 if (count_1 >= 1 and count_2 >= 1) else 0
+ else:
+ # vinyl / mono / di_identical: ONE overlapping match is enough.
+ # (Breaks the normal di_identical >=2 rule on purpose for index-based detection.)
+ functionality_count = 1 if count_1 >= 1 else 0
+
+ if functionality_count > 0:
+ functional_matches = tuple(matches1_hit) + tuple(matches2_hit)
+ all_matches.extend(functional_matches)
+
+ # Log detected functionality for debugging/user feedback.
+ # print(f"{monomer.smiles} has functionality: {functional_group['group_name']}")
+
+ detected_functionalities.append(
+ FunctionalGroupInfo(
+ functionality_type=ftype,
+ fg_name=functional_group["group_name"],
+ fg_smarts_1=smarts_1,
+ fg_count_1=count_1,
+ fg_1_indexes=tuple(matches1_hit) if matches1_hit else None,
+ fg_smarts_2=smarts_2,
+ fg_count_2=count_2,
+ fg_2_indexes=tuple(matches2_hit) if matches2_hit else None,
+ )
+ )
+
+ # Add to roles if any functionalities detected.
+ if detected_functionalities:
+ monomer_roles_out.append(
+ MonomerRole(
+ smiles=monomer.smiles,
+ name=monomer.name,
+ rdkit_mol=monomer.rdkit_mol,
+ functionalities=tuple(detected_functionalities),
+ is_monomer=False, # This is a product, not an input monomer
+ is_looped=False, # Yet to be processed in the loop
+ indexes_in_template=monomer.indexes_in_template,
+ )
+ )
+
+ # Store results for potential downstream use.
+ if not monomer_roles_out:
+ return False # No functional groups detected; handle as needed
+ # first break condition: if no monomer roles are detected, return False to indicate no further processing is needed.
+
+ return monomer_roles_out # Return list of MonomerRole; visualization not considered here.
def functional_group_highlighted_molecules_image_grid(self, session: Session) -> Image:
"""Convert monomer roles with detected functionalities into visualizations.
diff --git a/AutoREACTER/detectors/functional_groups_library.py b/AutoREACTER/detectors/functional_groups_library.py
deleted file mode 100644
index 1408d624..00000000
--- a/AutoREACTER/detectors/functional_groups_library.py
+++ /dev/null
@@ -1,213 +0,0 @@
-"""
-This module defines a library of functional groups relevant to polymer chemistry,
-particularly for the detection of monomers in reaction simulations. Each functional group is characterized by
-its functionality type (e.g., 'vinyl', 'mono', 'di_different', 'di_identical'),
-SMARTS patterns for substructure matching, and group names for identification. This library serves as a reference
-for the FunctionalGroupsDetector to identify and classify monomers based on their chemical structure.
-"""
-
-
-class FunctionalGroupsLibrary:
- def __init__(self):
- self.monomer_types = {
-
- # ============================================================
- # Hydroxy / Carboxylic Acid AB-Type Monomers
- # ============================================================
-
- "hydroxy_carboxylic_acid_monomer": {
- "functionality_type": "di_different",
- "smarts_1": "[OX2H1;!$([O][C,S]=*):1]",
- "smarts_2": "[CX3:2](=[O])[OX2H1]",
- "group_name": "hydroxy_carboxylic_acid",
- "comments": None,
- },
-
- "hydroxy_acid_halides_monomer": {
- "functionality_type": "di_different",
- "smarts_1": "[OX2H1;!$([O][C,S]=*):1]",
- "smarts_2": "[CX3:2](=[O])[Cl,Br,I]",
- "group_name": "hydroxy_acid_halide",
- "comments": "Hydroxy acid halides are highly reactive and less commonly used monomers for polyesterification compared to hydroxy carboxylic acids."
- },
-
- # ============================================================
- # Alcohol / Thiol Functional Monomers
- # ============================================================
-
- "diol_monomer": {
- "functionality_type": "di_identical",
- "smarts_1": "[OX2H1;!$([O][C,S]=*):1]",
- "group_name": "diol",
- "comments": None,
- },
-
- "dithiol_monomer": {
- "functionality_type": "di_identical",
- "smarts_1": "[SX2H1;!$([S][C,S]=*):1]",
- "group_name": "dithiol",
- "comments": None,
- },
-
- "hydroxy_thiol_monomer": {
- "functionality_type": "di_different",
- "smarts_1": "[OX2H1;!$([O][C,S]=*):1]",
- "smarts_2": "[SX2H1;!$([S][C,S]=*):2]",
- "group_name": "hydroxy_thiol",
- "comments": None,
- },
-
- # ============================================================
- # Amine / Amino Acid Monomers
- # ============================================================
-
- "amino_acid_monomer": {
- "functionality_type": "di_different",
- "smarts_1": "[NX3;H2,H1;!$([N][C,S]=*):1]",
- "smarts_2": "[CX3:2](=[O])[OX2H1]",
- "group_name": "amino_acid",
- "comments": None,
- },
-
- "di_amine_monomer": {
- "functionality_type": "di_identical",
- "smarts_1": "[NX3;H2,H1;!$([N][C,S]=*):1]",
- "group_name": "di_amine",
- "comments": None,
- },
-
- # ============================================================
- # Carboxylic Acid / Acid Halide / Ester Monomers
- # ============================================================
-
- "di_carboxylic_acid_monomer": {
- "functionality_type": "di_identical",
- "smarts_1": "[CX3:1](=[O])[OX2H1]",
- "group_name": "di_carboxylic_acid",
- "comments": None,
- },
-
- "di_carboxylic_acid_halide_monomer": {
- "functionality_type": "di_identical",
- "smarts_1": "[CX3:1](=[O])[Cl,Br,I]",
- "group_name": "di_carboxylic_acid_halide",
- "comments": None,
- },
-
- "carboxylic_acid_acid_halide_monomer": {
- "functionality_type": "di_different",
- "smarts_1": "[CX3:1](=[O])[OX2H1]",
- "smarts_2": "[CX3:2](=[O])[Cl,Br,I]",
- "group_name": "carboxylic_acid_acid_halide",
- "comments": "Mixed COOH/acid-halide AB monomer. Edge case; forms polyanhydride-type linkage, not polyester.",
- },
-
- "di_carboxylic_ester_monomer": {
- "functionality_type": "di_identical",
- "smarts_1": "[CX3:1](=[O])[OX2H0][#6]",
- "group_name": "di_carboxylic_ester",
- "comments": None,
- },
-
- # ============================================================
- # Isocyanate Monomers
- # ============================================================
-
- "di_isocyanate_monomer": {
- "functionality_type": "di_identical",
- "smarts_1": "[NX2]=[CX2:1]=[OX1]",
- "group_name": "di_isocyanate",
- "comments": None,
- },
-
- # ============================================================
- # Commented functional groups
- # ============================================================
-
- # ------------------------------------------------------------
- # Cyclic Anhydride / Epoxide Functional Groups
- # ------------------------------------------------------------
-
- # "di_cyclic_anhydride_monomer": {
- # "functionality_type": "di_identical",
- # "smarts_1": "[CX3,c;R:1](=[OX1])[OX2,o;R][CX3,c;R:2](=[OX1])",
- # "group_name": "di_cyclic_anhydride"
- # },
-
- # "di_epoxide_monomer": {
- # "functionality_type": "di_identical",
- # "smarts_1": "[CX4;H2,H1,H0;R:1]1[OX2;R:2][CX4;H1,H0;R:3]1",
- # "group_name": "di_epoxide"
- # },
-
- # ------------------------------------------------------------
- # Vinyl / Olefin Functional Groups
- # ------------------------------------------------------------
-
- # "vinyl_monomer": {
- # "functionality_type": "vinyl",
- # "smarts_1": "[C]=[C;D1]",
- # "group_name": "vinyl"
- # },
-
- # "cyclic_olefin_monomer": {
- # "functionality_type": "vinyl",
- # "smarts_1": "[CX3;R:1]=[CX3;R:2]",
- # "group_name": "cyclic_olefin"
- # },
-
- # ------------------------------------------------------------
- # Ring-Opening Functional Groups
- # ------------------------------------------------------------
-
- # "lactone_monomer": {
- # "functionality_type": "mono",
- # "smarts_1": "[CX3;R:1](=[OX1])[OX2;R:2]",
- # "group_name": "lactone"
- # },
-
- # "cyclic_anhydride_monomer": {
- # "functionality_type": "mono",
- # "smarts_1": "[C,c;R:1][CX3,c;R](=[OX1])[OX2,o;R][CX3,c;R](=[OX1])[C,c;R:2]",
- # "group_name": "cyclic_anhydride"
- # },
-
- # "epoxide_monomer": {
- # "functionality_type": "mono",
- # "smarts_1": "[CX4;R:3]1[OX2;R:4][CX4;R:5]1",
- # "group_name": "epoxide"
- # },
-
- # "lactam_monomer": {
- # "functionality_type": "mono",
- } # "smarts_1": "[CX3;R:1](=[OX1])[NX3;R:2]",
- # "group_name": "lactam"
- # },
- # "di_amine_monomer": {
- # "functionality_type": "di_identical",
- # "smarts_1": "[N&X3;H2,H1;!$(NC=*):3]",
-
- # "group_name": "di_amine"
- # },
- # "primery_di_amine_monomer": {
- # "functionality_type": "di_identical",
- # "smarts_1": "[C,c:6][NX3;H2;!$(N[C,S]=*)]",
- # "group_name": "di_primery_amine"
- # },
- # "di_cyclic_anhydride_monomer": {
- # "functionality_type": "di_identical",
- # "smarts_1": "[CX3,c;R:1](=[OX1])[OX2,o;R][CX3,c;R:2](=[OX1])",
- # "group_name": "di_cyclic_anhydride"
- # },
- # "di_isocyanate_monomer": {
- # "functionality_type": "di_identical",
- # "smarts_1": "[NX2:1]=[CX2]=[OX1,SX1:2]",
- # "group_name": "di_isocyanate"
- # },
- # "di_epoxide_monomer": {
- # "functionality_type": "di_identical",
- # "smarts_1": "[CX4;H2,H1,H0;R:1]1[OX2;R:2][CX4;H1,H0;R:3]1",
- # "group_name": "di_epoxide"
- # }
- # need to add more functional groups here from "J. Chem. Inf. Model. 2023, 63, 5539−5548"
- # is there monomers with both COCl and COOH groups?
diff --git a/AutoREACTER/detectors/functional_groups_library/__init__.py b/AutoREACTER/detectors/functional_groups_library/__init__.py
new file mode 100644
index 00000000..133353b7
--- /dev/null
+++ b/AutoREACTER/detectors/functional_groups_library/__init__.py
@@ -0,0 +1,5 @@
+"""Functional groups organized by reactive motif."""
+
+from .registry import FUNCTIONAL_GROUPS, FunctionalGroupsLibrary, load_functional_groups
+
+__all__ = ["FUNCTIONAL_GROUPS", "FunctionalGroupsLibrary", "load_functional_groups"]
diff --git a/AutoREACTER/detectors/functional_groups_library/active_centers.py b/AutoREACTER/detectors/functional_groups_library/active_centers.py
new file mode 100644
index 00000000..033e7062
--- /dev/null
+++ b/AutoREACTER/detectors/functional_groups_library/active_centers.py
@@ -0,0 +1,14 @@
+FUNCTIONAL_GROUPS = {
+ 'vinyl_chain_end_radical': {
+ 'functionality_type': 'vinyl',
+ 'smarts_1': '[C;!R;D3;v3]',
+ 'group_name': 'vinyl_chain_end_radical',
+ 'comments': None
+ },
+ # 'romp_alkylidene_motif': {
+ # 'functionality_type': 'mono',
+ # 'smarts_1': '[Ru]=[C]',
+ # 'group_name': 'romp_alkylidene',
+ # 'comments': None
+ # }
+}
\ No newline at end of file
diff --git a/AutoREACTER/detectors/functional_groups_library/aromatic_groups.py b/AutoREACTER/detectors/functional_groups_library/aromatic_groups.py
new file mode 100644
index 00000000..5bcf472b
--- /dev/null
+++ b/AutoREACTER/detectors/functional_groups_library/aromatic_groups.py
@@ -0,0 +1,32 @@
+# FUNCTIONAL_GROUPS = {
+# 'bis_p_halogenatedaryl_sulfone_monomer': {
+# 'functionality_type': 'di_identical',
+# 'smarts_1': '[c]([F,Cl,Br,I])[c][SX4](=[OX1])(=[OX1])',
+# 'group_name': 'bis(p-halogenatedaryl)sulfone',
+# 'comments': None
+# },
+# 'bis_p_fluoroaryl_ketone_monomer': {
+# 'functionality_type': 'di_identical',
+# 'smarts_1': '[c]([F])[c][CX3](=[OX1])',
+# 'group_name': 'bis(p-fluoroaryl)ketone_monomer',
+# 'comments': None
+# },
+# 'phenol_monomer': {
+# 'functionality_type': 'di_identical',
+# 'smarts_1': '[cH1][c][OX2H1]',
+# 'group_name': 'phenol',
+# 'comments': None
+# },
+# 'hydroxymethyl_phenol_monomer': {
+# 'functionality_type': 'mono',
+# 'smarts_1': '[c][CH2][OX2H1]',
+# 'group_name': 'hydroxymethyl_phenol',
+# 'comments': None
+# },
+# 'hindered_phenol_monomer': {
+# 'functionality_type': 'di_identical',
+# 'smarts_1': '[c]1([OX2H1])[c]([C])[c][cH1][c][c]1([C])',
+# 'group_name': 'hindered_phenol',
+# 'comments': None
+# }
+# }
\ No newline at end of file
diff --git a/AutoREACTER/detectors/functional_groups_library/carboxyl_and_carbonyl_groups.py b/AutoREACTER/detectors/functional_groups_library/carboxyl_and_carbonyl_groups.py
new file mode 100644
index 00000000..99f5eb25
--- /dev/null
+++ b/AutoREACTER/detectors/functional_groups_library/carboxyl_and_carbonyl_groups.py
@@ -0,0 +1,39 @@
+FUNCTIONAL_GROUPS = {
+ 'di_carboxylic_acid_monomer': {
+ 'functionality_type': 'di_identical',
+ 'smarts_1': '[CX3:1](=[O])[OX2H1]',
+ 'group_name': 'di_carboxylic_acid',
+ 'comments': None
+ },
+ 'di_carboxylic_acid_halide_monomer': {
+ 'functionality_type': 'di_identical',
+ 'smarts_1': '[CX3:1](=[O])[Cl,Br,I]',
+ 'group_name': 'di_carboxylic_acid_halide',
+ 'comments': None
+ },
+ 'di_carboxylic_ester_monomer': {
+ 'functionality_type': 'di_identical',
+ 'smarts_1': '[CX3:1](=[O])[OX2H0][#6]',
+ 'group_name': 'di_carboxylic_ester',
+ 'comments': None
+ },
+ 'phosgene_monomer': {
+ 'functionality_type': 'mono',
+ # Elaborated SMARTS strictly requires a carbonyl carbon bonded to exactly two chlorines
+ 'smarts_1': '[Cl:1]-[CX3:2](=[OX1:3])-[Cl:4]',
+ 'group_name': 'phosgene',
+ 'comments': None
+ },
+ 'diphenyl_carbonate_monomer': {
+ 'functionality_type': 'di_identical',
+ 'smarts_1': '[CX3](=[OX1])[OX2][c]',
+ 'group_name': 'diphenyl_carbonate',
+ 'comments': None
+ }
+ # 'formaldehyde_monomer': {
+ # 'functionality_type': 'mono',
+ # 'smarts_1': '[CH2]=[OX1]',
+ # 'group_name': 'formaldehyde',
+ # 'comments': None
+ # }
+}
\ No newline at end of file
diff --git a/AutoREACTER/detectors/functional_groups_library/halide_groups.py b/AutoREACTER/detectors/functional_groups_library/halide_groups.py
new file mode 100644
index 00000000..a9bcad58
--- /dev/null
+++ b/AutoREACTER/detectors/functional_groups_library/halide_groups.py
@@ -0,0 +1,8 @@
+# FUNCTIONAL_GROUPS = {
+# 'organic_dihalide_monomer': {
+# 'functionality_type': 'di_identical',
+# 'smarts_1': '[CX4][Cl,Br,I]',
+# 'group_name': 'organic_dihalide',
+# 'comments': None
+# }
+# }
diff --git a/AutoREACTER/detectors/functional_groups_library/heterocumulene_groups.py b/AutoREACTER/detectors/functional_groups_library/heterocumulene_groups.py
new file mode 100644
index 00000000..1730c037
--- /dev/null
+++ b/AutoREACTER/detectors/functional_groups_library/heterocumulene_groups.py
@@ -0,0 +1,11 @@
+
+
+FUNCTIONAL_GROUPS = {
+ 'di_isocyanate_monomer':
+ {
+ 'functionality_type': 'di_identical',
+ 'smarts_1': '[NX2]=[CX2:1]=[OX1]',
+ 'group_name': 'di_isocyanate',
+ 'comments': None
+ }
+ }
diff --git a/AutoREACTER/detectors/functional_groups_library/mixed_ab_groups.py b/AutoREACTER/detectors/functional_groups_library/mixed_ab_groups.py
new file mode 100644
index 00000000..9184e1f3
--- /dev/null
+++ b/AutoREACTER/detectors/functional_groups_library/mixed_ab_groups.py
@@ -0,0 +1,41 @@
+FUNCTIONAL_GROUPS = {
+ 'hydroxy_carboxylic_acid_monomer': {
+ 'functionality_type': 'di_different',
+ 'smarts_1': '[OX2H1;!$([O][C,S]=*):1]',
+ 'smarts_2': '[CX3:2](=[O])[OX2H1]',
+ 'group_name': 'hydroxy_carboxylic_acid',
+ 'comments': None
+ },
+
+ 'hydroxy_acid_halides_monomer': {
+ 'functionality_type': 'di_different',
+ 'smarts_1': '[OX2H1;!$([O][C,S]=*):1]',
+ 'smarts_2': '[CX3:2](=[O])[Cl,Br,I]',
+ 'group_name': 'hydroxy_acid_halide',
+ 'comments': None
+ },
+
+ 'amino_acid_monomer': {
+ 'functionality_type': 'di_different',
+ 'smarts_1': '[NX3;H2,H1;!$([N][C,S]=*):1]',
+ 'smarts_2': '[CX3:2](=[O])[OX2H1]',
+ 'group_name': 'amino_acid',
+ 'comments': None
+ },
+
+ 'carboxylic_acid_acid_halide_monomer': {
+ 'functionality_type': 'di_different',
+ 'smarts_1': '[CX3:1](=[O])[OX2H1]',
+ 'smarts_2': '[CX3:2](=[O])[Cl,Br,I]',
+ 'group_name': 'carboxylic_acid_acid_halide',
+ 'comments': None
+ },
+
+ 'hydroxy_thiol_monomer': {
+ 'functionality_type': 'di_different',
+ 'smarts_1': '[OX2H1;!$([O][C,S]=*):1]',
+ 'smarts_2': '[SX2H1;!$([S][C,S]=*):2]',
+ 'group_name': 'hydroxy_thiol',
+ 'comments': None
+ }
+}
\ No newline at end of file
diff --git a/AutoREACTER/detectors/functional_groups_library/nitrogen_groups.py b/AutoREACTER/detectors/functional_groups_library/nitrogen_groups.py
new file mode 100644
index 00000000..36aa22bb
--- /dev/null
+++ b/AutoREACTER/detectors/functional_groups_library/nitrogen_groups.py
@@ -0,0 +1,32 @@
+FUNCTIONAL_GROUPS = {
+ 'primary_amine_monomer': {
+ 'functionality_type': 'mono',
+ 'smarts_1': '[NX3H2;!$(NC=O);!$(NC=[N,O,S])]',
+ 'group_name': 'primary_amine',
+ 'comments': None
+ },
+ 'secondary_amine_monomer': {
+ 'functionality_type': 'mono',
+ 'smarts_1': '[NX3H1;!$(NC=O);!$(NC=[N,O,S])]',
+ 'group_name': 'secondary_amine',
+ 'comments': None
+ },
+ 'di_amine_monomer': {
+ 'functionality_type': 'di_identical',
+ 'smarts_1': '[NX3;H2,H1;!$([N][C,S]=*):1]',
+ 'group_name': 'di_amine',
+ 'comments': None
+ },
+ 'di_primary_amine_monomer': {
+ 'functionality_type': 'di_identical',
+ 'smarts_1': '[NX3H2;!$([N][C,S]=*)]',
+ 'group_name': 'di_primary_amine',
+ 'comments': None
+ },
+ # 'tetra_amine_monomer': {
+ # 'functionality_type': 'di_identical',
+ # 'smarts_1': '[c]([NX3H2;!$([N][C,S]=*)])[c][NX3H2;!$([N][C,S]=*)]',
+ # 'group_name': 'tetra_amine',
+ # 'comments': None
+ # }
+}
\ No newline at end of file
diff --git a/AutoREACTER/detectors/functional_groups_library/oxygen_groups.py b/AutoREACTER/detectors/functional_groups_library/oxygen_groups.py
new file mode 100644
index 00000000..d87891e2
--- /dev/null
+++ b/AutoREACTER/detectors/functional_groups_library/oxygen_groups.py
@@ -0,0 +1,20 @@
+FUNCTIONAL_GROUPS = {
+ 'diol_monomer': {
+ 'functionality_type': 'di_identical',
+ 'smarts_1': '[OX2H1;!$([O][C,S]=*):1]',
+ 'group_name': 'diol',
+ 'comments': None
+ },
+ # 'initiator_monomer': {
+ # 'functionality_type': 'mono',
+ # 'smarts_1': '[OX2H1;!$([O][C,S]=*)]',
+ # 'group_name': 'lactone_initiator',
+ # 'comments': None
+ # },
+ 'water_monomer': {
+ 'functionality_type': 'mono',
+ 'smarts_1': '[OH2:1]',
+ 'group_name': 'water',
+ 'comments': None
+ }
+}
\ No newline at end of file
diff --git a/AutoREACTER/detectors/functional_groups_library/registry.py b/AutoREACTER/detectors/functional_groups_library/registry.py
new file mode 100644
index 00000000..98c1391d
--- /dev/null
+++ b/AutoREACTER/detectors/functional_groups_library/registry.py
@@ -0,0 +1,62 @@
+"""Aggregate and validate all motif-based functional-group modules."""
+
+from .oxygen_groups import FUNCTIONAL_GROUPS as OXYGEN_GROUPS
+from .sulfur_groups import FUNCTIONAL_GROUPS as SULFUR_GROUPS
+from .nitrogen_groups import FUNCTIONAL_GROUPS as NITROGEN_GROUPS
+from .carboxyl_and_carbonyl_groups import FUNCTIONAL_GROUPS as CARBOXYL_AND_CARBONYL_GROUPS
+from .mixed_ab_groups import FUNCTIONAL_GROUPS as MIXED_AB_GROUPS
+from .ring_groups import FUNCTIONAL_GROUPS as RING_GROUPS
+from .vinyl_and_alkene_groups import FUNCTIONAL_GROUPS as VINYL_AND_ALKENE_GROUPS
+# from .aromatic_groups import FUNCTIONAL_GROUPS as AROMATIC_GROUPS
+from .silicon_groups import FUNCTIONAL_GROUPS as SILICON_GROUPS
+# from .halide_groups import FUNCTIONAL_GROUPS as HALIDE_GROUPS
+from .heterocumulene_groups import FUNCTIONAL_GROUPS as HETEROCUMULENE_GROUPS
+from .active_centers import FUNCTIONAL_GROUPS as ACTIVE_CENTERS
+
+_FUNCTIONAL_GROUP_MODULES = [
+ OXYGEN_GROUPS,
+ SULFUR_GROUPS,
+ NITROGEN_GROUPS,
+ CARBOXYL_AND_CARBONYL_GROUPS,
+ MIXED_AB_GROUPS,
+ RING_GROUPS,
+ VINYL_AND_ALKENE_GROUPS,
+ # AROMATIC_GROUPS,
+ SILICON_GROUPS,
+ # HALIDE_GROUPS,
+ HETEROCUMULENE_GROUPS,
+ ACTIVE_CENTERS,
+]
+
+
+def load_functional_groups() -> dict:
+ """Return one flat functional-group dictionary with duplicate protection."""
+ merged = {}
+ group_name_to_key = {}
+
+ for module in _FUNCTIONAL_GROUP_MODULES:
+ for entry_key, entry in module.items():
+ if entry_key in merged:
+ raise ValueError(f"Duplicate functional-group key: {entry_key}")
+
+ group_name = entry["group_name"]
+ if group_name in group_name_to_key:
+ previous = group_name_to_key[group_name]
+ raise ValueError(
+ f"Duplicate group_name {group_name!r} in {previous!r} and {entry_key!r}"
+ )
+
+ merged[entry_key] = entry
+ group_name_to_key[group_name] = entry_key
+
+ return merged
+
+
+FUNCTIONAL_GROUPS = load_functional_groups()
+
+
+class FunctionalGroupsLibrary:
+ """Backward-compatible class exposing ``self.monomer_types``."""
+
+ def __init__(self):
+ self.monomer_types = load_functional_groups()
diff --git a/AutoREACTER/detectors/functional_groups_library/ring_groups.py b/AutoREACTER/detectors/functional_groups_library/ring_groups.py
new file mode 100644
index 00000000..8aee8f8c
--- /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 00000000..117ec720
--- /dev/null
+++ b/AutoREACTER/detectors/functional_groups_library/silicon_groups.py
@@ -0,0 +1,14 @@
+FUNCTIONAL_GROUPS = {
+ 'dichlorosilane_monomer': {
+ 'functionality_type': 'di_identical',
+ 'smarts_1': '[Si][Cl]',
+ 'group_name': 'dichlorosilane',
+ 'comments': None
+ },
+ 'silanediol_monomer': {
+ 'functionality_type': 'di_identical',
+ 'smarts_1': '[Si][OX2H1]',
+ 'group_name': 'silanediol',
+ 'comments': None
+ }
+}
\ No newline at end of file
diff --git a/AutoREACTER/detectors/functional_groups_library/sulfur_groups.py b/AutoREACTER/detectors/functional_groups_library/sulfur_groups.py
new file mode 100644
index 00000000..fbc6b955
--- /dev/null
+++ b/AutoREACTER/detectors/functional_groups_library/sulfur_groups.py
@@ -0,0 +1,14 @@
+FUNCTIONAL_GROUPS = {
+ 'dithiol_monomer': {
+ 'functionality_type': 'di_identical',
+ 'smarts_1': '[SX2H1;!$([S][C,S]=*):1]',
+ 'group_name': 'dithiol',
+ 'comments': None
+ },
+ # 'sodium_sulfide_monomer': {
+ # 'functionality_type': 'mono',
+ # 'smarts_1': '[S-2]',
+ # 'group_name': 'sodium_sulfide',
+ # 'comments': None
+ # }
+}
\ No newline at end of file
diff --git a/AutoREACTER/detectors/functional_groups_library/vinyl_and_alkene_groups.py b/AutoREACTER/detectors/functional_groups_library/vinyl_and_alkene_groups.py
new file mode 100644
index 00000000..569a0039
--- /dev/null
+++ b/AutoREACTER/detectors/functional_groups_library/vinyl_and_alkene_groups.py
@@ -0,0 +1,26 @@
+FUNCTIONAL_GROUPS = {
+ 'vinyl_monomer': {
+ 'functionality_type': 'vinyl',
+ 'smarts_1': '[CH2]=[C;!R]',
+ 'group_name': 'vinyl',
+ 'comments': None
+ },
+ 'diene_monomer': {
+ 'functionality_type': 'di_identical',
+ 'smarts_1': '[CH2]=[C;!R]',
+ 'group_name': 'diene',
+ 'comments': None
+ },
+ # 'bis_alkene_monomer': {
+ # 'functionality_type': 'di_identical',
+ # 'smarts_1': '[C]=[C]',
+ # 'group_name': 'bis_alkene',
+ # 'comments': None
+ # }
+ 'tetrafluoroethylene_monomer': {
+ 'functionality_type': 'vinyl',
+ 'smarts_1': '[CX3](-[F])(-[F])=[CX3](-[F])(-[F])',
+ 'group_name': 'tetrafluoroethylene',
+ 'comments': None
+ }
+}
\ No newline at end of file
diff --git a/AutoREACTER/detectors/reaction_detector.py b/AutoREACTER/detectors/reaction_detector.py
index 5d7b68e3..6b7ad8c1 100644
--- a/AutoREACTER/detectors/reaction_detector.py
+++ b/AutoREACTER/detectors/reaction_detector.py
@@ -42,7 +42,7 @@
# Attempt to import internal library components
try:
- from reactions_library import ReactionLibrary
+ from AutoREACTER.detectors.reactions_library.registry import ReactionLibrary
except (ImportError, ModuleNotFoundError):
from .reactions_library import ReactionLibrary
@@ -63,7 +63,6 @@ class EmptyReactionListError(Exception):
This should be prevented by the reaction_selection method, but this error serves as a safeguard."""
pass
-
@dataclass(slots=True)
class ReactionInstance:
"""
@@ -239,7 +238,150 @@ def reaction_detector(self, session: "Session") -> None:
functional_group_2=fg_2
)
)
- session.reaction_instances = reaction_instances
+ if not reaction_instances:
+ raise EmptyReactionListError(
+ "\nNo reaction instances found for the specified monomer combination. "
+ "Please verify that your input monomer combinations are correct. "
+ "If you believe this represents a valid and standard reaction that AutoREACTER should support, "
+ "please submit an issue at https://github.com/NanoCIPHER-Lab/AutoREACTER/issues for consideration.\n"
+ "Thank you for helping improve AutoREACTER."
+ )
+ else:
+ session.reaction_instances = reaction_instances
+
+
+ def index_based_reaction_detector(
+ self, monomer_roles: List[MonomerRole]
+ ) -> List[ReactionInstance]:
+ """
+ Scans a list of index-based monomer roles to find all possible polymerization
+ reactions, same logic as reaction_detector but operating on a direct list of
+ MonomerRole objects (as produced by index_based_functional_groups_detector)
+ instead of session.monomer_roles.
+
+ Looping rule:
+ - Homo-polymerization (single monomer role): skip if that monomer role
+ is already looped (is_looped=True).
+ - Co-polymerization / same-reactant-two-FG (two monomer roles involved):
+ skip ONLY if BOTH monomer roles are already looped. If either one is
+ still fresh (is_looped=False), the pair is still looked up/processed.
+
+ Args:
+ monomer_roles: List of MonomerRole objects to analyze.
+
+ Returns:
+ List[ReactionInstance]: All detected reaction instances for this pass.
+ """
+ reaction_instances = []
+ seen_pairs: Set[Tuple] = set()
+
+ for reaction_name, reaction_info in self.reactions.items():
+ reactant_1_name = reaction_info.get("reactant_1")
+ reactant_2_name = reaction_info.get("reactant_2")
+ same_reactants = reaction_info.get("same_reactants", False)
+
+ # CASE 1: HOMO-POLYMERIZATION (e.g., A + A)
+ if same_reactants and reactant_2_name is None:
+ for monomer_role in monomer_roles:
+ # Single-monomer case: skip only if this monomer is already looped.
+ if monomer_role.is_looped:
+ continue
+
+ fg_hits = self._matching_fgs(monomer_role, reactant_1_name)
+ for fg in fg_hits:
+ pair_key = self._seen_pair_key(reaction_name, monomer_role, fg)
+ if pair_key not in seen_pairs:
+ seen_pairs.add(pair_key)
+ reaction_instances.append(
+ ReactionInstance(
+ reaction_name=reaction_name,
+ reaction_smarts=reaction_info["reaction"],
+ delete_atom=reaction_info["delete_atom"],
+ references=reaction_info["reference"],
+ same_reactants=same_reactants,
+ monomer_1=monomer_role,
+ functional_group_1=fg
+ )
+ )
+
+ # CASE 2: CO-POLYMERIZATION (e.g., A + B)
+ else:
+ for monomer_role_i in monomer_roles:
+ fg_hits_i = self._matching_fgs(monomer_role_i, reactant_1_name)
+ if not fg_hits_i:
+ continue
+
+ for fg_i in fg_hits_i:
+ for monomer_role_j in monomer_roles:
+ # Prevent a monomer reacting with itself in a co-monomer definition
+ if monomer_role_i == monomer_role_j:
+ continue
+
+ # Pairwise rule: skip only if BOTH are already looped.
+ if monomer_role_i.is_looped and monomer_role_j.is_looped:
+ continue
+
+ fg_hits_j = self._matching_fgs(monomer_role_j, reactant_2_name)
+ for fg_j in fg_hits_j:
+ pair_key = self._seen_pair_key(
+ reaction_name, monomer_role_i, fg_i, monomer_role_j, fg_j
+ )
+ if pair_key not in seen_pairs:
+ seen_pairs.add(pair_key)
+ reaction_instances.append(
+ ReactionInstance(
+ reaction_name=reaction_name,
+ reaction_smarts=reaction_info["reaction"],
+ delete_atom=reaction_info["delete_atom"],
+ references=reaction_info["reference"],
+ same_reactants=same_reactants,
+ monomer_1=monomer_role_i,
+ functional_group_1=fg_i,
+ monomer_2=monomer_role_j,
+ functional_group_2=fg_j
+ )
+ )
+
+ # CASE 1.1: Same reactant has two functional groups (e.g., A + A with FG1 and FG2)
+ if not same_reactants and reactant_2_name is not None:
+ for monomer_role in monomer_roles:
+ # Both "slots" are the same monomer role here, so the pairwise
+ # both-looped rule collapses to a single-monomer check.
+ if monomer_role.is_looped:
+ continue
+
+ fg_hits_1 = self._matching_fgs(monomer_role, reactant_1_name)
+ fg_hits_2 = self._matching_fgs(monomer_role, reactant_2_name)
+
+ for fg_1 in fg_hits_1:
+ for fg_2 in fg_hits_2:
+
+ # Skip identical FG objects
+ if fg_1 == fg_2:
+ continue
+
+ pair_key = self._seen_pair_key(
+ reaction_name, monomer_role, fg_1, monomer_role, fg_2
+ )
+
+ if pair_key not in seen_pairs:
+ seen_pairs.add(pair_key)
+
+ reaction_instances.append(
+ ReactionInstance(
+ reaction_name=reaction_name,
+ reaction_smarts=reaction_info["reaction"],
+ delete_atom=reaction_info["delete_atom"],
+ references=reaction_info["reference"],
+ same_reactants=same_reactants,
+ monomer_1=monomer_role,
+ functional_group_1=fg_1,
+ monomer_2=monomer_role,
+ functional_group_2=fg_2
+ )
+ )
+
+ return reaction_instances
def create_reaction_image(self, reactant_a_smiles: str, reactant_b_smiles: str,
reaction_smarts: str, reaction_name: str) -> Image.Image:
diff --git a/AutoREACTER/detectors/reactions_library.py b/AutoREACTER/detectors/reactions_library.py
deleted file mode 100644
index c4ed8486..00000000
--- a/AutoREACTER/detectors/reactions_library.py
+++ /dev/null
@@ -1,476 +0,0 @@
-import json
-from typing import Dict, Any
-
-
-"""
-TODO: Missing Polymerization Mechanisms
-
-Step-Growth Condensation / Addition
-- Polycarbonates: Diols + Phosgene or Diphenyl Carbonate
-- Polyureas: Diamines + Diisocyanates
-- Aromatic Polyimides: Dianhydrides + Diamines
-- Polybenzimidazoles (PBI): Tetraamines + Dicarboxylates
-- Phenol-Formaldehyde (Bakelite): Phenol + Formaldehyde
-
-Aromatic / High-Performance Polymers
-- Aromatic Polyethers (PEEK/Sulfones): Activated dihalides + Bisphenols
-- Spiro Polymers
-
-Sulfur / Silicon-Based Polymers
-- Polysiloxanes (Silicones): Hydrolysis/Condensation of Dichlorosilanes
-- Polysulfides: Dihalides + Sodium Sulfide
-
-Click / Ring-Opening / Metathesis Polymerizations
-- Thiol-Ene Click Polymerizations
-- Ring-Opening Metathesis Polymerization (ROMP)
-- Cycloaddition (Four-Center) Reactions
-
-Architectural / Supramolecular Polymers
-- Dendritic Polymers: Random Hyperbranched and Dendrimers
-- Pseudopolyrotaxanes and Polyrotaxanes
-
-Special Polymerization Environments
-- Enzymatic Polymerizations: In Vivo / In Vitro biocatalysis
-- Polymerization in Supercritical Carbon Dioxide
-- Thiophene Polymerizations: Oxidative Polymerization of Thiophenes
-"""
-
-
-class ReactionLibrary:
- def __init__(self):
- self.reactions = {
-
- # ============================================================
- # Polyesterification: Hydroxy Acids / Acid Halides
- # ============================================================
-
- "Hydroxy Carboxylic Acid Polycondensation(Polyesterification)": {
- "same_reactants": True,
- "reactant_1": "hydroxy_carboxylic_acid",
- "product": "polyester_chain",
- "delete_atom": True,
- "reaction": "[OX2H1;!$([O][C,S]=*):1]-[H:3].[CX3:2](=[O:5])[OX2H1:4]>>[OX2:1]-[CX3:2](=[O:5]).[O:4]-[H:3]",
- "reference": {
- "smarts": "https://pubs.acs.org/doi/10.1021/acs.jcim.3c00329",
- "reaction_and_mechanism": [
- "https://pubs.acs.org/doi/10.1021/ed048pA734.1",
- "https://pubs.acs.org/doi/10.1021/ed073pA312"
- ]
- },
- "comments": None
- },
-
- "Hydroxy Carboxylic and Hydroxy Carboxylic Polycondensation(Polyesterification)": {
- "same_reactants": False,
- "reactant_1": "hydroxy_carboxylic_acid",
- "reactant_2": "hydroxy_carboxylic_acid",
- "product": "polyester_chain",
- "delete_atom": True,
- "reaction": "[OX2H1;!$([O][C,S]=*):1]-[H:3].[CX3:2](=[O:5])[OX2H1:4]>>[OX2:1]-[CX3:2](=[O:5]).[O:4]-[H:3]",
- "reference": {
- "smarts": "https://pubs.acs.org/doi/10.1021/acs.jcim.3c00329",
- "reaction_and_mechanism": [
- "https://pubs.acs.org/doi/10.1021/ed048pA734.1",
- "https://pubs.acs.org/doi/10.1021/ed073pA312"
- ]
- },
- "comments": None
- },
-
- "Hydroxy Acid Halides Polycondensation(Polyesterification)": {
- "same_reactants": True,
- "reactant_1": "hydroxy_acid_halide",
- "product": "polyester_chain",
- "delete_atom": True,
- "reaction": "[OX2H1;!$([O][C,S]=*):1]-[H:3].[CX3:2](=[O:5])[Cl,Br,I:4]>>[OX2:1]-[CX3:2](=[O:5]).[Cl,Br,I:4]-[H:3]",
- "reference": {
- "smarts": None,
- "reaction_and_mechanism": [
- "https://pubs.acs.org/doi/10.1021/ed073pA312"
- ]
- },
- "comments": None
- },
-
- "Hydroxy Acid Halides Hydroxy Acid Halides Polycondensation(Polyesterification)": {
- "same_reactants": False,
- "reactant_1": "hydroxy_acid_halide",
- "reactant_2": "hydroxy_acid_halide",
- "product": "polyester_chain",
- "delete_atom": True,
- "reaction": "[OX2H1;!$([O][C,S]=*):1]-[H:3].[CX3:2](=[O:5])[Cl,Br,I:4]>>[OX2:1]-[CX3:2](=[O:5]).[Cl,Br,I:4]-[H:3]",
- "reference": {
- "smarts": None,
- "reaction_and_mechanism": [
- "https://pubs.acs.org/doi/10.1021/ed073pA312"
- ]
- },
- "comments": None
- },
-
- # ============================================================
- # Polyesterification: Diols + Diacids / Diacid Halides / Esters
- # ============================================================
-
- "Diol and Di-Carboxylic Acid Polycondensation(Polyesterification)": {
- "same_reactants": False,
- "reactant_1": "diol",
- "reactant_2": "di_carboxylic_acid",
- "product": "polyester_chain",
- "delete_atom": True,
- "reaction": "[CX3:1](=[O:3])[OX2H1:4].[OX2H1;!$([O][C,S]=*):2]-[H:5]>>[CX3:1](=[O:3])-[OX2:2].[O:4]-[H:5]",
- "reference": {
- "smarts": [
- "https://pubs.acs.org/doi/10.1021/acs.jcim.3c00329"
- ],
- "reaction_and_mechanism": [
- "https://pubs.acs.org/doi/10.1021/ed048pA734.1",
- "https://pubs.acs.org/doi/10.1021/ed073pA312"
- ]
- },
- "comments": None
- },
-
- "Diol and Di-Acid Halide Polycondensation(Polyesterification)": {
- "same_reactants": False,
- "reactant_1": "diol",
- "reactant_2": "di_carboxylic_acid_halide",
- "product": "polyester_chain",
- "delete_atom": True,
- "reaction": "[CX3:1](=[O:3])[Cl,Br,I:4].[OX2H1;!$([O][C,S]=*):2]-[H:5]>>[CX3:1](=[O:3])-[OX2:2].[Cl,Br,I:4]-[H:5]",
- "reference": {
- "smarts": [
- "https://pubs.acs.org/doi/10.1021/acs.jcim.3c00329"
- ],
- "reaction_and_mechanism": [
- "https://pubs.acs.org/doi/10.1021/ed048pA734.1",
- "https://pubs.acs.org/doi/10.1021/ed073pA312"
- ]
- },
- "comments": None
- },
-
- "Diol and Di-Carboxylic Ester Polycondensation(Transesterification)": {
- "same_reactants": False,
- "reactant_1": "diol",
- "reactant_2": "di_carboxylic_ester",
- "product": "polyester_chain",
- "delete_atom": True,
- "reaction": "[OX2H1;!$([O][C,S]=*):1]-[H:3].[CX3:2](=[O:5])[OX2H0:4][#6:6]>>[OX2:1]-[CX3:2](=[O:5]).[OX2:4](-[H:3])-[#6:6]",
- "reference": {
- "smarts": None,
- "reaction_and_mechanism": None
- },
- "comments": None
- },
-
- # ============================================================
- # Polyanhydride Formation
- # ============================================================
-
- "Carboxylic Acid and Acid Halide Polycondensation(Polyanhydride Formation)": {
- "same_reactants": True,
- "reactant_1": "carboxylic_acid_acid_halide",
- "product": "polyanhydride_chain",
- "delete_atom": True,
- "reaction": "[CX3:1](=[O:3])[Cl,Br,I:4].[CX3:2](=[O:5])[OX2H1:6]-[H:7]>>[CX3:1](=[O:3])-[OX2:6]-[CX3:2](=[O:5]).[Cl,Br,I:4]-[H:7]",
- "reference": {
- "smarts": None,
- "reaction_and_mechanism": None
- },
- "comments": None
- },
-
- # ============================================================
- # Polythioesterification
- # ============================================================
-
- "Dithiol and Di-Carboxylic Acid Halide Polycondensation(Polythioesterification)": {
- "same_reactants": False,
- "reactant_1": "dithiol",
- "reactant_2": "di_carboxylic_acid_halide",
- "product": "polythioester_chain",
- "delete_atom": True,
- "reaction": "[CX3:1](=[O:3])[Cl,Br,I:4].[SX2H1;!$([S][C,S]=*):2]-[H:5]>>[CX3:1](=[O:3])-[SX2:2].[Cl,Br,I:4]-[H:5]",
- "reference": {
- "smarts": None,
- "reaction_and_mechanism": None
- },
- "comments": None
- },
-
- "Dithiol and Di-Carboxylic Acid Polycondensation(Polythioesterification)": {
- "same_reactants": False,
- "reactant_1": "dithiol",
- "reactant_2": "di_carboxylic_acid",
- "product": "polythioester_chain",
- "delete_atom": True,
- "reaction": "[CX3:1](=[O:3])[OX2H1:4].[SX2H1;!$([S][C,S]=*):2]-[H:5]>>[CX3:1](=[O:3])-[SX2:2].[O:4]-[H:5]",
- "reference": {
- "smarts": None,
- "reaction_and_mechanism": None
- },
- "comments": "Possible thioesterification with water elimination, but generally less straightforward than acid-halide route."
- },
-
- # ============================================================
- # Polyamidation
- # ============================================================
-
- "Amino Acid Polycondensation (Polyamidation)": {
- "same_reactants": True,
- "reactant_1": "amino_acid",
- "product": "polyamide_chain",
- "delete_atom": True,
- "reaction": "[NX3;H2,H1;!$([N][C,S]=*):1]-[H:3].[CX3:2](=[O:4])[OX2H1:5]>>[NX3:1]-[CX3:2](=[O:4]).[O:5]-[H:3]",
- "reference": {
- "smarts": [
- "https://pubs.acs.org/doi/10.1021/acs.jcim.3c00329"
- ],
- "reaction_and_mechanism": [
- "https://pubs.acs.org/doi/10.1021/ed048pA734.1",
- "https://pubs.acs.org/doi/10.1021/ed073pA312"
- ]
- },
- "comments": None
- },
-
- "Amino Acid and Amino Acid Polycondensation (Polyamidation)": {
- "same_reactants": False,
- "reactant_1": "amino_acid",
- "reactant_2": "amino_acid",
- "product": "polyamide_chain",
- "delete_atom": True,
- "reaction": "[NX3;H2,H1;!$([N][C,S]=*):1]-[H:3].[CX3:2](=[O:4])[OX2H1:5]>>[NX3:1]-[CX3:2](=[O:4]).[O:5]-[H:3]",
- "reference": {
- "smarts": [
- "https://pubs.acs.org/doi/10.1021/acs.jcim.3c00329"
- ],
- "reaction_and_mechanism": [
- "https://pubs.acs.org/doi/10.1021/ed048pA734.1",
- "https://pubs.acs.org/doi/10.1021/ed073pA312"
- ]
- },
- "comments": None
- },
-
- "Di-Amine and Di-Carboxylic Acid Polycondensation (Polyamidation)": {
- "same_reactants": False,
- "reactant_1": "di_amine",
- "reactant_2": "di_carboxylic_acid",
- "product": "polyamide_chain",
- "delete_atom": True,
- "reaction": "[NX3;H2,H1;!$([N][C,S]=*):1]-[H:3].[CX3:2](=[O:4])[OX2H1:5]>>[NX3:1]-[CX3:2](=[O:4]).[O:5]-[H:3]",
- "reference": {
- "smarts": [
- "https://pubs.acs.org/doi/10.1021/acs.jcim.3c00329"
- ],
- "reaction_and_mechanism": [
- "https://pubs.acs.org/doi/10.1021/ed048pA734"
- ]
- },
- "comments": None
- },
-
- "Di-Amine and Di-Carboxylic Acid Halide Polycondensation (Polyamidation)": {
- "same_reactants": False,
- "reactant_1": "di_amine",
- "reactant_2": "di_carboxylic_acid_halide",
- "product": "polyamide_chain",
- "delete_atom": True,
- "reaction": "[NX3;H2,H1;!$([N][C,S]=*):1]-[H:3].[CX3:2](=[O:4])[Cl,Br,I:5]>>[NX3:1]-[CX3:2](=[O:4]).[Cl,Br,I:5]-[H:3]",
- "reference": {
- "smarts": [
- "https://pubs.acs.org/doi/10.1021/acs.jcim.3c00329"
- ],
- "reaction_and_mechanism": [
- "https://pubs.acs.org/doi/10.1021/ed048pA734"
- ]
- },
- "comments": None
- },
-
- # ============================================================
- # Mixed Polyester / Polythioester Formation
- # ============================================================
-
- "Hydroxy-Thiol and Di-Carboxylic Acid Halide Polycondensation through Hydroxy Group": {
- "same_reactants": False,
- "reactant_1": "hydroxy_thiol",
- "reactant_2": "di_carboxylic_acid_halide",
- "product": "mixed_polyester_polythioester_chain",
- "delete_atom": True,
- "reaction": "[CX3:1](=[O:3])[Cl,Br,I:4].[OX2H1;!$([O][C,S]=*):2]-[H:5]>>[CX3:1](=[O:3])-[OX2:2].[Cl,Br,I:4]-[H:5]",
- "reference": {
- "smarts": None,
- "reaction_and_mechanism": None
- },
- "comments": None
- },
-
- "Hydroxy-Thiol and Di-Carboxylic Acid Halide Polycondensation through Thiol Group": {
- "same_reactants": False,
- "reactant_1": "hydroxy_thiol",
- "reactant_2": "di_carboxylic_acid_halide",
- "product": "mixed_polyester_polythioester_chain",
- "delete_atom": True,
- "reaction": "[CX3:1](=[O:3])[Cl,Br,I:4].[SX2H1;!$([S][C,S]=*):2]-[H:5]>>[CX3:1](=[O:3])-[SX2:2].[Cl,Br,I:4]-[H:5]",
- "reference": {
- "smarts": None,
- "reaction_and_mechanism": None
- },
- "comments": None
- },
-
- # ============================================================
- # Polyurethane Formation
- # ============================================================
-
- "Diol and Di-Isocyanate Polyaddition(Polyurethane Formation)": {
- "same_reactants": False,
- "reactant_1": "diol",
- "reactant_2": "di_isocyanate",
- "product": "polyurethane_chain",
- "delete_atom": False,
- "reaction": "[OX2H1;!$([O][C,S]=*):1]-[H:3].[NX2:4]=[CX2:2]=[OX1:5]>>[OX2:1]-[CX3:2](=[OX1:5])-[NX3:4]-[H:3]",
- "reference": {
- "smarts": None,
- "reaction_and_mechanism": None
- },
- "comments": None
- },
-
- # ============================================================
- # Commented reactions
- # ============================================================
-
- # "Vinyl Addition Polymerization": {
- # "same_reactants": True,
- # "reactant_1": "vinyl",
- # "product": "polyvinyl_chain",
- # "delete_atom": False,
- # "reaction": "[CH2:1]=[CH;H1,H0;!R:2].[CH2:3]=[CH;H1,H0;!R:4]>>[CH2:1]-[CH:2]-[CH2:3]-[CH:4]"
- # },
-
- # "Cyclic Olefin Addition Polymerization": {
- # "same_reactants": True,
- # "reactant_1": "cyclic_olefin",
- # "product": "polycyclic_chain",
- # "delete_atom": False,
- # "reaction": "[CX3;R:1]=[CX3;R:2].[CX3;R:3]=[CX3;R:4]>>[CX4:1]-[CX4:2]-[CX4:3]-[CX4:4]"
- # },
-
- # "Vinyl Copolymerization": {
- # "same_reactants": False,
- # "reactant_1": "vinyl",
- # "reactant_2": "vinyl",
- # "product": "copolyvinyl_chain",
- # "delete_atom": False,
- # "reaction": "[CH2:1]=[CH;H1,H0;!R:2].[CH2:3]=[CH;H1,H0;!R:4]>>[CH2:1]-[CH:2]-[CH2:3]-[CH:4]"
- # },
-
- # "Cyclic Olefin and Vinyl Copolymerization": {
- # "same_reactants": False,
- # "reactant_1": "vinyl",
- # "reactant_2": "cyclic_olefin",
- # "product": "copolycyclicvinyl_chain",
- # "delete_atom": False,
- # "reaction": "[CH2:1]=[CH;H1,H0;!R:2].[CX3;R:3]=[CX3;R:4]>>[CX4:1]-[CX4:2]-[CH2:3]-[CH:4]"
- # },
-
- # "Cyclic Olefin Copolymerization": {
- # "same_reactants": False,
- # "reactant_1": "cyclic_olefin",
- # "reactant_2": "cyclic_olefin",
- # "product": "copolycyclic_chain",
- # "delete_atom": False,
- # "reaction": "[CX3;R:1]=[CX3;R:2].[CX3;R:3]=[CX3;R:4]>>[CX4:1]-[CX4:2]-[CX4:3]-[CX4:4]"
- # },
-
- # "Lactone Ring-Opening Polyesterification": {
- # "same_reactants": False,
- # "reactant_1": "lactone",
- # "reactant_2": "initiator",
- # "product": "polyester_chain",
- # "delete_atom": False
- # },
-
- # "Cyclic Anhydride and Epoxide Polyesterification": {
- # "same_reactants": False,
- # "reactant_1": "cyclic_anhydride_monomer",
- # "reactant_2": "diol_monomer",
- # "product": "polyester_chain",
- # "delete_atom": False
- # },
-
- # "Cyclic Anhydride and Epoxide Polyetherification": {
- # "same_reactants": False,
- # "reactant_1": "cyclic_anhydride",
- # "reactant_2": "epoxide",
- # "product": "polyether_chain",
- # "delete_atom": False
- # },
-
- # "Epoxide Ring-Opening Polyetherification": {
- # "same_reactants": False,
- # "reactant_1": "epoxide",
- # "reactant_2": "initiator",
- # "product": "polyether_chain",
- # "delete_atom": False
- # },
-
- # "Hindered Phenol Polyetherification": {
- # "same_reactants": True,
- # "reactant_1": "hindered_phenol",
- # "product": "polyether_chain",
- # "delete_atom": False
- # },
-
- # "Hindered Phenol Hindered Phenol Polyetherification": {
- # "same_reactants": False,
- # "reactant_1": "hindered_phenol",
- # "reactant_2": "hindered_phenol",
- # "product": "polyether_chain",
- # "delete_atom": False
- # },
-
- # "Bis(p-halogenatedaryl)sulfone Diol (without thiol) polycondensation": {
- # "same_reactants": False,
- # "reactant_1": "bis(p-halogenatedaryl)sulfone",
- # "reactant_2": "diol",
- # "product": "polyether_chain",
- # "delete_atom": True
- # },
-
- # "Bis(bis(p-fluoroaryl)ketone Diol (without thiol) polycondensation": {
- # "same_reactants": False,
- # "reactant_1": "bis(p-fluoroaryl)ketone_monomer",
- # "reactant_2": "diol_monomer",
- # "product": "polyether_chain",
- # "delete_atom": True
- # },
-
- # "Lactam Ring-Opening Polyamidation": {
- # "same_reactants": True,
- # "reactant_1": "lactam_monomer",
- # "product": "polyamide_chain",
- # "delete_atom": False
- # },
-
- # "Di-cyclic Anhydride and Di-Primary Amine Polycondensation (Polyimidation)": {
- # "same_reactants": False,
- # "reactant_1": "di_cyclic_anhydride_monomer",
- # "reactant_2": "di_amine_monomer",
- # "product": "polyimide_chain",
- # "delete_atom": True
- # },
-
- # "Di-Epoxide and Di-Isocyanate Polyamination": {
- # "same_reactants": False,
- # "reactant_1": "di_epoxide_monomer",
- # "reactant_2": "di_isocyanate_monomer",
- # "product": "polyamine_chain",
- # "delete_atom": False,
- # "reaction": "[NX2:3]=[CX2:4]=[OX1,SX1:5].[OX2,SX2;H1;!$([O,S]C=*):6]>>[NX3:3][CX3:4](=[OX1,SX1:5])[OX2,SX2;!$([O,S]C=*):6]"
- # },
- }
\ No newline at end of file
diff --git a/AutoREACTER/detectors/reactions_library/__init__.py b/AutoREACTER/detectors/reactions_library/__init__.py
new file mode 100644
index 00000000..8bd1e695
--- /dev/null
+++ b/AutoREACTER/detectors/reactions_library/__init__.py
@@ -0,0 +1,5 @@
+"""Polymer reactions organized by polymer/product family."""
+
+from .registry import REACTIONS, ReactionLibrary, load_reactions
+
+__all__ = ["REACTIONS", "ReactionLibrary", "load_reactions"]
diff --git a/AutoREACTER/detectors/reactions_library/cycloaddition_polymers.py b/AutoREACTER/detectors/reactions_library/cycloaddition_polymers.py
new file mode 100644
index 00000000..3d2a79f7
--- /dev/null
+++ b/AutoREACTER/detectors/reactions_library/cycloaddition_polymers.py
@@ -0,0 +1,21 @@
+# REACTIONS = {
+# 'Bis-Alkene Four-Center Cycloaddition Polymerization': {
+# 'same_reactants': True,
+# 'reactant_1': 'bis_alkene',
+# 'product': 'polycyclobutane_chain',
+# 'delete_atom': False,
+# 'reaction': '[C:1]=[C:2].[C:3]=[C:4]>>[C:1]1-[C:2]-[C:3]-[C:4]-1',
+# 'reference': {'smarts': None, 'reaction_and_mechanism': None},
+# 'comments': None
+# },
+# 'Bis-Alkene and Bis-Alkene Four-Center Copolymerization': {
+# 'same_reactants': False,
+# 'reactant_1': 'bis_alkene',
+# 'reactant_2': 'bis_alkene',
+# 'product': 'polycyclobutane_chain',
+# 'delete_atom': False,
+# 'reaction': '[C:1]=[C:2].[C:3]=[C:4]>>[C:1]1-[C:2]-[C:3]-[C:4]-1',
+# 'reference': {'smarts': None, 'reaction_and_mechanism': None},
+# 'comments': None
+# }
+# }
\ No newline at end of file
diff --git a/AutoREACTER/detectors/reactions_library/epoxy_polymers.py b/AutoREACTER/detectors/reactions_library/epoxy_polymers.py
new file mode 100644
index 00000000..dac56bcf
--- /dev/null
+++ b/AutoREACTER/detectors/reactions_library/epoxy_polymers.py
@@ -0,0 +1,30 @@
+REACTIONS = {
+ 'Primary Amine and Epoxide Polyaddition (Epoxy-Amine, First Addition)': {
+ 'same_reactants': False,
+ 'reactant_1': 'primary_amine',
+ 'reactant_2': 'di_epoxide',
+ 'product': 'secondary_amine_hydroxyl_product',
+ 'delete_atom': False,
+ # FIXED: N attacks the less hindered CH2 (:2), O stays on the more hindered CH (:3)
+ 'reaction': '[NX3H2:1]-[H:6].[CH2;X4:2]1[OX2:5][CH1;X4:3]1>>[NX3:1][C:2][C:3][OX2:5][H:6]',
+ 'reference': {
+ 'smarts': None,
+ 'reaction_and_mechanism': None
+ },
+ 'comments': None
+ },
+ 'Secondary Amine and Epoxide Polyaddition (Epoxy-Amine, Second Addition / Crosslink)': {
+ 'same_reactants': False,
+ 'reactant_1': 'secondary_amine',
+ 'reactant_2': 'di_epoxide',
+ 'product': 'tertiary_amine_crosslink_product',
+ 'delete_atom': False,
+ # FIXED: N attacks the less hindered CH2 (:2), O stays on the more hindered CH (:3)
+ 'reaction': '[NX3H1:1]-[H:6].[CH2;X4:2]1[OX2:5][CH1;X4:3]1>>[NX3:1][C:2][C:3][OX2:5][H:6]',
+ 'reference': {
+ 'smarts': None,
+ 'reaction_and_mechanism': None
+ },
+ 'comments': None
+ }
+}
\ No newline at end of file
diff --git a/AutoREACTER/detectors/reactions_library/metathesis_polymers.py b/AutoREACTER/detectors/reactions_library/metathesis_polymers.py
new file mode 100644
index 00000000..78fa98d4
--- /dev/null
+++ b/AutoREACTER/detectors/reactions_library/metathesis_polymers.py
@@ -0,0 +1,28 @@
+# REACTIONS = {
+# 'ROMP Initiation': {
+# 'same_reactants': False,
+# 'reactant_1': 'romp_alkylidene',
+# 'reactant_2': 'cyclic_olefin',
+# 'product': 'romp_chain_end',
+# 'delete_atom': False,
+# 'reaction': '[Ru:1]=[C:2].[CX3;R:3]=[CX3;R:4]>>[Ru:1]=[CX3:4].[C:2]-[CX3:3]',
+# 'reference': {
+# 'smarts': None,
+# 'reaction_and_mechanism': None
+# },
+# 'comments': None
+# },
+# 'ROMP Propagation': {
+# 'same_reactants': False,
+# 'reactant_1': 'romp_alkylidene',
+# 'reactant_2': 'cyclic_olefin',
+# 'product': 'romp_chain_end',
+# 'delete_atom': False,
+# 'reaction': '[Ru:1]=[C:2].[CX3;R:3]=[CX3;R:4]>>[Ru:1]=[CX3:4].[C:2]-[CX3:3]',
+# 'reference': {
+# 'smarts': None,
+# 'reaction_and_mechanism': None
+# },
+# 'comments': None
+# }
+# }
\ No newline at end of file
diff --git a/AutoREACTER/detectors/reactions_library/phenolic_resins.py b/AutoREACTER/detectors/reactions_library/phenolic_resins.py
new file mode 100644
index 00000000..ff71d190
--- /dev/null
+++ b/AutoREACTER/detectors/reactions_library/phenolic_resins.py
@@ -0,0 +1,28 @@
+# REACTIONS = {
+# 'Phenol and Formaldehyde Hydroxymethylation': {
+# 'same_reactants': False,
+# 'reactant_1': 'phenol',
+# 'reactant_2': 'formaldehyde',
+# 'product': 'hydroxymethyl_phenol',
+# 'delete_atom': False,
+# 'reaction': '[c:1]-[H:4].[CH2:2]=[OX1:3]>>[c:1]-[C:2]-[OX2:3]-[H:4]',
+# 'reference': {
+# 'smarts': None,
+# 'reaction_and_mechanism': None
+# },
+# 'comments': None
+# },
+# 'Hydroxymethyl Phenol and Phenol Condensation (Methylene Bridge Formation)': {
+# 'same_reactants': False,
+# 'reactant_1': 'hydroxymethyl_phenol',
+# 'reactant_2': 'phenol',
+# 'product': 'phenol_formaldehyde_chain',
+# 'delete_atom': True,
+# 'reaction': '[c:1]-[CH2:2]-[OX2H1:3]-[H:6].[c:4]-[H:5]>>[c:1]-[C:2]-[c:4].[OX2:3](-[H:5])-[H:6]',
+# 'reference': {
+# 'smarts': None,
+# 'reaction_and_mechanism': None
+# },
+# 'comments': None
+# }
+# }
\ No newline at end of file
diff --git a/AutoREACTER/detectors/reactions_library/polyamides.py b/AutoREACTER/detectors/reactions_library/polyamides.py
new file mode 100644
index 00000000..29b9e05b
--- /dev/null
+++ b/AutoREACTER/detectors/reactions_library/polyamides.py
@@ -0,0 +1,104 @@
+REACTIONS = {
+ 'Amino Acid Polycondensation (Polyamidation)': {
+ 'same_reactants': True,
+ 'reactant_1': 'amino_acid',
+ 'product': 'polyamide_chain',
+ 'delete_atom': True,
+ 'reaction': '[NX3;H2,H1;!$([N][C,S]=*):1]-[H:3].[CX3:2](=[O:4])[OX2H1:5]>>[NX3:1]-[CX3:2](=[O:4]).[O:5]-[H:3]',
+ 'reference': {
+ 'smarts': ['https://pubs.acs.org/doi/10.1021/acs.jcim.3c00329'],
+ 'reaction_and_mechanism': [
+ 'https://pubs.acs.org/doi/10.1021/ed048pA734.1',
+ 'https://pubs.acs.org/doi/10.1021/ed073pA312'
+ ]
+ },
+ 'comments': None
+ },
+ 'Amino Acid and Amino Acid Polycondensation (Polyamidation)': {
+ 'same_reactants': False,
+ 'reactant_1': 'amino_acid',
+ 'reactant_2': 'amino_acid',
+ 'product': 'polyamide_chain',
+ 'delete_atom': True,
+ 'reaction': '[NX3;H2,H1;!$([N][C,S]=*):1]-[H:3].[CX3:2](=[O:4])[OX2H1:5]>>[NX3:1]-[CX3:2](=[O:4]).[O:5]-[H:3]',
+ 'reference': {
+ 'smarts': ['https://pubs.acs.org/doi/10.1021/acs.jcim.3c00329'],
+ 'reaction_and_mechanism': [
+ 'https://pubs.acs.org/doi/10.1021/ed048pA734.1',
+ 'https://pubs.acs.org/doi/10.1021/ed073pA312'
+ ]
+ },
+ 'comments': None
+ },
+ 'Di-Amine and Di-Carboxylic Acid Polycondensation (Polyamidation)': {
+ 'same_reactants': False,
+ 'reactant_1': 'di_amine',
+ 'reactant_2': 'di_carboxylic_acid',
+ 'product': 'polyamide_chain',
+ 'delete_atom': True,
+ 'reaction': '[NX3;H2,H1;!$([N][C,S]=*):1]-[H:3].[CX3:2](=[O:4])[OX2H1:5]>>[NX3:1]-[CX3:2](=[O:4]).[O:5]-[H:3]',
+ 'reference': {
+ 'smarts': ['https://pubs.acs.org/doi/10.1021/acs.jcim.3c00329'],
+ 'reaction_and_mechanism': [
+ 'https://pubs.acs.org/doi/10.1021/ed048pA734.1'
+ ]
+ },
+ 'comments': None
+ },
+ 'Di-Amine and Di-Carboxylic Acid Halide Polycondensation (Polyamidation)': {
+ 'same_reactants': False,
+ 'reactant_1': 'di_amine',
+ 'reactant_2': 'di_carboxylic_acid_halide',
+ 'product': 'polyamide_chain',
+ 'delete_atom': True,
+ 'reaction': '[NX3;H2,H1;!$([N][C,S]=*):1]-[H:3].[CX3:2](=[O:4])[Cl,Br,I:5]>>[NX3:1]-[CX3:2](=[O:4]).[Cl,Br,I:5]-[H:3]',
+ 'reference': {
+ 'smarts': ['https://pubs.acs.org/doi/10.1021/acs.jcim.3c00329'],
+ 'reaction_and_mechanism': [
+ 'https://pubs.acs.org/doi/10.1021/ed048pA734'
+ ]
+ },
+ 'comments': None
+ },
+ 'Hydrolytic Initiation of Caprolactam': {
+ 'same_reactants': False,
+ 'reactant_1': 'water',
+ 'reactant_2': 'lactam',
+ 'product': 'polyamide_chain',
+ 'delete_atom': False,
+ 'reaction': '[O:1]-[H:12].[CX3:2]1(=[OX1:3])-[CX4:7]-[CX4:8]-[CX4:9]-[CX4:10]-[CX4:11]-[NX3:4]1-[H:6]>>[O:1]-[CX3:2](=[OX1:3])-[CX4:7]-[CX4:8]-[CX4:9]-[CX4:10]-[CX4:11]-[NX3:4](-[H:6])-[H:12]',
+ 'reference': {
+ 'smarts': None,
+ 'reaction_and_mechanism': ['https://doi.org/10.1002/047147875X']
+ },
+ 'comments': None,
+ 'Notes': 'Validated on 2026-07-26, Passed'
+ },
+ # 'Caprolactam Ring-Opening Polyamidation': {
+ # 'same_reactants': True,
+ # 'reactant_1': 'lactam',
+ # 'product': 'polyamide_chain',
+ # 'delete_atom': False,
+ # # Reactant 2 explicitly maps the 5 CH2 groups (maps 7 through 11) and uses '1' for the ring closure.
+ # # Product SMARTS removes the '.' and explicitly connects the opened chain.
+ # 'reaction': '[NX3:1]-[H:5].[CX3:2]1(=[OX1:3])-[CX4:7]-[CX4:8]-[CX4:9]-[CX4:10]-[CX4:11]-[NX3:4]1-[H:6]>>[NX3:1]-[CX3:2](=[OX1:3])-[CX4:7]-[CX4:8]-[CX4:9]-[CX4:10]-[CX4:11]-[NX3:4](-[H:5])-[H:6]',
+ # 'reference': {
+ # 'smarts': None,
+ # 'reaction_and_mechanism': None
+ # },
+ # 'comments': None
+ # },
+ # 'Lactam and Lactam Ring-Opening Copolyamidation': {
+ # 'same_reactants': False,
+ # 'reactant_1': 'lactam',
+ # 'reactant_2': 'lactam',
+ # 'product': 'polyamide_chain',
+ # 'delete_atom': False,
+ # 'reaction': '[NX3H1;R:1]-[H:2].[CX3;R:3](=[OX1:4])[NX3H1;R:5]-[H:6]>>[NX3:1]-[CX3:3](=[OX1:4]).[NX3:5](-[H:2])-[H:6]',
+ # 'reference': {
+ # 'smarts': None,
+ # 'reaction_and_mechanism': None
+ # },
+ # 'comments': None
+ # }
+}
\ No newline at end of file
diff --git a/AutoREACTER/detectors/reactions_library/polyanhydrides.py b/AutoREACTER/detectors/reactions_library/polyanhydrides.py
new file mode 100644
index 00000000..cf8672c8
--- /dev/null
+++ b/AutoREACTER/detectors/reactions_library/polyanhydrides.py
@@ -0,0 +1,28 @@
+REACTIONS = {
+ 'Carboxylic Acid and Acid Halide Polycondensation (Polyanhydride Formation)': {
+ 'same_reactants': True,
+ 'reactant_1': 'carboxylic_acid_acid_halide',
+ 'product': 'polyanhydride_chain',
+ 'delete_atom': True,
+ 'reaction': '[CX3:1](=[O:3])[Cl,Br,I:4].[CX3:6](=[O:5])[OX2:2]-[H:7]>>[CX3:1](=[O:3])-[OX2:2]-[CX3:6](=[O:5]).[Cl,Br,I:4]-[H:7]',
+ 'reference': {
+ 'smarts': None,
+ 'reaction_and_mechanism': None
+ },
+ 'notes': 'Atom maps 1 and 2 are reserved as AutoREACTER/LAMMPS bond/react initiator atoms.'
+ },
+
+ 'Carboxylic Acid and Acid Halide Copolycondensation (Polyanhydride Copolymerization)': {
+ 'same_reactants': False,
+ 'reactant_1': 'carboxylic_acid_acid_halide',
+ 'reactant_2': 'carboxylic_acid_acid_halide',
+ 'product': 'polyanhydride_chain',
+ 'delete_atom': True,
+ 'reaction': '[CX3:1](=[O:3])[Cl,Br,I:4].[CX3:6](=[O:5])[OX2:2]-[H:7]>>[CX3:1](=[O:3])-[OX2:2]-[CX3:6](=[O:5]).[Cl,Br,I:4]-[H:7]',
+ 'reference': {
+ 'smarts': None,
+ 'reaction_and_mechanism': None
+ },
+ 'notes': 'Atom maps 1 and 2 are reserved as AutoREACTER/LAMMPS bond/react initiator atoms.'
+ }
+}
\ No newline at end of file
diff --git a/AutoREACTER/detectors/reactions_library/polybenzimidazoles.py b/AutoREACTER/detectors/reactions_library/polybenzimidazoles.py
new file mode 100644
index 00000000..31f07aaf
--- /dev/null
+++ b/AutoREACTER/detectors/reactions_library/polybenzimidazoles.py
@@ -0,0 +1,17 @@
+# """
+# This is an EDGE case for polybenzimidazole formation reactions. This needs to be futher studied and realease the reaction only after thorough validation.
+# """
+
+# REACTIONS = {'Tetra-Amine and Di-Carboxylic Acid Polycondensation (PBI Formation)':
+# {
+# 'same_reactants': False,
+# 'reactant_1': 'tetra_amine',
+# 'reactant_2': 'di_carboxylic_acid',
+# 'product': 'polybenzimidazole_chain',
+# 'delete_atom': True,
+# 'reaction': '[c:7]([NX3H2:1](-[H:6])-[H:9])-[c:8]([NX3H2:2](-[H:10])-[H:11]).[CX3:3](=[OX1:4])[OX2H1:5]-[H:12]>>[c:7]1-[NX3:1](-[H:6])-[CX3:3]=[NX2:2]-[c:8]-1.[OX2:4](-[H:9])-[H:10].[OX2:5](-[H:11])-[H:12]',
+# 'reference': {'smarts': None,
+# 'reaction_and_mechanism': None},
+# 'comments': 'UNTESTED new chemistry; one benzimidazole ring-forming event with two mapped waters.'
+# }
+# }
diff --git a/AutoREACTER/detectors/reactions_library/polycarbonates.py b/AutoREACTER/detectors/reactions_library/polycarbonates.py
new file mode 100644
index 00000000..10fd7054
--- /dev/null
+++ b/AutoREACTER/detectors/reactions_library/polycarbonates.py
@@ -0,0 +1,32 @@
+REACTIONS = {
+ 'Diol and Phosgene Polycondensation(Polycarbonate Formation)':
+ {
+ 'same_reactants': False,
+ 'reactant_1': 'diol',
+ 'reactant_2': 'phosgene',
+ 'product': 'polycarbonate_chain',
+ 'delete_atom': True,
+ 'reaction': '[OX2:1]-[H:4].[CX3:2](=[OX1:5])[Cl:3]>>[OX2:1]-[CX3:2](=[OX1:5]).[Cl:3]-[H:4]',
+ 'reference':
+ {
+ 'smarts': None,
+ 'reaction_and_mechanism': None
+ },
+ 'comments': None,
+ },
+ 'Diol and Diphenyl Carbonate Polycondensation(Transcarbonation)':
+ {
+ 'same_reactants': False,
+ 'reactant_1': 'diol',
+ 'reactant_2': 'diphenyl_carbonate',
+ 'product': 'polycarbonate_chain',
+ 'delete_atom': True,
+ 'reaction': '[OX2:1]-[H:4].[CX3:2](=[OX1:5])[OX2:3][c:6]>>[OX2:1]-[CX3:2](=[OX1:5]).[OX2:3](-[H:4])-[c:6]',
+ 'reference':
+ {
+ 'smarts': None,
+ 'reaction_and_mechanism': None
+ },
+ 'comments': None
+ }
+ }
\ No newline at end of file
diff --git a/AutoREACTER/detectors/reactions_library/polyesters.py b/AutoREACTER/detectors/reactions_library/polyesters.py
new file mode 100644
index 00000000..2c77c637
--- /dev/null
+++ b/AutoREACTER/detectors/reactions_library/polyesters.py
@@ -0,0 +1,139 @@
+REACTIONS = {
+ 'Hydroxy Carboxylic Acid Polycondensation(Polyesterification)':
+ {
+ 'same_reactants': True,
+ 'reactant_1': 'hydroxy_carboxylic_acid',
+ 'product': 'polyester_chain',
+ 'delete_atom': True,
+ 'reaction': '[OX2;!$([O][C,S]=*):1]-[H:3].[CX3:2](=[O:5])[OX2:4]>>[OX2:1]-[CX3:2](=[O:5]).[O:4]-[H:3]',
+ 'reference': {
+ 'smarts': 'https://pubs.acs.org/doi/10.1021/acs.jcim.3c00329',
+ 'reaction_and_mechanism': [
+ 'https://pubs.acs.org/doi/10.1021/ed048pA734.1',
+ 'https://pubs.acs.org/doi/10.1021/ed073pA312'
+ ]
+ },
+ 'comments': 'Fixed SMARTS to prevent RDKit explicit node valence crashes.'
+ },
+ 'Hydroxy Carboxylic and Hydroxy Carboxylic Polycondensation(Polyesterification)':
+ {
+ 'same_reactants': False,
+ 'reactant_1': 'hydroxy_carboxylic_acid',
+ 'reactant_2': 'hydroxy_carboxylic_acid',
+ 'product': 'polyester_chain',
+ 'delete_atom': True,
+ 'reaction': '[OX2;!$([O][C,S]=*):1]-[H:3].[CX3:2](=[O:5])[OX2:4]>>[OX2:1]-[CX3:2](=[O:5]).[O:4]-[H:3]',
+ 'reference': {
+ 'smarts': 'https://pubs.acs.org/doi/10.1021/acs.jcim.3c00329',
+ 'reaction_and_mechanism': [
+ 'https://pubs.acs.org/doi/10.1021/ed048pA734.1',
+ 'https://pubs.acs.org/doi/10.1021/ed073pA312'
+ ]
+ },
+ 'comments': 'Fixed SMARTS to prevent RDKit explicit node valence crashes.'
+ },
+ 'Hydroxy Acid Halides Polycondensation(Polyesterification)':
+ {
+ 'same_reactants': True,
+ 'reactant_1': 'hydroxy_acid_halide',
+ 'product': 'polyester_chain',
+ 'delete_atom': True,
+ 'reaction': '[OX2;!$([O][C,S]=*):1]-[H:3].[CX3:2](=[O:5])[Cl,Br,I:4]>>[OX2:1]-[CX3:2](=[O:5]).[Cl,Br,I:4]-[H:3]',
+ 'reference': {
+ 'smarts': None,
+ 'reaction_and_mechanism': ['https://pubs.acs.org/doi/10.1021/ed073pA312']
+ },
+ 'comments': 'Fixed SMARTS to prevent RDKit explicit node valence crashes.'
+ },
+ 'Hydroxy Acid Halides Hydroxy Acid Halides Polycondensation(Polyesterification)':
+ {
+ 'same_reactants': False,
+ 'reactant_1': 'hydroxy_acid_halide',
+ 'reactant_2': 'hydroxy_acid_halide',
+ 'product': 'polyester_chain',
+ 'delete_atom': True,
+ 'reaction': '[OX2;!$([O][C,S]=*):1]-[H:3].[CX3:2](=[O:5])[Cl,Br,I:4]>>[OX2:1]-[CX3:2](=[O:5]).[Cl,Br,I:4]-[H:3]',
+ 'reference': {
+ 'smarts': None,
+ 'reaction_and_mechanism': ['https://pubs.acs.org/doi/10.1021/ed073pA312']
+ },
+ 'comments': 'Fixed SMARTS to prevent RDKit explicit node valence crashes.'
+ },
+ 'Diol and Di-Carboxylic Acid Polycondensation(Polyesterification)':
+ {
+ 'same_reactants': False,
+ 'reactant_1': 'diol',
+ 'reactant_2': 'di_carboxylic_acid',
+ 'product': 'polyester_chain',
+ 'delete_atom': True,
+ 'reaction': '[CX3:1](=[O:3])[OX2:4].[OX2;!$([O][C,S]=*):2]-[H:5]>>[CX3:1](=[O:3])-[OX2:2].[O:4]-[H:5]',
+ 'reference': {
+ 'smarts': ['https://pubs.acs.org/doi/10.1021/acs.jcim.3c00329'],
+ 'reaction_and_mechanism': [
+ 'https://pubs.acs.org/doi/10.1021/ed048pA734.1',
+ 'https://pubs.acs.org/doi/10.1021/ed073pA312'
+ ]
+ },
+ 'comments': 'Fixed SMARTS to prevent RDKit explicit node valence crashes.'
+ },
+ 'Diol and Di-Acid Halide Polycondensation(Polyesterification)':
+ {
+ 'same_reactants': False,
+ 'reactant_1': 'diol',
+ 'reactant_2': 'di_carboxylic_acid_halide',
+ 'product': 'polyester_chain',
+ 'delete_atom': True,
+ 'reaction': '[CX3:1](=[O:3])[Cl,Br,I:4].[OX2;!$([O][C,S]=*):2]-[H:5]>>[CX3:1](=[O:3])-[OX2:2].[Cl,Br,I:4]-[H:5]',
+ 'reference': {
+ 'smarts': ['https://pubs.acs.org/doi/10.1021/acs.jcim.3c00329'],
+ 'reaction_and_mechanism': [
+ 'https://pubs.acs.org/doi/10.1021/ed048pA734.1',
+ 'https://pubs.acs.org/doi/10.1021/ed073pA312'
+ ]
+ },
+ 'comments': 'Fixed SMARTS to prevent RDKit explicit node valence crashes.'
+ },
+ # 'Diol and Di-Carboxylic Ester Polycondensation(Transesterification)':
+ # {
+ # 'same_reactants': False,
+ # 'reactant_1': 'diol',
+ # 'reactant_2': 'di_carboxylic_ester',
+ # 'product': 'polyester_chain',
+ # 'delete_atom': True,
+ # 'reaction': '[OX2;!$([O][C,S]=*):1]-[H:3].[CX3:2](=[O:5])[OX2:4][#6:6]>>[OX2:1]-[CX3:2](=[O:5]).[OX2:4](-[H:3])-[#6:6]',
+ # 'reference': {
+ # 'smarts': None,
+ # 'reaction_and_mechanism': None
+ # },
+ # 'comments': None
+ # },
+ # Skipped for later additinos after proper validations
+ # 'Lactone Ring-Opening Polyesterification':
+ # {
+ # 'same_reactants': False,
+ # 'reactant_1': 'lactone',
+ # 'reactant_2': 'lactone_initiator',
+ # 'product': 'polyester_chain',
+ # 'delete_atom': False,
+ # 'reaction': '[OX2:1]-[H:2].[CX3:3]1(=[OX1:4])-[CX4:6]-[CX4:7]-[CX4:8]-[CX4:9]-[CX4:10]-[OX2:5]1>>[OX2:1]-[CX3:3](=[OX1:4])-[CX4:6]-[CX4:7]-[CX4:8]-[CX4:9]-[CX4:10]-[OX2:5]-[H:2]',
+ # 'reference': {
+ # 'smarts': None,
+ # 'reaction_and_mechanism': None
+ # },
+ # 'comments': None
+ # },
+ # 'Cyclic Anhydride and Epoxide Polyesterification':
+ # {
+ # 'same_reactants': False,
+ # 'reactant_1': 'cyclic_anhydride',
+ # 'reactant_2': 'epoxide',
+ # 'product': 'polyester_chain',
+ # 'delete_atom': False,
+ # 'reaction': '[CX3:1]1(=[OX1:2])-[OX2:3]-[CX3:4](=[OX1:5])-[CX4:9]-[CX4:10]1.[CX4:6]2-[OX2:7]-[CX4:8]2>>[CX3:1](=[OX1:2])-[OX2:7]-[CX4:6]-[CX4:8]-[OX2:3]-[CX3:4](=[OX1:5])-[CX4:9]-[CX4:10]',
+ # 'reference': {
+ # 'smarts': None,
+ # 'reaction_and_mechanism': None
+ # },
+ # 'comments': None
+ # }
+}
\ No newline at end of file
diff --git a/AutoREACTER/detectors/reactions_library/polyethers.py b/AutoREACTER/detectors/reactions_library/polyethers.py
new file mode 100644
index 00000000..b0c24d18
--- /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 00000000..d407f397
--- /dev/null
+++ b/AutoREACTER/detectors/reactions_library/polyimides.py
@@ -0,0 +1,15 @@
+# REACTIONS = {
+# 'Tetra-Amine and Di-Carboxylic Acid Polycondensation (PBI Formation)': {
+# 'same_reactants': False,
+# 'reactant_1': 'tetra_amine',
+# 'reactant_2': 'di_carboxylic_acid',
+# 'product': 'polybenzimidazole_chain',
+# 'delete_atom': True,
+# 'reaction': '[c:7]([NX3H2:1](-[H:6])-[H:9])-[c:8]([NX3H2:2](-[H:10])-[H:11]).[CX3:3](=[OX1:4])[OX2H1:5]-[H:12]>>[c:7]1-[NX3:1](-[H:6])-[CX3:3]=[NX2:2]-[c:8]-1.[OX2:4](-[H:9])-[H:10].[OX2:5](-[H:11])-[H:12]',
+# 'reference': {
+# 'smarts': None,
+# 'reaction_and_mechanism': None
+# },
+# 'comments': None
+# }
+# }
\ No newline at end of file
diff --git a/AutoREACTER/detectors/reactions_library/polysiloxanes.py b/AutoREACTER/detectors/reactions_library/polysiloxanes.py
new file mode 100644
index 00000000..83441ffe
--- /dev/null
+++ b/AutoREACTER/detectors/reactions_library/polysiloxanes.py
@@ -0,0 +1,40 @@
+REACTIONS = {
+ 'Dichlorosilane Hydrolysis to Silanol': {
+ 'same_reactants': False,
+ 'reactant_1': 'dichlorosilane',
+ 'reactant_2': 'water',
+ 'product': 'silanediol',
+ 'delete_atom': True,
+ 'reaction': '[Si:1]-[Cl:3].[OX2H2:2](-[H:4])-[H:5]>>[Si:1]-[OX2:2]-[H:4].[Cl:3]-[H:5]',
+ 'reference': {
+ 'smarts': None,
+ 'reaction_and_mechanism': None
+ },
+ 'notes': 'Atom maps 1 and 2 are reserved as AutoREACTER/LAMMPS bond/react initiator atoms.'
+ },
+ 'Silanediol Polycondensation(Polysiloxane Formation)': {
+ 'same_reactants': True,
+ 'reactant_1': 'silanediol',
+ 'product': 'polysiloxane_chain',
+ 'delete_atom': True,
+ 'reaction': '[Si:1]-[OX2H1:2]-[H:5].[Si:3]-[OX2H1:4]-[H:6]>>[Si:1]-[OX2:2]-[Si:3].[OX2:4](-[H:5])-[H:6]',
+ 'reference': {
+ 'smarts': None,
+ 'reaction_and_mechanism': None
+ },
+ 'notes': None
+ },
+ 'Silanediol and Silanediol Copolycondensation(Polysiloxane Formation)': {
+ 'same_reactants': False,
+ 'reactant_1': 'silanediol',
+ 'reactant_2': 'silanediol',
+ 'product': 'polysiloxane_chain',
+ 'delete_atom': True,
+ 'reaction': '[Si:1]-[OX2H1:2]-[H:5].[Si:3]-[OX2H1:4]-[H:6]>>[Si:1]-[OX2:2]-[Si:3].[OX2:4](-[H:5])-[H:6]',
+ 'reference': {
+ 'smarts': None,
+ 'reaction_and_mechanism': None
+ },
+ 'notes': None
+ }
+}
\ No newline at end of file
diff --git a/AutoREACTER/detectors/reactions_library/polysulfides.py b/AutoREACTER/detectors/reactions_library/polysulfides.py
new file mode 100644
index 00000000..4b0482b2
--- /dev/null
+++ b/AutoREACTER/detectors/reactions_library/polysulfides.py
@@ -0,0 +1,15 @@
+# REACTIONS = {
+# 'Organic Dihalide and Sodium Sulfide Polycondensation(Polysulfide Formation)': {
+# 'same_reactants': False,
+# 'reactant_1': 'organic_dihalide',
+# 'reactant_2': 'sodium_sulfide',
+# 'product': 'polysulfide_chain',
+# 'delete_atom': True,
+# 'reaction': '[CX4:1]-[Cl,Br,I:2].[S-2:3].[Na+:4].[Na+:5]>>[CX4:1]-[S-:3].[Cl-,Br-,I-:2].[Na+:4].[Na+:5]',
+# 'reference': {
+# 'smarts': None,
+# 'reaction_and_mechanism': None
+# },
+# 'comments': None
+# }
+# }
\ No newline at end of file
diff --git a/AutoREACTER/detectors/reactions_library/polythioesters.py b/AutoREACTER/detectors/reactions_library/polythioesters.py
new file mode 100644
index 00000000..830a8a50
--- /dev/null
+++ b/AutoREACTER/detectors/reactions_library/polythioesters.py
@@ -0,0 +1,54 @@
+REACTIONS = {
+ 'Dithiol and Di-Carboxylic Acid Halide Polycondensation(Polythioesterification)': {
+ 'same_reactants': False,
+ 'reactant_1': 'dithiol',
+ 'reactant_2': 'di_carboxylic_acid_halide',
+ 'product': 'polythioester_chain',
+ 'delete_atom': True,
+ 'reaction': '[CX3:1](=[O:3])[Cl,Br,I:4].[SX2H1;!$([S][C,S]=*):2]-[H:5]>>[CX3:1](=[O:3])-[SX2:2].[Cl,Br,I:4]-[H:5]',
+ 'reference': {
+ 'smarts': None,
+ 'reaction_and_mechanism': None
+ },
+ 'comments': None
+ },
+ 'Dithiol and Di-Carboxylic Acid Polycondensation(Polythioesterification)': {
+ 'same_reactants': False,
+ 'reactant_1': 'dithiol',
+ 'reactant_2': 'di_carboxylic_acid',
+ 'product': 'polythioester_chain',
+ 'delete_atom': True,
+ 'reaction': '[CX3:1](=[O:3])[OX2H1:4].[SX2H1;!$([S][C,S]=*):2]-[H:5]>>[CX3:1](=[O:3])-[SX2:2].[O:4]-[H:5]',
+ 'reference': {
+ 'smarts': None,
+ 'reaction_and_mechanism': None
+ },
+ 'comments': None
+ },
+ 'Hydroxy-Thiol and Di-Carboxylic Acid Halide Polycondensation through Hydroxy Group': {
+ 'same_reactants': False,
+ 'reactant_1': 'hydroxy_thiol',
+ 'reactant_2': 'di_carboxylic_acid_halide',
+ 'product': 'mixed_polyester_polythioester_chain',
+ 'delete_atom': True,
+ 'reaction': '[CX3:1](=[O:3])[Cl,Br,I:4].[OX2H1;!$([O][C,S]=*):2]-[H:5]>>[CX3:1](=[O:3])-[OX2:2].[Cl,Br,I:4]-[H:5]',
+ 'reference': {
+ 'smarts': None,
+ 'reaction_and_mechanism': None
+ },
+ 'comments': None
+ },
+ 'Hydroxy-Thiol and Di-Carboxylic Acid Halide Polycondensation through Thiol Group': {
+ 'same_reactants': False,
+ 'reactant_1': 'hydroxy_thiol',
+ 'reactant_2': 'di_carboxylic_acid_halide',
+ 'product': 'mixed_polyester_polythioester_chain',
+ 'delete_atom': True,
+ 'reaction': '[CX3:1](=[O:3])[Cl,Br,I:4].[SX2H1;!$([S][C,S]=*):2]-[H:5]>>[CX3:1](=[O:3])-[SX2:2].[Cl,Br,I:4]-[H:5]',
+ 'reference': {
+ 'smarts': None,
+ 'reaction_and_mechanism': None
+ },
+ 'comments': None
+ }
+}
\ No newline at end of file
diff --git a/AutoREACTER/detectors/reactions_library/polyureas.py b/AutoREACTER/detectors/reactions_library/polyureas.py
new file mode 100644
index 00000000..854b672e
--- /dev/null
+++ b/AutoREACTER/detectors/reactions_library/polyureas.py
@@ -0,0 +1,15 @@
+REACTIONS = {
+ 'Di-Amine and Di-Isocyanate Polyaddition(Polyurea Formation)': {
+ 'same_reactants': False,
+ 'reactant_1': 'di_amine',
+ 'reactant_2': 'di_isocyanate',
+ 'product': 'polyurea_chain',
+ 'delete_atom': False,
+ 'reaction': '[NX3;H2:1]-[C:3].[NX2:4]=[CX2:2]=[OX1:5]>>[NX3:1]-[CX3:2](=[OX1:5])-[NX2:4]-[C:3]',
+ 'reference': {
+ 'smarts': None,
+ 'reaction_and_mechanism': None
+ },
+ 'comments': None
+ }
+}
\ No newline at end of file
diff --git a/AutoREACTER/detectors/reactions_library/polyurethanes.py b/AutoREACTER/detectors/reactions_library/polyurethanes.py
new file mode 100644
index 00000000..3d908a6f
--- /dev/null
+++ b/AutoREACTER/detectors/reactions_library/polyurethanes.py
@@ -0,0 +1,29 @@
+REACTIONS = {
+ 'Diol and Di-Isocyanate Polyaddition(Polyurethane Formation)': {
+ 'same_reactants': False,
+ 'reactant_1': 'diol',
+ 'reactant_2': 'di_isocyanate',
+ 'product': 'polyurethane_chain',
+ 'delete_atom': False,
+ 'reaction': '[OX2H1;!$([O][C,S]=*):1]-[H:3].[NX2:4]=[CX2:2]=[OX1:5]>>[OX2:1]-[CX3:2](=[OX1:5])-[NX3:4]-[H:3]',
+ 'reference': {
+ 'smarts': None,
+ 'reaction_and_mechanism': None
+ },
+ 'comments': None
+ },
+
+ 'Dithiol and Di-Isocyanate Polyaddition(Polythiourethane Formation)': {
+ 'same_reactants': False,
+ 'reactant_1': 'dithiol',
+ 'reactant_2': 'di_isocyanate',
+ 'product': 'polythiourethane_chain',
+ 'delete_atom': False,
+ 'reaction': '[SX2H1;!$([S][C,S]=*):1]-[H:3].[NX2:4]=[CX2:2]=[OX1:5]>>[SX2:1]-[CX3:2](=[OX1:5])-[NX3:4]-[H:3]',
+ 'reference': {
+ 'smarts': None,
+ 'reaction_and_mechanism': None
+ },
+ 'comments': None
+ }
+}
\ No newline at end of file
diff --git a/AutoREACTER/detectors/reactions_library/registry.py b/AutoREACTER/detectors/reactions_library/registry.py
new file mode 100644
index 00000000..957eeadd
--- /dev/null
+++ b/AutoREACTER/detectors/reactions_library/registry.py
@@ -0,0 +1,260 @@
+"""Aggregate and validate all polymer-family reaction modules."""
+
+from __future__ import annotations
+
+try:
+ from rdkit.Chem import rdChemReactions
+except ImportError: # pragma: no cover
+ rdChemReactions = None
+
+
+try:
+ from .polyesters import REACTIONS as POLYESTERS
+ # from .polyethers import REACTIONS as POLYETHERS
+ from .polyamides import REACTIONS as POLYAMIDES
+ from .polyanhydrides import REACTIONS as POLYANHYDRIDES
+ from .polythioesters import REACTIONS as POLYTHIOESTERS
+ from .polyurethanes import REACTIONS as POLYURETHANES
+ from .polyureas import REACTIONS as POLYUREAS
+ from .epoxy_polymers import REACTIONS as EPOXY_POLYMERS
+ from .vinyl_polymers import REACTIONS as VINYL_POLYMERS
+ from .polycarbonates import REACTIONS as POLYCARBONATES
+ # from .polyimides import REACTIONS as POLYIMIDES
+ # from .polybenzimidazoles import REACTIONS as POLYBENZIMIDAZOLES
+ # from .phenolic_resins import REACTIONS as PHENOLIC_RESINS
+ from .polysiloxanes import REACTIONS as POLYSILOXANES
+ # from .polysulfides import REACTIONS as POLYSULFIDES
+ from .thiol_ene_polymers import REACTIONS as THIOL_ENE_POLYMERS
+ # from .metathesis_polymers import REACTIONS as METATHESIS_POLYMERS
+ # from .cycloaddition_polymers import REACTIONS as CYCLOADDITION_POLYMERS
+
+except ImportError as e:
+ from polyesters import REACTIONS as POLYESTERS
+ from polyamides import REACTIONS as POLYAMIDES
+ from polyanhydrides import REACTIONS as POLYANHYDRIDES
+ from polythioesters import REACTIONS as POLYTHIOESTERS
+ from polyurethanes import REACTIONS as POLYURETHANES
+ from polyureas import REACTIONS as POLYUREAS
+ from epoxy_polymers import REACTIONS as EPOXY_POLYMERS
+ from vinyl_polymers import REACTIONS as VINYL_POLYMERS
+ from polycarbonates import REACTIONS as POLYCARBONATES
+ from polysiloxanes import REACTIONS as POLYSILOXANES
+ from thiol_ene_polymers import REACTIONS as THIOL_ENE_POLYMERS
+
+
+_REACTION_MODULES = [
+ POLYESTERS,
+ # POLYETHERS,
+ POLYAMIDES,
+ POLYANHYDRIDES,
+ POLYTHIOESTERS,
+ POLYURETHANES,
+ POLYUREAS,
+ EPOXY_POLYMERS,
+ VINYL_POLYMERS,
+ POLYCARBONATES,
+ # POLYIMIDES,
+ # POLYBENZIMIDAZOLES,
+ # PHENOLIC_RESINS,
+ POLYSILOXANES,
+ # POLYSULFIDES,
+ THIOL_ENE_POLYMERS,
+ # METATHESIS_POLYMERS,
+ # CYCLOADDITION_POLYMERS,
+]
+
+
+class ReactionLibraryValidationError(ValueError):
+ """Raised when a reaction-library SMARTS violates AutoREACTER rules."""
+
+
+def _atom_maps_in_templates(templates) -> set[int]:
+ """Return all nonzero atom-map numbers present in RDKit templates."""
+ atom_maps: set[int] = set()
+
+ for template in templates:
+ for atom in template.GetAtoms():
+ atom_map = atom.GetAtomMapNum()
+ if atom_map:
+ atom_maps.add(atom_map)
+
+ return atom_maps
+
+
+def _has_bond_between_atom_maps(
+ templates,
+ atom_map_1: int,
+ atom_map_2: int,
+) -> bool:
+ """Return True if any template contains a bond between two atom maps."""
+ target = {atom_map_1, atom_map_2}
+
+ for template in templates:
+ for bond in template.GetBonds():
+ begin_map = bond.GetBeginAtom().GetAtomMapNum()
+ end_map = bond.GetEndAtom().GetAtomMapNum()
+
+ if {begin_map, end_map} == target:
+ return True
+
+ return False
+
+
+def _validate_reaction_smarts(
+ reaction_name: str,
+ reaction: dict,
+) -> list[str]:
+ """
+ Validate one reaction-library entry.
+
+ AutoREACTER convention:
+ atom maps :1 and :2 are reserved as LAMMPS bond/react initiator atoms.
+
+ By default this validator requires:
+ - reaction["reaction"] exists
+ - maps 1 and 2 exist in reactants
+ - maps 1 and 2 exist in products
+ - products contain a bond between map 1 and map 2
+
+ A reaction can override this with:
+ "initiator_atom_maps": (a, b)
+
+ A special reaction can skip this check with:
+ "validate_initiator_bond": False
+ """
+ errors: list[str] = []
+
+ smarts = reaction.get("reaction")
+
+ if not smarts:
+ return [f"{reaction_name}: missing required key 'reaction'"]
+
+ if reaction.get("validate_initiator_bond", True) is False:
+ return errors
+
+ if rdChemReactions is None:
+ return [f"{reaction_name}: RDKit is required to validate reaction SMARTS"]
+
+ initiator_atom_maps = reaction.get("initiator_atom_maps", (1, 2))
+
+ if len(initiator_atom_maps) != 2:
+ return [
+ f"{reaction_name}: initiator_atom_maps must contain exactly two atom maps"
+ ]
+
+ initiator_1, initiator_2 = map(int, initiator_atom_maps)
+
+ try:
+ rdkit_reaction = rdChemReactions.ReactionFromSmarts(smarts)
+ except Exception as error:
+ return [f"{reaction_name}: invalid reaction SMARTS: {error}"]
+
+ if rdkit_reaction is None:
+ return [f"{reaction_name}: RDKit could not parse reaction SMARTS"]
+
+ reactant_templates = [
+ rdkit_reaction.GetReactantTemplate(i)
+ for i in range(rdkit_reaction.GetNumReactantTemplates())
+ ]
+
+ product_templates = [
+ rdkit_reaction.GetProductTemplate(i)
+ for i in range(rdkit_reaction.GetNumProductTemplates())
+ ]
+
+ reactant_maps = _atom_maps_in_templates(reactant_templates)
+ product_maps = _atom_maps_in_templates(product_templates)
+
+ required_maps = {initiator_1, initiator_2}
+
+ missing_reactant_maps = required_maps - reactant_maps
+ missing_product_maps = required_maps - product_maps
+
+ if missing_reactant_maps:
+ errors.append(
+ f"{reaction_name}: initiator atom maps missing from reactants: "
+ f"{sorted(missing_reactant_maps)}"
+ )
+
+ if missing_product_maps:
+ errors.append(
+ f"{reaction_name}: initiator atom maps missing from products: "
+ f"{sorted(missing_product_maps)}"
+ )
+
+ product_has_initiator_bond = _has_bond_between_atom_maps(
+ product_templates,
+ initiator_1,
+ initiator_2,
+ )
+
+ if not product_has_initiator_bond:
+ errors.append(
+ f"{reaction_name}: product does not contain required "
+ f"AutoREACTER initiator bond between atom maps "
+ f"{initiator_1} and {initiator_2}"
+ )
+
+ return errors
+
+
+def validate_reactions(reactions: dict) -> None:
+ """Validate the merged AutoREACTER reaction library."""
+ errors: list[str] = []
+
+ for reaction_name, reaction in reactions.items():
+ if not isinstance(reaction, dict):
+ errors.append(f"{reaction_name}: reaction entry must be a dictionary")
+ continue
+
+ errors.extend(_validate_reaction_smarts(reaction_name, reaction))
+
+ if errors:
+ message = "\n".join(f" - {error}" for error in errors)
+ raise ReactionLibraryValidationError(
+ "Reaction library validation failed:\n" + message
+ )
+
+
+def load_reactions() -> dict:
+ """Return one flat reaction dictionary with duplicate-name protection."""
+ merged = {}
+
+ for module in _REACTION_MODULES:
+ for reaction_name, reaction in module.items():
+ if reaction_name in merged:
+ raise ValueError(f"Duplicate reaction name: {reaction_name}")
+ merged[reaction_name] = reaction
+
+ validate_reactions(merged)
+
+ return merged
+
+
+REACTIONS = load_reactions()
+
+
+class ReactionLibrary:
+ """Backward-compatible class exposing ``self.reactions``."""
+
+ def __init__(self):
+ self.reactions = load_reactions()
+
+
+if __name__ == "__main__":
+ REACTIONS = load_reactions()
+ num = 0
+
+ with open("reactions.txt", "w") as f:
+ for reaction in REACTIONS.items():
+ f.write(str(reaction) + "\n")
+
+ reaction_len = len(REACTIONS)
+
+ import os
+
+ file_abs_path = os.path.abspath("reactions.txt")
+ print(
+ f"reactions.txt has been written to {file_abs_path}, "
+ f"num reactions: {reaction_len}"
+ )
\ No newline at end of file
diff --git a/AutoREACTER/detectors/reactions_library/thiol_ene_polymers.py b/AutoREACTER/detectors/reactions_library/thiol_ene_polymers.py
new file mode 100644
index 00000000..59b9a9da
--- /dev/null
+++ b/AutoREACTER/detectors/reactions_library/thiol_ene_polymers.py
@@ -0,0 +1,44 @@
+REACTIONS = {
+ 'Dithiol and Diene Thiol-Ene Click Polymerization': {
+ 'same_reactants': False,
+ 'reactant_1': 'dithiol',
+ 'reactant_2': 'diene',
+ 'product': 'poly_thioether_chain',
+ 'delete_atom': False,
+
+ # Thiol-ene addition:
+ #
+ # S-H + CH2=C
+ # ↓
+ # S-CH2-C-H
+ #
+ # AutoREACTER/LAMMPS convention:
+ # maps 1 and 2 are the initiator atoms
+ # the new bond is 1-2
+ #
+ # Important:
+ # Product map 2 is written as [C:2], not [CH2:2].
+ # When reactants contain explicit hydrogens (Chem.AddHs),
+ # [CH2:2] would add an additional hydrogen specification
+ # while RDKit also preserves the mapped atom's existing H
+ # neighbors, producing an invalid carbon valence.
+ #
+ # Map 1 = thiol sulfur
+ # Map 2 = terminal alkene carbon
+ # Map 3 = substituted alkene carbon
+ # Map 5 = transferred thiol hydrogen
+ 'reaction': (
+ '[SX2H1:1]-[H:5].'
+ '[CH2:2]=[C;!R:3]'
+ '>>'
+ '[SX2:1]-[C:2]-[C:3]-[H:5]'
+ ),
+
+ 'reference': {
+ 'smarts': None,
+ 'reaction_and_mechanism': None
+ },
+
+ 'comments': None
+ }
+}
\ No newline at end of file
diff --git a/AutoREACTER/detectors/reactions_library/vinyl_polymers.py b/AutoREACTER/detectors/reactions_library/vinyl_polymers.py
new file mode 100644
index 00000000..34bf66ac
--- /dev/null
+++ b/AutoREACTER/detectors/reactions_library/vinyl_polymers.py
@@ -0,0 +1,435 @@
+REACTIONS = {
+
+ # =========================================================================
+ # Vinyl Addition Polymerization
+ # =========================================================================
+
+ 'Vinyl Addition Polymerization Initiation': {
+ 'same_reactants': True,
+ 'reactant_1': 'vinyl',
+ 'product': 'vinyl_chain_end_radical',
+ 'delete_atom': False,
+
+ # H-T self-initiation cheat:
+ #
+ # tail=head + tail=head
+ # ↓
+ # cap-tail-head-tail-head*
+ #
+ # AutoREACTER/LAMMPS rule:
+ # maps 1 and 2 are the initiator atoms.
+ # the new bond is 1-2.
+ #
+ # Map 3 = first vinyl terminal CH2 tail; capped as CH3
+ # Map 1 = first vinyl substituted head
+ # Map 2 = second vinyl terminal CH2 tail
+ # Map 4 = second vinyl substituted head; becomes active radical
+ 'reaction': (
+ '[CH2:3]=[C;!R:1].'
+ '[CH2:2]=[C;!R:4]'
+ '>>'
+ '[CH3:3]-[C:1]-[CH2:2]-[C;!R:4]'
+ ),
+
+ 'reference': {
+ 'smarts': None,
+ 'reaction_and_mechanism': None
+ },
+
+ 'comments': None,
+
+ 'notes': (
+ 'Self-initiation cheat for styrene/vinyl. H-T topology. '
+ 'New bond is maps 1-2. Map 3 is capped as CH3 so the '
+ 'post-initiation template matches propagation. Map 4 is the '
+ 'new active chain-end radical.'
+ ),
+ },
+
+
+ 'Vinyl Addition Polymerization Propagation': {
+ 'same_reactants': False,
+ 'reactant_1': 'vinyl',
+ 'reactant_2': 'vinyl_chain_end_radical',
+ 'product': 'vinyl_chain_end_radical',
+ 'delete_atom': False,
+
+ # H-T propagation:
+ #
+ # chain-head* + tail=head
+ # ↓
+ # chain-head-tail-head*
+ #
+ # AutoREACTER/LAMMPS rule:
+ # maps 1 and 2 are the initiator atoms.
+ # the new bond is 1-2.
+ #
+ # Map 1 = existing radical chain end/head.
+ # This is forced by the loop-detected
+ # vinyl_chain_end_radical index.
+ #
+ # Map 2 = incoming vinyl terminal CH2 tail
+ # Map 3 = incoming vinyl substituted head; becomes new radical
+ 'reaction': (
+ '[CH2:2]=[C;!R:3].'
+ '[C;!R:1]'
+ '>>'
+ '[C:1]-[CH2:2]-[C;!R:3]'
+ ),
+
+ 'reference': {
+ 'smarts': None,
+ 'reaction_and_mechanism': None
+ },
+
+ 'comments': None,
+
+ 'notes': (
+ 'Head-to-tail vinyl propagation. Existing chain-end radical map '
+ '1 bonds to incoming vinyl tail map 2. Map 3 becomes the new '
+ 'active chain-end radical. Map 1 is selected from explicit '
+ 'radical detection during loop progression.'
+ ),
+ },
+'Vinyl Radical Coupling Termination (Same Chain)': {
+ '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,
+ 'notes': (
+ 'Head-to-head radical coupling termination between two radical '
+ 'chain ends of the SAME monomer type. Maps 1 and 2 are the two '
+ 'active radical head carbons and form the new 1-2 termination bond.'
+ ),
+},
+
+'Vinyl Radical Coupling Termination (Cross Chain)': {
+ 'same_reactants': False,
+ 'reactant_1': 'vinyl_chain_end_radical',
+ 'reactant_2': 'vinyl_chain_end_radical', # FIXED: was missing entirely
+ '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,
+ 'notes': (
+ 'Same coupling chemistry as the same-chain entry above, but '
+ 'reactant_2 is explicitly given so this pairs radical chain ends '
+ 'coming from TWO DIFFERENT monomer types (e.g. a PMMA-derived '
+ 'radical terminating against a TEGDMA-derived radical).'
+ ),
+},
+
+
+ # =========================================================================
+ # Vinyl Copolymerization Initiation
+ # =========================================================================
+
+ 'Vinyl Copolymerization Initiation': {
+ 'same_reactants': False,
+ 'reactant_1': 'vinyl',
+ 'reactant_2': 'vinyl',
+ 'product': 'vinyl_chain_end_radical',
+ 'delete_atom': False,
+
+ # H-T branchable-vinyl copolymerization initiation:
+ #
+ # branchable tail=head + vinyl tail=head
+ # ↓
+ # capped-branchable-head-tail-vinyl-head*
+ #
+ # This intentionally forces the rare branchable vinyl into the seed.
+ #
+ # AutoREACTER/LAMMPS rule:
+ # maps 1 and 2 are the initiator atoms.
+ # the new bond is 1-2.
+ #
+ # Map 3 = branchable vinyl terminal CH2 tail; capped as CH3
+ # Map 1 = branchable vinyl substituted head
+ # Map 2 = incoming normal vinyl terminal CH2 tail
+ # Map 4 = incoming normal vinyl substituted head; becomes active radical
+ 'reaction': (
+ '[CH2:3]=[C;!R:1].'
+ '[CH2:2]=[C;!R:4]'
+ '>>'
+ '[CH3:3]-[C:1]-[CH2:2]-[C;!R;D3;v3;+0:4]'
+ ),
+
+ 'reference': {
+ 'smarts': None,
+ 'reaction_and_mechanism': None
+ },
+
+ 'comments': None,
+
+ 'notes': (
+ 'Head-to-tail vinyl copolymerization initiation. '
+ 'Maps 1 and 2 form the new bond. Map 3 is capped as CH3 '
+ 'and map 4 becomes the active chain-end radical.'
+ ),
+ },
+
+
+ # =========================================================================
+ # Currently Disabled Cyclic / Legacy Vinyl 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]'
+ # ),
+ # '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
+ # },
+
+
+ # -------------------------------------------------------------------------
+ # Cyclic olefin reactions intentionally remain disabled
+ # -------------------------------------------------------------------------
+
+ # 'Cyclic Olefin Addition Polymerization': {
+ # 'same_reactants': True,
+ # 'reactant_1': 'cyclic_olefin',
+ # 'product': 'polycyclic_chain',
+ # 'delete_atom': False,
+ #
+ # 'reaction': (
+ # '[CX3;R:1]=[CX3;R:2].'
+ # '[CX3;R:3]=[CX3;R:4]'
+ # '>>'
+ # '[CX4:1]-[CX4:2]-[CX4:3]-[CX4:4]'
+ # ),
+ #
+ # 'reference': {
+ # 'smarts': None,
+ # 'reaction_and_mechanism': None
+ # },
+ #
+ # 'comments': None
+ # },
+
+
+ # 'Cyclic Olefin and Vinyl Copolymerization': {
+ # 'same_reactants': False,
+ # 'reactant_1': 'vinyl',
+ # 'reactant_2': 'cyclic_olefin',
+ # 'product': 'copolycyclicvinyl_chain',
+ # 'delete_atom': False,
+ #
+ # 'reaction': (
+ # '[CH2:1]=[C;!R:2].'
+ # '[C;R:3]=[C;R:4]'
+ # '>>'
+ # '[C:1]-[C:2]-[C:3]-[C:4]'
+ # ),
+ #
+ # 'reference': {
+ # 'smarts': None,
+ # 'reaction_and_mechanism': None
+ # },
+ #
+ # 'comments': None
+ # },
+
+
+ # 'Cyclic Olefin Copolymerization': {
+ # 'same_reactants': False,
+ # 'reactant_1': 'cyclic_olefin',
+ # 'reactant_2': 'cyclic_olefin',
+ # 'product': 'copolycyclic_chain',
+ # 'delete_atom': False,
+ #
+ # 'reaction': (
+ # '[CX3;R:1]=[CX3;R:2].'
+ # '[CX3;R:3]=[CX3;R:4]'
+ # '>>'
+ # '[CX4:1]-[CX4:2]-[CX4:3]-[CX4:4]'
+ # ),
+ #
+ # 'reference': {
+ # 'smarts': None,
+ # 'reaction_and_mechanism': None
+ # },
+ #
+ # 'comments': None
+ # },
+
+
+ # =========================================================================
+ # Tetrafluoroethylene / PTFE
+ # =========================================================================
+
+ 'Tetrafluoroethylene Initiation': {
+ 'same_reactants': False,
+ 'reactant_1': 'tetrafluoroethylene',
+ 'reactant_2': 'tetrafluoroethylene',
+ 'product': 'vinyl_chain_end_radical',
+ 'delete_atom': False,
+
+ # AutoREACTER/LAMMPS rule:
+ #
+ # map 1 + map 2
+ # ↓
+ # new 1-2 bond
+ #
+ # Map 1 = existing initiating/active atom
+ # Map 2 = one TFE carbon
+ # Map 3 = second TFE carbon
+ # Maps 4-7 = fluorines
+ 'reaction': (
+ '[*:1].'
+ '[CX3:2](-[F:4])(-[F:5])='
+ '[CX3:3](-[F:6])(-[F:7])'
+ '>>'
+ '[*:1]-'
+ '[CX4:2](-[F:4])(-[F:5])-'
+ '[CX4:3](-[F:6])(-[F:7])'
+ ),
+
+ 'reference': {
+ 'smarts': None,
+ 'reaction_and_mechanism': None
+ },
+
+ 'comments': (
+ 'Initiation of TFE to form the first active center'
+ ),
+
+ 'notes': (
+ 'The new TFE initiation bond is between atom maps 1 and 2.'
+ ),
+ },
+
+
+ 'Tetrafluoroethylene Propagation': {
+ 'same_reactants': False,
+ 'reactant_1': 'vinyl_chain_end_radical',
+ 'reactant_2': 'tetrafluoroethylene',
+ 'product': 'vinyl_chain_end_radical',
+ 'delete_atom': False,
+
+ # PTFE propagation:
+ #
+ # chain-1-2 3=8
+ #
+ # ↓
+ #
+ # chain-1-2-3-8
+ #
+ # IMPORTANT:
+ #
+ # Map 1-2 is already bonded in the reactant.
+ #
+ # The new propagation bond is:
+ #
+ # 2-3
+ #
+ # Therefore this reaction must override the registry default
+ # initiator_atom_maps=(1, 2).
+ 'initiator_atom_maps': (2, 3),
+
+ 'reaction': (
+ '[*:1]-'
+ '[CX4:2](-[F:4])(-[F:5]).'
+ '[CX3:3](-[F:6])(-[F:7])='
+ '[CX3:8](-[F:9])(-[F:10])'
+ '>>'
+ '[*:1]-'
+ '[CX4:2](-[F:4])(-[F:5])-'
+ '[CX4:3](-[F:6])(-[F:7])-'
+ '[CX4:8](-[F:9])(-[F:10])'
+ ),
+
+ 'reference': {
+ 'smarts': None,
+ 'reaction_and_mechanism': None
+ },
+
+ 'comments': (
+ 'Propagation step for PTFE chain growth'
+ ),
+
+ 'notes': (
+ 'The new propagation bond is atom maps 2-3. '
+ 'Maps 1-2 are already connected in the incoming chain, '
+ 'so initiator_atom_maps is explicitly set to (2, 3).'
+ ),
+ },
+
+
+ # =========================================================================
+ # Optional PTFE Termination
+ # =========================================================================
+
+ # 'Tetrafluoroethylene Termination (Recombination)': {
+ # 'same_reactants': True,
+ # 'reactant_1': 'vinyl_chain_end_radical',
+ # 'product': 'ptfe_chain',
+ # 'delete_atom': False,
+ #
+ # # Two growing chains meet and recombine at their active
+ # # tetrafluoroethylene chain centers.
+ #
+ # 'reaction': (
+ # '[*:1]-[CX4:2](-[F:4])(-[F:5]).'
+ # '[*:3]-[CX4:6](-[F:7])(-[F:8])'
+ # '>>'
+ # '[*:1]-'
+ # '[CX4:2](-[F:4])(-[F:5])-'
+ # '[CX4:6](-[F:7])(-[F:8])-'
+ # '[*:3]'
+ # ),
+ #
+ # 'reference': {
+ # 'smarts': None,
+ # 'reaction_and_mechanism': None
+ # },
+ #
+ # 'comments': (
+ # 'UNTESTED: Radical recombination termination for PTFE'
+ # )
+ # },
+}
\ No newline at end of file
diff --git a/AutoREACTER/input_parser.py b/AutoREACTER/input_parser.py
index be05278d..64c6ed4e 100644
--- a/AutoREACTER/input_parser.py
+++ b/AutoREACTER/input_parser.py
@@ -2,6 +2,7 @@
import logging
from dataclasses import dataclass
from pathlib import Path
+
from typing import Any, Literal, Optional
from PIL.Image import Image
@@ -39,9 +40,11 @@ class SmilesValidationError(InputError):
class DuplicateMonomerError(InputError):
"""Raised when duplicate monomer definitions are detected."""
+
class CompatibilityError(InputError):
"""Raised when input combinations are incompatible with the current workflow."""
+
# Type aliases for clarity and validation.
CompositionMethodType = Literal["counts", "ratio"]
ForceFieldType = Literal[
@@ -75,20 +78,21 @@ class MonomerEntry:
count: Dictionary mapping replica tags to integer counts in counts mode.
ratio: Molar ratio of the monomer in ratio mode.
rdkit_mol: RDKit Mol object corresponding to the monomer SMILES.
- molecule_3Dmol_path: Optional file path to the 3D .mol representation.
+ molecule_3Dmol_path: Optional file path to the 3D .mol representation. Later stages will generate this file.
+ lmp_molecule_file: Optional file path to the LAMMPS molecule file. Later stages will generate this file.
num_atoms: Number of atoms in the monomer with hydrogens included.
molecular_weight: Molecular weight of the monomer from RDKit.
status: Boolean indicating whether the monomer should be included.
"""
-
id: int
data_id: str
name: str | None
smiles: str
- count: dict | None # None only if ratio mode.
- ratio: float | None # None only if counts mode.
+ count: dict | None
+ ratio: float | None
rdkit_mol: Chem.Mol | None = None
molecule_3Dmol_path: Optional[Path] = None
+ lmp_molecule_file: Optional[Path] = None
num_atoms: int | None = None
molecular_weight: float | None = None
status: bool = True
@@ -142,6 +146,14 @@ class SimulationSetup:
ratio: Optional mapping of monomer IDs to ratios.
number_of_total_atoms: Optional list of total atom targets.
box_estimates: Optional estimated box size placeholder.
+ deep_search: Enables deeper reaction/functional-group searching.
+ reaction_iteration_depth: Maximum reaction progression depth. Defaults
+ to 5 when omitted. A value of 0 disables looping. Values greater
+ than 0 enable looping for that many iterations.
+ wildcards: Enables wildcard handling when supported downstream.
+ deduplicate_reaction_templates: Enables LAMMPS template deduplication.
+ write_second_reaction_stage: Enables writing the second reaction-stage
+ LAMMPS input files.
"""
simulation_name: str
@@ -149,12 +161,20 @@ class SimulationSetup:
density: list[float]
force_field: str | None
monomers: list[MonomerEntry]
+ input_json: dict | None = None
+ max_loop_count: int | None = None
simulations: list[Simulation] | None = None
composition_method: CompositionMethodType | None = None
composition: dict[str, Any] | None = None
ratio: dict[int, float] | None = None
number_of_total_atoms: list[int] | None = None
box_estimates: float | None = None
+ deep_search: bool = True
+ loop: bool = True
+ reaction_iteration_depth: int = 5
+ wildcards: bool = False
+ deduplicate_reaction_templates: bool = True
+ write_second_reaction_stage: bool = False
class InputParser:
@@ -179,7 +199,7 @@ def validate_inputs(self, inputs: dict) -> SimulationSetup:
self.validate_basic_format(inputs)
simulation_name = inputs["simulation_name"]
- simulations_list = inputs["simulations"]
+ simulations_list = inputs["simulations"]
composition_method = self._get_inputs_mode(simulations_list)
validated_simulations = self._validate_simulations(
@@ -203,6 +223,64 @@ def validate_inputs(self, inputs: dict) -> SimulationSetup:
inputs.get("force_field", None)
)
+ reaction_iteration_depth = self._validate_reaction_iteration_depth(
+ inputs
+ )
+
+ if reaction_iteration_depth == 0:
+ loop = False
+ max_loop_count = None
+ else:
+ loop = True
+ max_loop_count = reaction_iteration_depth
+
+ deep_search = self._validate_bool_option(
+ inputs,
+ key="deep_search",
+ default=True,
+ aliases=[
+ "deepsearch",
+ "deep_search",
+ "deep-search",
+ "DEEP_SEARCH",
+ ],
+ )
+ wildcards = self._validate_bool_option(
+ inputs,
+ key="wildcards",
+ default=False,
+ aliases=[
+ "wildcard",
+ "wildcards",
+ "use_wildcards",
+ "use-wildcards",
+ ],
+ )
+ deduplicate_reaction_templates = self._validate_bool_option(
+ inputs,
+ key="deduplicate_reaction_templates",
+ default=True,
+ aliases=[
+ "deduplicate_reaction_templates",
+ "deduplicate-templates",
+ "template_deduplication",
+ "template_dedup",
+ "dedup_templates",
+ ],
+ )
+ write_second_reaction_stage = self._validate_bool_option(
+ inputs,
+ key="write_second_reaction_stage",
+ default=True,
+ aliases=[
+ "write_second_reaction_stage",
+ "write-second-reaction-stage",
+ "second_stage",
+ "write_stage_2",
+ "stage_2",
+ ],
+ )
+
return SimulationSetup(
simulation_name=simulation_name,
temperature=validated_simulations["temperatures"],
@@ -212,6 +290,14 @@ def validate_inputs(self, inputs: dict) -> SimulationSetup:
composition_method=composition_method,
composition=validated_simulations,
force_field=force_field,
+ loop=loop,
+ max_loop_count=max_loop_count,
+ input_json=inputs,
+ deep_search=deep_search,
+ reaction_iteration_depth=reaction_iteration_depth,
+ wildcards=wildcards,
+ deduplicate_reaction_templates=deduplicate_reaction_templates,
+ write_second_reaction_stage=write_second_reaction_stage,
)
def molecule_representation_of_initial_molecules(
@@ -281,7 +367,26 @@ def validate_basic_format(self, inputs: dict) -> None:
raise InputSchemaError(
f"Missing required key: {key!r} in inputs dictionary."
)
-
+
+ simulation_name = inputs["simulation_name"]
+ if not isinstance(simulation_name, str) or not simulation_name.strip():
+ raise InputSchemaError(
+ "'simulation_name' must be a non-empty string. "
+ f"Got: {simulation_name!r}"
+ )
+
+ monomers = inputs["monomers"]
+
+ if not isinstance(monomers, list):
+ raise InputSchemaError(
+ "'monomers' must be a list."
+ )
+
+ for monomer in monomers:
+ if not isinstance(monomer, dict):
+ raise InputSchemaError(
+ f"Each monomer entry must be a dictionary. Got: {monomer!r}"
+ )
def _get_inputs_mode(self, simulations_list: list) -> CompositionMethodType:
"""
@@ -349,7 +454,9 @@ def _validate_temperature(self, temp: Any) -> float:
NumericFieldError: If the value is not a positive number.
"""
if isinstance(temp, bool) or not isinstance(temp, (int, float)):
- raise NumericFieldError(f"'temperature' must be a number. Got: {temp!r}")
+ raise NumericFieldError(
+ f"'temperature' must be a number. Got: {temp!r}"
+ )
if temp <= 0:
raise NumericFieldError(
@@ -384,11 +491,11 @@ def _validate_density(self, density: Any) -> float:
)
return density_value
-
+
_FF_ALIASES: dict[str, ForceFieldType] = {
"pcff-iff": "PCFF-IFF",
"pcff": "PCFF",
- "compass": "compass",
+ "compass": "Compass",
"cvff-iff": "CVFF-IFF",
"cvff": "CVFF",
"clay-ff": "Clay-FF",
@@ -401,7 +508,6 @@ def _validate_density(self, density: Any) -> float:
"gaff": "GAFF",
}
-
def _validate_force_field(self, force_field: Any) -> ForceFieldType:
"""
Validates and normalizes the force field input.
@@ -417,9 +523,13 @@ def _validate_force_field(self, force_field: Any) -> ForceFieldType:
normalized_input = force_field.strip().lower()
if normalized_input not in self._FF_ALIASES:
- raise InputSchemaError(f"Unsupported force field: {force_field!r}")
+ raise InputSchemaError(
+ f"Unsupported force field: {force_field!r}"
+ )
- canonical_force_field = self._FF_ALIASES[normalized_input]
+ canonical_force_field = self._FF_ALIASES[
+ normalized_input
+ ]
if canonical_force_field in ["OPLSAA", "GAFF"]:
raise CompatibilityError(
@@ -478,7 +588,11 @@ def _validate_composition(
if method == "ratio":
total_atoms = target.get("total_atoms")
- if isinstance(total_atoms, bool) or not isinstance(total_atoms, int) or total_atoms <= 0:
+ if (
+ isinstance(total_atoms, bool)
+ or not isinstance(total_atoms, int)
+ or total_atoms <= 0
+ ):
raise NumericFieldError(
f"'total_atoms' must be a positive integer for ratio mode. Got: {total_atoms!r}"
)
@@ -511,6 +625,11 @@ def _validate_system_monomer_keys(
allowed_names: set[str] = set()
for monomer_id, monomer in enumerate(monomers, start=1):
+ if not isinstance(monomer, dict):
+ raise InputSchemaError(
+ f"Each monomer entry must be a dictionary. Got: {monomer!r}"
+ )
+
name = monomer.get("name")
if not isinstance(name, str) or not name.strip():
@@ -519,11 +638,17 @@ def _validate_system_monomer_keys(
allowed_names.add(name)
- field_name = "monomer_counts" if method == "counts" else "monomer_ratios"
+ field_name = (
+ "monomer_counts"
+ if method == "counts"
+ else "monomer_ratios"
+ )
for system in systems:
tag = system["tag"]
- provided_names = set(system.get(field_name, {}).keys())
+ provided_names = set(
+ system.get(field_name, {}).keys()
+ )
extra = provided_names - allowed_names
missing = allowed_names - provided_names
@@ -560,9 +685,14 @@ def _validate_monomer_entry(
monomers = inputs.get("monomers")
if not isinstance(monomers, list):
- raise InputSchemaError("'monomers' must be a list.")
+ raise InputSchemaError(
+ "'monomers' must be a list."
+ )
- for monomer_id, monomer_dict in enumerate(monomers, start=1):
+ for monomer_id, monomer_dict in enumerate(
+ monomers,
+ start=1,
+ ):
if not isinstance(monomer_dict, dict):
raise InputSchemaError(
f"Each monomer entry must be a dictionary. Got: {monomer_dict!r}"
@@ -573,17 +703,31 @@ def _validate_monomer_entry(
name = f"data_{monomer_id}"
smiles_raw = monomer_dict.get("smiles")
- smiles, mol = self._validate_smiles(smiles_raw)
- seen_smiles = self.validate_no_duplicate_smiles(smiles, seen_smiles)
+ smiles, mol = self._validate_smiles(
+ smiles_raw
+ )
+
+ seen_smiles = (
+ self.validate_no_duplicate_smiles(
+ smiles,
+ seen_smiles,
+ )
+ )
- num_atoms, molecular_weight = self._derive_molecule_properties(mol)
+ num_atoms, molecular_weight = (
+ self._derive_molecule_properties(
+ mol
+ )
+ )
if method == "counts":
count_map: dict[str, int] = {}
for system in systems:
tag = system["tag"]
- monomer_counts = system["monomer_counts"]
+ monomer_counts = system[
+ "monomer_counts"
+ ]
if name not in monomer_counts:
raise InputSchemaError(
@@ -591,7 +735,12 @@ def _validate_monomer_entry(
)
value = monomer_counts[name]
- if isinstance(value, bool) or not isinstance(value, int) or value < 0:
+
+ if (
+ isinstance(value, bool)
+ or not isinstance(value, int)
+ or value < 0
+ ):
raise NumericFieldError(
f"Invalid count for monomer {name!r} in system '{tag}'."
)
@@ -603,7 +752,9 @@ def _validate_monomer_entry(
else:
first_system = systems[0]
- ratios = first_system["monomer_ratios"]
+ ratios = first_system[
+ "monomer_ratios"
+ ]
if name not in ratios:
raise InputSchemaError(
@@ -612,7 +763,14 @@ def _validate_monomer_entry(
ratio_value = ratios[name]
- if isinstance(ratio_value, bool) or not isinstance(ratio_value, (int, float)) or ratio_value < 0:
+ if (
+ isinstance(ratio_value, bool)
+ or not isinstance(
+ ratio_value,
+ (int, float),
+ )
+ or ratio_value < 0
+ ):
raise NumericFieldError(
f"Invalid ratio for monomer {name!r}."
)
@@ -636,7 +794,10 @@ def _validate_monomer_entry(
return validated_monomers
- def _derive_molecule_properties(self, mol: Chem.Mol) -> tuple[int, float]:
+ def _derive_molecule_properties(
+ self,
+ mol: Chem.Mol,
+ ) -> tuple[int, float]:
"""
Derives simple molecule properties from an RDKit Mol object.
@@ -647,8 +808,14 @@ def _derive_molecule_properties(self, mol: Chem.Mol) -> tuple[int, float]:
Tuple of number of atoms with hydrogens included and molecular weight.
"""
mol_with_h = Chem.AddHs(mol)
- num_atoms = int(mol_with_h.GetNumAtoms())
- molecular_weight = float(Descriptors.MolWt(mol))
+ num_atoms = int(
+ mol_with_h.GetNumAtoms()
+ )
+
+ molecular_weight = float(
+ Descriptors.MolWt(mol)
+ )
+
return num_atoms, molecular_weight
def _int_to_dict(self, integer: int) -> dict:
@@ -661,7 +828,10 @@ def _int_to_dict(self, integer: int) -> dict:
"""
return {"_": integer}
- def _validate_smiles(self, smiles: Any) -> tuple[str, Chem.Mol]:
+ def _validate_smiles(
+ self,
+ smiles: Any,
+ ) -> tuple[str, Chem.Mol]:
"""
Validates a SMILES string through RDKit and canonicalizes it.
@@ -674,23 +844,35 @@ def _validate_smiles(self, smiles: Any) -> tuple[str, Chem.Mol]:
Raises:
SmilesValidationError: If RDKit cannot parse the string.
"""
- if not isinstance(smiles, str) or not smiles.strip():
+ if (
+ not isinstance(smiles, str)
+ or not smiles.strip()
+ ):
raise SmilesValidationError(
f"SMILES must be a non-empty string. Got: {smiles!r}"
)
smiles_clean = smiles.strip()
- mol = Chem.MolFromSmiles(smiles_clean)
+ mol = Chem.MolFromSmiles(
+ smiles_clean
+ )
if mol is None:
raise SmilesValidationError(
f"Invalid SMILES string: {smiles!r}. RDKit failed to parse it."
)
- canonical_smiles = Chem.MolToSmiles(mol, canonical=True)
+ canonical_smiles = Chem.MolToSmiles(
+ mol,
+ canonical=True,
+ )
+
return canonical_smiles, mol
- def _validate_numeric_fields(self, inputs: dict) -> None:
+ def _validate_numeric_fields(
+ self,
+ inputs: dict,
+ ) -> None:
"""
Legacy numeric validation helper.
@@ -698,33 +880,74 @@ def _validate_numeric_fields(self, inputs: dict) -> None:
This method is kept for compatibility with older input schemas and is
not currently used in the main validate_inputs flow.
"""
- density = inputs.get("density", None)
- if isinstance(density, bool) or not isinstance(density, (int, float)) or density <= 0:
+ density = inputs.get(
+ "density",
+ None,
+ )
+
+ if (
+ isinstance(density, bool)
+ or not isinstance(
+ density,
+ (int, float),
+ )
+ or density <= 0
+ ):
raise NumericFieldError(
f"'density' must be a positive number. Got: {density!r}"
)
- temps = inputs.get("temperature", None)
+ temps = inputs.get(
+ "temperature",
+ None,
+ )
+
if temps is None:
raise NumericFieldError(
"'temperature' is required as a number or list of numbers."
)
- temps_list = temps if isinstance(temps, list) else [temps]
+ temps_list = (
+ temps
+ if isinstance(temps, list)
+ else [temps]
+ )
+
for temp in temps_list:
- if isinstance(temp, bool) or not isinstance(temp, (int, float)) or temp <= 0:
+ if (
+ isinstance(temp, bool)
+ or not isinstance(
+ temp,
+ (int, float),
+ )
+ or temp <= 0
+ ):
raise NumericFieldError(
f"Temperature values must be positive numbers. Got: {temp!r}"
)
- num_monomers = inputs.get("number_of_monomers", None)
- if not isinstance(num_monomers, dict) or not num_monomers:
+ num_monomers = inputs.get(
+ "number_of_monomers",
+ None,
+ )
+
+ if (
+ not isinstance(
+ num_monomers,
+ dict,
+ )
+ or not num_monomers
+ ):
raise NumericFieldError(
"'number_of_monomers' must be a non-empty dict of monomer_id -> positive int."
)
for monomer_id, count in num_monomers.items():
- if isinstance(count, bool) or not isinstance(count, int) or count <= 0:
+ if (
+ isinstance(count, bool)
+ or not isinstance(count, int)
+ or count <= 0
+ ):
raise NumericFieldError(
f"Monomer count for {monomer_id!r} must be a positive integer. Got: {count!r}"
)
@@ -750,7 +973,10 @@ def validate_no_duplicate_smiles(
"Each monomer must have a unique SMILES string."
)
- seen_monomer_list.append(current_monomer)
+ seen_monomer_list.append(
+ current_monomer
+ )
+
return seen_monomer_list
def _validate_single_simulation(
@@ -761,46 +987,106 @@ def _validate_single_simulation(
"""
Validates one Simulation object after initial construction.
"""
- if not isinstance(simulation.tag, str) or not simulation.tag.strip():
+ if (
+ not isinstance(simulation.tag, str)
+ or not simulation.tag.strip()
+ ):
raise InputSchemaError(
"Each system must include a non-empty 'tag'."
)
- if isinstance(simulation.temperature, bool) or not isinstance(simulation.temperature, (int, float)) or simulation.temperature <= 0:
+ if (
+ isinstance(
+ simulation.temperature,
+ bool,
+ )
+ or not isinstance(
+ simulation.temperature,
+ (int, float),
+ )
+ or simulation.temperature <= 0
+ ):
raise NumericFieldError(
f"Simulation '{simulation.tag}' has invalid temperature."
)
- if isinstance(simulation.density, bool) or not isinstance(simulation.density, (int, float)) or simulation.density <= 0:
+ if (
+ isinstance(
+ simulation.density,
+ bool,
+ )
+ or not isinstance(
+ simulation.density,
+ (int, float),
+ )
+ or simulation.density <= 0
+ ):
raise NumericFieldError(
f"Simulation '{simulation.tag}' has invalid density."
)
if method == "counts":
- if not isinstance(simulation.monomer_counts, dict) or not simulation.monomer_counts:
+ if (
+ not isinstance(
+ simulation.monomer_counts,
+ dict,
+ )
+ or not simulation.monomer_counts
+ ):
raise InputSchemaError(
"'monomer_counts' must be provided in counts mode."
)
- for monomer, value in simulation.monomer_counts.items():
- if isinstance(value, bool) or not isinstance(value, int) or value < 0:
+ for monomer, value in (
+ simulation.monomer_counts.items()
+ ):
+ if (
+ isinstance(value, bool)
+ or not isinstance(value, int)
+ or value < 0
+ ):
raise NumericFieldError(
f"Invalid count for monomer {monomer!r}: {value!r}"
)
elif method == "ratio":
- if isinstance(simulation.total_atoms, bool) or not isinstance(simulation.total_atoms, int) or simulation.total_atoms <= 0:
+ if (
+ isinstance(
+ simulation.total_atoms,
+ bool,
+ )
+ or not isinstance(
+ simulation.total_atoms,
+ int,
+ )
+ or simulation.total_atoms <= 0
+ ):
raise NumericFieldError(
"'total_atoms' must be a positive integer in ratio mode."
)
- if not isinstance(simulation.monomer_ratios, dict) or not simulation.monomer_ratios:
+ if (
+ not isinstance(
+ simulation.monomer_ratios,
+ dict,
+ )
+ or not simulation.monomer_ratios
+ ):
raise InputSchemaError(
"'monomer_ratios' must be provided in ratio mode."
)
- for monomer, value in simulation.monomer_ratios.items():
- if isinstance(value, bool) or not isinstance(value, (int, float)) or value < 0:
+ for monomer, value in (
+ simulation.monomer_ratios.items()
+ ):
+ if (
+ isinstance(value, bool)
+ or not isinstance(
+ value,
+ (int, float),
+ )
+ or value < 0
+ ):
raise NumericFieldError(
f"Invalid ratio value for monomer {monomer!r}: {value!r}"
)
@@ -839,7 +1125,10 @@ def _validate_simulations(
tag = system.get("tag")
- if not isinstance(tag, str) or not tag.strip():
+ if (
+ not isinstance(tag, str)
+ or not tag.strip()
+ ):
raise InputSchemaError(
"Each system must include a non-empty 'tag'."
)
@@ -851,39 +1140,71 @@ def _validate_simulations(
seen_tags.add(tag)
- system["temperature"] = self._validate_temperature(
- system.get("temperature")
+ system["temperature"] = (
+ self._validate_temperature(
+ system.get("temperature")
+ )
)
- system["density"] = self._validate_density(
- system.get("density")
+
+ system["density"] = (
+ self._validate_density(
+ system.get("density")
+ )
)
- temperatures.append(system["temperature"])
- density.append(system["density"])
+ temperatures.append(
+ system["temperature"]
+ )
+
+ density.append(
+ system["density"]
+ )
if method == "ratio":
- total_atoms = system.get("total_atoms")
+ total_atoms = system.get(
+ "total_atoms"
+ )
- if isinstance(total_atoms, bool) or not isinstance(total_atoms, int) or total_atoms <= 0:
+ if (
+ isinstance(total_atoms, bool)
+ or not isinstance(
+ total_atoms,
+ int,
+ )
+ or total_atoms <= 0
+ ):
raise NumericFieldError(
"'total_atoms' must be a positive integer in ratio mode."
)
- ratios = system.get("monomer_ratios")
+ ratios = system.get(
+ "monomer_ratios"
+ )
- if not isinstance(ratios, dict) or not ratios:
+ if (
+ not isinstance(ratios, dict)
+ or not ratios
+ ):
raise InputSchemaError(
"'monomer_ratios' must be provided in ratio mode."
)
for monomer, value in ratios.items():
- if isinstance(value, bool) or not isinstance(value, (int, float)) or value < 0:
+ if (
+ isinstance(value, bool)
+ or not isinstance(
+ value,
+ (int, float),
+ )
+ or value < 0
+ ):
raise NumericFieldError(
f"Invalid ratio value for monomer {monomer!r}: {value!r}"
)
if reference_ratios is None:
reference_ratios = ratios
+
elif ratios != reference_ratios:
raise InputSchemaError(
"All systems must use identical 'monomer_ratios'."
@@ -891,26 +1212,46 @@ def _validate_simulations(
simulation = Simulation(
tag=system["tag"],
- temperature=system["temperature"],
+ temperature=system[
+ "temperature"
+ ],
density=system["density"],
monomer_counts=None,
monomer_ratios=ratios,
total_atoms=total_atoms,
)
- self._validate_single_simulation(simulation, method)
- simulations.append(simulation)
+ self._validate_single_simulation(
+ simulation,
+ method,
+ )
+
+ simulations.append(
+ simulation
+ )
elif method == "counts":
- counts = system.get("monomer_counts")
+ counts = system.get(
+ "monomer_counts"
+ )
- if not isinstance(counts, dict) or not counts:
+ if (
+ not isinstance(counts, dict)
+ or not counts
+ ):
raise InputSchemaError(
"'monomer_counts' must be provided in counts mode."
)
for monomer, value in counts.items():
- if isinstance(value, bool) or not isinstance(value, int) or value < 0:
+ if (
+ isinstance(value, bool)
+ or not isinstance(
+ value,
+ int,
+ )
+ or value < 0
+ ):
raise NumericFieldError(
f"Invalid count for monomer {monomer!r}: {value!r}"
)
@@ -922,15 +1263,23 @@ def _validate_simulations(
simulation = Simulation(
tag=system["tag"],
- temperature=system["temperature"],
+ temperature=system[
+ "temperature"
+ ],
density=system["density"],
monomer_counts=counts,
monomer_ratios=None,
total_atoms=None,
)
- self._validate_single_simulation(simulation, method)
- simulations.append(simulation)
+ self._validate_single_simulation(
+ simulation,
+ method,
+ )
+
+ simulations.append(
+ simulation
+ )
return {
"method": method,
@@ -940,6 +1289,247 @@ def _validate_simulations(
"simulations": simulations,
}
+ def _validate_loop(
+ self,
+ inputs: dict,
+ ) -> tuple[bool, int | None]:
+ """
+ Backward-compatible wrapper for older loop validation tests.
+
+ New logic is handled by _validate_reaction_iteration_depth.
+ """
+ reaction_iteration_depth = (
+ self._validate_reaction_iteration_depth(
+ inputs
+ )
+ )
+
+ if reaction_iteration_depth == 0:
+ return False, None
+
+ return True, reaction_iteration_depth
+
+ def _normalized_option_key(
+ self,
+ key: str,
+ ) -> str:
+ """
+ Normalize workflow option keys so common spellings are accepted.
+
+ This is intentionally used only for optional workflow switches, not for
+ the core input schema.
+ """
+ return (
+ key.strip()
+ .lower()
+ .replace("_", "")
+ .replace("-", "")
+ .replace(" ", "")
+ )
+
+ def _get_workflow_option(
+ self,
+ inputs: dict,
+ key: str,
+ default: Any,
+ aliases: list[str] | None = None,
+ ) -> Any:
+ """
+ Read an optional workflow setting using a canonical key plus aliases.
+
+ The lookup is case-insensitive and ignores underscores, hyphens, and
+ spaces. If multiple aliases are provided with conflicting values, the
+ input is rejected.
+ """
+ aliases = aliases or []
+ accepted_keys = [
+ key,
+ *aliases,
+ ]
+
+ accepted_normalized = {
+ self._normalized_option_key(
+ option_key
+ )
+ for option_key in accepted_keys
+ }
+
+ matches = []
+
+ for input_key, value in inputs.items():
+ if (
+ self._normalized_option_key(
+ str(input_key)
+ )
+ in accepted_normalized
+ ):
+ matches.append(
+ (
+ input_key,
+ value,
+ )
+ )
+
+ if not matches:
+ return default
+
+ first_value = matches[0][1]
+
+ for input_key, value in matches[1:]:
+ if value != first_value:
+ raise InputConflictError(
+ f"Conflicting values were provided for workflow option "
+ f"'{key}': {matches!r}"
+ )
+
+ return first_value
+
+ def _validate_bool_option(
+ self,
+ inputs: dict,
+ key: str,
+ default: bool,
+ aliases: list[str] | None = None,
+ ) -> bool:
+ """
+ Validate an optional boolean workflow switch.
+
+ Only the new workflow options use this relaxed alias/lowercase lookup.
+ Core schema keys such as simulations and monomers remain strict.
+ """
+ value = self._get_workflow_option(
+ inputs=inputs,
+ key=key,
+ default=default,
+ aliases=aliases,
+ )
+
+ if isinstance(value, bool):
+ return value
+
+ if isinstance(value, str):
+ normalized_value = (
+ value.strip().lower()
+ )
+
+ if normalized_value in {
+ "true",
+ "yes",
+ "y",
+ "on",
+ "1",
+ }:
+ return True
+
+ if normalized_value in {
+ "false",
+ "no",
+ "n",
+ "off",
+ "0",
+ }:
+ return False
+
+ raise InputSchemaError(
+ f"'{key}' must be a boolean value. Got: {value!r}"
+ )
+
+ def _validate_reaction_iteration_depth(
+ self,
+ inputs: dict,
+ ) -> int:
+ """
+ Validate the reaction-iteration depth option.
+
+ Accepted keys include:
+ - reaction_iteration_depth
+ - rxn_iteration_depth
+ - reaction_depth
+ - loop
+ - max_loop_count
+ - iterations
+
+ Accepted values:
+ - missing key: defaults to 5
+ - positive integer: enables looping for that many iterations
+ - 0: disables looping
+ - False or false-like strings: disables looping
+
+ Raises:
+ InputSchemaError: If the value is negative, non-integer, or invalid.
+ """
+ value = self._get_workflow_option(
+ inputs=inputs,
+ key="reaction_iteration_depth",
+ default=5,
+ aliases=[
+ "rxn_iteration_depth",
+ "reaction_depth",
+ "iteration_depth",
+ "max_loop_count",
+ "max_iterations",
+ "iterations",
+ "loop",
+ ],
+ )
+
+ if isinstance(value, bool):
+ return 5 if value else 0
+
+ if isinstance(value, str):
+ normalized_value = (
+ value.strip().lower()
+ )
+
+ if normalized_value in {
+ "false",
+ "no",
+ "n",
+ "off",
+ "0",
+ "none",
+ }:
+ return 0
+
+ if normalized_value in {
+ "true",
+ "yes",
+ "y",
+ "on",
+ }:
+ return 5
+
+ try:
+ value = int(
+ normalized_value
+ )
+
+ except ValueError as error:
+ raise InputSchemaError(
+ "'reaction_iteration_depth' must be an integer greater "
+ "than or equal to 0, or a boolean value. "
+ f"Got: {value!r}"
+ ) from error
+
+ if (
+ isinstance(value, bool)
+ or not isinstance(value, int)
+ ):
+ raise InputSchemaError(
+ "'reaction_iteration_depth' must be an integer greater "
+ f"than or equal to 0, or a boolean value. Got: {value!r}"
+ )
+
+ if value < 0:
+ raise InputSchemaError(
+ "'reaction_iteration_depth' must be greater than or equal to 0. "
+ f"Got: {value!r}"
+ )
+
+ return value
+
+
+
if __name__ == "__main__":
# Sample input data for quick manual verification of the parser.
@@ -1078,4 +1668,4 @@ def _validate_simulations(
print(parser.validate_inputs(inputs_ratio))
print("\nValidating Force Field Input:")
- print(parser.validate_inputs(input_ff))
+ print(parser.validate_inputs(input_ff))
\ No newline at end of file
diff --git a/AutoREACTER/reaction_preparation/build_reaction_system.py b/AutoREACTER/reaction_preparation/build_reaction_system.py
deleted file mode 100644
index 8b6ce963..00000000
--- a/AutoREACTER/reaction_preparation/build_reaction_system.py
+++ /dev/null
@@ -1 +0,0 @@
-# WILL BE NEW PLACE HOLDER FOR Main.py helper functions, NOT TO BE DELETED
\ No newline at end of file
diff --git a/AutoREACTER/reaction_preparation/deduplication_detector.py b/AutoREACTER/reaction_preparation/deduplication_detector.py
new file mode 100644
index 00000000..54b11c9a
--- /dev/null
+++ b/AutoREACTER/reaction_preparation/deduplication_detector.py
@@ -0,0 +1,1850 @@
+"""
+Graph-based reaction deduplication for RDKit molecules and LAMMPS
+molecule templates.
+
+The comparison intentionally ignores coordinates, atom IDs, and bond IDs.
+RDKit comparisons use chemical element, radical state, and bond type.
+LAMMPS comparisons use atom type and bond type.
+"""
+
+from __future__ import annotations
+
+import re
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+import networkx as nx
+from rdkit import Chem
+
+if TYPE_CHECKING:
+ from AutoREACTER.reaction_preparation.reaction_processor.prepare_reactions import (
+ ReactionMetadata,
+ )
+
+
+class DeduplicationDetector:
+ """Detect duplicate pre/post-reaction graph pairs."""
+
+ DEEP_CHECK = True
+
+ NODE_ATTRIBUTE = "atom_label"
+ EDGE_ATTRIBUTE = "bond_label"
+
+ RADICAL_COUNT_ATTRIBUTE = "radical_count"
+ RADICAL_PRESENT_ATTRIBUTE = "contains_radical"
+ RADICAL_SIGNATURE_ATTRIBUTE = "radical_signature"
+
+ LAMMPS_COMPARISON_GROUP = "lammps"
+ RDKIT_COMPARISON_GROUP = "rdkit"
+
+ _PRE_PHASE = "pre"
+ _POST_PHASE = "post"
+
+ _BOND_RELATIONSHIP = "bond"
+ _ATOM_CORRESPONDENCE_RELATIONSHIP = "atom_correspondence"
+
+ _LAMMPS_RELEVANT_SECTIONS = {
+ "Types",
+ "Bonds",
+ }
+
+ _LAMMPS_SECTION_HEADERS = {
+ "Coords",
+ "Types",
+ "Charges",
+ "Molecules",
+ "Bonds",
+ "Angles",
+ "Dihedrals",
+ "Impropers",
+ "Special Bond Counts",
+ "Special Bonds",
+ }
+
+ _LAMMPS_MAP_SECTION_HEADERS = {
+ "InitiatorIDs",
+ "EdgeIDs",
+ "Equivalences",
+ "DeleteIDs",
+ "Wildcards",
+ }
+
+ def __init__(self) -> None:
+ """Initialize independent graph-comparison caches."""
+ self.seen_reactions: dict[str, list[nx.Graph]] = {
+ self.LAMMPS_COMPARISON_GROUP: [],
+ self.RDKIT_COMPARISON_GROUP: [],
+ }
+
+ self.seen_reaction_pairs: dict[
+ str,
+ list[tuple[nx.Graph, nx.Graph]],
+ ] = {
+ self.LAMMPS_COMPARISON_GROUP: [],
+ self.RDKIT_COMPARISON_GROUP: [],
+ }
+
+ # ------------------------------------------------------------------
+ # Duplicate-detection API
+ # ------------------------------------------------------------------
+
+ def is_duplicate(
+ self,
+ pre_template_graph: nx.Graph,
+ post_template_graph: nx.Graph,
+ comparison_group: str,
+ ) -> bool:
+ """
+ Check whether an equivalent coupled pre/post pair was previously
+ cached.
+
+ The pre- and post-reaction graphs are coupled using atom
+ correspondence edges. This requires one consistent atom mapping
+ to satisfy both reaction phases.
+
+ This path is mainly used for RDKit molecules where pre and post
+ graphs have already been relabeled into the same atom-index space.
+ """
+ coupled_graph = self._couple_graphs(
+ pre_template_graph=pre_template_graph,
+ post_template_graph=post_template_graph,
+ )
+
+ return self.is_duplicate_coupled_graph(
+ coupled_graph=coupled_graph,
+ comparison_group=comparison_group,
+ )
+
+ def is_duplicate_coupled_graph(
+ self,
+ coupled_graph: nx.Graph,
+ comparison_group: str,
+ ) -> bool:
+ """
+ Check whether an already-coupled pre/post graph was previously cached.
+
+ This is used for LAMMPS templates where atom correspondence comes from
+ the RXN_*.map Equivalences section instead of matching atom IDs.
+ """
+ node_match = nx.algorithms.isomorphism.categorical_node_match(
+ ["phase", self.NODE_ATTRIBUTE],
+ [None, None],
+ )
+
+ edge_match = nx.algorithms.isomorphism.categorical_edge_match(
+ ["relationship", self.EDGE_ATTRIBUTE],
+ [None, None],
+ )
+
+ seen_graphs = self.seen_reactions.setdefault(
+ comparison_group,
+ [],
+ )
+
+ for seen_graph in seen_graphs:
+ if (
+ coupled_graph.graph.get(
+ self.RADICAL_SIGNATURE_ATTRIBUTE
+ )
+ != seen_graph.graph.get(
+ self.RADICAL_SIGNATURE_ATTRIBUTE
+ )
+ ):
+ continue
+
+ if (
+ coupled_graph.number_of_nodes()
+ != seen_graph.number_of_nodes()
+ ):
+ continue
+
+ if (
+ coupled_graph.number_of_edges()
+ != seen_graph.number_of_edges()
+ ):
+ continue
+
+ if nx.is_isomorphic(
+ coupled_graph,
+ seen_graph,
+ node_match=node_match,
+ edge_match=edge_match,
+ ):
+ return True
+
+ seen_graphs.append(coupled_graph.copy())
+ return False
+
+ def is_duplicate_lammps_template_pair(
+ self,
+ pre_file_path: str | Path,
+ post_file_path: str | Path,
+ comparison_group: str | None = None,
+ map_file_path: str | Path | None = None,
+ wildcards: bool = False,
+ ) -> 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.
+
+ When ``wildcards`` is True, the EdgeIDs from the map file are treated
+ as wildcard atoms. Those pre-template atoms and their equivalent
+ post-template atoms are removed before graph comparison.
+ """
+ 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)
+
+ if map_file_path is None:
+ map_file_path = self._lammps_map_path_from_template_path(
+ pre_file_path
+ )
+ else:
+ map_file_path = Path(map_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}."
+ )
+
+ if wildcards:
+ pre_graph, post_graph, pre_to_post_mapping = (
+ self._apply_lammps_wildcards(
+ pre_graph=pre_graph,
+ post_graph=post_graph,
+ pre_to_post_mapping=pre_to_post_mapping,
+ map_file_path=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")
+
+ @classmethod
+ def _is_lammps_map_section_header(cls, line: str) -> bool:
+ """Return True if a stripped line starts a map-file section."""
+ if not line:
+ return False
+
+ first_token = line.split()[0]
+ return (
+ line in cls._LAMMPS_MAP_SECTION_HEADERS
+ or first_token in cls._LAMMPS_MAP_SECTION_HEADERS
+ )
+
+ @staticmethod
+ def _is_lammps_count_line(line: str) -> bool:
+ """Return True for count lines such as '11 edgeIDs'."""
+ return re.match(
+ r"^\s*\d+\s+(?:edgeIDs|equivalences|wildcards)\s*$",
+ line,
+ flags=re.IGNORECASE,
+ ) is not None
+
+ @classmethod
+ def _read_lammps_map_integer_section(
+ cls,
+ map_file_path: str | Path,
+ section_name: str,
+ ) -> list[list[int]]:
+ """Read integer rows from a LAMMPS bond/react map-file section."""
+ map_file_path = Path(map_file_path)
+ current_section: str | None = None
+ rows: list[list[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
+
+ first_token = line.split()[0]
+
+ if cls._is_lammps_map_section_header(line):
+ current_section = first_token
+ continue
+
+ if current_section != section_name:
+ continue
+
+ try:
+ rows.append([int(part) for part in line.split()])
+ except ValueError:
+ continue
+
+ return rows
+
+ @classmethod
+ def _read_lammps_equivalences(
+ cls,
+ 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}
+ """
+ mapping: dict[int, int] = {}
+
+ for row in cls._read_lammps_map_integer_section(
+ map_file_path,
+ "Equivalences",
+ ):
+ if len(row) < 2:
+ continue
+
+ pre_atom_id = row[0]
+ post_atom_id = row[1]
+
+ 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
+
+ @classmethod
+ def _read_lammps_edge_ids(
+ cls,
+ map_file_path: str | Path,
+ ) -> list[int]:
+ """Read pre-template EdgeIDs from a LAMMPS bond/react map file."""
+ edge_ids: list[int] = []
+
+ for row in cls._read_lammps_map_integer_section(
+ map_file_path,
+ "EdgeIDs",
+ ):
+ if not row:
+ continue
+
+ edge_ids.append(row[0])
+
+ return edge_ids
+
+ @staticmethod
+ def _remove_lammps_map_section(
+ lines: list[str],
+ section_name: str,
+ ) -> list[str]:
+ """Remove a named map-file section and its body."""
+ section_headers = {
+ "InitiatorIDs",
+ "EdgeIDs",
+ "Equivalences",
+ "DeleteIDs",
+ "Wildcards",
+ }
+
+ output: list[str] = []
+ inside_target = False
+
+ for line in lines:
+ stripped = line.strip()
+ first_token = stripped.split()[0] if stripped else ""
+
+ is_header = (
+ stripped in section_headers
+ or first_token in section_headers
+ )
+
+ if is_header and first_token == section_name:
+ inside_target = True
+ continue
+
+ if inside_target and is_header:
+ inside_target = False
+
+ if not inside_target:
+ output.append(line)
+
+ return output
+
+ def _replace_lammps_count_lines(
+ self,
+ lines: list[str],
+ edge_count: int,
+ equivalence_count: int,
+ wildcard_count: int,
+ ) -> list[str]:
+ """
+ Replace the map-file count block.
+
+ The final order is:
+ N edgeIDs
+ N equivalences
+ N wildcards
+ """
+ filtered_lines = [
+ line
+ for line in lines
+ if not self._is_lammps_count_line(line.strip())
+ ]
+
+ insert_idx = 0
+ for idx, line in enumerate(filtered_lines):
+ if line.strip().startswith("#") or not line.strip():
+ insert_idx = idx + 1
+ continue
+ break
+
+ count_lines = [
+ f"{edge_count} edgeIDs\n",
+ f"{equivalence_count} equivalences\n",
+ f"{wildcard_count} wildcards\n",
+ ]
+
+ return (
+ filtered_lines[:insert_idx]
+ + count_lines
+ + filtered_lines[insert_idx:]
+ )
+
+ @staticmethod
+ def _read_lammps_single_column_section(
+ map_file_path: str | Path,
+ section_name: str,
+ ) -> list[int]:
+ """
+ Read a single-column integer section from a LAMMPS bond/react map file.
+ """
+ map_file_path = Path(map_file_path)
+
+ section_headers = {
+ "InitiatorIDs",
+ "EdgeIDs",
+ "Equivalences",
+ "DeleteIDs",
+ "Wildcards",
+ }
+
+ current_section: str | None = None
+ values: list[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
+
+ first_token = line.split()[0]
+
+ if line in section_headers:
+ current_section = line
+ continue
+
+ if first_token in section_headers:
+ current_section = first_token
+ continue
+
+ if current_section != section_name:
+ continue
+
+ try:
+ values.append(int(first_token))
+ except ValueError:
+ continue
+
+ return values
+
+ def _write_lammps_wildcard_map_file(
+ self,
+ map_file_path: str | Path,
+ wildcard_ids: list[int],
+ ) -> None:
+ """
+ Replace the original RXN_*.map with a wildcard-enabled map.
+
+ The map path is unchanged. The Wildcards section is written from the
+ pre-template EdgeIDs.
+ """
+ map_file_path = Path(map_file_path)
+
+ # Preserve input order while removing repeated IDs.
+ seen_wildcards: set[int] = set()
+ wildcard_ids = [
+ atom_id
+ for atom_id in wildcard_ids
+ if not (
+ atom_id in seen_wildcards
+ or seen_wildcards.add(atom_id)
+ )
+ ]
+
+ original_lines = map_file_path.read_text(
+ encoding="utf-8",
+ ).splitlines()
+
+ header_lines: list[str] = []
+
+ for line in original_lines:
+ stripped = line.strip()
+
+ if not stripped:
+ continue
+
+ if stripped.startswith("#"):
+ header_lines.append(stripped)
+ continue
+
+ break
+
+ initiator_ids = self._read_lammps_single_column_section(
+ map_file_path,
+ "InitiatorIDs",
+ )
+ edge_ids = self._read_lammps_edge_ids(map_file_path)
+ equivalences = self._read_lammps_equivalences(map_file_path)
+ delete_ids = self._read_lammps_single_column_section(
+ map_file_path,
+ "DeleteIDs",
+ )
+
+ lines: list[str] = []
+
+ for header_line in header_lines:
+ lines.append(f"{header_line}\n")
+
+ lines.append("\n")
+ lines.append(f"{len(edge_ids)} edgeIDs\n")
+ lines.append(f"{len(equivalences)} equivalences\n")
+
+ if delete_ids:
+ lines.append(f"{len(delete_ids)} deleteIDs\n")
+
+ lines.append(f"{len(wildcard_ids)} wildcards\n")
+ lines.append("\n")
+
+ lines.append("InitiatorIDs\n")
+ lines.append("\n")
+ for atom_id in initiator_ids:
+ lines.append(f"{atom_id}\n")
+ lines.append("\n")
+
+ lines.append("EdgeIDs\n")
+ lines.append("\n")
+ for atom_id in edge_ids:
+ lines.append(f"{atom_id}\n")
+ lines.append("\n")
+
+ lines.append("Equivalences\n")
+ lines.append("\n")
+ for pre_atom_id, post_atom_id in equivalences.items():
+ lines.append(f"{pre_atom_id:<5} {post_atom_id}\n")
+ lines.append("\n")
+
+ if delete_ids:
+ lines.append("DeleteIDs\n")
+ lines.append("\n")
+ for atom_id in delete_ids:
+ lines.append(f"{atom_id}\n")
+ lines.append("\n")
+
+ lines.append("Wildcards\n")
+ lines.append("\n")
+ for atom_id in wildcard_ids:
+ lines.append(f"{atom_id}\n")
+
+ map_file_path.write_text(
+ "".join(lines),
+ encoding="utf-8",
+ )
+
+ @staticmethod
+ def _remove_nodes_if_present(
+ graph: nx.Graph,
+ node_ids: list[int],
+ ) -> nx.Graph:
+ """Return a graph copy with selected nodes removed."""
+ graph_copy = graph.copy()
+
+ graph_copy.remove_nodes_from(
+ node_id
+ for node_id in node_ids
+ if node_id in graph_copy
+ )
+
+ return graph_copy
+
+ def _apply_lammps_wildcards(
+ self,
+ pre_graph: nx.Graph,
+ post_graph: nx.Graph,
+ pre_to_post_mapping: dict[int, int],
+ map_file_path: Path,
+ ) -> tuple[nx.Graph, nx.Graph, dict[int, int]]:
+ """
+ Apply wildcard-style template comparison.
+
+ EdgeIDs are defined in pre-template atom ID space. Their equivalent
+ post-template atom IDs are obtained from the Equivalences section.
+ These atoms are removed before graph comparison.
+ """
+ pre_edge_ids = self._read_lammps_edge_ids(map_file_path)
+
+ post_edge_ids = [
+ pre_to_post_mapping[pre_atom_id]
+ for pre_atom_id in pre_edge_ids
+ if pre_atom_id in pre_to_post_mapping
+ ]
+
+ pre_graph = self._remove_nodes_if_present(
+ pre_graph,
+ pre_edge_ids,
+ )
+ post_graph = self._remove_nodes_if_present(
+ post_graph,
+ post_edge_ids,
+ )
+
+ pre_edge_id_set = set(pre_edge_ids)
+ post_edge_id_set = set(post_edge_ids)
+
+ pre_to_post_mapping = {
+ pre_atom_id: post_atom_id
+ for pre_atom_id, post_atom_id in pre_to_post_mapping.items()
+ if pre_atom_id not in pre_edge_id_set
+ and post_atom_id not in post_edge_id_set
+ }
+
+ return pre_graph, post_graph, pre_to_post_mapping
+
+ def _couple_lammps_graphs(
+ self,
+ pre_graph: nx.Graph,
+ post_graph: nx.Graph,
+ pre_to_post_mapping: dict[int, int],
+ source: Path | str,
+ ) -> nx.Graph:
+ """
+ Couple LAMMPS pre/post template graphs using RXN_*.map equivalences.
+
+ Unlike the RDKit coupling path, this does not require matching atom IDs
+ in pre and post files. The map file defines correspondence.
+ """
+ coupled_graph = nx.Graph()
+
+ coupled_graph.graph[self.RADICAL_SIGNATURE_ATTRIBUTE] = (
+ pre_graph.graph.get(self.RADICAL_COUNT_ATTRIBUTE, 0),
+ post_graph.graph.get(self.RADICAL_COUNT_ATTRIBUTE, 0),
+ pre_graph.graph.get(self.RADICAL_PRESENT_ATTRIBUTE, False),
+ post_graph.graph.get(self.RADICAL_PRESENT_ATTRIBUTE, False),
+ )
+
+ self._add_phase_to_coupled_graph(
+ source_graph=pre_graph,
+ coupled_graph=coupled_graph,
+ phase=self._PRE_PHASE,
+ )
+
+ self._add_phase_to_coupled_graph(
+ source_graph=post_graph,
+ coupled_graph=coupled_graph,
+ phase=self._POST_PHASE,
+ )
+
+ for pre_atom_id, post_atom_id in pre_to_post_mapping.items():
+ if pre_atom_id not in pre_graph:
+ raise ValueError(
+ f"Map file {source} references pre atom "
+ f"{pre_atom_id}, but that atom is not in the "
+ "pre-template graph."
+ )
+
+ if post_atom_id not in post_graph:
+ raise ValueError(
+ f"Map file {source} references post atom "
+ f"{post_atom_id}, but that atom is not in the "
+ "post-template graph."
+ )
+
+ coupled_graph.add_edge(
+ (self._PRE_PHASE, pre_atom_id),
+ (self._POST_PHASE, post_atom_id),
+ relationship=self._ATOM_CORRESPONDENCE_RELATIONSHIP,
+ **{
+ self.EDGE_ATTRIBUTE: None,
+ },
+ )
+
+ return coupled_graph
+
+ def is_duplicate_pair(
+ self,
+ pre_graph: nx.Graph,
+ post_graph: nx.Graph,
+ comparison_group: str,
+ ) -> bool:
+ """
+ Check whether an equivalent uncoupled pre/post graph pair was
+ previously cached.
+
+ A reaction is considered a duplicate only when both its reactant
+ graph and product graph match the same cached reaction entry.
+ """
+ node_match = nx.algorithms.isomorphism.categorical_node_match(
+ self.NODE_ATTRIBUTE,
+ None,
+ )
+
+ edge_match = nx.algorithms.isomorphism.categorical_edge_match(
+ self.EDGE_ATTRIBUTE,
+ None,
+ )
+
+ seen_pairs = self.seen_reaction_pairs.setdefault(
+ comparison_group,
+ [],
+ )
+
+ for seen_pre_graph, seen_post_graph in seen_pairs:
+ if (
+ pre_graph.number_of_nodes()
+ != seen_pre_graph.number_of_nodes()
+ ):
+ continue
+
+ if (
+ pre_graph.number_of_edges()
+ != seen_pre_graph.number_of_edges()
+ ):
+ continue
+
+ if (
+ post_graph.number_of_nodes()
+ != seen_post_graph.number_of_nodes()
+ ):
+ continue
+
+ if (
+ post_graph.number_of_edges()
+ != seen_post_graph.number_of_edges()
+ ):
+ continue
+
+ pre_matches = nx.is_isomorphic(
+ pre_graph,
+ seen_pre_graph,
+ node_match=node_match,
+ edge_match=edge_match,
+ )
+
+ if not pre_matches:
+ continue
+
+ post_matches = nx.is_isomorphic(
+ post_graph,
+ seen_post_graph,
+ node_match=node_match,
+ edge_match=edge_match,
+ )
+
+ if post_matches:
+ return True
+
+ seen_pairs.append(
+ (
+ pre_graph.copy(),
+ post_graph.copy(),
+ )
+ )
+
+ return False
+
+ def compare_graphs(
+ self,
+ molecule_file_paths: list[str | Path],
+ wildcards: bool = False,
+ ) -> dict[str, bool]:
+ """
+ Compare LAMMPS pre/post molecule-template pairs.
+
+ A pre-template filename must contain ``pre``. Its post-template
+ path is determined by replacing the first occurrence of ``pre``
+ with ``post``.
+ """
+ results: dict[str, bool] = {}
+
+ for file_path_value in molecule_file_paths:
+ pre_file_path = Path(file_path_value)
+
+ if "pre" not in pre_file_path.name:
+ continue
+
+ post_file_path = pre_file_path.with_name(
+ pre_file_path.name.replace(
+ "pre",
+ "post",
+ 1,
+ )
+ )
+
+ if not post_file_path.is_file():
+ print(
+ "Skipping reaction because its post-template file "
+ f"does not exist: {post_file_path}"
+ )
+ continue
+
+ duplicate = self.is_duplicate_lammps_template_pair(
+ pre_file_path=pre_file_path,
+ post_file_path=post_file_path,
+ comparison_group=self.LAMMPS_COMPARISON_GROUP,
+ wildcards=wildcards,
+ )
+
+ results[str(pre_file_path)] = duplicate
+
+ status = "Duplicate" if duplicate else "Unique"
+
+ print(
+ f"{status} reaction: "
+ f"{pre_file_path.name} -> {post_file_path.name}"
+ )
+
+ return results
+
+ def compare_graphs_mol(
+ self,
+ reaction_metadata_items: list["ReactionMetadata"],
+ index_source: str = "template",
+ deep_check: bool = True,
+ ) -> list["ReactionMetadata"]:
+
+
+ """
+ Detect duplicate reactions using in-memory RDKit molecules.
+
+ Each call performs one independent deduplication pass over the
+ supplied accumulated reaction pool. The RDKit coupled-graph cache
+ is therefore cleared before the comparison starts.
+
+ Reactions that are already inactive are ignored. Repeated references
+ to the exact same ReactionMetadata object are removed from the
+ returned list without disabling the retained object. This matters
+ because setting ``activity_stats`` to False on one repeated reference
+ would otherwise disable every occurrence of that same object.
+
+ For distinct ReactionMetadata objects, the first unique reaction is
+ retained. Later equivalent reactions are disabled by setting
+ ``activity_stats`` to False and are excluded from the returned pool.
+ """
+ self.clear_cache(self.RDKIT_COMPARISON_GROUP)
+
+ unique_reactions: list["ReactionMetadata"] = []
+ retained_object_ids: set[int] = set()
+
+ for reaction_index, reaction_metadata in enumerate(
+ reaction_metadata_items,
+ start=1,
+ ):
+ if not reaction_metadata.activity_stats:
+ continue
+
+ reaction_object_id = id(reaction_metadata)
+
+ if reaction_object_id in retained_object_ids:
+ continue
+
+ reactant_mol = reaction_metadata.reactant_combined_RDmol
+ product_mol = reaction_metadata.product_combined_RDmol
+
+ if reactant_mol is None:
+ raise ValueError(
+ f"Reaction {reaction_index} does not contain a "
+ "combined reactant RDKit molecule."
+ )
+
+ if product_mol is None:
+ raise ValueError(
+ f"Reaction {reaction_index} does not contain a "
+ "combined product RDKit molecule."
+ )
+
+ reactant_to_product_mapping = (
+ self._select_reactant_to_product_mapping(
+ reaction_metadata=reaction_metadata,
+ reaction_index=reaction_index,
+ index_source=index_source,
+ )
+ )
+
+ reactant_indices = set(reactant_to_product_mapping)
+ product_indices = set(
+ reactant_to_product_mapping.values()
+ )
+
+ product_to_reactant_mapping = {
+ product_idx: reactant_idx
+ for reactant_idx, product_idx
+ in reactant_to_product_mapping.items()
+ }
+
+ if len(product_to_reactant_mapping) != len(
+ reactant_to_product_mapping
+ ):
+ raise ValueError(
+ f"Reaction {reaction_index} contains a non-bijective "
+ "reactant-to-product mapping."
+ )
+
+ pre_graph = self.rdkit_mol_to_networkx(
+ molecule=reactant_mol,
+ atom_idxs=reactant_indices,
+ deep_check=deep_check,
+ )
+
+ post_graph = self.rdkit_mol_to_networkx(
+ molecule=product_mol,
+ atom_idxs=product_indices,
+ idx_relabel=product_to_reactant_mapping,
+ deep_check=deep_check
+ )
+
+ reactant_radical_count = self._count_radical_atoms(
+ reactant_mol
+ )
+ product_radical_count = self._count_radical_atoms(
+ product_mol
+ )
+
+ pre_graph.graph[self.RADICAL_COUNT_ATTRIBUTE] = (
+ reactant_radical_count
+ )
+ post_graph.graph[self.RADICAL_COUNT_ATTRIBUTE] = (
+ product_radical_count
+ )
+
+ pre_graph.graph[self.RADICAL_PRESENT_ATTRIBUTE] = (
+ reactant_radical_count > 0
+ )
+ post_graph.graph[self.RADICAL_PRESENT_ATTRIBUTE] = (
+ product_radical_count > 0
+ or bool(getattr(reaction_metadata, "is_radical", False))
+ )
+
+ duplicate = self.is_duplicate(
+ pre_template_graph=pre_graph,
+ post_template_graph=post_graph,
+ comparison_group=self.RDKIT_COMPARISON_GROUP,
+ )
+
+ if duplicate:
+ reaction_metadata.activity_stats = False
+ continue
+
+ retained_object_ids.add(reaction_object_id)
+ unique_reactions.append(reaction_metadata)
+
+ return unique_reactions
+
+ @classmethod
+ def _one_neighbor_edge_environment_signature(
+ cls,
+ atom: Chem.Atom,
+ included_atom_indices: set[int],
+ ) -> tuple[tuple[str, int, bool, str, int, bool, str], ...]:
+ """
+ Return a one-bond external chemical-environment signature for
+ boundary atoms.
+
+ Only neighbors outside the restricted comparison graph are used.
+ This avoids walking into the next molecule or building a larger
+ shell. Internal atoms return an empty signature.
+ """
+ external_neighbor_signatures = []
+
+ try:
+ atom.GetOwningMol().UpdatePropertyCache(strict=False)
+ except RuntimeError:
+ pass
+
+ for bond in atom.GetBonds():
+ neighbor = bond.GetOtherAtom(atom)
+
+ if neighbor.GetIdx() in included_atom_indices:
+ continue
+
+ external_neighbor_signatures.append(
+ (
+ neighbor.GetSymbol(),
+ neighbor.GetFormalCharge(),
+ neighbor.GetIsAromatic(),
+ str(neighbor.GetHybridization()),
+ cls._safe_total_hydrogen_count(neighbor),
+ cls._is_radical_atom(neighbor),
+ str(bond.GetBondType()),
+ )
+ )
+
+ return tuple(sorted(external_neighbor_signatures))
+
+ @staticmethod
+ def _safe_total_hydrogen_count(atom: Chem.Atom) -> int:
+ """Return total hydrogen count without failing on unsanitized mols."""
+ try:
+ return atom.GetTotalNumHs()
+ except RuntimeError:
+ explicit_h_neighbors = sum(
+ neighbor.GetAtomicNum() == 1
+ for neighbor in atom.GetNeighbors()
+ )
+ return explicit_h_neighbors + atom.GetNumExplicitHs()
+
+ def clear_cache(
+ self,
+ comparison_group: str | None = None,
+ ) -> None:
+ """
+ Clear graph-comparison caches.
+
+ Args:
+ comparison_group:
+ Specific cache group to clear. All groups are cleared
+ when omitted.
+ """
+ if comparison_group is None:
+ for seen_graphs in self.seen_reactions.values():
+ seen_graphs.clear()
+
+ for seen_pairs in self.seen_reaction_pairs.values():
+ seen_pairs.clear()
+
+ return
+
+ self.seen_reactions.setdefault(
+ comparison_group,
+ [],
+ ).clear()
+
+ self.seen_reaction_pairs.setdefault(
+ comparison_group,
+ [],
+ ).clear()
+
+ # ------------------------------------------------------------------
+ # RDKit graph conversion
+ # ------------------------------------------------------------------
+
+ def rdkit_mol_to_networkx(
+ self,
+ molecule: Chem.Mol,
+ atom_idxs: set[int] | None = None,
+ idx_relabel: dict[int, int] | None = None,
+ deep_check: bool = True,
+ ) -> nx.Graph:
+ """
+ Convert an RDKit molecule into a NetworkX graph.
+
+ Coordinates are not read or stored.
+ """
+ if molecule is None:
+ raise ValueError(
+ "Cannot create a graph from a None RDKit molecule."
+ )
+
+ included_atom_indices = (
+ atom_idxs
+ if atom_idxs is not None
+ else {
+ atom.GetIdx()
+ for atom in molecule.GetAtoms()
+ }
+ )
+
+ if idx_relabel is not None:
+ missing_relabels = sorted(
+ included_atom_indices - idx_relabel.keys()
+ )
+
+ if missing_relabels:
+ raise ValueError(
+ "The atom-index relabel mapping does not contain "
+ f"entries for atom indices {missing_relabels}."
+ )
+
+ graph = nx.Graph()
+
+ for atom in molecule.GetAtoms():
+ atom_index = atom.GetIdx()
+
+ if atom_index not in included_atom_indices:
+ continue
+
+ node_id = self._resolve_node_id(
+ atom_index=atom_index,
+ idx_relabel=idx_relabel,
+ )
+
+ is_radical = self._is_radical_atom(atom)
+
+
+ if not deep_check:
+ atom_label = (
+ atom.GetSymbol(),
+ is_radical,
+ )
+ else:
+ atom_label = (
+ atom.GetSymbol(),
+ is_radical,
+ self._one_neighbor_edge_environment_signature(
+ atom=atom,
+ included_atom_indices=included_atom_indices,
+ ),
+ )
+
+ graph.add_node(
+ node_id,
+ **{
+ self.NODE_ATTRIBUTE: atom_label,
+ },
+ )
+
+ for bond in molecule.GetBonds():
+ atom1_index = bond.GetBeginAtomIdx()
+ atom2_index = bond.GetEndAtomIdx()
+
+ if (
+ atom1_index not in included_atom_indices
+ or atom2_index not in included_atom_indices
+ ):
+ continue
+
+ node1_id = self._resolve_node_id(
+ atom_index=atom1_index,
+ idx_relabel=idx_relabel,
+ )
+
+ node2_id = self._resolve_node_id(
+ atom_index=atom2_index,
+ idx_relabel=idx_relabel,
+ )
+
+ graph.add_edge(
+ node1_id,
+ node2_id,
+ **{
+ self.EDGE_ATTRIBUTE: str(
+ bond.GetBondType()
+ ),
+ },
+ )
+
+ return graph
+
+ # ------------------------------------------------------------------
+ # LAMMPS graph conversion
+ # ------------------------------------------------------------------
+
+ def lammps_molecule_to_networkx(
+ self,
+ file_path: str | Path,
+ ) -> nx.Graph:
+ """
+ Convert a LAMMPS molecule-template file into a NetworkX graph.
+
+ Only the ``Types`` and ``Bonds`` sections are included.
+ """
+ file_path = Path(file_path)
+
+ if not file_path.is_file():
+ raise FileNotFoundError(
+ f"LAMMPS molecule file does not exist: {file_path}"
+ )
+
+ sections = self._read_lammps_sections(file_path)
+
+ if "Types" not in sections:
+ raise ValueError(
+ f"Types section was not found in {file_path}."
+ )
+
+ graph = nx.Graph()
+
+ self._add_lammps_atoms(
+ graph=graph,
+ type_lines=sections["Types"],
+ file_path=file_path,
+ )
+
+ self._add_lammps_bonds(
+ graph=graph,
+ bond_lines=sections.get("Bonds", []),
+ file_path=file_path,
+ )
+
+ return graph
+
+ def write_wildcard_maps(
+ self,
+ template_files: list["ReactionMetadata"],
+ ) -> list["ReactionMetadata"]:
+ """
+ Write Wildcards sections for active LAMMPS templates without
+ performing duplicate filtering.
+ """
+ active_templates: list["ReactionMetadata"] = []
+
+ for template in template_files:
+ if not template.activity_stats:
+ continue
+
+ if template.map_file is None:
+ template.activity_stats = False
+ continue
+
+ map_file_path = Path(template.map_file)
+
+ if not map_file_path.is_file():
+ template.activity_stats = False
+ continue
+
+ wildcard_ids = self._read_lammps_edge_ids(map_file_path)
+
+ self._write_lammps_wildcard_map_file(
+ map_file_path=map_file_path,
+ wildcard_ids=wildcard_ids,
+ )
+
+ template.map_file = map_file_path
+
+ delete_map_file = getattr(
+ template,
+ "map_file_with_delete_ids",
+ None,
+ )
+ if delete_map_file is not None:
+ delete_map_file = Path(delete_map_file)
+ if delete_map_file.is_file():
+ self._write_lammps_wildcard_map_file(
+ map_file_path=delete_map_file,
+ wildcard_ids=wildcard_ids,
+ )
+ template.map_file_with_delete_ids = delete_map_file
+
+ active_templates.append(template)
+
+ return active_templates
+
+ def compare_lammps_templates(
+ self,
+ template_files: list["ReactionMetadata"],
+ wildcards: bool = False,
+ ) -> list["ReactionMetadata"]:
+ """
+ Compare LAMMPS pre/post molecule-template pairs.
+
+ Filters a list of ReactionMetadata objects by generating LAMMPS graphs
+ for their pre and post reaction files, detecting duplicates, setting
+ duplicate reactions inactive, and returning only active unique templates.
+ """
+ self.clear_cache(self.LAMMPS_COMPARISON_GROUP)
+
+ unique_templates: list["ReactionMetadata"] = []
+
+ for template in template_files:
+ if not template.activity_stats:
+ continue
+
+ 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."
+ )
+ template.activity_stats = False
+ continue
+
+ pre_file_path = Path(template.pre_reaction_file)
+ post_file_path = Path(template.post_reaction_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}"
+ )
+ template.activity_stats = False
+ 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}"
+ )
+ template.activity_stats = False
+ continue
+
+ map_file_value = getattr(template, "map_file", None)
+ map_file_path = (
+ Path(map_file_value)
+ if map_file_value is not None
+ else self._lammps_map_path_from_template_path(
+ pre_file_path
+ )
+ )
+
+ 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,
+ map_file_path=map_file_path,
+ wildcards=wildcards,
+ )
+
+ if duplicate:
+ template.activity_stats = False
+ print(
+ f"Duplicate template disabled: "
+ f"RXN_{template.reaction_id}"
+ )
+ continue
+
+ if wildcards and map_file_path.is_file():
+ wildcard_ids = self._read_lammps_edge_ids(map_file_path)
+
+ self._write_lammps_wildcard_map_file(
+ map_file_path=map_file_path,
+ wildcard_ids=wildcard_ids,
+ )
+
+ template.map_file = map_file_path
+
+ delete_map_file = getattr(
+ template,
+ "map_file_with_delete_ids",
+ None,
+ )
+ if delete_map_file is not None:
+ delete_map_file = Path(delete_map_file)
+ if delete_map_file.is_file():
+ self._write_lammps_wildcard_map_file(
+ map_file_path=delete_map_file,
+ wildcard_ids=wildcard_ids,
+ )
+ template.map_file_with_delete_ids = delete_map_file
+
+ template.activity_stats = True
+ unique_templates.append(template)
+
+ return unique_templates
+
+ # ------------------------------------------------------------------
+ # Reaction-index selection
+ # ------------------------------------------------------------------
+
+ def _select_reactant_to_product_mapping(
+ self,
+ reaction_metadata: "ReactionMetadata",
+ reaction_index: int,
+ index_source: str,
+ ) -> dict[int, int]:
+ """Select the atom-index mapping used for graph restriction."""
+ if index_source == "template":
+ mapping = (
+ reaction_metadata.template_reactant_to_product_mapping
+ )
+
+ if not mapping:
+ raise ValueError(
+ f"Reaction {reaction_index} does not contain a "
+ "template_reactant_to_product_mapping."
+ )
+
+ return mapping
+
+ if index_source == "first_shell":
+ first_shell_indices = reaction_metadata.first_shell
+ full_mapping = (
+ reaction_metadata.reactant_to_product_mapping
+ )
+
+ if not first_shell_indices:
+ raise ValueError(
+ f"Reaction {reaction_index} does not contain "
+ "first_shell indices."
+ )
+
+ if not full_mapping:
+ raise ValueError(
+ f"Reaction {reaction_index} does not contain a "
+ "reactant_to_product_mapping."
+ )
+
+ return {
+ reactant_index: full_mapping[reactant_index]
+ for reactant_index in first_shell_indices
+ if reactant_index in full_mapping
+ }
+
+ raise ValueError(
+ f"Unsupported index_source {index_source!r}. "
+ "Expected 'template' or 'first_shell'."
+ )
+
+ # ------------------------------------------------------------------
+ # Coupled-graph helpers
+ # ------------------------------------------------------------------
+
+ def _couple_graphs(
+ self,
+ pre_template_graph: nx.Graph,
+ post_template_graph: nx.Graph,
+ ) -> nx.Graph:
+ """
+ Combine reactant and product graphs using atom-correspondence
+ edges.
+
+ This RDKit path expects pre and post graph atom IDs to already match.
+ """
+ pre_atom_ids = set(pre_template_graph.nodes)
+ post_atom_ids = set(post_template_graph.nodes)
+
+ if pre_atom_ids != post_atom_ids:
+ missing_from_post = sorted(
+ pre_atom_ids - post_atom_ids
+ )
+
+ missing_from_pre = sorted(
+ post_atom_ids - pre_atom_ids
+ )
+
+ raise ValueError(
+ "Pre- and post-reaction graphs must contain matching "
+ "atom IDs. "
+ f"Missing from post graph: {missing_from_post}. "
+ f"Missing from pre graph: {missing_from_pre}."
+ )
+
+ coupled_graph = nx.Graph()
+
+ coupled_graph.graph[self.RADICAL_SIGNATURE_ATTRIBUTE] = (
+ pre_template_graph.graph.get(
+ self.RADICAL_COUNT_ATTRIBUTE,
+ 0,
+ ),
+ post_template_graph.graph.get(
+ self.RADICAL_COUNT_ATTRIBUTE,
+ 0,
+ ),
+ pre_template_graph.graph.get(
+ self.RADICAL_PRESENT_ATTRIBUTE,
+ False,
+ ),
+ post_template_graph.graph.get(
+ self.RADICAL_PRESENT_ATTRIBUTE,
+ False,
+ ),
+ )
+
+ self._add_phase_to_coupled_graph(
+ source_graph=pre_template_graph,
+ coupled_graph=coupled_graph,
+ phase=self._PRE_PHASE,
+ )
+
+ self._add_phase_to_coupled_graph(
+ source_graph=post_template_graph,
+ coupled_graph=coupled_graph,
+ phase=self._POST_PHASE,
+ )
+
+ for atom_id in pre_atom_ids:
+ coupled_graph.add_edge(
+ (self._PRE_PHASE, atom_id),
+ (self._POST_PHASE, atom_id),
+ relationship=self._ATOM_CORRESPONDENCE_RELATIONSHIP,
+ **{
+ self.EDGE_ATTRIBUTE: None,
+ },
+ )
+
+ return coupled_graph
+
+ def _add_phase_to_coupled_graph(
+ self,
+ source_graph: nx.Graph,
+ coupled_graph: nx.Graph,
+ phase: str,
+ ) -> None:
+ """Add one reaction phase to a coupled graph."""
+ for atom_id, attributes in source_graph.nodes(data=True):
+ atom_label = attributes.get(self.NODE_ATTRIBUTE)
+
+ if atom_label is None:
+ raise ValueError(
+ f"Node {atom_id} is missing the required "
+ f"{self.NODE_ATTRIBUTE!r} attribute."
+ )
+
+ coupled_graph.add_node(
+ (phase, atom_id),
+ phase=phase,
+ **{
+ self.NODE_ATTRIBUTE: atom_label,
+ },
+ )
+
+ for atom1_id, atom2_id, attributes in source_graph.edges(
+ data=True
+ ):
+ bond_label = attributes.get(self.EDGE_ATTRIBUTE)
+
+ if bond_label is None:
+ raise ValueError(
+ f"Edge {atom1_id}-{atom2_id} is missing the required "
+ f"{self.EDGE_ATTRIBUTE!r} attribute."
+ )
+
+ coupled_graph.add_edge(
+ (phase, atom1_id),
+ (phase, atom2_id),
+ relationship=self._BOND_RELATIONSHIP,
+ **{
+ self.EDGE_ATTRIBUTE: bond_label,
+ },
+ )
+
+ @staticmethod
+ def _resolve_node_id(
+ atom_index: int,
+ idx_relabel: dict[int, int] | None,
+ ) -> int:
+ """Resolve an RDKit atom index to its graph node ID."""
+ if idx_relabel is None:
+ return atom_index
+
+ return idx_relabel[atom_index]
+
+ # ------------------------------------------------------------------
+ # LAMMPS parsing helpers
+ # ------------------------------------------------------------------
+
+ def _read_lammps_sections(
+ self,
+ file_path: Path,
+ ) -> dict[str, list[str]]:
+ """Read relevant sections from a LAMMPS molecule file."""
+ sections: dict[str, list[str]] = {}
+ current_section: str | None = None
+
+ with file_path.open(
+ "r",
+ encoding="utf-8",
+ ) as file:
+ for raw_line in file:
+ line = raw_line.split(
+ "#",
+ maxsplit=1,
+ )[0].strip()
+
+ if not line:
+ continue
+
+ if line in self._LAMMPS_SECTION_HEADERS:
+ if line in self._LAMMPS_RELEVANT_SECTIONS:
+ current_section = line
+ sections.setdefault(
+ current_section,
+ [],
+ )
+ else:
+ current_section = None
+
+ continue
+
+ if current_section is not None:
+ sections[current_section].append(line)
+
+ return sections
+
+ def _add_lammps_atoms(
+ self,
+ graph: nx.Graph,
+ type_lines: list[str],
+ file_path: Path,
+ ) -> None:
+ """Add atoms from a LAMMPS ``Types`` section."""
+ for line in type_lines:
+ parts = line.split()
+
+ if len(parts) < 2:
+ raise ValueError(
+ f"Invalid Types line in {file_path}: {line!r}"
+ )
+
+ try:
+ atom_id = int(parts[0])
+ except ValueError as error:
+ raise ValueError(
+ f"Invalid atom ID in {file_path}: {line!r}"
+ ) from error
+
+ atom_type = parts[1]
+
+ if atom_id in graph:
+ raise ValueError(
+ f"Duplicate atom ID {atom_id} in {file_path}."
+ )
+
+ graph.add_node(
+ atom_id,
+ **{
+ self.NODE_ATTRIBUTE: atom_type,
+ },
+ )
+
+ def _add_lammps_bonds(
+ self,
+ graph: nx.Graph,
+ bond_lines: list[str],
+ file_path: Path,
+ ) -> None:
+ """Add bonds from a LAMMPS ``Bonds`` section."""
+ for line in bond_lines:
+ parts = line.split()
+
+ if len(parts) < 4:
+ raise ValueError(
+ f"Invalid Bonds line in {file_path}: {line!r}"
+ )
+
+ try:
+ bond_id = int(parts[0])
+ atom1_id = int(parts[2])
+ atom2_id = int(parts[3])
+ except ValueError as error:
+ raise ValueError(
+ f"Invalid Bonds line in {file_path}: {line!r}"
+ ) from error
+
+ bond_type = parts[1]
+
+ self._validate_bond_atoms(
+ graph=graph,
+ bond_id=bond_id,
+ atom1_id=atom1_id,
+ atom2_id=atom2_id,
+ source=file_path,
+ )
+
+ graph.add_edge(
+ atom1_id,
+ atom2_id,
+ **{
+ self.EDGE_ATTRIBUTE: bond_type,
+ },
+ )
+
+ @staticmethod
+ def _validate_bond_atoms(
+ graph: nx.Graph,
+ bond_id: int,
+ atom1_id: int,
+ atom2_id: int,
+ source: Path | str,
+ ) -> None:
+ """Ensure both atoms referenced by a bond exist."""
+ undefined_atoms = [
+ atom_id
+ for atom_id in (atom1_id, atom2_id)
+ if atom_id not in graph
+ ]
+
+ if undefined_atoms:
+ raise ValueError(
+ f"Bond {bond_id} references undefined atom IDs "
+ f"{undefined_atoms} in {source}."
+ )
+
+ @classmethod
+ def _count_radical_atoms(
+ cls,
+ molecule: Chem.Mol,
+ ) -> int:
+ """Count radical atoms in a complete RDKit molecule."""
+ return sum(
+ cls._is_radical_atom(atom)
+ for atom in molecule.GetAtoms()
+ )
+
+ @staticmethod
+ def _is_radical_atom(atom: Chem.Atom) -> bool:
+ """Return True for an explicit or structurally under-valent radical atom.
+
+ Progression normally sets ``NumRadicalElectrons`` on the cleaned
+ product. The structural fallback is needed because the original
+ ``RunReactants`` product stored in ``ReactionMetadata`` can retain
+ incomplete valence bookkeeping before later sanitization.
+
+ The fallback is intentionally limited to neutral, non-aromatic carbon
+ atoms used by the current vinyl-radical implementation.
+ """
+ if atom.GetNumRadicalElectrons() > 0:
+ return True
+
+ if atom.GetAtomicNum() != 6:
+ return False
+
+ if atom.GetFormalCharge() != 0:
+ return False
+
+ if atom.GetIsAromatic():
+ return False
+
+ try:
+ atom.GetOwningMol().UpdatePropertyCache(strict=False)
+ if atom.GetNumImplicitHs() > 0:
+ return False
+ except RuntimeError:
+ pass
+
+ graph_bond_valence = sum(
+ bond.GetBondTypeAsDouble()
+ for bond in atom.GetBonds()
+ )
+
+ explicit_hydrogen_neighbors = sum(
+ neighbor.GetAtomicNum() == 1
+ for neighbor in atom.GetNeighbors()
+ )
+
+ effective_valence = graph_bond_valence
+
+ if explicit_hydrogen_neighbors == 0:
+ effective_valence += atom.GetNumExplicitHs()
+
+ return abs(effective_valence - 3.0) < 1.0e-6
+
+
+def _main() -> None:
+ """Run the standalone LAMMPS deduplication example."""
+ folder_path = Path(
+ "/mnt/c/Users/janit/Documents/GitHub/AutoREACTER/"
+ "examples/AutoREACTER_outputs/"
+ "Epoxy_Test_Primary_Diamine_Diepoxy/"
+ )
+
+ if not folder_path.is_dir():
+ raise NotADirectoryError(
+ f"Invalid folder path: {folder_path}"
+ )
+
+ pre_template_files = sorted(
+ file_path
+ for file_path in folder_path.glob("*.molecule")
+ if "pre" in file_path.name
+ )
+
+ print(
+ f"Found {len(pre_template_files)} "
+ "pre-reaction molecule files."
+ )
+
+ detector = DeduplicationDetector()
+ results = detector.compare_graphs(pre_template_files)
+
+ print("\nDeduplication results:")
+
+ for file_path, duplicate in results.items():
+ status = "duplicate" if duplicate else "unique"
+
+ print(f"{Path(file_path).name}: {status}")
+
+
+if __name__ == "__main__":
+ _main()
diff --git a/AutoREACTER/reaction_preparation/ff_wrapper/FF_files/pcff.frc b/AutoREACTER/reaction_preparation/ff_wrapper/FF_files/pcff.frc
index 07ecac85..7d7a2780 100644
--- a/AutoREACTER/reaction_preparation/ff_wrapper/FF_files/pcff.frc
+++ b/AutoREACTER/reaction_preparation/ff_wrapper/FF_files/pcff.frc
@@ -163,6 +163,7 @@
1.0 1 p 30.97380 P 4 general phosphorous atom
1.0 1 p= 30.97380 P 5 phosphazene phosphorous atom
1.0 1 s 32.06400 S 2 sp3 sulfur
+ 1.0 1 s_m 32.06400 S 2 sulfone sulfur # Duplicated from base parameter 's'
1.0 1 s' 32.06400 S 1 S in thioketone group
1.0 1 s- 32.06400 S 1 partial double sulfur
1.0 1 s1 32.06400 S 2 sp3 sulfur involved in (S-S) group of disulfides
@@ -487,6 +488,7 @@
2.0 2 br op 0.3140 -0.3140
2.0 2 br p -0.2156 0.2156
2.0 2 br s -0.0437 0.0437
+ 2.0 2 br s_m -0.0437 0.0437 # Duplicated from base parameter 's'
2.0 2 br s' 0.0034 -0.0034
2.0 2 br si -0.3273 0.3273
2.0 2 br sp 0.0034 -0.0034
@@ -523,6 +525,7 @@
2.0 2 c p 0.0110 -0.0110
3.1 12 c p= -0.0500 0.0500
1.0 1 c s 0.0650 -0.0650
+ 1.0 1 c s_m 0.0650 -0.0650 # Duplicated from base parameter 's'
2.2 9 c si -0.1350 0.1350
2.0 2 c si -0.1767 0.1767
1.0 4 c sio -0.1000 0.1000
@@ -550,6 +553,7 @@
2.0 2 c- op 0.3241 -0.3241
2.0 2 c- p -0.0857 0.0857
2.0 2 c- s -0.0087 0.0087
+ 2.0 2 c- s_m -0.0087 0.0087 # Duplicated from base parameter 's'
2.0 2 c- s- -0.1223 -0.3777
2.0 2 c- si -0.2775 0.2775
2.0 1 c= c= 0.0000 0.0000
@@ -578,6 +582,7 @@
2.0 2 c= op 0.3583 -0.3583
2.0 2 c= p -0.0380 0.0380
2.0 2 c= s -0.0120 0.0120
+ 2.0 2 c= s_m -0.0120 0.0120 # Duplicated from base parameter 's'
2.0 2 c= s' 0.0732 -0.0732
2.0 2 c= si -0.2270 0.2270
2.0 2 c= sp 0.0732 -0.0732
@@ -605,6 +610,7 @@
2.0 2 c=1 op 0.3583 -0.3583
2.0 2 c=1 p -0.0380 0.0380
2.0 2 c=1 s -0.0120 0.0120
+ 2.0 2 c=1 s_m -0.0120 0.0120 # Duplicated from base parameter 's'
2.0 2 c=1 s' 0.0732 -0.0732
2.0 2 c=1 si -0.2270 0.2270
2.0 2 c=1 sp 0.0732 -0.0732
@@ -633,6 +639,7 @@
2.0 2 c=2 op 0.3583 -0.3583
2.0 2 c=2 p -0.0380 0.0380
2.0 2 c=2 s -0.0120 0.0120
+ 2.0 2 c=2 s_m -0.0120 0.0120 # Duplicated from base parameter 's'
2.0 2 c=2 s' 0.0732 -0.0732
2.0 2 c=2 si -0.2270 0.2270
2.0 2 c=2 sp 0.0732 -0.0732
@@ -658,6 +665,7 @@
1.0 1 c_0 op 0.0283 -0.0283
2.0 2 c_0 p -0.2396 0.2396
2.0 2 c_0 s -0.0140 0.0140
+ 2.0 2 c_0 s_m -0.0140 0.0140 # Duplicated from base parameter 's'
2.0 3 c_0 s' 0.0000 0.0000
2.0 2 c_0 si -0.4405 0.4405
1.0 1 c_0 sp -0.0130 0.0130
@@ -685,6 +693,7 @@
1.0 1 c_1 op 0.0283 -0.0283
2.0 2 c_1 p -0.2396 0.2396
2.0 2 c_1 s -0.0140 0.0140
+ 2.0 2 c_1 s_m -0.0140 0.0140 # Duplicated from base parameter 's'
2.0 3 c_1 s' 0.0000 0.0000
2.0 2 c_1 si -0.4405 0.4405
1.0 1 c_1 sp -0.0130 0.0130
@@ -712,6 +721,7 @@
2.0 2 cl p -0.2544 0.2544
3.1 12 cl p= -0.1200 0.1200
2.0 2 cl s -0.0898 0.0898
+ 2.0 2 cl s_m -0.0898 0.0898 # Duplicated from base parameter 's'
2.0 2 cl s' -0.0457 0.0457
2.0 2 cl si -0.3598 0.3598
2.0 2 cl sp -0.0457 0.0457
@@ -738,6 +748,7 @@
1.0 2 cp p -0.0380 0.0380
3.1 12 cp p= -0.0600 0.0600
2.0 2 cp s -0.0120 0.0120
+ 2.0 2 cp s_m -0.0120 0.0120 # Duplicated from base parameter 's'
2.0 2 cp s' 0.0732 -0.0732
2.2 9 cp si -0.1170 0.1170
2.0 2 cp si -0.2270 0.2270
@@ -764,6 +775,7 @@
2.0 2 ct o 0.0675 -0.0675
2.0 2 ct p -0.1335 0.1335
2.0 2 ct s -0.0522 0.0522
+ 2.0 2 ct s_m -0.0522 0.0522 # Duplicated from base parameter 's'
2.0 2 ct si -0.3266 0.3266
2.0 4 cz oo 0.5000 -0.5000
2.0 4 cz oz 0.1000 -0.1000
@@ -785,6 +797,7 @@
2.0 2 f p -0.3869 0.3869
3.1 12 f p= -0.1800 0.1800
2.0 2 f s -0.2380 0.2380
+ 2.0 2 f s_m -0.2380 0.2380 # Duplicated from base parameter 's'
2.0 2 f s' -0.2011 0.2011
2.0 2 f si -0.4789 0.4789
2.0 2 f sp -0.2011 0.2011
@@ -794,6 +807,7 @@
2.0 2 h p -0.0356 0.0356
3.1 12 h p= -0.0500 0.0500
2.0 2 h s 0.1392 -0.1392
+ 2.0 2 h s_m 0.1392 -0.1392 # Duplicated from base parameter 's'
2.0 2 h s' 0.1932 -0.1932
2.2 9 h si -0.1260 0.1260
2.0 2 h si -0.1537 0.1537
@@ -835,6 +849,7 @@
2.0 2 i op 0.3297 -0.3297
2.0 2 i p -0.2110 0.2110
2.0 2 i s -0.0345 0.0345
+ 2.0 2 i s_m -0.0345 0.0345 # Duplicated from base parameter 's'
2.0 2 i s' 0.0140 -0.0140
2.0 2 i si -0.3263 0.3263
2.0 2 i sp 0.0140 -0.0140
@@ -853,6 +868,7 @@
2.0 2 n p -0.3359 0.3359
3.1 12 n p= -0.1200 0.1200
2.0 2 n s -0.1753 0.1753
+ 2.0 2 n s_m -0.1753 0.1753 # Duplicated from base parameter 's'
2.0 2 n s' -0.1346 0.1346
2.0 2 n si -0.4368 0.4368
2.0 2 n sp -0.1346 0.1346
@@ -869,6 +885,7 @@
2.0 2 n+ op 0.3418 -0.0918
2.0 2 n+ p -0.1994 0.4494
2.0 2 n+ s -0.0255 0.2755
+ 2.0 2 n+ s_m -0.0255 0.2755 # Duplicated from base parameter 's'
2.0 2 n+ s' 0.0159 0.2341
2.0 2 n+ si -0.3083 0.5583
2.0 2 n+ sp 0.0159 0.2341
@@ -885,6 +902,7 @@
2.0 2 n= p -0.3359 0.3359
3.1 12 n= p= -0.3500 0.3500
2.0 2 n= s -0.1753 0.1753
+ 2.0 2 n= s_m -0.1753 0.1753 # Duplicated from base parameter 's'
2.0 2 n= s' -0.1346 0.1346
2.0 2 n= si -0.4368 0.4368
2.0 2 n= sp -0.1346 0.1346
@@ -899,6 +917,7 @@
2.0 2 n=1 op 0.1684 -0.1684
2.0 2 n=1 p -0.3359 0.3359
2.0 2 n=1 s -0.1753 0.1753
+ 2.0 2 n=1 s_m -0.1753 0.1753 # Duplicated from base parameter 's'
2.0 2 n=1 s' -0.1346 0.1346
2.0 2 n=1 si -0.4368 0.4368
2.0 2 n=1 sp -0.1346 0.1346
@@ -913,6 +932,7 @@
2.0 2 n=2 op 0.1684 -0.1684
2.0 2 n=2 p -0.3359 0.3359
2.0 2 n=2 s -0.1753 0.1753
+ 2.0 2 n=2 s_m -0.1753 0.1753 # Duplicated from base parameter 's'
2.0 2 n=2 s' -0.1346 0.1346
2.0 2 n=2 si -0.4368 0.4368
2.0 2 n=2 sp -0.1346 0.1346
@@ -925,6 +945,7 @@
2.0 2 na op 0.2369 -0.2369
2.0 2 na p -0.2518 0.2518
2.0 2 na s -0.0966 0.0966
+ 2.0 2 na s_m -0.0966 0.0966 # Duplicated from base parameter 's'
2.0 2 na s' -0.0551 0.0551
2.0 2 na si -0.3501 0.3501
2.0 2 na sp -0.0551 0.0551
@@ -936,6 +957,7 @@
2.0 2 nh op 0.3148 -0.3148
2.0 2 nh p -0.1375 0.1375
2.0 2 nh s 0.0046 -0.0046
+ 2.0 2 nh s_m 0.0046 -0.0046 # Duplicated from base parameter 's'
2.0 2 nh s' 0.0454 -0.0454
2.0 2 nh si -0.2278 0.2278
2.0 2 nh sp 0.0454 -0.0454
@@ -946,6 +968,7 @@
2.0 2 nn op 0.1684 -0.1684
2.0 2 nn p -0.3359 0.3359
2.0 2 nn s -0.1753 0.1753
+ 2.0 2 nn s_m -0.1753 0.1753 # Duplicated from base parameter 's'
2.0 2 nn s' -0.1346 0.1346
2.0 2 nn si -0.4368 0.4368
2.0 2 nn sp -0.1346 0.1346
@@ -955,6 +978,7 @@
2.0 2 np op 0.1684 -0.1684
2.0 2 np p -0.3359 0.3359
2.0 2 np s -0.1753 0.1753
+ 2.0 2 np s_m -0.1753 0.1753 # Duplicated from base parameter 's'
2.0 2 np s' -0.1346 0.1346
2.0 2 np si -0.4368 0.4368
2.0 2 np sp -0.1346 0.1346
@@ -965,6 +989,7 @@
2.0 2 o p -0.2548 0.2548
3.1 12 o p= -0.1400 0.1400
2.0 2 o s -0.1143 0.1143
+ 2.0 2 o s_m -0.1143 0.1143 # Duplicated from base parameter 's'
2.0 2 o s' -0.0766 0.0766
2.0 2 o si -0.3425 0.3425
2.0 2 o sp -0.0766 0.0766
@@ -976,6 +1001,7 @@
2.0 2 o_1 op 0.0000 0.0000
2.0 2 o_1 p -0.4933 0.4933
2.0 2 o_1 s -0.3386 0.3386
+ 2.0 2 o_1 s_m -0.3386 0.3386 # Duplicated from base parameter 's'
2.0 2 o_1 s' -0.3024 0.3024
2.0 2 o_1 si -0.5883 0.5883
2.0 2 o_1 sp -0.3024 0.3024
@@ -984,6 +1010,7 @@
2.0 2 op op 0.0000 0.0000
2.0 2 op p -0.4933 0.4933
2.0 2 op s -0.3386 0.3386
+ 2.0 2 op s_m -0.3386 0.3386 # Duplicated from base parameter 's'
2.0 2 op s' -0.3024 0.3024
2.0 2 op si -0.5883 0.5883
2.0 2 op sp -0.3024 0.3024
@@ -992,14 +1019,20 @@
3.0 10 oss sz -0.1309 0.1309
2.0 2 p p 0.0000 0.0000
2.0 2 p s 0.1600 -0.1600
+ 2.0 2 p s_m 0.1600 -0.1600 # Duplicated from base parameter 's'
2.0 2 p s' 0.2106 -0.2106
2.0 2 p s- 0.1824 -0.6824
2.0 2 p si -0.1069 0.1069
2.0 2 p sp 0.2106 -0.2106
2.0 2 s s 0.0000 0.0000
+ 2.0 2 s s_m 0.0000 0.0000 # Duplicated from base parameter 's'
+ 2.0 2 s_m s_m 0.0000 0.0000 # Duplicated from base parameter 's'
2.0 2 s s' 0.0455 -0.0455
+ 2.0 2 s_m s' 0.0455 -0.0455 # Duplicated from base parameter 's'
2.0 2 s si -0.2634 0.2634
+ 2.0 2 s_m si -0.2634 0.2634 # Duplicated from base parameter 's'
2.0 2 s sp 0.0455 -0.0455
+ 2.0 2 s_m sp 0.0455 -0.0455 # Duplicated from base parameter 's'
2.0 2 s' s' 0.0000 0.0000
2.0 2 s' si -0.3172 0.3172
2.0 2 s' sp 0.0000 0.0000
@@ -1670,6 +1703,7 @@
2.1 8 c h 1.1010 345.0000 -691.8900 844.6000
1.0 1 c h 1.1010 341.0000 -691.8900 844.6000
1.0 1 c n 1.4520 327.1657 -547.8990 526.5000
+ 1.0 1 c2 nn 1.4520 327.1657 -547.8990 526.5000 # AutoREACTER addition; copied from base c n
1.0 1 c n+ 1.5185 293.1700 -603.7882 629.6900
1.1 1 c n= 1.4750 336.0000 0.0000 0.0000
1.1 1 c n=1 1.4750 336.0000 0.0000 0.0000
@@ -1683,6 +1717,8 @@
2.1 6 c o_2 1.4457 326.7273 -608.5306 689.0333
2.0 5 c oz 1.4457 326.7273 -608.5306 689.0333
1.0 1 c s 1.8230 225.2768 -327.7057 488.9722
+ 1.0 1 c s_m 1.8230 225.2768 -327.7057 488.9722 # Duplicated from base parameter 's'
+ 1.0 1 cp s_m 1.8230 225.2768 -327.7057 488.9722 # AutoREACTER addition; copied from base c s
2.2 9 c si 1.8995 189.6536 -279.4210 307.5135
1.0 4 c sio 1.9073 157.0049 -237.7023 356.0328
1.0 1 c+ nr 1.3834 380.4600 -814.4300 1153.3000
@@ -1702,6 +1738,7 @@
1.0 1 c=2 h 1.0883 365.7679 -725.5404 781.6621
2.1 8 c=2 o= 1.1600 1112.0000 0.0000 0.0000
2.1 8 c=2 s' 1.5526 567.3600 0.0000 0.0000
+ 2.1 8 c=2 s_m 1.5526 567.3600 0.0000 0.0000 # Duplicated from base parameter 's'
2.1 8 c_0 cp 1.4890 339.3574 -655.7236 670.2362
2.1 8 c_0 h 1.1220 304.8631 -623.3705 700.2828
2.1 8 c_0 o_1 1.2160 823.7948 -1878.7940 2303.5311
@@ -1749,6 +1786,7 @@
2.1 8 h h 0.7414 414.0000 0.0000 0.0000
3.1 12 h p= 1.3861 285.2043 -575.6851 677.8456
1.0 1 h s 1.3261 275.1123 -531.3181 562.9630
+ 1.0 1 h s_m 1.3261 275.1123 -531.3181 562.9630 # Duplicated from base parameter 's'
2.2 9 h si 1.4783 202.7798 -305.3603 280.2685
1.0 4 h sio 1.4802 187.1010 -280.7306 258.8998
1.0 1 h* n 1.0100 462.7500 -1053.6300 1545.7570
@@ -1756,6 +1794,7 @@
1.0 1 h* na 1.0060 466.7400 -1073.6018 1251.1056
1.0 1 h* nh 1.0053 463.9230 -1050.8070 1284.7262
1.0 1 h* nn 1.0012 465.8608 -1066.2360 1496.5647
+ 1.0 1 hn2 nn 1.0012 465.8608 -1066.2360 1496.5647 # AutoREACTER addition; copied from base h* nn
1.0 1 h* nr 1.0023 462.3900 -1044.6000 1468.7000
1.0 1 h* o 0.9650 532.5062 -1282.9050 2004.7658
1.2 3 h* o* 0.9700 563.2800 -1428.2200 1902.1200
@@ -1777,13 +1816,17 @@
4.0 13 o p 1.6100 245.2000 0.0000 0.0000
2.1 8 o= o= 1.2074 847.4400 0.0000 0.0000
2.1 8 o= s' 1.4308 743.7600 0.0000 0.0000
+ 2.1 8 o= s_m 1.4308 743.7600 0.0000 0.0000 # Duplicated from base parameter 's'
3.0 10 oas sz 1.5923 392.6680 -1004.4800 3452.8601
3.0 10 ob sz 1.6446 393.6690 -989.8420 1461.9800
3.0 10 osh sz 1.6125 420.0240 -845.6110 1438.6300
1.0 4 osi sio 1.6562 306.1232 -517.3424 673.7067
3.0 10 oss sz 1.6155 325.4430 -943.3640 1454.6700
1.0 1 s s 2.0559 197.6560 -196.1366 644.4103
+ 1.0 1 s s_m 2.0559 197.6560 -196.1366 644.4103 # Duplicated from base parameter 's'
+ 1.0 1 s_m s_m 2.0559 197.6560 -196.1366 644.4103 # Duplicated from base parameter 's'
2.2 9 si si 2.3384 114.2164 -140.4212 80.7084
+ 1.0 1 c_2 na 1.4570 365.8052 -699.6368 998.4842 # AutoREACTER addition; copied from base c na
#quadratic_angle cff91_auto
@@ -2133,7 +2176,7 @@
!--- --- ----- ----- ----- -------- -------- -------- --------
3.0 10 oah az oah 119.5540 56.2161 67.5146 75.6704
3.0 10 oah az oas 135.8500 1.5716 -23.2602 24.2341
- 3.0 10 oah az ob 96.9383 41.2978 -101.1850 180.8230
+ 3.0 10 oah az ob 96.9383 41.2978 -101.1850 180.8230
3.0 10 oas az oas 114.1500 112.9470 -37.6330 22.7467
3.0 10 oas az ob 97.0360 73.0531 -31.9551 5.5982
3.0 10 ob az ob 97.0360 73.0531 -31.9551 5.5982
@@ -2163,6 +2206,7 @@
2.1 6 c c o_2 107.4100 63.3907 -13.4513 1.6650
2.0 5 c c oz 105.4100 63.3907 -13.4513 0.0000
1.0 1 c c s 112.5642 47.0276 -10.6790 -10.1687
+ 1.0 1 c c s_m 112.5642 47.0276 -10.6790 -10.1687 # Duplicated from base parameter 's'
2.2 9 c c si 112.6700 39.5160 -7.4430 0.0000
1.0 1 c- c h 109.6700 37.9190 -7.3877 -8.0694
1.3 1 c- c n 100.5663 52.0966 -5.2642 -10.7045
@@ -2201,9 +2245,13 @@
2.1 6 h c o_2 107.6880 65.4801 -10.3498 5.8866
2.0 5 h c oz 107.6880 70.4801 -10.3498 0.0000
1.0 1 h c s 107.8522 51.4949 -13.5270 7.0260
+ 1.0 1 h c s_m 107.8522 51.4949 -13.5270 7.0260 # Duplicated from base parameter 's'
2.2 9 h c si 112.0355 28.7721 -13.9523 0.0000
1.0 4 h c sio 111.5360 30.2481 -15.5255 0.0000
1.0 1 s c s 111.5000 27.9677 0.0000 0.0000
+ 1.0 1 s_m c s 111.5000 27.9677 0.0000 0.0000 # Duplicated from base parameter 's'
+ 1.0 1 s c s_m 111.5000 27.9677 0.0000 0.0000 # Duplicated from base parameter 's'
+ 1.0 1 s_m c s_m 111.5000 27.9677 0.0000 0.0000 # Duplicated from base parameter 's'
1.0 1 nr c+ nr 117.4500 83.9840 0.0000 0.0000
1.0 1 c c- o- 115.0600 59.0960 -15.1430 -12.9820
1.0 1 h c- o- 112.7500 61.1530 -14.0190 -13.2380
@@ -2405,10 +2453,19 @@
3.1 12 o p= o 95.5000 87.7686 -4.5699 -17.8523
4.0 13 o p o 109.0000 45.0000 0.0000 0.0000
1.0 1 c s c 97.5000 57.6938 -5.0559 -11.8206
+ 1.0 1 c s_m c 97.5000 57.6938 -5.0559 -11.8206 # Duplicated from base parameter 's'
1.0 1 c s h 96.8479 56.7336 14.2713 0.0000
+ 1.0 1 c s_m h 96.8479 56.7336 14.2713 0.0000 # Duplicated from base parameter 's'
1.0 1 c s s 100.3000 57.2900 -6.5301 -11.8204
+ 1.0 1 c s_m s 100.3000 57.2900 -6.5301 -11.8204 # Duplicated from base parameter 's'
+ 1.0 1 c s s_m 100.3000 57.2900 -6.5301 -11.8204 # Duplicated from base parameter 's'
+ 1.0 1 c s_m s_m 100.3000 57.2900 -6.5301 -11.8204 # Duplicated from base parameter 's'
1.0 1 h s h 94.3711 54.9676 0.0000 0.0000
+ 1.0 1 h s_m h 94.3711 54.9676 0.0000 0.0000 # Duplicated from base parameter 's'
1.0 1 h s s 97.2876 54.4281 0.0000 0.0000
+ 1.0 1 h s_m s 97.2876 54.4281 0.0000 0.0000 # Duplicated from base parameter 's'
+ 1.0 1 h s s_m 97.2876 54.4281 0.0000 0.0000 # Duplicated from base parameter 's'
+ 1.0 1 h s_m s_m 97.2876 54.4281 0.0000 0.0000 # Duplicated from base parameter 's'
2.1 8 o- s' o- 119.3290 135.0000 0.0000 0.0000
2.2 9 c si c 113.1855 36.2069 -20.3939 20.0172
2.2 9 c si h 112.0977 36.4832 -12.8094 0.0000
@@ -2434,6 +2491,10 @@
3.0 10 osh sz osh 115.0310 68.3381 49.4314 116.2400
3.0 10 osh sz oss 110.6700 117.5060 -49.8921 0.0000
3.0 10 oss sz oss 110.6120 154.1860 -68.6595 23.6292
+ 1.0 1 cp cp s_m 112.5642 47.0276 -10.6790 -10.1687 # AutoREACTER addition; copied from base c c s
+ 1.0 1 cp s_m cp 97.5000 57.6938 -5.0559 -11.8206 # AutoREACTER addition; copied from base c s c
+ 1.0 1 cp s_m o= 113.1000 42.3000 0.0000 0.0000 # AutoREACTER addition; based on auto * s o
+ 1.0 1 o= s_m o= 115.0000 50.0000 0.0000 0.0000 # AutoREACTER addition; generic O=S=O sulfone angle
#torsion_1 cff91_auto
@@ -2707,6 +2768,7 @@
2.1 6 c c c o_2 0.0000 0.0 0.0000 0.0 -0.2500 0.0
2.0 5 c c c oz -3.6896 0.0 0.0000 0.0 0.0000 0.0
1.0 1 c c c s -0.7017 0.0 0.0201 0.0 0.1040 0.0
+ 1.0 1 c c c s_m -0.7017 0.0 0.0201 0.0 0.1040 0.0 # Duplicated from base parameter 's'
2.2 9 c c c si 0.0000 0.0 0.0514 0.0 -0.1430 0.0
1.3 1 c- c c c_1 0.0972 0.0 0.0722 0.0 -0.2581 0.0
1.3 1 c- c c cp 0.0972 0.0 0.0722 0.0 -0.2581 0.0
@@ -2733,6 +2795,7 @@
1.0 1 c_1 c c n 0.0972 0.0 0.0722 0.0 -0.2581 0.0
1.3 1 c_1 c c o -0.0858 0.0 -0.1320 0.0 -0.5909 0.0
1.3 1 c_1 c c s 0.0972 0.0 0.0722 0.0 -0.2581 0.0
+ 1.3 1 c_1 c c s_m 0.0972 0.0 0.0722 0.0 -0.2581 0.0 # Duplicated from base parameter 's'
2.1 8 cl c c cl 0.0000 0.0 0.0000 0.0 -0.1000 0.0
2.1 8 cl c c f 0.0000 0.0 0.0000 0.0 -0.1000 0.0
2.1 8 cl c c h 0.0000 0.0 0.0000 0.0 -0.1000 0.0
@@ -2755,9 +2818,11 @@
2.1 6 h c c o_2 0.0000 0.0 0.0000 0.0 -0.2500 0.0
2.0 5 h c c oz -3.6896 0.0 0.0000 0.0 0.0000 0.0
1.0 1 h c c s -0.2078 0.0 -0.1060 0.0 -0.3595 0.0
+ 1.0 1 h c c s_m -0.2078 0.0 -0.1060 0.0 -0.3595 0.0 # Duplicated from base parameter 's'
2.2 9 h c c si 0.0000 0.0 0.0514 0.0 -0.1430 0.0
1.3 1 n c c o -0.1820 0.0 -0.1084 0.0 -0.7047 0.0
1.3 1 n c c s 0.0972 0.0 0.0722 0.0 -0.2581 0.0
+ 1.3 1 n c c s_m 0.0972 0.0 0.0722 0.0 -0.2581 0.0 # Duplicated from base parameter 's'
2.1 7 n_2 c c n_2 0.0000 0.0 0.0060 0.0 -0.1441 0.0
2.1 7 n_2 c c o_2 0.0000 0.0 0.0000 0.0 -0.1441 0.0
1.0 1 na c c na 0.3805 0.0 0.3547 0.0 -0.1102 0.0
@@ -2765,6 +2830,9 @@
2.1 6 o_2 c c o_2 -0.6070 0.0 0.0060 0.0 -0.1441 0.0
2.0 5 oz c c oz -0.6070 0.0 0.0060 0.0 -0.1441 0.0
1.0 1 s c c s -1.2002 0.0 -1.2999 0.0 -0.1626 0.0
+ 1.0 1 s_m c c s -1.2002 0.0 -1.2999 0.0 -0.1626 0.0 # Duplicated from base parameter 's'
+ 1.0 1 s c c s_m -1.2002 0.0 -1.2999 0.0 -0.1626 0.0 # Duplicated from base parameter 's'
+ 1.0 1 s_m c c s_m -1.2002 0.0 -1.2999 0.0 -0.1626 0.0 # Duplicated from base parameter 's'
1.0 1 c c c- o- 1.7311 0.0 1.8510 0.0 -0.1933 0.0
1.0 1 h c c- o- -2.5999 0.0 1.0488 0.0 -0.2089 0.0
1.3 1 n c c- o- 0.0899 0.0 0.1220 0.0 0.0905 0.0
@@ -2846,11 +2914,20 @@
1.0 1 o c c_0 h -0.0390 0.0 1.4052 0.0 0.0757 0.0
1.0 1 o c c_0 o_1 0.6444 0.0 0.7897 0.0 1.0604 0.0
2.1 8 c c c_1 n_2 0.1693 0.0 -0.0090 0.0 -0.0687 0.0
+ 2.1 8 nn c_1 c1 c=1 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->nn, c->c1/c=1
+ 2.1 8 nn c_1 c1 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->nn, c->c1/c2
+ 2.1 8 nn c_1 c 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->nn, c->c/c2
+ 2.1 8 nn c_1 c c3 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->nn, c->c/c3
+ 2.1 8 nn 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->nn, c->c2
+ 2.1 8 nn c_1 c2 hc 0.1693 0.0 -0.0090 0.0 -0.0687 0.0 # AutoREACTER addition; copied from h c c_1 n_2 reversed; mapped n_2->nn, c->c2, h->hc
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 o_1 c_1 c1 c=1 0.0442 0.0 0.0292 0.0 0.0562 0.0 # AutoREACTER addition; copied from c c c_1 o_1 reversed; mapped c->c1/c=1
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 nn c_1 c1 hc 0.1693 0.0 -0.0090 0.0 -0.0687 0.0 # AutoREACTER addition; copied from h c c_1 n_2 reversed; mapped n_2->nn, c->c1, h->hc
2.1 8 h c c_1 o_1 -0.1804 0.0 0.0012 0.0 0.0371 0.0
2.1 8 h c c_1 o_2 -0.6359 0.0 1.4807 0.0 -0.0438 0.0
1.0 1 n c c_1 n -0.0892 0.0 0.1259 0.0 -0.0884 0.0
@@ -2918,13 +2995,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 +3052,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 +3269,65 @@
2.1 8 oh c_1 cg hc -0.6359 0.0 1.4807 0.0 -0.0438 0.0 # AutoREACTER addition; copied from h c c_1 o_2 reversed; mapped o_2->oh, c->cg, h->hc
2.1 8 oh c_1 c2 na 1.8341 0.0 2.0603 0.0 -0.0195 0.0 # AutoREACTER addition; copied from c c c_1 o_2 reversed; mapped o_2->oh, c->c2/na
2.1 8 oh c_1 cg na 1.8341 0.0 2.0603 0.0 -0.0195 0.0 # AutoREACTER addition; copied from c c c_1 o_2 reversed; mapped o_2->oh, c->cg/na
+ 1.0 1 c c s c -0.5073 0.0 0.0155 0.0 -0.0671 0.0 # AutoREACTER addition; generic aliphatic-sulfur dihedral
+ 1.0 1 cp cp s_m cp -0.5073 0.0 0.0155 0.0 -0.0671 0.0 # AutoREACTER addition; mapped from c c s c for cp-cp-s_m-cp
+ 1.0 1 cp cp s_m o= 0.2433 0.0 0.0000 0.0 0.1040 0.0 # AutoREACTER addition; mapped for cp-cp-s_m-o=
+ 1.0 1 c1 c2 nn c2 0.0883 0.0 0.0000 0.0 -0.0198 0.0 # AutoREACTER addition; mapped from c c c c for c1-c2-nn-c2
+ 1.0 1 c1 c2 nn cp 0.0972 0.0 0.0722 0.0 -0.2581 0.0 # AutoREACTER addition; mapped from c c c n for c1-c2-nn-cp
+ 1.0 1 c1 c2 nn hn -0.0228 0.0 0.0280 0.0 -0.1863 0.0 # AutoREACTER addition; mapped from h c c n for c1-c2-nn-hn
+ 1.0 1 cp cp nn hn2 0.0143 0.0 -0.0132 0.0 0.0091 0.0 # AutoREACTER addition; mapped from c c n c_1 for cp-cp-nn-hn2
+ 1.0 1 hc c1 c2 nn 0.0972 0.0 0.0722 0.0 -0.2581 0.0 # AutoREACTER addition; mapped for hc-c1-c2-nn
+ 1.0 1 hc c2 nn c2 0.0000 0.0 0.0514 0.0 -0.1430 0.0 # AutoREACTER addition; mapped for hc-c2-nn-c2
+ 1.0 1 hc c2 nn cp 0.0972 0.0 0.0722 0.0 -0.2581 0.0 # AutoREACTER addition; mapped for hc-c2-nn-cp
+ 1.0 1 hc c2 nn hn -0.0228 0.0 0.0280 0.0 -0.1863 0.0 # AutoREACTER addition; mapped for hc-c2-nn-hn
+ 1.0 1 hn2 nn cp cp 0.0143 0.0 -0.0132 0.0 0.0091 0.0 # AutoREACTER addition; mapped for hn2-nn-cp-cp
+ 1.0 1 o= s_m o= * 0.0860 0.0 5.1995 0.0 0.0000 0.0 # AutoREACTER addition; mapped for o=-s_m-o=-*
+ 1.0 1 cp cp cp s_m 0.0000 0.0 4.8498 0.0 0.0000 0.0 # AutoREACTER addition; mapped from cp-cp-cp-o
+ 1.0 1 h cp cp s_m 0.0000 0.0 1.7234 0.0 0.0000 0.0 # AutoREACTER addition; mapped from h-cp-cp-o
+ 1.0 1 hc cp cp s_m 0.0000 0.0 1.7234 0.0 0.0000 0.0 # AutoREACTER addition; mapped from h-cp-cp-o
+ 1.0 1 c= c1 cp cp 0.0000 0.0 0.5000 0.0 0.0000 0.0 # AutoREACTER addition; mapped from c= c=1 cp cp
+ 1.0 1 c2 c2 n=2 ct 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero to pass validator
+ 1.0 1 c2 n=2 ct o_1 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero to pass validator
+ 1.0 1 hc c2 n=2 ct 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero to pass validator
+ 1.0 1 o_1 c=2 na c2 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero to pass validator
+ 1.0 1 o_1 c=2 na hn 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero to pass validator
+ 1.0 1 sc c=2 na c2 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero to pass validator
+ 1.0 1 sc c=2 na hn 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero to pass validator
+ 1.0 1 c2 c1 ct nt 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for linear nitrile
+ 1.0 1 c2 c=2 ct nt 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for linear nitrile
+ 1.0 1 c= c1 ct nt 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for linear nitrile
+ 1.0 1 c= c=1 ct nt 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for linear nitrile
+ 1.0 1 hc c1 ct nt 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for linear nitrile
+ 1.0 1 hc c=1 ct nt 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for linear nitrile
+ 1.0 1 hc c=2 ct nt 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for linear nitrile
+ 1.0 1 c2 na c_2 na 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for c2-na-c_2-na
+ 1.0 1 c2 na c_2 o_1 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for c2-na-c_2-o_1
+ 1.0 1 hc c2 na c_2 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for hc-c2-na-c_2
+ 1.0 1 hn na c_2 na 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for hn-na-c_2-na
+ 1.0 1 hn na c_2 o_1 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for hn-na-c_2-na
+ 1.0 1 o_1 c_1 c1 o_2 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for validator
+ 1.0 1 o_1 c_1 c1 oh 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for validator
+ 1.0 1 o_2 c_1 c1 o_2 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for validator
+ 1.0 1 o_2 c_1 c1 oh 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for validator
+ 1.0 1 c_1 c1 o_2 c_1 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for validator
+ 1.0 1 c= c1 o_2 c_1 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for validator
+ 1.0 1 o_1 c_1 c2 o_2 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for validator
+ 1.0 1 o_1 c_1 c2 oh 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for validator
+ 1.0 1 o_2 c_1 c2 o_2 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for validator
+ 1.0 1 o_2 c_1 c2 oh 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for validator
+ 1.0 1 oh c_1 c1 c3 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for validator
+ 1.0 1 oh c_1 c1 hc 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for validator
+ 1.0 1 oh c_1 c1 o_2 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for validator
+ 1.0 1 oh c_1 c1 oh 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for validator
+ 1.0 1 oh c_1 c2 o_2 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for validator
+ 1.0 1 oh c_1 c2 oh 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for validator
+ 1.0 1 hn2 na c_2 o_1 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for validator
+ 1.0 1 hn2 na c_2 na 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for validator
+ 1.0 1 hn na c_2 o_1 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER addition; tiny non-zero for validator
+ 2.1 8 o_1 c_1 c c= 0.0442 0.0 0.0292 0.0 0.0562 0.0 # AutoREACTER addition; copied from c c c_1 o_1 reversed; mapped terminal c -> c=
+ 1.0 1 cp n=2 ct o_1 0.0001 0.0 0.0000 0.0 0.0000 0.0 # AutoREACTER fallback; unsupported cp-n=2-ct-o_1 torsion; near-zero barrier
+ 2.1 7 c1 o_2 c_2 nn -2.9522 0.0 2.4047 0.0 0.0000 0.0 # AutoREACTER addition; mapped from nn-c_2-o_2-c2 reversed; c2->c1 terminal mapping
+
#wilson_out_of_plane cff91
> E = K * (Chi - Chi0)^2
@@ -3374,6 +3526,7 @@
2.0 3 p 4.2950 0.21500
3.1 12 p= 4.3000 0.21500
2.0 1 s 4.0270 0.07100
+ 2.0 1 s_m 4.0270 0.07100 # Duplicated from base parameter 's'
2.1 8 s' 4.0270 0.25000
2.0 1 s' 4.0270 0.07100
2.2 9 si 4.4500 0.19000
diff --git a/AutoREACTER/reaction_preparation/ff_wrapper/REACTER_files_builder.py b/AutoREACTER/reaction_preparation/ff_wrapper/REACTER_files_builder.py
index 1114a480..ebfc4bff 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,
@@ -40,36 +41,14 @@
import logging
logger = logging.getLogger(__name__)
-@dataclass(slots=True)
-class LMPMoleculeFiles:
- """Simple container for paired LAMMPS data and molecule files."""
- lmp_molecule_file: Path # Associated *.molecule molecule template
-
-@dataclass(slots=True)
-class MoleculeFile:
- """Wrapper associating a molecule ID with its generated data files."""
- id: str
- molecule_files: Optional[LMPMoleculeFiles]
-
-@dataclass(slots=True)
-class TemplateFile:
- """
- Container for pre- and post-reaction template file pairs.
- """
- reaction_id: Optional[int]
- map_file: Optional[Path]
- pre_reaction_file: Optional[LMPMoleculeFiles]
- post_reaction_file: Optional[LMPMoleculeFiles]
@dataclass(slots=True)
class REACTERFiles:
- """
- Complete collection of output files from the LUNAR workflow.
- """
- force_field_data: Path # force_field.data (FF parameters)
- in_file: Path # in.fix_bond_react.script (LAMMPS input)
- molecule_files: list[MoleculeFile]
- template_files: list[TemplateFile]
+ """Run-level files plus final monomer/reaction metadata lists."""
+ force_field_data: Path
+ in_file: Path
+ molecule_files: list
+ template_files: list
class REACTERFilesBuilder:
def __init__(
@@ -92,7 +71,11 @@ def __init__(
)
self.force_field = self.updated_inputs_with_3d_mols.force_field
-
+ self.wildcards = self.updated_inputs_with_3d_mols.wildcards
+ self.deduplicate_reaction_templates = (
+ self.updated_inputs_with_3d_mols.deduplicate_reaction_templates
+ )
+
def _get_ending_integer(self, s: str) -> int | None:
"""
@@ -406,7 +389,7 @@ def _map_file_write(self, reactant_to_product, initiator_atoms, edge_atoms, dele
current_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# 2. Build the custom header
- map_file = f"# This nominal superimpose file for the {self.force_field} forcefield was generated on {current_time} for {file_name}.\n\n"
+ map_file = f"# This nominal superimpose file for the {self.force_field} forcefield was generated on {current_time} for {file_name} by AutoREACTER.\n\n"
# Write counts
map_file += f"{len(edge_atoms)} edgeIDs\n"
@@ -505,6 +488,13 @@ def _build_bond_react_templates(self,
template_indexes_reactant.append(int(key + 1))
template_indexes_product.append(int(value + 1))
+ # Deleted atoms do not have product equivalences, but they must still
+ # be present in the pre-reaction template so LAMMPS can delete them.
+ for atom_index in delete_ids:
+ atom_index_1_based = int(atom_index + 1)
+ if atom_index_1_based not in template_indexes_reactant:
+ template_indexes_reactant.append(atom_index_1_based)
+
# Iterate the provided file dictionary and only process entries that start with "pre_"
# so that we can find their matching "post_{num}" counterparts.
# Wrapped file_dict.items() in list() to prevent RuntimeError
@@ -615,27 +605,29 @@ def _build_bond_react_templates(self,
]
- # Construct the .map file contents using the reindexed template-space mappings
- # 1. Standard map file (Pass [] to suppress delete_ids)
+ # 1. Standard map file.
+ # This is the default map used by AutoREACTER-generated
+ # LAMMPS scripts, so DeleteIDs are intentionally suppressed.
map_file_contents = self._map_file_write(
template_reactant_to_template_product,
initiator_atoms_t,
edge_atoms_t,
- [],
+ [],
file_name=map_file_name
)
map_path = os.path.join(self.cache_dir, map_file_name)
with open(map_path, "w") as f:
f.write(map_file_contents)
- # 2. Supplementary map file (With delete_ids, if any exist)
+ # 2. Optional map file with DeleteIDs.
+ map_path_del = None
if delete_ids_t:
map_file_name_del = f"RXN_{num}_with_delete_ids.map"
map_file_contents_del = self._map_file_write(
template_reactant_to_template_product,
initiator_atoms_t,
edge_atoms_t,
- delete_ids_t,
+ delete_ids_t,
file_name=map_file_name_del
)
map_path_del = os.path.join(self.cache_dir, map_file_name_del)
@@ -644,29 +636,21 @@ def _build_bond_react_templates(self,
return pre_out, post_out, map_path
- def _copy_lunar_files_to_cache(self, ff_files: FFFiles) -> tuple[Path, Path, list[MoleculeFile]]:
+ def _copy_lunar_files_to_cache(self, ff_files: FFFiles) -> tuple[Path, Path]:
"""
- Copy all LUNAR output files into cache_dir.
-
- Copies force_field.data, in_file, and all molecule files (.lmpmol -> .molecule).
-
- Parameters
- ----------
- ff_files : FFFiles
+ Copy LUNAR output files into cache_dir.
- Returns
- -------
- tuple[Path, Path, list[MoleculeFile]]
- - force_field_data destination path
- - in_file destination path
- - list of MoleculeFile with updated cache paths
+ The run-level files are returned directly. Monomer-level molecule
+ files are attached to the existing MonomerEntry objects on
+ session.inputs.monomers.
"""
ff_dest = self.cache_dir / ff_files.force_field_data.name
try:
shutil.copy2(ff_files.force_field_data, ff_dest)
except Exception as e:
raise FileNotFoundError(
- f"Failed to copy force field data from '{ff_files.force_field_data}' to '{ff_dest}'."
+ f"Failed to copy force field data from "
+ f"'{ff_files.force_field_data}' to '{ff_dest}'."
) from e
if not ff_dest.is_file():
@@ -677,17 +661,52 @@ def _copy_lunar_files_to_cache(self, ff_files: FFFiles) -> tuple[Path, Path, lis
in_dest = self.cache_dir / ff_files.in_file.name
shutil.copy2(ff_files.in_file, in_dest)
- molecule_files: list[MoleculeFile] = []
- for mol in ff_files.molecule_files:
- src = mol.molecule_files.lmp_molecule_file
- dest = self.cache_dir / f"{mol.id}.molecule"
+ monomers_by_key = {}
+ for monomer_entry in self.session.inputs.monomers:
+ monomers_by_key[str(monomer_entry.id)] = monomer_entry
+ monomers_by_key[str(monomer_entry.data_id)] = monomer_entry
+ if monomer_entry.name is not None:
+ monomers_by_key[str(monomer_entry.name)] = monomer_entry
+
+ for mol_file in ff_files.molecule_files:
+ src = mol_file.molecule_files.lmp_molecule_file
+ mol_id = str(mol_file.id)
+ dest = self.cache_dir / f"{mol_id}.molecule"
+ shutil.copy2(src, dest)
+
+ monomer_entry = monomers_by_key.get(mol_id)
+ if monomer_entry is None:
+ logger.warning(
+ "Could not attach copied molecule file %s to a MonomerEntry "
+ "using molecule id %s.",
+ dest,
+ mol_id,
+ )
+ continue
+
+ monomer_entry.lmp_molecule_file = dest
+
+ return ff_dest, in_dest
+
+
+
+ def _copy_path_to_final(self, src: Path | str | None, final_dir: Path) -> Path | None:
+ """Copy one generated REACTER file to the final output directory."""
+ if src is None:
+ return None
+
+ src = Path(src)
+ if not src.is_file():
+ logger.warning("Skipping missing REACTER output file: %s", src)
+ return None
+
+ final_dir.mkdir(parents=True, exist_ok=True)
+ dest = final_dir / src.name
+
+ if src.resolve() != dest.resolve():
shutil.copy2(src, dest)
- molecule_files.append(MoleculeFile(
- id=mol.id,
- molecule_files=LMPMoleculeFiles(lmp_molecule_file=dest)
- ))
- return ff_dest, in_dest, molecule_files
+ return dest
def molecule_template_preparation(self, session: "Session") -> None:
@@ -709,9 +728,8 @@ def molecule_template_preparation(self, session: "Session") -> None:
"""
ff_files = session.ff_files
prepared_reactions_with_3d_mols = session.reaction_metadata # <--- CHANGED FROM session.inputs
- template_files = []
pre_and_post_files = ff_files.template_files
- force_field_data, in_file, molecule_files = self._copy_lunar_files_to_cache(ff_files)
+ force_field_data, in_file = self._copy_lunar_files_to_cache(ff_files)
updated_inputs_with_3d_mols= session.inputs
# Iterate each reaction entry and build templates for that single reaction only.
for rxn in pre_and_post_files:
@@ -754,33 +772,98 @@ def molecule_template_preparation(self, session: "Session") -> None:
edge_atoms = edge_atoms,
delete_ids = delete_ids
)
- template_files.append(TemplateFile(
- reaction_id = id,
- map_file = Path(map_path),
- pre_reaction_file = LMPMoleculeFiles(
- lmp_molecule_file = Path(pre_out)
- ),
- post_reaction_file = LMPMoleculeFiles(
- lmp_molecule_file = Path(post_out)
- )
- ))
+ current_rxn_metadata.map_file = Path(map_path)
+
+ optional_delete_map = Path(map_path).with_name(
+ f"RXN_{id}_with_delete_ids.map"
+ )
+ current_rxn_metadata.map_file_with_delete_ids = (
+ optional_delete_map if optional_delete_map.is_file() else None
+ )
+
+ current_rxn_metadata.pre_reaction_file = Path(pre_out)
+ current_rxn_metadata.post_reaction_file = Path(post_out)
+ final_dir = Path(session.output_dir)
+
+ force_field_data = self._copy_path_to_final(force_field_data, final_dir)
+ in_file = self._copy_path_to_final(in_file, final_dir)
+
+ if force_field_data is None:
+ raise FileNotFoundError("force_field.data was not copied to final output.")
+ if in_file is None:
+ raise FileNotFoundError("LAMMPS input file was not copied to final output.")
+
+ # Store final monomer molecule paths directly on MonomerEntry.
+ for monomer_entry in session.inputs.monomers:
+ monomer_entry.lmp_molecule_file = self._copy_path_to_final(
+ monomer_entry.lmp_molecule_file,
+ final_dir,
+ )
+
+ # Store final reaction template/map paths directly on ReactionMetadata.
+ for reaction in session.reaction_metadata:
+ reaction.map_file = self._copy_path_to_final(
+ reaction.map_file,
+ final_dir,
+ )
+ if (
+ reaction.map_file_with_delete_ids is None
+ and reaction.map_file is not None
+ and reaction.reaction_id is not None
+ ):
+ delete_map_path = Path(reaction.map_file).with_name(
+ f"RXN_{reaction.reaction_id}_with_delete_ids.map"
+ )
+ if delete_map_path.is_file():
+ reaction.map_file_with_delete_ids = delete_map_path
+
+ reaction.map_file_with_delete_ids = self._copy_path_to_final(
+ reaction.map_file_with_delete_ids,
+ final_dir,
+ )
+ reaction.pre_reaction_file = self._copy_path_to_final(
+ reaction.pre_reaction_file,
+ final_dir,
+ )
+ reaction.post_reaction_file = self._copy_path_to_final(
+ reaction.post_reaction_file,
+ final_dir,
+ )
+
+ active_template_reactions = [
+ reaction
+ for reaction in session.reaction_metadata
+ if reaction.activity_stats
+ and reaction.map_file is not None
+ and reaction.pre_reaction_file is not None
+ and reaction.post_reaction_file is not None
+ ]
+
+ detector = DeduplicationDetector()
+
+ if self.deduplicate_reaction_templates:
+ active_template_reactions = detector.compare_lammps_templates(
+ template_files=active_template_reactions,
+ wildcards=self.wildcards,
+ )
+ else:
+ print("[INFO] Skipping LAMMPS reaction-template deduplication.")
+
+ if self.wildcards:
+ active_template_reactions = detector.write_wildcard_maps(
+ template_files=active_template_reactions,
+ )
+
reacter_files = REACTERFiles(
force_field_data=force_field_data,
in_file=in_file,
- molecule_files=molecule_files,
- template_files=template_files
- )
- from AutoREACTER.cache import RunDirectoryManager # Import here to avoid circular dependency issues, since RunDirectoryManager also imports REACTERFilesBuilder
- # Move generated files to final output directory using RunDirectoryManager
- run_manager = RunDirectoryManager(session.output_dir.parent)
- reacter_files = run_manager.move_reacter_files(
- reacter_files,
- staging_dir=session.staging_dir,
- final_dir=session.output_dir
+ molecule_files=list(session.inputs.monomers),
+ template_files=active_template_reactions,
)
session.reacter_files = reacter_files
+ print(f"[OK] Moved files → {final_dir}")
return None
\ No newline at end of file
diff --git a/AutoREACTER/reaction_preparation/ff_wrapper/ff_wrapper.py b/AutoREACTER/reaction_preparation/ff_wrapper/ff_wrapper.py
index b8e69d7b..6fef10a1 100644
--- a/AutoREACTER/reaction_preparation/ff_wrapper/ff_wrapper.py
+++ b/AutoREACTER/reaction_preparation/ff_wrapper/ff_wrapper.py
@@ -1,8 +1,7 @@
from pathlib import Path
from dataclasses import dataclass
from typing import Optional, TYPE_CHECKING
-from AutoREACTER.input_parser import SimulationSetup
-from AutoREACTER.reaction_preparation.reaction_processor.prepare_reactions import ReactionMetadata
+
if TYPE_CHECKING:
from AutoREACTER.session import Session
diff --git a/AutoREACTER/reaction_preparation/ff_wrapper/lunar_client/config.py b/AutoREACTER/reaction_preparation/ff_wrapper/lunar_client/config.py
index 96b9c6fc..a6dba3d1 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/Janitha/Documents/GitHub/LUNAR'
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 0995aec8..4ba2b285 100644
--- a/AutoREACTER/reaction_preparation/ff_wrapper/lunar_client/lunar_executor.py
+++ b/AutoREACTER/reaction_preparation/ff_wrapper/lunar_client/lunar_executor.py
@@ -174,6 +174,7 @@ def run_bond_react_merge(
all2lmp_results: list[All2LMPResult]
) -> FFFiles:
"""Executes bond_react_merge.py to create the final unified simulation setup."""
+ print(f"[LUNAR bond_react_merge] Running bond_react_merge with input file {merge_input_file_path}")
env = os.environ.copy()
env["QT_QPA_PLATFORM"] = "offscreen"
subprocess.run(
@@ -183,7 +184,8 @@ def run_bond_react_merge(
"-files", f"infile:{merge_input_file_path.name}",
"-atomstyle", "full",
"-tl", "T",
- "-wrd", "T",
+ "-wrd", "F",
+ "-map", "F"
],
cwd=str(self.cache_bond_react_merge),
env=env,
diff --git a/AutoREACTER/reaction_preparation/ff_wrapper/molecule_3d_preparation.py b/AutoREACTER/reaction_preparation/ff_wrapper/molecule_3d_preparation.py
index 6500e37c..a9316f97 100644
--- a/AutoREACTER/reaction_preparation/ff_wrapper/molecule_3d_preparation.py
+++ b/AutoREACTER/reaction_preparation/ff_wrapper/molecule_3d_preparation.py
@@ -219,69 +219,160 @@ def _optimization(
cache_dir: Path,
separate_fragments: bool = False,
) -> Path:
- """Embed a molecule in 3D, optionally separate fragments, optimize geometry,
- and save the result as a .mol file.
+ """Repair, embed, optimize, and save a molecule without adding hydrogens."""
- The optimization process includes:
- 1. Sanitization and property cache update
- 2. 3D coordinate embedding using ETKDG method
- 3. Optional fragment separation for multi-molecule complexes
- 4. Geometry optimization using MMFF force field
- 5. File saving
+ # Work on a copy so the ReactionMetadata molecule and its indexing remain
+ # unchanged outside this 3D preparation step.
+ mol = Chem.Mol(mol)
+ n_atoms_start = mol.GetNumAtoms(onlyExplicit=True)
- Args:
- molecule_name: Name used for the output file.
- mol: RDKit molecule to be optimized.
- cache_dir: Directory where the .mol file will be saved.
- separate_fragments: Whether to separate disconnected fragments before optimization.
+ try:
+ mol = self._repair_reaction_molecule_for_3d(mol)
+ except Exception as error:
+ raise OptimizationError(
+ f"Failed to repair molecule {molecule_name} before 3D embedding: "
+ f"{error}"
+ ) from error
- Returns:
- Path to the saved .mol file.
+ # Remove any old or partial conformers before embedding.
+ mol.RemoveAllConformers()
- Raises:
- OptimizationError: If atom count changes or optimization fails.
- """
- # Record initial atom count for integrity check
- n_atoms_start = mol.GetNumAtoms(onlyExplicit=True)
+ params = AllChem.ETKDGv3()
+ params.randomSeed = 0xF00D
+
+ # --- ADDED PARAMETERS FOR STERICALLY CONGESTED POLYMERS ---
+ # Use random coordinates for large, flexible, or dense macro-structures
+ params.useRandomCoords = True
+ # Force RDKit to output a structure even if the distance bounds aren't perfectly smoothed
+ params.ignoreSmoothingFailures = True
+ # ----------------------------------------------------------
- # Update property cache and sanitize molecule
- mol.UpdatePropertyCache(strict=False)
- Chem.SanitizeMol(
- mol,
- sanitizeOps=Chem.SanitizeFlags.SANITIZE_ALL
- ^ Chem.SanitizeFlags.SANITIZE_PROPERTIES,
- )
+ embed_result = AllChem.EmbedMolecule(mol, params)
- # Generate initial 3D coordinates using ETKDG method
- result = AllChem.EmbedMolecule(mol, AllChem.ETKDG())
- if result == -1:
- raise OptimizationError(f"Failed to embed molecule {molecule_name} in 3D.")
+ if embed_result == -1:
+ raise OptimizationError(
+ f"Failed to embed molecule {molecule_name} in 3D."
+ )
- # Separate fragments if this is a complex (reactants or products)
if separate_fragments:
mol = self._separate_fragments_3d(mol)
- # Perform geometry optimization using MMFF force field
- ff_result = AllChem.MMFFOptimizeMolecule(mol)
+ # MMFF generally works for these carbon radicals, but keep a UFF fallback
+ # for structures for which MMFF lacks parameters.
+ if AllChem.MMFFHasAllMoleculeParams(mol):
+ ff_result = AllChem.MMFFOptimizeMolecule(
+ mol,
+ maxIters=1000,
+ )
+ force_field_name = "MMFF"
+ elif AllChem.UFFHasAllMoleculeParams(mol):
+ ff_result = AllChem.UFFOptimizeMolecule(
+ mol,
+ maxIters=1000,
+ )
+ force_field_name = "UFF"
+ else:
+ ff_result = None
+ force_field_name = None
+ print(
+ f"Warning: no MMFF or UFF parameters are available for "
+ f"{molecule_name}. Saving the embedded geometry without "
+ "force-field optimization."
+ )
+
if ff_result == -1:
- print(f"MMFF optimization failed for {molecule_name}.")
+ raise OptimizationError(
+ f"{force_field_name} optimization failed for {molecule_name}."
+ )
+
if ff_result == 1:
- print(f"Warning: MMFF optimization did not converge for {molecule_name}.")
+ print(
+ f"Warning: {force_field_name} optimization did not converge "
+ f"for {molecule_name}."
+ )
- # Verify atom count integrity (no atoms lost during optimization)
- if n_atoms_start != mol.GetNumAtoms(onlyExplicit=True):
+ n_atoms_end = mol.GetNumAtoms(onlyExplicit=True)
+
+ if n_atoms_start != n_atoms_end:
raise OptimizationError(
- f"Atom count mismatch for {molecule_name}: started with {n_atoms_start} "
- f"explicit atoms but ended with {mol.GetNumAtoms(onlyExplicit=True)}."
+ f"Atom count mismatch for {molecule_name}: started with "
+ f"{n_atoms_start} explicit atoms but ended with {n_atoms_end}."
)
- # Ensure cache directory exists
os.makedirs(cache_dir, exist_ok=True)
- # Save optimized structure to file
output_path = Path(cache_dir) / f"{molecule_name}.mol"
print(f"Saving optimized {molecule_name} to {output_path}")
- Chem.MolToMolFile(mol, str(output_path))
+ Chem.MolToMolFile(
+ mol,
+ str(output_path),
+ includeStereo=True,
+ kekulize=False,
+ )
return output_path
+
+ def _repair_reaction_molecule_for_3d(self, mol: Mol) -> Mol:
+ """Repair RDKit reaction-product valence bookkeeping before 3D embedding.
+
+ RunReactants may produce atoms that have both:
+ 1. explicit hydrogen atoms as neighbors, and
+ 2. a nonzero NumExplicitHs property inherited from the product SMARTS.
+
+ That double-counts hydrogens and can produce apparent carbon valences of
+ five or six. This method changes atom properties only; it does not add or
+ remove atoms.
+
+ Neutral, non-aromatic carbon atoms with bond valence three are retained as
+ carbon-centered radicals instead of being given an implicit hydrogen.
+ """
+ repaired = Chem.RWMol(Chem.Mol(mol))
+ repaired.UpdatePropertyCache(strict=False)
+ Chem.FastFindRings(repaired)
+
+ for atom in repaired.GetAtoms():
+ explicit_h_neighbors = sum(
+ neighbor.GetAtomicNum() == 1
+ for neighbor in atom.GetNeighbors()
+ )
+
+ # The hydrogen atoms already exist as real graph atoms. Clear only the
+ # duplicate SMARTS hydrogen-count property.
+ if explicit_h_neighbors > 0:
+ if atom.GetNumExplicitHs() > 0:
+ atom.SetNumExplicitHs(0)
+
+ # Do not let RDKit add another implicit hydrogen during sanitization.
+ atom.SetNoImplicit(True)
+
+ if atom.GetAtomicNum() != 6:
+ continue
+
+ bond_valence = sum(
+ bond.GetValenceContrib(atom)
+ for bond in atom.GetBonds()
+ )
+
+ # Preserve neutral carbon-centered radical chain ends.
+ if (
+ not atom.GetIsAromatic()
+ and atom.GetFormalCharge() == 0
+ and abs(bond_valence - 3.0) < 1.0e-6
+ ):
+ atom.SetNoImplicit(True)
+ atom.SetNumRadicalElectrons(1)
+
+ # Radical-radical coupling gives each carbon its fourth valence.
+ elif bond_valence >= 4.0:
+ atom.SetNumRadicalElectrons(0)
+
+ repaired_mol = repaired.GetMol()
+ repaired_mol.ClearComputedProps()
+ repaired_mol.UpdatePropertyCache(strict=False)
+
+ # Full sanitization is now safe because the duplicate hydrogen counts
+ # and radical valences have been repaired.
+ Chem.SanitizeMol(repaired_mol)
+
+ return repaired_mol
\ No newline at end of file
diff --git a/AutoREACTER/reaction_preparation/ff_wrapper/template_builder.py b/AutoREACTER/reaction_preparation/ff_wrapper/template_builder.py
deleted file mode 100644
index 2d00c684..00000000
--- a/AutoREACTER/reaction_preparation/ff_wrapper/template_builder.py
+++ /dev/null
@@ -1,37 +0,0 @@
-from pathlib import Path
-from typing import List
-
-from AutoREACTER.input_parser import SimulationSetup
-from AutoREACTER.reaction_preparation.reaction_processor.prepare_reactions import ReactionMetadata
-from AutoREACTER.reaction_preparation.lunar_client.molecule_3d_preparation import Molecule3DPreparation
-from AutoREACTER.reaction_preparation.lunar_client.lunar_api_wrapper import LunarAPIWrapper
-
-
-class TemplateBuilder:
- """
- The TemplateBuilder class is responsible for constructing reaction templates based on the prepared reactions and monomer information. It takes the processed reaction data and generates the necessary files and structures for use in simulations.
-
- Attributes:
- cache_dir: Path to the directory where intermediate files and templates will be stored.
- simulation_setup: An instance of SimulationSetup containing the parsed input data and configuration for the simulation.
- """
-
- def __init__(self, cache_dir: Path, ):
- self.cache_dir = cache_dir
-
-
- def build_templates(self, simulation_setup: SimulationSetup, prepared_reactions: List[ReactionMetadata]) -> None:
- """
- Build reaction templates from the prepared reactions.
-
- Args:
- simulation_setup: An instance of SimulationSetup containing the parsed input data and configuration for the simulation.
- prepared_reactions: A list of ReactionMetadata objects containing information about each prepared reaction.
-
- Returns:
- None. The function generates template files in the specified cache directory.
- """
- molecule3dpreparation = Molecule3DPreparation(self.cache_dir)
- updated_inputs_with_3d_mols, prepared_reactions_with_3d_mols = molecule3dpreparation.prepare_molecule_3d_geometry(simulation_setup, prepared_reactions)
- lunar_api_wrapper = LunarAPIWrapper(self.cache_dir)
- lunar_api_wrapper.lunar_workflow(updated_inputs_with_3d_mols, prepared_reactions_with_3d_mols)
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 bbea598d..00000000
--- a/AutoREACTER/reaction_preparation/reaction_processor/fragment_comparison.py
+++ /dev/null
@@ -1,238 +0,0 @@
-# THIS WILL BE NEW PLACE HOLDER FOR NETWORKX CODE, NOT TO BE DELETED
-
-# """
-# Chemical Fragment Extraction and Comparison Utility
-# This module provides functions to extract specific fragments from RDKit molecules,
-# cap open valences with placeholder atoms (Francium), and compare these fragments
-# against a history of processed structures to identify unique chemical transformations.
-# """
-
-# from rdkit import Chem
-
-# import copy
-
-# def dict_keys_to_list(input_dict):
-# """
-# Converts a dictionary of atom mappings into two separate lists of indices.
-
-# Args:
-# input_dict (dict): A dictionary where keys represent reactant atom indices
-# and values represent product atom indices.
-
-# Returns:
-# tuple: (reactant_indices, product_indices) as lists of integers.
-# """
-# # Convert keys and values to integers to ensure consistent indexing
-# reactant_indices = [int(k) for k in input_dict.keys()]
-# product_indices = [int(v) for v in input_dict.values()]
-# return reactant_indices, product_indices
-
-
-# def extract_fragment_by_indices(mol, atom_indices_to_keep):
-# """
-# Creates a new molecule containing only the atoms specified by the provided indices.
-# All other atoms are removed.
-
-# Args:
-# mol (rdkit.Chem.rdchem.Mol): The source RDKit molecule.
-# atom_indices_to_keep (list): List of atom indices to retain in the fragment.
-
-# Returns:
-# rdkit.Chem.rdchem.Mol: The extracted molecular fragment, or None if input is invalid.
-# """
-# if mol is None:
-# return None
-
-# # 1. Convert to an RWMol (Read-Write Molecule) object to allow structural editing
-# rwmol = Chem.RWMol(mol)
-
-# # 2. Identify atoms to remove
-# # We find the difference between all atoms in the molecule and the ones we want to keep
-# all_indices = [a.GetIdx() for a in mol.GetAtoms()]
-# atom_indices_to_remove = [idx for idx in all_indices if idx not in atom_indices_to_keep]
-
-# # 3. Sort indices in reverse (descending) order
-# # Crucial: Removing atoms shifts the indices of subsequent atoms.
-# # Removing from highest index to lowest prevents this shifting issue.
-# sorted_indices = sorted(atom_indices_to_remove, reverse=True)
-
-# # 4. Iterate and remove atoms from the RWMol
-# for idx in sorted_indices:
-# rwmol.RemoveAtom(idx)
-
-# # Convert back to a standard Molecule object
-# new_mol = rwmol.GetMol()
-
-# # 5. Attempt Sanitization
-# # Fragments often have "broken" valences. We try to sanitize to refresh
-# # molecular properties, but wrap it in a try-except to prevent valence errors
-# # from crashing the execution.
-# try:
-# Chem.SanitizeMol(new_mol)
-# except Exception:
-# # If sanitization fails (e.g., due to invalid valences), we proceed with the raw fragment
-# pass
-
-# return new_mol
-
-# def cap_open_valences_with_fr(new_mol_fragment, francium_atomic_num=87):
-# """
-# Identifies atoms with unsatisfied valences and attaches Francium (Fr) atoms
-# as placeholders. This is useful for maintaining structural context in fragments.
-
-# Args:
-# new_mol_fragment (rdkit.Chem.rdchem.Mol): The fragment to cap.
-# francium_atomic_num (int): The atomic number to use for capping (default 87 for Fr).
-
-# Returns:
-# dict: A dictionary containing the RDKit object, SMILES string, and InChI string.
-# """
-# # Handle empty or null fragments
-# if new_mol_fragment is None or new_mol_fragment.GetNumAtoms() == 0:
-# return {"object": None, "smiles": "", "inchi": ""}
-
-# rw_mol = Chem.RWMol(new_mol_fragment)
-
-# # Use a static list of atoms to avoid iterator invalidation during modification
-# atoms = list(rw_mol.GetAtoms())
-
-# for atom in atoms:
-# atom_idx = atom.GetIdx()
-# try:
-# # Determine the expected valence for the atom type
-# default_valence = Chem.GetPeriodicTable().GetDefaultValence(atom.GetAtomicNum())
-
-# # Calculate the current valence based on existing bonds
-# current_valence = sum(bond.GetBondTypeAsDouble() for bond in atom.GetBonds())
-
-# # Handle cases where default valence might be a tuple (multiple oxidation states)
-# base_valence = default_valence[0] if isinstance(default_valence, tuple) else default_valence
-
-# # Calculate how many bonds are "missing"
-# open_valences = max(0, int(base_valence) - int(current_valence))
-
-# # Add Francium atoms for each open valence
-# if open_valences > 0:
-# for _ in range(open_valences):
-# fr_atom = Chem.Atom(francium_atomic_num)
-# fr_idx = rw_mol.AddAtom(fr_atom)
-# # Connect the placeholder atom with a single bond
-# rw_mol.AddBond(atom_idx, fr_idx, Chem.BondType.SINGLE)
-# except Exception:
-# # Skip atoms where valence cannot be determined (e.g., certain metals)
-# continue
-
-# # Finalize the molecule after capping
-# capped_mol = rw_mol.GetMol()
-# try:
-# Chem.SanitizeMol(capped_mol)
-# except Exception as e:
-# # Some fragments (e.g., highly unusual or intentionally "broken" structures)
-# # may fail RDKit sanitization. We keep the unsanitized molecule to preserve
-# # behavior, but log the issue for easier debugging.
-# print(f"Warning: RDKit sanitization failed for capped fragment: {e}")
-
-# return {
-# "object": capped_mol,
-# "smiles": Chem.MolToSmiles(capped_mol),
-# "inchi": Chem.MolToInchi(capped_mol),
-# }
-
-# def compare_fragments(mol1_info, mol2_info):
-# """
-# Compares two molecular info dictionaries to determine if they represent
-# the same chemical structure.
-
-# Args:
-# mol1_info (dict): Dictionary containing 'smiles' and/or 'inchi'.
-# mol2_info (dict): Dictionary containing 'smiles' and/or 'inchi'.
-
-# Returns:
-# bool: True if molecules match by SMILES or InChI, False otherwise.
-# """
-# if not mol1_info or not mol2_info:
-# return False
-
-# # Check SMILES identity
-# smiles1, smiles2 = mol1_info.get("smiles"), mol2_info.get("smiles")
-# if smiles1 and smiles2 and smiles1 == smiles2:
-# return True
-
-# # Check InChI identity (more robust for tautomers/stereoisomers in some cases)
-# inchi1, inchi2 = mol1_info.get("inchi"), mol2_info.get("inchi")
-# if inchi1 and inchi2 and inchi1 == inchi2:
-# return True
-# return False
-
-# def compare_rdkit_fragments(processed_dict, combined_reactant_mol, combined_product_mol, template_mapped_dict):
-# """
-# Main logic to extract fragments from a reaction and check if this specific
-# transformation has been encountered before.
-
-# Args:
-# processed_dict (dict): A history of previously seen fragments.
-# combined_reactant_mol (rdkit.Chem.rdchem.Mol): The full reactant molecule.
-# combined_product_mol (rdkit.Chem.rdchem.Mol): The full product molecule.
-# template_mapped_dict (dict): Mapping of atom indices involved in the reaction.
-
-# Returns:
-# tuple: (bool, updated_processed_dict). True if the fragment pair was already known.
-# """
-# # Create deep copies to avoid modifying the original molecules in memory
-# react_mol = copy.deepcopy(combined_reactant_mol)
-# prod_mol = copy.deepcopy(combined_product_mol)
-
-# # Get the indices of the atoms involved in the reaction center
-# react_indices, prod_indices = dict_keys_to_list(template_mapped_dict)
-
-# # Process Reactant Fragment
-# raw_react_frag = extract_fragment_by_indices(react_mol, react_indices)
-# try:
-# react_smiles = Chem.MolToSmiles(raw_react_frag) if raw_react_frag else ""
-# except Exception:
-# react_smiles = ""
-# try:
-# react_inchi = Chem.MolToInchi(raw_react_frag) if raw_react_frag else ""
-# except Exception:
-# react_inchi = ""
-# raw_react_info = {"smiles": react_smiles, "inchi": react_inchi}
-# capped_react_info = cap_open_valences_with_fr(raw_react_frag)
-
-# # Process Product Fragment
-# raw_prod_frag = extract_fragment_by_indices(prod_mol, prod_indices)
-# try:
-# prod_smiles = Chem.MolToSmiles(raw_prod_frag) if raw_prod_frag else ""
-# except Exception:
-# prod_smiles = ""
-# try:
-# prod_inchi = Chem.MolToInchi(raw_prod_frag) if raw_prod_frag else ""
-# except Exception:
-# prod_inchi = ""
-# raw_prod_info = {"smiles": prod_smiles, "inchi": prod_inchi}
-# capped_prod_info = cap_open_valences_with_fr(raw_prod_frag)
-
-# # Compare against history
-# for proc_id, history in processed_dict.items():
-# hist_react = history['reactant_info']
-# hist_prod = history['product_info']
-
-# # Check if current reactant matches history (either raw or capped)
-# react_match = compare_fragments(hist_react, raw_react_info) or \
-# compare_fragments(hist_react, capped_react_info)
-
-# # Check if current product matches history (either raw or capped)
-# prod_match = compare_fragments(hist_prod, raw_prod_info) or \
-# compare_fragments(hist_prod, capped_prod_info)
-
-# # If both reactant and product fragments match an entry, it's a duplicate
-# if react_match and prod_match:
-# return True, processed_dict
-
-# # If it's a new transformation, add it to the history
-# new_id = len(processed_dict) + 1
-# processed_dict[new_id] = {
-# "reactant_info": capped_react_info,
-# "product_info": capped_prod_info
-# }
-
-# return False, processed_dict
diff --git a/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py b/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py
index 9c2d9510..68d7b385 100644
--- a/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py
+++ b/AutoREACTER/reaction_preparation/reaction_processor/prepare_reactions.py
@@ -1,70 +1,115 @@
-"""
-Module for preparing chemical reactions for analysis, including atom mapping between reactants and products,
-reaction metadata extraction, and visualization utilities using RDKit.
+"""Prepare chemical reactions and generate reaction metadata.
-This module processes reaction SMARTS, applies atom mappings, identifies key reaction features (e.g., first shell,
-initiators, byproducts), and generates metadata and visualizations for downstream analysis. It also includes validation checks to ensure mapping
-consistency and completeness.
+This module processes reaction SMARTS, applies atom mappings, identifies
+reaction features such as first-shell atoms, initiators, and byproducts, and
+generates metadata and visualizations for downstream analysis.
"""
# WARNING:
-# When modifying this file for dataframe or any other indexing variables use idx and idxs, do not use index or indices or similar.
+# For dataframe and mapping variables, use idx and idxs rather than index or
+# indices. Other naming can cause mapping-validation errors.
from dataclasses import dataclass
from functools import reduce
from pathlib import Path
from typing import Dict, List, Optional, TYPE_CHECKING
+import pandas as pd
+from PIL.Image import Image
from rdkit import Chem
from rdkit.Chem import AllChem, Draw, rdmolops
-from PIL.Image import Image
-import pandas as pd
-from AutoREACTER.detectors.reaction_detector import ReactionInstance
+from AutoREACTER.detectors.reaction_detector import (
+ FunctionalGroupInfo,
+ ReactionInstance,
+)
+import logging
+logger = logging.getLogger(__name__)
+from AutoREACTER.reaction_preparation.reaction_processor.reaction_progression import (
+ ReactionProgression,
+)
+from AutoREACTER.reaction_preparation.deduplication_detector import (
+ DeduplicationDetector,
+)
from AutoREACTER.reaction_preparation.reaction_processor.utils import (
- add_dict_as_new_columns, add_column_safe, compare_set, prepare_paths
+ add_column_safe,
+ add_dict_as_new_columns,
+ compare_set,
+ prepare_paths,
+)
+from AutoREACTER.reaction_preparation.reaction_processor.walker import (
+ reaction_atom_walker,
)
-from AutoREACTER.reaction_preparation.reaction_processor.walker import reaction_atom_walker
-# Use TYPE_CHECKING to prevent circular imports with session.py
if TYPE_CHECKING:
from AutoREACTER.session import Session
class MappingError(Exception):
- """Custom exception raised when atom mapping between reactants and products fails or is inconsistent."""
+ """Raised when reactant-to-product atom mapping is invalid."""
class SMARTSParsingError(Exception):
- """Custom exception raised when parsing SMILES or reaction SMARTS fails."""
+ """Raised when a reactant SMILES string cannot be parsed."""
+
+
+class ZeroActiveReactionsError(Exception):
+ """Raised when no active reactions remain in the dataset."""
@dataclass(slots=True)
class ReactionMetadata:
+ """Store molecular structures, mappings, and analysis for one reaction.
+
+ This dataclass holds all information required to describe a single
+ prepared reaction, including the combined reactant and product RDKit
+ molecules, forward and reverse atom mappings, detected reaction-site
+ features, file paths, and activity status.
+
+ Attributes:
+ reaction_id: Unique integer identifier assigned to this reaction.
+ reactant_combined_RDmol: Combined reactant RDKit molecule.
+ product_combined_RDmol: Combined product RDKit molecule.
+ reactant_to_product_mapping: Mapping from reactant atom index to
+ product atom index.
+ product_to_reactant_mapping: Mapping from product atom index to
+ reactant atom index.
+ template_reactant_to_product_mapping: Subset mapping covering the
+ reaction template atoms (reactant index -> product index).
+ edge_atoms: Atom indices that form the boundary of the reaction
+ template in the reactant molecule.
+ first_shell: Reactant atom indices identified as the first-shell
+ reaction environment (mapped atoms in the product with template
+ map numbers below 999).
+ initiators: The two reactant atom indices whose product counterparts
+ carry template map numbers 1 and 2.
+ byproduct_indices: Reactant indices of atoms that end up in the
+ smallest product fragment when atoms are deleted during reaction.
+ reaction_smarts: SMARTS string describing the reaction transformation.
+ reactant_smiles: Combined SMILES representation of the reactants.
+ product_smiles: Combined SMILES representation of the products.
+ csv_path: Path to the CSV file storing the atom mapping dataframe.
+ reaction_dataframe: DataFrame containing atom mappings and reaction
+ feature columns (first_shell, initiators, byproduct_idx, etc.).
+ delete_atom: Whether atoms are removed from the reactants during the
+ reaction (e.g., condensation byproducts).
+ delete_atom_idx: Primary reactant index of the atom to delete, if any.
+ reactant_combined_3Dmol_path: Path to the 3D reactant structure file,
+ if generated.
+ product_combined_3Dmol_path: Path to the 3D product structure file,
+ if generated.
+ map_file: Path to the standard map file generated by REACTER.
+ map_file_with_delete_ids: Path to the map file including deleted atom
+ indices, if applicable.
+ pre_reaction_file: Path to the pre-reaction molecule file.
+ post_reaction_file: Path to the post-reaction molecule file.
+ is_radical: Whether the reaction involves radical species.
+ radical_atom_idxs: Tuple of reactant indices flagged as radical atoms.
+ activity_stats: True if this reaction is active and unique; set to
+ False for duplicate or failed reactions so they can be filtered
+ downstream.
"""
- Stores comprehensive metadata for a single reaction including molecular structures,
- atom mappings, and analysis results.
- reaction_id: Unique identifier for the reaction instance
- reactant_combined_mol: RDKit molecule object representing combined reactants
- product_combined_mol: RDKit molecule object representing combined products
- reactant_to_product_mapping: Dictionary mapping reactant atom indices to product atom indices
- product_to_reactant_mapping: Dictionary mapping product atom indices back to reactant atom indices
- template_reactant_to_product_mapping: Optional dictionary mapping reactant indices in the template to product indices
- edge_atoms: Optional list of reactant atom indices that are at the edge of the local environment
- first_shell: Optional list of reactant atom indices in the first coordination shell (reaction center)
- initiators: Optional list of reactant atom indices that are initiators (map numbers 1 or 2)
- byproduct_indices: Optional list of reactant atom indices corresponding to detected byproducts
- reaction_smarts: Optional string of the reaction SMARTS pattern
- reactant_smarts: Optional string of the combined reactant SMILES
- product_smarts: Optional string of the combined product SMILES
- csv_path: Optional Path to the CSV file containing atom mappings and analysis
- reaction_dataframe: Optional pandas DataFrame containing detailed mapping and analysis results
- delete_atom: Boolean indicating whether the reaction involves a delete atom (byproduct)
- delete_atom_idx: Optional integer index of the reactant atom that corresponds to the byproduct
- reactant_combined_3Dmol_path: Optional Path to the 3D structure file for the combined reactants
- product_combined_3Dmol_path: Optional Path to the 3D structure file for the combined products
- activity_stats: Boolean indicating whether this reaction should be included in activity statistics (e.g., not a duplicate)
- """
+
reaction_id: int
reactant_combined_RDmol: Chem.Mol
product_combined_RDmol: Chem.Mol
@@ -84,247 +129,465 @@ class ReactionMetadata:
delete_atom_idx: Optional[int] = None
reactant_combined_3Dmol_path: Optional[Path] = None
product_combined_3Dmol_path: Optional[Path] = None
+ map_file: Optional[Path] = None
+ map_file_with_delete_ids: Optional[Path] = None
+ pre_reaction_file: Optional[Path] = None
+ post_reaction_file: Optional[Path] = None
+ is_radical: bool = False
+ radical_atom_idxs: Optional[tuple[int, ...]] = ()
activity_stats: bool = True
-
class PrepareReactions:
- """Processes chemical reactions: builds atom mappings, identifies reaction centers, and detects byproducts."""
+ """Build reaction products, atom mappings, metadata, and visualizations.
+
+ This class orchestrates the conversion of detected reaction instances
+ (``ReactionInstance`` objects) into fully mapped ``ReactionMetadata``
+ objects. It runs RDKit reaction transforms, validates atom mappings,
+ identifies first-shell atoms and initiators, detects byproducts, and
+ writes per-reaction CSV files to the staging cache.
+
+ Attributes:
+ session: Shared AutoREACTER session providing inputs, directories, and
+ state such as the reaction ID counter.
+ inputs: Shortcut to ``session.inputs``.
+ staging_dir: Root staging directory for the run.
+ cache: Working cache directory (currently the same as ``staging_dir``).
+ csv_cache: Subdirectory where per-reaction mapping CSVs are saved.
+ """
def __init__(self, session: "Session"):
- """Initialize using the shared AutoREACTER session object."""
+ """Initialize the processor with the shared AutoREACTER session.
+
+ Args:
+ session: The active AutoREACTER ``Session`` object. A reaction ID
+ counter is attached to the session if it does not already
+ exist.
+ """
self.session = session
self.inputs = session.inputs
-
- # In AutoREACTER, staging_dir is the working/cache directory.
self.staging_dir = Path(session.staging_dir)
self.cache = self.staging_dir
self.csv_cache = prepare_paths(self.cache, "csv_cache")
- # --- PUBLIC ---
+ if not hasattr(session, "reaction_id_counter"):
+ session.reaction_id_counter = 0
+
+ def prepare_reactions(self, session):
+ """Prepare initial reactions and optionally run reaction progression.
- def prepare_reactions(self, session: "Session") -> list[ReactionMetadata]:
+ 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).
"""
- Main pipeline: processes reaction instances, detects duplicates, and enriches metadata with template mappings.
-
+ prepared_reactions = self._prepare_reactions_stage(session)
+ session.reaction_metadata = prepared_reactions
+
+ if session.inputs.loop:
+ print("\n[INFO] Reaction progression is enabled. ")
+ 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)
+ self.deduplication_detector = DeduplicationDetector()
+ session.reaction_metadata = (
+ self.deduplication_detector.compare_graphs_mol(
+ session.reaction_metadata,
+ deep_check=self.session.inputs.deep_search,
+ )
+ )
+
+
+ return session
+
+ def _zero_active_reactions_error(
+ self,
+ reaction_metadata: list[ReactionMetadata],
+ ) -> None:
+ """Raise an error when the metadata contains no active reactions.
+
Args:
- session: The main Session object containing reaction instances to process
-
+ reaction_metadata: List of ``ReactionMetadata`` objects to inspect.
+
+ Raises:
+ ZeroActiveReactionsError: If none of the reactions have
+ ``activity_stats`` set to True.
+ """
+ if any(reaction.activity_stats for reaction in reaction_metadata):
+ return
+
+ raise ZeroActiveReactionsError(
+ "No active reactions found in the dataset. "
+ "This is an AutoREACTER error indicating that no active "
+ "reactions were found in the dataset. Please raise an issue at "
+ "https://github.com/NanoCIPHER-Lab/AutoREACTER/issues"
+ )
+
+ def _prepare_reactions_stage(
+ self,
+ session: "Session",
+ loop: bool = False,
+ ) -> list[ReactionMetadata]:
+ """Process reaction instances and add template mapping metadata.
+
+ Converts detected reactions into mapped metadata, removes duplicate
+ reactions, and then walks the local environment around each reaction
+ to determine template-level mappings and edge atoms. The enriched
+ dataframes are saved to CSV for downstream use.
+
+ Args:
+ session: The active ``Session`` or, in recursive calls, a list-like
+ object containing ``ReactionInstance`` objects. ``loop`` mode
+ changes how reactants are sourced from each instance.
+ loop: If True, reactants are taken directly from the RDKit
+ molecules attached to the reaction instance (used during the
+ progression/looping stage).
+
Returns:
- List of processed ReactionMetadata objects with template mappings and edge atoms
+ A list of unique ``ReactionMetadata`` objects with template
+ mappings and edge atoms populated for active reactions.
"""
- reaction_instances = session.reaction_instances
-
- # Process and filter reactions
- reactions_metadata = self._process_reaction_instances(reaction_instances)
- unique_reaction_metadata = self._detect_duplicates(reactions_metadata)
-
+ try:
+ reaction_instances = session.reaction_instances
+ except AttributeError:
+ reaction_instances = session
+
+ reaction_metadata = self._process_reaction_instances(
+ reaction_instances,
+ loop=loop,
+ )
+ unique_reaction_metadata = self._detect_duplicates(
+ reaction_metadata
+ )
+
for reaction in unique_reaction_metadata:
- # Skip reactions marked as duplicates
if not reaction.activity_stats:
continue
-
- combined_reactant_molecule_object = reaction.reactant_combined_RDmol
+
+ reactant_mol = reaction.reactant_combined_RDmol
reaction_dataframe = reaction.reaction_dataframe
csv_save_path = reaction.csv_path
-
- # Build full atom mapping dictionary from dataframe
- fully_mapped_dict = reaction_dataframe.set_index("reactant_idx")["product_idx"].to_dict()
+
+ # Extract the full atom mapping and first-shell list from the
+ # dataframe; these are needed to determine the template subset.
+ fully_mapped_dict = reaction_dataframe.set_index(
+ "reactant_idx"
+ )["product_idx"].to_dict()
first_shell = reaction_dataframe["first_shell"].dropna().tolist()
-
- # Generate template mapping by walking reaction graph
+
+ # Walk outward from the first-shell atoms to find the minimal
+ # template mapping and the edge atoms of the reaction site.
template_mapped_dict, edge_atoms = reaction_atom_walker(
- combined_reactant_molecule_object,
+ reactant_mol,
first_shell,
- fully_mapped_dict
+ fully_mapped_dict,
)
-
- # Add template mapping and edge atoms to dataframe
+
+ # Attach template mapping columns and edge-atom list to the
+ # dataframe, then persist the updated version to CSV.
reaction_dataframe = add_dict_as_new_columns(
reaction_dataframe,
template_mapped_dict,
- titles=["template_reactant_idx", "template_product_idx"]
+ titles=[
+ "template_reactant_idx",
+ "template_product_idx",
+ ],
)
-
- # Add edge atoms as a new column in the dataframe
reaction_dataframe = add_column_safe(
reaction_dataframe,
edge_atoms,
- "edge_atoms"
+ "edge_atoms",
)
-
- # Save updated dataframe back to CSV and update metadata
+
reaction.reaction_dataframe = reaction_dataframe.copy()
- # Save the updated dataframe with template mappings and edge atoms to CSV
reaction_dataframe.to_csv(csv_save_path, index=False)
- # Update metadata with template mapping and edge atoms
reaction.edge_atoms = edge_atoms
- # Store the template mapping in the metadata for later use
- reaction.template_reactant_to_product_mapping = template_mapped_dict
+ reaction.template_reactant_to_product_mapping = (
+ template_mapped_dict
+ )
- # Store the finalized metadata inside the session
- session.reaction_metadata = unique_reaction_metadata
return unique_reaction_metadata
-
- # --- PIPELINE STEPS (PRIVATE) ---
-
- def _process_reaction_instances(self, detected_reactions: list[ReactionInstance]) -> list[ReactionMetadata]:
- """
- Converts ReactionInstance objects into ReactionMetadata by building molecules and running reactions.
-
+
+ def _process_reaction_instances(
+ self,
+ detected_reactions: list[ReactionInstance],
+ loop: bool = False,
+ ) -> list[ReactionMetadata]:
+ """Convert reaction instances into mapped reaction metadata.
+
+ For each detected reaction, this method prepares the reactant RDKit
+ molecules (from SMILES in the initial stage or from existing molecules
+ in loop mode), builds the RDKit reaction object, and delegates product
+ generation and mapping to ``_process_reaction_products``.
+
Args:
- detected_reactions: List of detected reaction instances
-
+ detected_reactions: List of ``ReactionInstance`` objects produced
+ by the reaction detector.
+ loop: Whether the call is part of the reaction progression loop.
+
Returns:
- List of ReactionMetadata objects with atom mappings
+ A list of ``ReactionMetadata`` objects, one per successfully
+ mapped product set.
"""
- csv_cache = self.csv_cache
- reaction_metadata = []
+ reaction_metadata: list[ReactionMetadata] = []
for reaction in detected_reactions:
- rxn_smarts = reaction.reaction_smarts
- reactant_smiles_1 = reaction.monomer_1.smiles
+ if loop and (
+ reaction.monomer_1.rdkit_mol is None
+ or (
+ reaction.monomer_2 is not None
+ and reaction.monomer_2.rdkit_mol is None
+ )
+ ):
+ logger.warning(
+ "Skipping reaction %s: monomer rdkit_mol is None "
+ "(likely failed sanitization upstream).",
+ reaction.reaction_name,
+ )
+ continue
+
same_reactants = reaction.same_reactants
-
- # Handle case where both reactants are identical
- if same_reactants:
- reactant_smiles_2 = reactant_smiles_1
- else:
- reactant_smiles_2 = reaction.monomer_2.smiles
+ forced_idxs_1 = None
+ forced_idxs_2 = None
+
+ if loop:
+ # In loop mode, restrict accepted initiators to atoms that
+ # belong to the previously matched functional groups. This
+ # prevents the reaction from jumping to an unrelated site
+ # during polymerization-like progression.
+ forced_idxs_1 = self._flatten_fg_indexes(
+ reaction.functional_group_1
+ )
+ if reaction.functional_group_2 is not None:
+ forced_idxs_2 = self._flatten_fg_indexes(
+ reaction.functional_group_2
+ )
- delete_atoms = reaction.delete_atom
+ mol_reactant_1 = self._copy_loop_reactant_mol(
+ reaction.monomer_1
+ )
+ monomer_2 = (
+ reaction.monomer_1
+ if same_reactants
+ else reaction.monomer_2
+ )
+ mol_reactant_2 = self._copy_loop_reactant_mol(
+ monomer_2
+ )
- # Build reaction and reactant molecules
- rxn = self._build_reaction(rxn_smarts)
- # This function also runs the reaction and builds metadata for each product set, including atom mappings and byproduct detection
- mol_reactant_1, mol_reactant_2 = self._build_reactants(reactant_smiles_1, reactant_smiles_2)
- # Build reaction tuple based on whether reactants are the same or different. If same, only one ordering is needed. If different, both orderings are processed to account for reaction directionality.
- reaction_tuple = self._build_reaction_tuple(same_reactants, mol_reactant_1, mol_reactant_2)
+ # The ReactionInstance already defines reactant-slot order.
+ reaction_tuple = [[mol_reactant_1, mol_reactant_2]]
+ else:
+ reactant_smiles_1 = reaction.monomer_1.smiles
+ reactant_smiles_2 = (
+ reactant_smiles_1
+ if same_reactants
+ else reaction.monomer_2.smiles
+ )
+ mol_reactant_1, mol_reactant_2 = self._build_reactants(
+ reactant_smiles_1,
+ reactant_smiles_2,
+ )
+ reaction_tuple = self._build_reaction_tuple(
+ same_reactants,
+ mol_reactant_1,
+ mol_reactant_2,
+ )
- # Process products and build metadata
+ rxn = self._build_reaction(reaction.reaction_smarts)
reaction_metadata = self._process_reaction_products(
- rxn,
- csv_cache,
- reaction_tuple,
- delete_atoms,
- reaction_metadata
+ rxn=rxn,
+ csv_cache=self.csv_cache,
+ reaction_tuple=reaction_tuple,
+ delete_atoms=reaction.delete_atom,
+ reaction_metadata=reaction_metadata,
+ forced_indexes_1=forced_idxs_1,
+ forced_indexes_2=forced_idxs_2,
)
-
+
return reaction_metadata
- def _detect_duplicates(self, reaction_metadata_list: list[ReactionMetadata]) -> list[ReactionMetadata]:
- """
- Filters duplicate reactions based on reactant and product molecules.
-
- Args:
- reaction_metadata_list: List of reaction metadata to filter
-
- Returns:
- List of unique reactions; duplicates marked with activity_stats=False
- """
- unique_metadata: list[ReactionMetadata] = []
-
- for reaction in reaction_metadata_list:
- # Compare current reaction's reactants and products against unique reactions collected so far
- reactants = reaction.reactant_combined_RDmol
- products = reaction.product_combined_RDmol
+ def _copy_loop_reactant_mol(self, monomer_role) -> Chem.Mol:
+ """Copy a loop-mode reactant without changing generated products.
- # Keep reaction if it's unique, otherwise mark as duplicate
- if compare_set(unique_metadata, reactants, products):
- unique_metadata.append(reaction)
- else:
- reaction.activity_stats = False
-
- return unique_metadata
-
- def _process_reaction_products(self,
- rxn: Chem.rdChemReactions.ChemicalReaction,
- csv_cache: Path,
- reaction_tuple: list,
- delete_atoms: bool = True,
- reaction_metadata: Optional[list[ReactionMetadata]] = None
- ) -> list[ReactionMetadata]:
+ Initial input monomers are stored as RDKit molecules without explicit
+ hydrogens before the loop starts, so they still need ``Chem.AddHs``.
+ Generated products already passed through reaction-progression
+ sanitization and may contain explicit hydrogens, radical electrons,
+ and no-implicit flags. Adding hydrogens to those products again can
+ change the representation that is later sent to LUNAR.
"""
- Runs reactions on reactant pairs and builds metadata for each product set.
-
+ if monomer_role is None or monomer_role.rdkit_mol is None:
+ raise SMARTSParsingError(
+ "Loop-mode reactant is missing its RDKit molecule."
+ )
+
+ loop_mol = Chem.Mol(monomer_role.rdkit_mol)
+ loop_mol.UpdatePropertyCache(strict=False)
+
+ if getattr(monomer_role, "is_monomer", False):
+ return Chem.AddHs(loop_mol)
+
+ return loop_mol
+
+ def _process_reaction_products(
+ self,
+ rxn: Chem.rdChemReactions.ChemicalReaction,
+ csv_cache: Path,
+ reaction_tuple: list,
+ delete_atoms: bool = True,
+ reaction_metadata: Optional[list[ReactionMetadata]] = None,
+ forced_indexes_1: Optional[set] = None,
+ forced_indexes_2: Optional[set] = None,
+ ) -> list[ReactionMetadata]:
+ """Run reactions and build metadata for each generated product set.
+
+ Applies the RDKit reaction to each reactant pair, attempts reverse
+ ordering on failure, builds atom mappings, validates them, identifies
+ first-shell atoms and initiators, detects byproducts, and stores the
+ result as ``ReactionMetadata`` with a persistent CSV file.
+
Args:
- rxn: RDKit ChemicalReaction object
- csv_cache: Path to cache directory for saving CSVs
- reaction_tuple: List of reactant pairs to process
- delete_atoms: Whether to detect and track byproducts
- reaction_metadata: Accumulator list for metadata objects
-
+ rxn: RDKit ``ChemicalReaction`` object built from SMARTS.
+ csv_cache: Directory where mapping CSV files are written.
+ reaction_tuple: List of [reactant_1, reactant_2] pairs to react.
+ delete_atoms: Whether the reaction removes a byproduct fragment.
+ reaction_metadata: Mutable list to append new metadata to. A new
+ list is created if None is supplied.
+ forced_indexes_1: Allowed initiator atom indices for reactant 1
+ (used in loop mode). None disables the filter.
+ forced_indexes_2: Allowed initiator atom indices for reactant 2
+ (used in loop mode). None disables the filter.
+
Returns:
- Updated list of ReactionMetadata objects
+ The updated list of ``ReactionMetadata`` objects.
"""
if reaction_metadata is None:
reaction_metadata = []
-
+
for pair in reaction_tuple:
- r1, r2 = Chem.Mol(pair[0]), Chem.Mol(pair[1])
-
- # Assign unique map numbers and isotopes to track atoms through reaction
+ r1 = Chem.Mol(pair[0])
+ r2 = Chem.Mol(pair[1])
self._assign_atom_map_numbers_and_set_isotopes(r1, r2)
- # Run the reaction to get products
+
products = rxn.RunReactants((r1, r2))
-
- # If no products are generated, skip to the next reactant pair
if not products:
- continue
-
- # Process each product set generated by the reaction
- for product_set in products:
- df = pd.DataFrame(columns=["reactant_idx", "product_idx"])
+ # print(
+ # "Reaction failed in default order, "
+ # "trying reverse order..."
+ # ) # this was a debug print, not needed in normal operation
+ products = rxn.RunReactants((r2, r1))
- # Combine molecules for mapping
- reactant_combined = Chem.CombineMols(r1, r2)
- if len(product_set) == 1:
- product_combined = product_set[0]
- else:
- product_combined = reduce(Chem.CombineMols, product_set)
-
- # Restore atom map numbers from isotopes (which survive the reaction)
- self._reassign_atom_map_numbers_by_isotope(product_combined)
-
- # Build bidirectional atom index mappings
- mapping_dict, df = self._build_atom_index_mapping(reactant_combined, product_combined)
- reverse_mapping = {v: k for k, v in mapping_dict.items()}
-
- # Restore map numbers for visualization
- self._reveal_template_map_numbers(product_combined)
+ if not products:
+ print(
+ "Reaction failed in both orders, "
+ "skipping this reactant pair."
+ )
+ print(
+ f"\n[ERROR] RDKit failed to react "
+ f"{r1.GetNumAtoms()} atoms with "
+ f"{r2.GetNumAtoms()} atoms."
+ )
+ print(f"Reactant 1 SMILES: {Chem.MolToSmiles(r1)}")
+ print(f"Reactant 2 SMILES: {Chem.MolToSmiles(r2)}")
+ print(
+ "Reaction SMARTS: "
+ f"{Chem.rdChemReactions.ReactionToSmarts(rxn)}\n"
+ )
- # Validate mapping consistency
- self._validate_mapping(df, reactant_combined, product_combined)
+ for product_set in products:
+ reactant_combined = Chem.CombineMols(r1, r2)
+ product_combined = (
+ product_set[0]
+ if len(product_set) == 1
+ else reduce(Chem.CombineMols, product_set)
+ )
- # Identify atoms involved in reaction center and initiators
- first_shell, initiator_idxs = self._assign_first_shell_and_initiators(
+ self._reassign_atom_map_numbers_by_isotope(
+ product_combined
+ )
+ mapping_dict, mapping_df = self._build_atom_index_mapping(
reactant_combined,
product_combined,
- reverse_mapping
)
+ reverse_mapping = {
+ product_idx: reactant_idx
+ for reactant_idx, product_idx in mapping_dict.items()
+ }
- # Detect byproducts (smallest fragments)
- byproduct_reactant_idxs = self._detect_byproducts(product_combined, reverse_mapping, delete_atoms)
-
- # Combine all mapping data into single dataframe
- df_combined = pd.concat([
- df,
- pd.Series(first_shell, name="first_shell"),
- pd.Series(initiator_idxs, name="initiators"),
- pd.Series(byproduct_reactant_idxs, name="byproduct_idx")
- ], axis=1).astype(pd.Int64Dtype())
+ self._reveal_template_map_numbers(product_combined)
+ self._validate_mapping(
+ mapping_df,
+ reactant_combined,
+ product_combined,
+ )
- total_products = len(reaction_metadata) + 1
+ first_shell, initiator_idxs = (
+ self._assign_first_shell_and_initiators(
+ reactant_combined,
+ product_combined,
+ reverse_mapping,
+ )
+ )
- # Clear isotopes before saving to restore normal chemistry
- self._clear_isotopes(reactant_combined, product_combined)
+ # When loop-mode restrictions are active, discard product sets
+ # whose initiators fall outside the allowed functional-group
+ # atom sets.
+ if (
+ forced_indexes_1 is not None
+ or forced_indexes_2 is not None
+ ) and not self._initiators_within_forced_indexes(
+ initiator_idxs,
+ r1.GetNumAtoms(),
+ forced_indexes_1,
+ forced_indexes_2,
+ ):
+ continue
+
+ byproduct_reactant_idxs = self._detect_byproducts(
+ product_combined,
+ reverse_mapping,
+ delete_atoms,
+ )
- # Save mapping dataframe to CSV
- df_combined.to_csv(csv_cache / f"reaction_{total_products}.csv", index=False)
+ reaction_df = pd.concat(
+ [
+ mapping_df,
+ pd.Series(first_shell, name="first_shell"),
+ pd.Series(initiator_idxs, name="initiators"),
+ pd.Series(
+ byproduct_reactant_idxs,
+ name="byproduct_idx",
+ ),
+ ],
+ axis=1,
+ ).astype(pd.Int64Dtype())
+
+ self.session.reaction_id_counter += 1
+ reaction_id = self.session.reaction_id_counter
+ csv_path = csv_cache / f"reaction_{reaction_id}.csv"
+
+ self._clear_isotopes(
+ reactant_combined,
+ product_combined,
+ )
+ reaction_df.to_csv(csv_path, index=False)
- # Create and store metadata object
reaction_metadata.append(
ReactionMetadata(
- reaction_id=total_products,
+ reaction_id=reaction_id,
reactant_combined_RDmol=reactant_combined,
product_combined_RDmol=product_combined,
reactant_to_product_mapping=mapping_dict,
@@ -332,304 +595,540 @@ def _process_reaction_products(self,
first_shell=first_shell,
initiators=initiator_idxs,
byproduct_indices=byproduct_reactant_idxs,
- csv_path=csv_cache / f"reaction_{total_products}.csv",
- reaction_dataframe=df_combined,
+ csv_path=csv_path,
+ reaction_dataframe=reaction_df,
delete_atom=delete_atoms,
- delete_atom_idx=byproduct_reactant_idxs[0] if byproduct_reactant_idxs else None,
- activity_stats=True
+ delete_atom_idx=(
+ byproduct_reactant_idxs[0]
+ if byproduct_reactant_idxs
+ else None
+ ),
+ activity_stats=True,
)
)
-
return reaction_metadata
-
- # --- CORE REACTION LOGIC ---
-
- def _assign_first_shell_and_initiators(self,
- reactant_combined: Chem.Mol,
- product_combined: Chem.Mol,
- reversed_mapping_dict: dict[int, int]) -> tuple[list[int], list[int]]:
+
+ def _detect_duplicates(
+ self,
+ reaction_metadata_list: list[ReactionMetadata],
+ ) -> list[ReactionMetadata]:
+ """Return unique reaction metadata based on reactants and products.
+
+ Two reactions are considered duplicates if their combined reactant and
+ combined product molecules are structurally identical. Duplicate
+ entries are retained in the returned list but marked inactive via
+ ``activity_stats = False``.
+
+ Args:
+ reaction_metadata_list: List of ``ReactionMetadata`` objects to
+ deduplicate.
+
+ Returns:
+ The same list, with duplicate reactions flagged as inactive.
"""
- 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).
-
+ unique_metadata: list[ReactionMetadata] = []
+
+ for reaction in reaction_metadata_list:
+ if compare_set(
+ unique_metadata,
+ reaction.reactant_combined_RDmol,
+ reaction.product_combined_RDmol,
+ ):
+ unique_metadata.append(reaction)
+ else:
+ reaction.activity_stats = False
+
+ return unique_metadata
+
+ def _flatten_fg_indexes(
+ self,
+ fg: Optional["FunctionalGroupInfo"],
+ ) -> Optional[set]:
+ """Flatten functional-group match indexes into one allowed idx set.
+
+ A functional group can have multiple SMARTS matches, each match being
+ a tuple or list of atom indices. This helper merges all matches for
+ both functional group slots into a single set of allowed atom indices.
+
+ Args:
+ fg: ``FunctionalGroupInfo`` object, or None.
+
+ Returns:
+ A set of atom indices, or None if no matches are available.
+ """
+ if fg is None:
+ return None
+
+ combined = set()
+ if fg.fg_1_indexes:
+ combined.update(
+ atom_idx
+ for match in fg.fg_1_indexes
+ for atom_idx in match
+ )
+ if fg.fg_2_indexes:
+ combined.update(
+ atom_idx
+ for match in fg.fg_2_indexes
+ for atom_idx in match
+ )
+
+ return combined or None
+
+ def _initiators_within_forced_indexes(
+ self,
+ initiator_idxs: list[int],
+ r1_atom_count: int,
+ forced_indexes_1: Optional[set],
+ forced_indexes_2: Optional[set],
+ ) -> bool:
+ """Check initiator atoms against their reactants' allowed idx sets.
+
+ Initiator indices are global indices into the combined reactant
+ molecule. Indices below ``r1_atom_count`` belong to reactant 1; the
+ rest belong to reactant 2 after subtracting the offset.
+
+ Args:
+ initiator_idxs: Reactant indices of the two initiator atoms.
+ r1_atom_count: Number of atoms in reactant 1 before combining.
+ forced_indexes_1: Allowed indices for reactant 1, or None.
+ forced_indexes_2: Allowed indices for reactant 2, or None.
+
+ Returns:
+ True if every initiator lies within its respective allowed set,
+ or if no restriction is applied to that reactant.
+ """
+ for idx in initiator_idxs:
+ if idx < r1_atom_count:
+ if (
+ forced_indexes_1 is not None
+ and idx not in forced_indexes_1
+ ):
+ return False
+ else:
+ local_idx = idx - r1_atom_count
+ if (
+ forced_indexes_2 is not None
+ and local_idx not in forced_indexes_2
+ ):
+ return False
+
+ return True
+
+ def _index_based_reaction_preparation(
+ self,
+ reaction_instances,
+ ):
+ """Prepare index-based reaction instances in loop mode.
+
+ Creates a fresh ``PrepareReactions`` instance and runs the preparation
+ stage with ``loop=True``. This helper is typically invoked by the
+ progression machinery when reactions need to be re-prepared after each
+ loop iteration.
+
+ Args:
+ reaction_instances: Collection of ``ReactionInstance`` objects.
+
+ Returns:
+ List of ``ReactionMetadata`` objects produced in loop mode.
+ """
+ prepare_reactions = PrepareReactions(self.session)
+ return prepare_reactions._prepare_reactions_stage(
+ reaction_instances,
+ loop=True,
+ )
+
+ def _assign_first_shell_and_initiators(
+ self,
+ reactant_combined: Chem.Mol,
+ product_combined: Chem.Mol,
+ reversed_mapping_dict: dict[int, int],
+ ) -> tuple[list[int], list[int]]:
+ """Identify first-shell atoms and the two reaction initiators.
+
+ First-shell atoms are defined as product atoms whose atom map number
+ is below 999 (i.e., they were assigned a template map number by the
+ reaction SMARTS) and that can be traced back to a reactant atom via
+ the reverse mapping. The two atoms whose product counterparts have map
+ numbers 1 and 2 are labeled as initiators.
+
Args:
- reactant_combined: Combined reactant molecule
- product_combined: Combined product molecule
- reversed_mapping_dict: Product idx -> Reactant idx mapping
-
+ reactant_combined: Combined reactant molecule with isotope-based
+ tracking map numbers still intact on product-mapped atoms.
+ product_combined: Combined product molecule with template map
+ numbers revealed via ``_reveal_template_map_numbers``.
+ reversed_mapping_dict: Mapping from product atom index to reactant
+ atom index.
+
Returns:
- Tuple of (first_shell atom indices, initiator atom indices)
-
+ A tuple of (first_shell, initiator_idxs), where each is a list of
+ reactant atom indices.
+
Raises:
- ValueError: If exactly 2 initiators are not found
+ ValueError: If a mapped product atom has no reverse mapping, or if
+ the number of initiator atoms is not exactly two.
"""
first_shell = []
initiator_idxs = []
- for p_atom in product_combined.GetAtoms():
- # Only process atoms with valid map numbers (< 999 indicates non-byproduct)
- if p_atom.GetAtomMapNum() < 999:
- p_idx = p_atom.GetIdx()
+ for product_atom in product_combined.GetAtoms():
+ map_num = product_atom.GetAtomMapNum()
+ if map_num >= 999:
+ continue
- if p_idx not in reversed_mapping_dict:
- raise ValueError(f"Mapping error: product atom {p_idx} not found in mapping_dict")
+ product_idx = product_atom.GetIdx()
+ if product_idx not in reversed_mapping_dict:
+ raise ValueError(
+ f"Mapping error: product atom {product_idx} "
+ "not found in mapping_dict"
+ )
- r_idx = reversed_mapping_dict[p_idx]
- atom = reactant_combined.GetAtomWithIdx(r_idx)
- atom.SetAtomMapNum(p_atom.GetAtomMapNum())
+ reactant_idx = reversed_mapping_dict[product_idx]
+ reactant_atom = reactant_combined.GetAtomWithIdx(
+ reactant_idx
+ )
+ reactant_atom.SetAtomMapNum(map_num)
+ first_shell.append(reactant_idx)
- first_shell.append(r_idx)
+ if map_num in (1, 2):
+ initiator_idxs.append(reactant_idx)
- # Initiators are atoms with map numbers 1 or 2
- if p_atom.GetAtomMapNum() in [1, 2]:
- initiator_idxs.append(r_idx)
-
if len(initiator_idxs) != 2:
- raise ValueError(f"Expected 2 initiators, got {len(initiator_idxs)}: {initiator_idxs}")
+ raise ValueError(
+ f"Expected 2 initiators, got {len(initiator_idxs)}: "
+ f"{initiator_idxs}"
+ )
return first_shell, initiator_idxs
-
- def _detect_byproducts(self,
- product_combined: Chem.Mol,
- reversed_mapping_dict: dict[int, int],
- delete_atoms: bool) -> list[int]:
- """
- Identifies byproduct atoms (smallest molecular fragment) and maps them back to reactant space.
-
+
+ def _detect_byproducts(
+ self,
+ product_combined: Chem.Mol,
+ reversed_mapping_dict: dict[int, int],
+ delete_atoms: bool,
+ ) -> list[int]:
+ """Map atoms in the smallest product fragment to reactant idxs.
+
+ When ``delete_atoms`` is True, the reaction is assumed to produce a
+ removable byproduct. The smallest disconnected fragment in the product
+ is treated as that byproduct, and its atoms are translated back to the
+ reactant-index space using the reverse mapping.
+
Args:
- product_combined: Combined product molecule
- reversed_mapping_dict: Product idx -> Reactant idx mapping
- delete_atoms: Whether to perform byproduct detection
-
+ product_combined: Combined product molecule, possibly containing
+ multiple disconnected fragments.
+ reversed_mapping_dict: Mapping from product atom index to reactant
+ atom index.
+ delete_atoms: If False, an empty list is returned immediately.
+
Returns:
- List of reactant indices corresponding to byproduct atoms
+ Reactant indices of the atoms composing the detected byproduct.
"""
if not delete_atoms:
return []
- # Get tuples of original atom indices for each fragment
- frags_indices = rdmolops.GetMolFrags(product_combined)
-
- # Find the tuple with the smallest number of atoms
- smallest_frag_indices = min(frags_indices, key=len)
+ fragment_idxs = rdmolops.GetMolFrags(product_combined)
+ smallest_fragment_idxs = min(fragment_idxs, key=len)
- byproduct_reactant_indices = []
+ return [
+ reversed_mapping_dict[product_idx]
+ for product_idx in smallest_fragment_idxs
+ if product_idx in reversed_mapping_dict
+ ]
- # Map byproduct product indices back to reactant indices
- for p_idx in smallest_frag_indices:
- if p_idx in reversed_mapping_dict:
- byproduct_reactant_indices.append(reversed_mapping_dict[p_idx])
+ def _validate_mapping(
+ self,
+ df: pd.DataFrame,
+ reactant: Chem.Mol,
+ product: Chem.Mol,
+ ) -> None:
+ """Validate mapping columns, uniqueness, bounds, and completeness.
+
+ Ensures that every atom in both the reactant and product molecules is
+ accounted for exactly once in the mapping dataframe and that all
+ indices are within bounds.
- return byproduct_reactant_indices
-
- def _validate_mapping(self, df: pd.DataFrame, reactant: Chem.Mol, product: Chem.Mol) -> None:
- """
- Validates atom mapping consistency: checks for required columns, duplicates, bounds, and completeness.
-
Args:
- df: Dataframe containing reactant_idx and product_idx columns
- reactant: Reactant molecule
- product: Product molecule
-
+ df: DataFrame containing at least ``reactant_idx`` and
+ ``product_idx`` columns.
+ reactant: Combined reactant RDKit molecule.
+ product: Combined product RDKit molecule.
+
Raises:
- MappingError: If any validation check fails
+ MappingError: If the dataframe is empty, missing required columns,
+ unbalanced, contains duplicates, has out-of-bounds indices, or
+ does not cover every atom in either molecule.
"""
- # Ensure dataframe exists and has required columns
if df is None or df.empty:
- raise MappingError("Mapping validation failed: empty dataframe")
+ raise MappingError(
+ "Mapping validation failed: empty dataframe"
+ )
- # Check for required columns
required_cols = {"reactant_idx", "product_idx"}
if not required_cols.issubset(df.columns):
- raise MappingError(f"Mapping validation error: required columns {required_cols} not found in dataframe.")
+ raise MappingError(
+ "Mapping validation error: required columns "
+ f"{required_cols} not found in dataframe."
+ )
- # Extract indices and perform validation checks
r_idxs = df["reactant_idx"].dropna().tolist()
p_idxs = df["product_idx"].dropna().tolist()
- # Atom counts must match
if len(r_idxs) != len(p_idxs):
- raise MappingError(f"Mapping validation error: mismatch in atom counts between reactant and product.")
-
- # No duplicate mappings (1-to-1 mapping required)
+ raise MappingError(
+ "Mapping validation error: mismatch in atom counts "
+ "between reactant and product."
+ )
if len(set(r_idxs)) != len(r_idxs):
- raise MappingError(f"Mapping validation error: duplicate indices found in reactant mapping.")
+ raise MappingError(
+ "Mapping validation error: duplicate idxs found in "
+ "reactant mapping."
+ )
if len(set(p_idxs)) != len(p_idxs):
- raise MappingError(f"Mapping validation error: duplicate indices found in product mapping.")
-
- # Indices must be within molecule bounds
- if any(idx >= reactant.GetNumAtoms() for idx in r_idxs):
- raise MappingError(f"Mapping validation error: reactant index out of bounds.")
- if any(idx >= product.GetNumAtoms() for idx in p_idxs):
- raise MappingError(f"Mapping validation error: product index out of bounds.")
+ raise MappingError(
+ "Mapping validation error: duplicate idxs found in "
+ "product mapping."
+ )
+ if any(
+ idx < 0 or idx >= reactant.GetNumAtoms()
+ for idx in r_idxs
+ ):
+ raise MappingError(
+ "Mapping validation error: reactant idx out of bounds."
+ )
- # All atoms must be mapped (complete mapping)
+ if any(
+ idx < 0 or idx >= product.GetNumAtoms()
+ for idx in p_idxs
+ ):
+ raise MappingError(
+ "Mapping validation error: product idx out of bounds."
+ )
if len(r_idxs) != reactant.GetNumAtoms():
- raise MappingError(f"Mapping validation error: incomplete mapping for reactant.")
+ raise MappingError(
+ "Mapping validation error: incomplete mapping for "
+ "reactant."
+ )
if len(p_idxs) != product.GetNumAtoms():
- raise MappingError(f"Mapping validation error: incomplete mapping for product.")
+ raise MappingError(
+ "Mapping validation error: incomplete mapping for product."
+ )
+
+ def _assign_atom_map_numbers_and_set_isotopes(
+ self,
+ r1: Chem.Mol,
+ r2: Chem.Mol,
+ ) -> None:
+ """Assign tracking map numbers and isotopes to reactant atoms.
+
+ Atoms in reactant 1 are tagged with 1001-based numbers, and atoms in
+ reactant 2 with 2001-based numbers. Both the atom map number and the
+ isotope are set to the same value so that product atoms can later be
+ traced back to their originating reactant atoms regardless of how the
+ reaction SMARTS rewrites the molecule.
- # --- ATOM MAPPING ---
-
- def _assign_atom_map_numbers_and_set_isotopes(self, r1: Chem.Mol, r2: Chem.Mol) -> None:
- """
- Assigns unique map numbers and isotopes to reactant atoms for tracking through reaction.
- Isotopes survive RDKit's reaction engine, allowing atom identity recovery post-reaction.
-
Args:
- r1: First reactant molecule
- r2: Second reactant molecule
+ r1: First reactant molecule; modified in place.
+ r2: Second reactant molecule; modified in place.
"""
- # Assign map numbers 1001+ to first reactant atoms
for atom in r1.GetAtoms():
idx = 1001 + atom.GetIdx()
atom.SetAtomMapNum(idx)
- atom.SetIsotope(idx) # Isotope survives the reaction
+ atom.SetIsotope(idx)
- # Assign map numbers 2001+ to second reactant atoms
for atom in r2.GetAtoms():
idx = 2001 + atom.GetIdx()
atom.SetAtomMapNum(idx)
- atom.SetIsotope(idx) # Isotope survives the reaction
-
- def _reassign_atom_map_numbers_by_isotope(self, mol: Chem.Mol) -> None:
- """
- Restores atom map numbers from isotope values after reaction.
- RDKit's reaction engine preserves isotopes, allowing recovery of original atom identities.
-
+ atom.SetIsotope(idx)
+
+ def _reassign_atom_map_numbers_by_isotope(
+ self,
+ mol: Chem.Mol,
+ ) -> None:
+ """Restore product atom map numbers from tracking isotopes.
+
+ After RDKit runs the reaction, atoms that survive from the reactants
+ retain their original isotope values. This method copies those values
+ back into the atom map number slot and clears the isotope so that the
+ product can be aligned with the reactant via ``_build_atom_index_mapping``.
+
Args:
- mol: Product molecule with isotope information
+ mol: Product molecule; modified in place.
"""
for atom in mol.GetAtoms():
surviving_idx = atom.GetIsotope()
if surviving_idx != 0:
- atom.SetAtomMapNum(surviving_idx) # Restore original map number
- atom.SetIsotope(0) # Clear isotope to restore normal chemistry
+ atom.SetAtomMapNum(surviving_idx)
+ atom.SetIsotope(0)
+
+ def _build_atom_index_mapping(
+ self,
+ reactant_combined: Chem.Mol,
+ product_combined: Chem.Mol,
+ ) -> tuple[dict[int, int], pd.DataFrame]:
+ """Build reactant-to-product atom mapping using map numbers.
+
+ Matches atoms across the combined reactant and product molecules by
+ their shared atom map numbers. Atoms with map number 0 (e.g., newly
+ added hydrogens or atoms that lost their tag) are ignored because they
+ cannot be traced unambiguously.
- def _build_atom_index_mapping(self,
- reactant_combined: Chem.Mol,
- product_combined: Chem.Mol) -> tuple[dict[int, int], pd.DataFrame]:
- """
- Builds bidirectional atom index mapping between reactants and products using map numbers.
-
Args:
- reactant_combined: Combined reactant molecule
- product_combined: Combined product molecule
-
+ reactant_combined: Combined reactant molecule with tracking map
+ numbers on surviving atoms.
+ product_combined: Combined product molecule with matching map
+ numbers on surviving atoms.
+
Returns:
- Tuple of (mapping dict: reactant_idx -> product_idx, dataframe with mapping)
+ A tuple of (mapping_dict, mapping_df). ``mapping_dict`` maps
+ reactant atom index to product atom index. ``mapping_df`` contains
+ the same data in two columns, ``reactant_idx`` and ``product_idx``.
"""
- mapping_dict = {}
-
- # Pre-index product atoms by map number for O(1) lookup
product_map = {
atom.GetAtomMapNum(): atom.GetIdx()
for atom in product_combined.GetAtoms()
if atom.GetAtomMapNum() != 0
}
-
+
+ mapping_dict = {}
rows = []
- for r_atom in reactant_combined.GetAtoms():
- r_map_num = r_atom.GetAtomMapNum()
- # Match reactant atom to product atom via map number
- if r_map_num != 0 and r_map_num in product_map:
- r_idx = r_atom.GetIdx()
- p_idx = product_map[r_map_num]
+ for reactant_atom in reactant_combined.GetAtoms():
+ reactant_map_num = reactant_atom.GetAtomMapNum()
+ if (
+ reactant_map_num == 0
+ or reactant_map_num not in product_map
+ ):
+ continue
- mapping_dict[r_idx] = p_idx
- rows.append({
- "reactant_idx": r_idx,
- "product_idx": p_idx
- })
+ reactant_idx = reactant_atom.GetIdx()
+ product_idx = product_map[reactant_map_num]
+ mapping_dict[reactant_idx] = product_idx
+ rows.append(
+ {
+ "reactant_idx": reactant_idx,
+ "product_idx": product_idx,
+ }
+ )
- df = pd.DataFrame(rows)
- return mapping_dict, df
+ return mapping_dict, pd.DataFrame(rows)
def _reveal_template_map_numbers(self, mol: Chem.Mol) -> None:
- """
- Restores map numbers from RDKit's internal 'old_mapno' property for visualization.
- RDKit stores original map numbers in this property after reaction execution.
-
+ """Restore template map numbers from RDKit's ``old_mapno`` property.
+
+ RDKit reaction SMARTS with atom maps stores the original template map
+ number in the ``old_mapno`` atom property. Copying it back to the atom
+ map number makes the reaction-center atoms visible to downstream first-
+ shell and initiator detection.
+
Args:
- mol: Product molecule
+ mol: Product molecule; modified in place.
"""
for atom in mol.GetAtoms():
- if atom.HasProp('old_mapno'):
- map_num = atom.GetIntProp('old_mapno')
- atom.SetAtomMapNum(map_num)
+ if atom.HasProp("old_mapno"):
+ atom.SetAtomMapNum(atom.GetIntProp("old_mapno"))
+
+ def _clear_isotopes(
+ self,
+ mol_1: Chem.Mol,
+ mol_2: Chem.Mol,
+ ) -> None:
+ """Remove tracking isotopes from two molecules.
- def _clear_isotopes(self, mol_1: Chem.Mol, mol_2: Chem.Mol) -> None:
- """
- Clears isotope values from molecules to restore normal chemistry after using isotopes for atom tracking.
-
Args:
- mol_1: First molecule to clear
- mol_2: Second molecule to clear
+ mol_1: First molecule; modified in place.
+ mol_2: Second molecule; modified in place.
"""
for atom in mol_1.GetAtoms():
atom.SetIsotope(0)
for atom in mol_2.GetAtoms():
atom.SetIsotope(0)
- # --- BUILDERS ---
-
- def _build_reaction(self, rxn_smarts: str) -> Chem.rdChemReactions.ChemicalReaction:
- """Builds RDKit ChemicalReaction object from SMARTS string."""
- return AllChem.ReactionFromSmarts(rxn_smarts)
-
- def _build_reactants(self, reactant_smiles_1: str, reactant_smiles_2: str) -> tuple[Chem.Mol, Chem.Mol]:
+ def _build_reaction(
+ self,
+ rxn_smarts: str,
+ ) -> Chem.rdChemReactions.ChemicalReaction:
+ """Build an RDKit reaction from a SMARTS string.
+
+ Args:
+ rxn_smarts: Reaction SMARTS describing the transformation.
+
+ Returns:
+ An RDKit ``ChemicalReaction`` object ready for ``RunReactants``.
"""
- Builds reactant molecules from SMILES strings with explicit hydrogens added.
-
+ return AllChem.ReactionFromSmarts(rxn_smarts)
+
+ def _build_reactants(
+ self,
+ reactant_smiles_1: str,
+ reactant_smiles_2: str,
+ ) -> tuple[Chem.Mol, Chem.Mol]:
+ """Build two explicit-hydrogen reactant molecules from SMILES.
+
Args:
- reactant_smiles_1: SMILES string for first reactant
- reactant_smiles_2: SMILES string for second reactant
-
+ reactant_smiles_1: SMILES string for the first reactant.
+ reactant_smiles_2: SMILES string for the second reactant.
+
Returns:
- Tuple of (reactant1 molecule, reactant2 molecule) with explicit hydrogens
+ A tuple of two RDKit molecules with explicit hydrogens added.
+
+ Raises:
+ SMARTSParsingError: If either SMILES string cannot be parsed by
+ RDKit.
"""
mol_reactant_1 = Chem.MolFromSmiles(reactant_smiles_1)
if mol_reactant_1 is None:
- raise SMARTSParsingError(f"Failed to parse first reactant SMILES: {reactant_smiles_1!r}")
- mol_reactant_1 = Chem.AddHs(mol_reactant_1)
-
+ raise SMARTSParsingError(
+ "Failed to parse first reactant SMILES: "
+ f"{reactant_smiles_1!r}"
+ )
+
mol_reactant_2 = Chem.MolFromSmiles(reactant_smiles_2)
if mol_reactant_2 is None:
- raise SMARTSParsingError(f"Failed to parse second reactant SMILES: {reactant_smiles_2!r}")
- mol_reactant_2 = Chem.AddHs(mol_reactant_2)
-
- return mol_reactant_1, mol_reactant_2
-
- def _build_reaction_tuple(self, same_reactants: bool, mol_reactant_1: Chem.Mol, mol_reactant_2: Chem.Mol) -> list:
- """
- Builds list of reactant pairs to process. If reactants are identical, returns single pair.
- Otherwise returns both orderings to account for reaction directionality.
-
+ raise SMARTSParsingError(
+ "Failed to parse second reactant SMILES: "
+ f"{reactant_smiles_2!r}"
+ )
+
+ return Chem.AddHs(mol_reactant_1), Chem.AddHs(mol_reactant_2)
+
+ def _build_reaction_tuple(
+ self,
+ same_reactants: bool,
+ mol_reactant_1: Chem.Mol,
+ mol_reactant_2: Chem.Mol,
+ ) -> list:
+ """Build the ordered reactant pairs to pass to RDKit.
+
+ When the two reactants are the same molecule, only one ordering is
+ needed. Otherwise, both orderings are attempted so that asymmetric
+ SMARTS can match either reactant in either slot.
+
Args:
- same_reactants: Whether both reactants are identical
- mol_reactant_1: First reactant molecule
- mol_reactant_2: Second reactant molecule
-
+ same_reactants: True if reactant 1 and reactant 2 are identical.
+ mol_reactant_1: First reactant molecule.
+ mol_reactant_2: Second reactant molecule.
+
Returns:
- List of reactant pairs [[r1, r2], ...] to process
+ A list of [reactant_1, reactant_2] pairs.
"""
if same_reactants:
return [[mol_reactant_1, mol_reactant_1]]
- return [[mol_reactant_1, mol_reactant_2], [mol_reactant_2, mol_reactant_1]]
-
- # --- HELPERS ---
-
+ return [
+ [mol_reactant_1, mol_reactant_2],
+ [mol_reactant_2, mol_reactant_1],
+ ]
+
def _is_consecutive(self, num_list: list[int]) -> bool:
- """
- Checks if list contains consecutive integers with no duplicates.
-
+ """Return whether values are unique consecutive integers.
+
Args:
- num_list: List of integers to check
-
+ num_list: List of integers to inspect.
+
Returns:
- True if list is consecutive and has no duplicates, False otherwise
+ True if the list is non-empty, contains no duplicates, and spans a
+ contiguous range of integers; otherwise False.
"""
if not num_list:
return False
@@ -639,22 +1138,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:
@@ -666,71 +1172,100 @@ def reaction_templates_highlighted_image_grid(
names = []
for metadata in metadata_list:
+ if not metadata.activity_stats:
+ continue
+
reactant = Chem.RWMol(metadata.reactant_combined_RDmol)
product = Chem.RWMol(metadata.product_combined_RDmol)
- names.extend([f"pre_{metadata.reaction_id}", f"post_{metadata.reaction_id}"])
+ names.extend(
+ [
+ f"pre_{metadata.reaction_id}",
+ f"post_{metadata.reaction_id}",
+ ]
+ )
- # Clear atom maps for clean visualization
+ # Remove atom map numbers so they do not clutter the image.
for atom in reactant.GetAtoms():
atom.SetAtomMapNum(0)
for atom in product.GetAtoms():
atom.SetAtomMapNum(0)
- df = metadata.reaction_dataframe
+ reaction_df = metadata.reaction_dataframe
atoms: List[int] = []
color_map: Dict[int, tuple] = {}
- # Select atoms to highlight based on type
if highlight_type == "template":
- atoms = list((metadata.template_reactant_to_product_mapping or {}).keys())
- for a in atoms:
- color_map[a] = (0.2, 0.6, 1.0) # blue
-
+ atoms = list(
+ (
+ metadata.template_reactant_to_product_mapping
+ or {}
+ ).keys()
+ )
+ color_map = {
+ atom_idx: (0.2, 0.6, 1.0)
+ for atom_idx in atoms
+ }
elif highlight_type == "edge":
atoms = metadata.edge_atoms or []
- for a in atoms:
- color_map[a] = (1.0, 0.4, 0.0) # orange
-
+ color_map = {
+ atom_idx: (1.0, 0.4, 0.0)
+ for atom_idx in atoms
+ }
elif highlight_type == "initiators":
- atoms = df["initiators"].dropna().astype(int).tolist() if df is not None else []
- for a in atoms:
- color_map[a] = (0.0, 0.8, 0.2) # green
-
+ if reaction_df is not None:
+ atoms = (
+ reaction_df["initiators"]
+ .dropna()
+ .astype(int)
+ .tolist()
+ )
+ color_map = {
+ atom_idx: (0.0, 0.8, 0.2)
+ for atom_idx in atoms
+ }
elif highlight_type == "delete":
if metadata.delete_atom and metadata.byproduct_indices:
atoms = metadata.byproduct_indices
- for a in atoms:
- color_map[a] = (1.0, 0.0, 0.0) # red
+ color_map = {
+ atom_idx: (1.0, 0.0, 0.0)
+ for atom_idx in atoms
+ }
mols.extend([reactant, product])
-
- # Build atom mappings for highlighting
- forward_map = metadata.reactant_to_product_mapping
- reactant_atoms = atoms
- # Map reactant atoms to product atoms
- product_atoms = []
- for r_idx in reactant_atoms:
- if r_idx in forward_map:
- product_atoms.append(forward_map[r_idx])
-
- # Build color maps for reactant and product
- reactant_color_map = {a: color_map[a] for a in reactant_atoms}
- product_color_map = {p: color_map[r] for r, p in forward_map.items() if r in reactant_atoms}
+ # Translate reactant highlight atoms to product indices using the
+ # forward mapping so the same atoms are colored on both sides of
+ # the reaction arrow.
+ forward_map = metadata.reactant_to_product_mapping
+ product_atoms = [
+ forward_map[reactant_idx]
+ for reactant_idx in atoms
+ if reactant_idx in forward_map
+ ]
+ reactant_color_map = {
+ atom_idx: color_map[atom_idx]
+ for atom_idx in atoms
+ }
+ product_color_map = {
+ product_idx: color_map[reactant_idx]
+ for reactant_idx, product_idx in forward_map.items()
+ if reactant_idx in atoms
+ }
+
+ highlight_lists.extend([atoms, product_atoms])
+ highlight_colors.extend(
+ [reactant_color_map, product_color_map]
+ )
- highlight_lists.append(reactant_atoms)
- highlight_lists.append(product_atoms)
- highlight_colors.append(reactant_color_map)
- highlight_colors.append(product_color_map)
+ if not mols:
+ return None
- # Generate grid image with 2 molecules per row
- img = Draw.MolsToGridImage(
+ return Draw.MolsToGridImage(
mols,
legends=names,
molsPerRow=2,
highlightAtomLists=highlight_lists,
highlightAtomColors=highlight_colors,
- subImgSize=(400, 400),
+ subImgSize=(1000, 1000),
useSVG=False,
)
- return img
\ No newline at end of file
diff --git a/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py
new file mode 100644
index 00000000..dcac0562
--- /dev/null
+++ b/AutoREACTER/reaction_preparation/reaction_processor/reaction_progression.py
@@ -0,0 +1,805 @@
+"""
+Iterative reaction-progression engine for AutoREACTER.
+
+This module drives the discovery of follow-up reactions by repeatedly
+re-detecting functional groups in products produced during earlier
+reaction-generation rounds. Detected functional groups are turned into
+new reaction instances, prepared into fully described reaction metadata,
+deduplicated, and then fed back into the next loop iteration. A hard
+iteration cap and a pool-growth guard prevent unbounded execution.
+"""
+
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING
+
+from rdkit import Chem
+
+from AutoREACTER.detectors.functional_groups_detector import (
+ FunctionalGroupsDetector,
+)
+from AutoREACTER.detectors.reaction_detector import ReactionDetector
+from AutoREACTER.reaction_preparation.deduplication_detector import (
+ DeduplicationDetector,
+)
+from AutoREACTER.reaction_preparation.reaction_processor.warning_asci import (
+ print_warning,
+)
+
+if TYPE_CHECKING:
+ from AutoREACTER.detectors.functional_groups_detector import MonomerRole
+ from AutoREACTER.reaction_preparation.reaction_processor.prepare_reactions import (
+ ReactionInstance,
+ ReactionMetadata,
+ )
+ from AutoREACTER.session import Session
+
+
+# Prevent progression from continuing indefinitely when newly generated
+# products keep exposing additional detectable functional groups.
+MAX_LOOP = 5
+
+
+@dataclass(slots=True)
+class MonomerRoleforIndexBasedFGDetection:
+ """Describe a molecule prepared for index-based functional-group detection.
+
+ These lightweight containers bridge the gap between prepared reaction
+ products and the index-based functional-group detector. The stored atom
+ indexes refer to positions in the parent reaction template, which makes
+ it possible to trace any newly detected functional group back to the
+ product (and ultimately the reactant) that produced it.
+
+ Attributes:
+ smiles: Canonical SMILES of the prepared product.
+ name: Generated identifier for this potential monomer.
+ indexes_in_template: Atom indexes within the reaction template.
+ is_monomer: Whether this molecule should be treated as a monomer.
+ is_looped: Whether this molecule has already been processed by a
+ progression iteration.
+ rdkit_mol: Optional RDKit molecule object for downstream use.
+ """
+
+ smiles: str
+ name: str
+ indexes_in_template: list[int]
+ is_monomer: bool = False
+ is_looped: bool = False
+ rdkit_mol: Chem.Mol | None = None
+
+
+@dataclass(slots=True)
+class ReactionProgressionSession:
+ """Track state shared across reaction-progression iterations.
+
+ This object is attached to the main ``Session`` so that other stages of
+ the pipeline can inspect or update progression-related bookkeeping.
+
+ Attributes:
+ monomer_roles: Roles that have been produced by progression and
+ are eligible for subsequent functional-group detection.
+ iteration: Current progression loop iteration (1-indexed).
+ """
+
+ monomer_roles: list["MonomerRole"] = field(default_factory=list)
+ iteration: int = 0
+
+
+class ReactionProgression:
+ """Coordinate iterative functional-group detection and reaction generation.
+
+ The progression workflow repeatedly broadens the set of considered
+ monomers by using products from the previous round. In each iteration:
+
+ 1. Active products are sanitized and converted into monomer-like roles.
+ 2. Functional groups are re-detected in those products by index.
+ 3. Compatible reactions are detected from the expanded monomer pool.
+ 4. Reaction instances are prepared into full ``ReactionMetadata``.
+ 5. Radical centers are annotated before deduplication.
+ 6. Duplicate reaction products are merged.
+ 7. The loop continues if the active reaction pool has grown.
+
+ The class also owns the cleanup and sanitization of RDKit product
+ molecules, including special handling for radical carbons that are
+ deliberately under-valent in the underlying reaction SMARTS.
+ """
+
+ def __init__(self, session: "Session", preparer=None):
+ """Initialize detectors and attach progression state to a session.
+
+ Args:
+ session: The active AutoREACTER ``Session`` that contains the
+ current monomer roles and reaction metadata.
+ preparer: An existing ``PrepareReactions`` instance used to
+ convert ``ReactionInstance`` objects into ``ReactionMetadata``.
+ """
+ self.session = session
+ self.preparer = preparer
+
+ # Attach a dedicated progression sub-session to the main session so
+ # that loop state is visible elsewhere in the pipeline.
+ self.session.reaction_progression_session = (
+ ReactionProgressionSession()
+ )
+
+ self.fg_detector = FunctionalGroupsDetector()
+ self.rxn_detector = ReactionDetector()
+ self.deduplication_detector = DeduplicationDetector()
+
+ # Loop warnings are printed only once per session, so that users are not overwhelmed by repeated messages.
+ print_warning()
+
+ def reaction_progression(
+ self,
+ max_loop: int = MAX_LOOP,
+ ) -> list["ReactionMetadata"]:
+ """Run progression until no further useful reactions are generated.
+
+ Each iteration detects functional groups in generated products, finds
+ compatible reactions, prepares them, and removes duplicates. The loop
+ stops when no new functional groups or reactions are found, when the
+ active reaction pool does not grow, or when ``max_loop`` is reached.
+
+ Args:
+ max_loop: Maximum number of progression iterations.
+
+ Returns:
+ Prepared and deduplicated reaction metadata accumulated across
+ all iterations.
+ """
+ # Respect the validated reaction progression depth from input_parser
+ # unless the caller explicitly supplies a loop limit.
+
+ if max_loop is None:
+ max_loop = self.session.inputs.reaction_iteration_depth
+
+ if max_loop <= 0:
+ return list(self.session.reaction_metadata)
+ print(
+ f"Using reaction_iteration_depth={max_loop} for "
+ "reaction progression."
+ )
+
+ iteration = 0
+ # Start from the monomer roles already present in the session.
+ monomer_roles_in_loop = list(self.session.monomer_roles)
+ # Accumulate reaction metadata across iterations for deduplication.
+ all_prepared_reactions = list(self.session.reaction_metadata)
+
+ while iteration < max_loop:
+ iteration += 1
+ self.session.reaction_progression_session.iteration = iteration
+
+ if iteration == 1:
+ # On the first pass, convert input monomer SMILES into
+ # explicit RDKit molecules so detectors can work on them.
+ self._populate_monomer_roles()
+ else:
+ print(
+ f"Starting iteration {iteration} "
+ "of the reaction progression loop."
+ )
+
+ # Mark roles from earlier iterations so detectors can distinguish
+ # already processed molecules from newly added molecules.
+ self._set_is_looped_flag(monomer_roles_in_loop)
+
+ # Snapshot the pool size so we can decide whether this iteration
+ # produced any genuinely new chemistry.
+ initial_reaction_pool_size = self._count_active_reactions(
+ self.session.reaction_metadata
+ )
+ print(
+ f"Initial reaction pool size at iteration {iteration}: "
+ f"{initial_reaction_pool_size}"
+ )
+
+ # Convert active products into the form required by the
+ # index-based functional-group detector.
+ roles_for_fg_detection = (
+ self._prepare_products_for_idx_based_fg_detection()
+ )
+ fg_detection_results = (
+ self.fg_detector.index_based_functional_groups_detector(
+ roles_for_fg_detection
+ )
+ )
+
+ # No new functional groups means no new chemistry is possible.
+ if not fg_detection_results:
+ print(
+ f"No new functional groups detected in iteration "
+ f"{iteration}. Ending the reaction progression loop."
+ )
+ break
+
+ # Expand the monomer pool with functional groups found in this
+ # iteration's products.
+ monomer_roles_in_loop.extend(fg_detection_results)
+ self.session.monomer_roles = monomer_roles_in_loop
+
+ # Search for reactions that involve the expanded monomer set.
+ reaction_instances = (
+ self.rxn_detector.index_based_reaction_detector(
+ monomer_roles_in_loop
+ )
+ )
+
+ if not reaction_instances:
+ print(
+ f"No new reactions detected in iteration {iteration}. "
+ "Ending the reaction progression loop."
+ )
+ break
+
+ if not isinstance(reaction_instances, list):
+ reaction_instances = list(reaction_instances)
+
+ # Convert raw reaction instances into fully prepared metadata.
+ prepared_reactions = self._index_based_reaction_preparation(
+ reaction_instances=reaction_instances
+ )
+
+ # Radical identity must be available before NetworkX
+ # deduplication, because equivalent products may differ only in
+ # how radical centers are represented.
+ self._annotate_radicals_before_deduplication(
+ prepared_reactions
+ )
+
+ all_prepared_reactions.extend(prepared_reactions)
+ self.session.reaction_metadata = all_prepared_reactions
+
+ # Equivalent products can be generated through different reaction
+ # paths, so deduplication occurs after preparation.
+ all_prepared_reactions = (
+ self.deduplication_detector.compare_graphs_mol(
+ all_prepared_reactions,
+ deep_check=self.session.inputs.deep_search,
+ )
+ )
+ self.session.reaction_metadata = all_prepared_reactions
+
+ deduplicated_reaction_count = self._count_active_reactions(
+ all_prepared_reactions
+ )
+
+ # If the pool did not grow, further iterations are unlikely to
+ # yield new chemistry.
+ if self._loop_break_condition(
+ size_before=initial_reaction_pool_size,
+ size_after=deduplicated_reaction_count,
+ ):
+ return self._store_reactions(all_prepared_reactions)
+
+ return all_prepared_reactions
+
+ def _index_based_reaction_preparation(
+ self,
+ reaction_instances: list["ReactionInstance"],
+ ) -> list["ReactionMetadata"]:
+ """Convert detected reaction instances into reaction metadata.
+
+ This is a thin wrapper around the preparer's loop-aware preparation
+ stage, which builds full ``ReactionMetadata`` records (products,
+ mappings, activity statistics, etc.) from the raw instances.
+
+ Args:
+ reaction_instances: Reaction instances produced by the detector.
+
+ Returns:
+ Fully prepared reaction metadata.
+ """
+ return self.preparer._prepare_reactions_stage(
+ reaction_instances,
+ loop=True,
+ )
+
+ def _prepare_products_for_idx_based_fg_detection(
+ self,
+ ) -> list[MonomerRoleforIndexBasedFGDetection]:
+ """Prepare active products for index-based functional-group detection.
+
+ Active reaction products are converted into cleaned SMILES strings
+ and sanitized RDKit molecules. Their template atom indexes are
+ retained so that functional groups detected in the product can be
+ traced back to the reaction that produced them.
+
+ Returns:
+ A list of roles ready for index-based functional-group detection.
+ """
+ prepared_monomer_roles: list[
+ MonomerRoleforIndexBasedFGDetection
+ ] = []
+
+ for reaction in self.session.reaction_metadata:
+ # Skip reactions that were deactivated during preparation.
+ if not reaction.activity_stats:
+ continue
+
+ product_mol = reaction.product_combined_RDmol
+ product_is_single_fragment = (
+ len(Chem.GetMolFrags(product_mol)) == 1
+ )
+ indexes_in_template, product_mol = self._get_product_idxs(
+ reaction.template_reactant_to_product_mapping,
+ product_mol,
+ )
+
+ sanitized_mol, success = self._sanitize_molecule(product_mol)
+
+ # Record radical metadata when sanitization succeeds; otherwise
+ # mark the product as non-radical.
+ if success and sanitized_mol is not None:
+ # Keep the stage handoff molecule consistent. For
+ # non-deletion, single-fragment products, the sanitized
+ # molecule has the same atom indexing as the stored product
+ # and can safely become the source for both the current
+ # post-template and the next loop reactant.
+ if (
+ not reaction.delete_atom
+ and product_is_single_fragment
+ ):
+ reaction.product_combined_RDmol = Chem.Mol(
+ sanitized_mol
+ )
+
+ self._set_reaction_radical_metadata(
+ reaction,
+ sanitized_mol,
+ )
+ else:
+ reaction.is_radical = False
+ reaction.radical_atom_idxs = ()
+
+ if not success:
+ print(
+ f"Skipping reaction product {reaction.reaction_id}: "
+ "RDKit molecule sanitization failed."
+ )
+
+ prepared_monomer_roles.append(
+ MonomerRoleforIndexBasedFGDetection(
+ smiles=self._get_product_smiles(sanitized_mol),
+ name=f"new_{reaction.reaction_id}",
+ indexes_in_template=indexes_in_template,
+ rdkit_mol=sanitized_mol,
+ )
+ )
+
+ return prepared_monomer_roles
+
+ def _store_reactions(
+ self,
+ reactions: list["ReactionMetadata"],
+ ) -> list["ReactionMetadata"]:
+ """Persist reaction metadata in the session and return it.
+
+ Args:
+ reactions: Final deduplicated reaction metadata.
+
+ Returns:
+ The same list, now stored on the session.
+ """
+ self.session.reaction_metadata = reactions
+ return reactions
+
+ def _sanitize_molecule(
+ self,
+ mol: Chem.Mol,
+ ) -> tuple[Chem.Mol | None, bool]:
+ """Clean and sanitize a product while preserving radical centers.
+
+ RDKit ``RunReactants`` output is often not directly sanitizable,
+ especially when the reaction SMARTS intentionally leaves a carbon
+ under-valent (radical). This method strips tracking properties,
+ recomputes ring information, and adjusts explicit hydrogens and
+ radical electrons until the molecule either sanitizes cleanly or
+ is returned in the best possible state.
+
+ Args:
+ mol: Raw product molecule from reaction preparation.
+
+ Returns:
+ A tuple of (sanitized molecule or best-effort molecule,
+ success flag indicating whether RDKit sanitization succeeded).
+ """
+ cleaned_mol = self._clean_product(mol)
+ patched_mol = Chem.RWMol(cleaned_mol)
+
+ # Rebuild valence and ring state without raising on the first error.
+ patched_mol.UpdatePropertyCache(strict=False)
+ Chem.FastFindRings(patched_mol)
+
+ for atom in patched_mol.GetAtoms():
+ # Focus on carbons, where radical centers are expected.
+ if atom.GetAtomicNum() != 6:
+ continue
+
+ # Disable automatic implicit-H addition so we can manage valence
+ # explicitly for the radical center.
+ atom.SetNoImplicit(True)
+
+ heavy_valence = int(
+ sum(
+ bond.GetValenceContrib(atom)
+ for bond in atom.GetBonds()
+ )
+ )
+ explicit_hs = atom.GetNumExplicitHs()
+ radical_electrons = atom.GetNumRadicalElectrons()
+
+ # A carbon that has reached valence four is no longer radical.
+ if (
+ heavy_valence + explicit_hs >= 4
+ and radical_electrons > 0
+ ):
+ atom.SetNumRadicalElectrons(0)
+ radical_electrons = 0
+
+ # Reduce explicit hydrogens when reaction output is over-valent.
+ if (
+ heavy_valence
+ + explicit_hs
+ + radical_electrons
+ > 4
+ ):
+ explicit_hs = max(
+ 0,
+ 4 - heavy_valence - radical_electrons,
+ )
+ atom.SetNumExplicitHs(explicit_hs)
+
+ # Preserve a neutral, trivalent carbon as the new radical center.
+ if (
+ heavy_valence + explicit_hs == 3
+ and atom.GetFormalCharge() == 0
+ ):
+ atom.SetNumRadicalElectrons(1)
+
+ patched_mol = patched_mol.GetMol()
+ patched_mol.ClearComputedProps()
+
+ # First attempt: sanitize the valence-patched molecule.
+ try:
+ Chem.SanitizeMol(patched_mol)
+ return patched_mol, True
+ except Exception:
+ pass
+
+ # Second attempt: explicitly mark under-valent carbons as radicals
+ # and try sanitization again.
+ radical_fixed_mol = self._fix_radical_and_sanitize(
+ patched_mol
+ )
+
+ try:
+ Chem.SanitizeMol(radical_fixed_mol)
+ return radical_fixed_mol, True
+ except Exception:
+ # If sanitization still fails, return the best-effort molecule
+ # with updated caches so downstream code can still inspect it.
+ radical_fixed_mol.UpdatePropertyCache(strict=False)
+ Chem.FastFindRings(radical_fixed_mol)
+ return radical_fixed_mol, False
+
+ def _fix_radical_and_sanitize(
+ self,
+ raw_mol: Chem.Mol,
+ query: str = "[CH;X3;v3]",
+ ) -> Chem.Mol:
+ """Represent deliberately under-valent carbons as radicals.
+
+ ``RunReactants`` output can be unsanitized. The radical carbon is
+ deliberately under-valent in the reaction SMARTS, so this method adds
+ the radical electron needed for RDKit sanitization and SMILES
+ round-tripping.
+
+ Args:
+ raw_mol: Molecule that may contain an under-valent radical carbon.
+ query: SMARTS used to identify the radical carbon. Defaults to a
+ neutral carbon with one hydrogen, three explicit connections,
+ and total valence three.
+
+ Returns:
+ Molecule with radical valence represented explicitly.
+ """
+ mol = Chem.RWMol(raw_mol)
+ mol.UpdatePropertyCache(strict=False)
+ Chem.FastFindRings(mol)
+
+ query_mol = Chem.MolFromSmarts(query)
+ hits = mol.GetSubstructMatches(query_mol)
+
+ for match in hits:
+ atom = mol.GetAtomWithIdx(match[0])
+ atom.SetNoImplicit(True)
+
+ # Ensure the matched carbon has exactly one explicit hydrogen.
+ if atom.GetTotalNumHs() != 1:
+ atom.SetNumExplicitHs(1)
+
+ # Add the single radical electron that completes the valence
+ # representation.
+ atom.SetNumRadicalElectrons(1)
+
+ return mol.GetMol()
+
+ def _clean_product(self, mol: Chem.Mol) -> Chem.Mol:
+ """Return a copy of a molecule stripped of preparation artifacts.
+
+ Atom maps, isotope labels, and internal RDKit tracking properties are
+ removed so that downstream SMILES are canonical and do not carry
+ state from the reaction engine.
+
+ Args:
+ mol: Molecule to clean.
+
+ Returns:
+ A new molecule with atom maps, isotopes, and tracking properties
+ cleared.
+ """
+ cleaned_mol = Chem.Mol(mol)
+
+ for atom in cleaned_mol.GetAtoms():
+ atom.SetAtomMapNum(0)
+ atom.SetIsotope(0)
+
+ if atom.HasProp("old_mapno"):
+ atom.ClearProp("old_mapno")
+ if atom.HasProp("react_atom_idx"):
+ atom.ClearProp("react_atom_idx")
+
+ return cleaned_mol
+
+ def _get_product_smiles(self, mol: Chem.Mol) -> str:
+ """Convert a cleaned product molecule to canonical SMILES.
+
+ Args:
+ mol: Product molecule (may be ``None`` if sanitization failed).
+
+ Returns:
+ Canonical SMILES string, or an empty string if conversion fails.
+ """
+ cleaned_mol = self._clean_product(mol)
+
+ try:
+ return Chem.MolToSmiles(cleaned_mol)
+ except Exception:
+ return ""
+
+ def _get_product_idxs(
+ self,
+ template_reactant_to_product_mapping: dict[int, int],
+ mol: Chem.Mol,
+ ) -> tuple[list[int], Chem.Mol]:
+ """Return product indexes and the molecule containing those indexes.
+
+ When a product contains disconnected fragments, only the fragment with
+ the greatest number of heavy atoms is retained and the indexes are
+ remapped into that fragment.
+
+ Args:
+ template_reactant_to_product_mapping: Mapping from reactant atom
+ indexes to product atom indexes in the original template.
+ mol: Product molecule, possibly multi-fragment.
+
+ Returns:
+ A tuple of (list of remapped product indexes, retained fragment).
+ """
+ product = Chem.Mol(mol)
+ product_idxs = list(
+ template_reactant_to_product_mapping.values()
+ )
+
+ if len(Chem.GetMolFrags(product)) > 1:
+ product, product_idxs = self._keep_largest_fragment(
+ product,
+ product_idxs,
+ )
+
+ return product_idxs, product
+
+ def _keep_largest_fragment(
+ self,
+ mol: Chem.Mol,
+ product_idxs: list[int],
+ ) -> tuple[Chem.Mol, list[int]]:
+ """Keep the largest heavy-atom fragment and remap its atom indexes.
+
+ Args:
+ mol: Multi-fragment product molecule.
+ product_idxs: Product-side atom indexes to retain.
+
+ Returns:
+ A tuple of (largest fragment molecule, product indexes remapped
+ into that fragment).
+
+ Raises:
+ ValueError: If no fragments could be extracted from the molecule.
+ """
+ fragment_atom_mappings: list[tuple[int, ...]] = []
+ fragments = Chem.GetMolFrags(
+ mol,
+ asMols=True,
+ sanitizeFrags=True,
+ fragsMolAtomMapping=fragment_atom_mappings,
+ )
+
+ if not fragments:
+ raise ValueError(
+ "No fragments found in the product molecule."
+ )
+
+ # Select the fragment with the most heavy atoms.
+ largest_fragment_position = max(
+ range(len(fragments)),
+ key=lambda position: (
+ fragments[position].GetNumHeavyAtoms()
+ ),
+ )
+ largest_fragment = fragments[largest_fragment_position]
+ original_atom_idxs = fragment_atom_mappings[
+ largest_fragment_position
+ ]
+
+ # Build a map from original atom indexes to their positions in the
+ # largest fragment.
+ original_to_new_idx = {
+ original_idx: new_idx
+ for new_idx, original_idx in enumerate(original_atom_idxs)
+ }
+ remapped_product_idxs = [
+ original_to_new_idx[product_idx]
+ for product_idx in product_idxs
+ if product_idx in original_to_new_idx
+ ]
+
+ return largest_fragment, remapped_product_idxs
+
+ def _set_is_looped_flag(
+ self,
+ monomer_roles: list["MonomerRole"],
+ ) -> None:
+ """Mark supplied monomer roles as processed by the current loop.
+
+ Args:
+ monomer_roles: Monomer roles to flag as looped.
+ """
+ for monomer_role in monomer_roles:
+ monomer_role.is_looped = True
+
+ def _populate_monomer_roles(self) -> None:
+ """Create RDKit molecules for roles identified as monomers.
+
+ Input monomers are typically supplied as SMILES; this method ensures
+ that each one has an associated RDKit molecule before detection runs.
+ """
+ for monomer in self.session.monomer_roles:
+ if monomer.is_monomer:
+ monomer.rdkit_mol = self._smiles_to_rdkit_mol(
+ monomer.smiles
+ )
+
+ def _smiles_to_rdkit_mol(
+ self,
+ smiles: str,
+ ) -> Chem.Mol | None:
+ """Parse a SMILES string into an RDKit molecule.
+
+ Args:
+ smiles: SMILES string to parse.
+
+ Returns:
+ The parsed RDKit molecule, or ``None`` if parsing fails.
+ """
+ return Chem.MolFromSmiles(smiles)
+
+ def _loop_break_condition(
+ self,
+ size_before: int,
+ size_after: int,
+ ) -> bool:
+ """Return whether the active reaction pool failed to grow.
+
+ Args:
+ size_before: Number of active reactions before deduplication.
+ size_after: Number of active reactions after deduplication.
+
+ Returns:
+ ``True`` if the pool did not grow and the loop should stop.
+ """
+ if size_after <= size_before:
+ print(
+ "Breaking the loop as the pool did not grow "
+ f"(before={size_before}, after={size_after})."
+ )
+ return True
+
+ return False
+
+ def _count_active_reactions(
+ self,
+ reactions: list["ReactionMetadata"],
+ ) -> int:
+ """Count reactions included in activity statistics.
+
+ Args:
+ reactions: Reaction metadata to inspect.
+
+ Returns:
+ Number of reactions with truthy ``activity_stats``.
+ """
+ return sum(
+ bool(reaction.activity_stats)
+ for reaction in reactions
+ )
+
+ def _set_reaction_radical_metadata(
+ self,
+ reaction: "ReactionMetadata",
+ sanitized_product: Chem.Mol,
+ ) -> None:
+ """Store product radical atoms in reactant-index space.
+
+ Deduplication relabels product atoms into reactant-index space, so
+ radical indexes are converted through
+ ``product_to_reactant_mapping``.
+
+ Args:
+ reaction: Reaction metadata to annotate.
+ sanitized_product: Sanitized product molecule in which radical
+ electrons have already been assigned.
+ """
+ product_radical_idxs = {
+ atom.GetIdx()
+ for atom in sanitized_product.GetAtoms()
+ if atom.GetNumRadicalElectrons() > 0
+ }
+ radical_reactant_idxs = {
+ reaction.product_to_reactant_mapping[product_idx]
+ for product_idx in product_radical_idxs
+ if product_idx in reaction.product_to_reactant_mapping
+ }
+
+ reaction.is_radical = bool(radical_reactant_idxs)
+ reaction.radical_atom_idxs = tuple(
+ sorted(radical_reactant_idxs)
+ )
+
+ def _annotate_radicals_before_deduplication(
+ self,
+ reactions: list["ReactionMetadata"],
+ ) -> None:
+ """Sanitize products and record radical atoms before deduplication.
+
+ This must run before ``compare_graphs_mol`` because the deduplication
+ step relies on consistent radical annotation to distinguish otherwise
+ isomorphic products.
+
+ Args:
+ reactions: Newly prepared reaction metadata to annotate.
+ """
+ for reaction in reactions:
+ if not reaction.activity_stats:
+ continue
+
+ product_mol = reaction.product_combined_RDmol
+
+ if product_mol is None:
+ reaction.is_radical = False
+ reaction.radical_atom_idxs = ()
+ continue
+
+ sanitized_mol, success = self._sanitize_molecule(
+ product_mol
+ )
+
+ if not success or sanitized_mol is None:
+ reaction.is_radical = False
+ reaction.radical_atom_idxs = ()
+ continue
+
+ self._set_reaction_radical_metadata(
+ reaction,
+ sanitized_mol,
+ )
diff --git a/AutoREACTER/reaction_preparation/reaction_processor/utils.py b/AutoREACTER/reaction_preparation/reaction_processor/utils.py
index a08d587a..227c70c3 100644
--- a/AutoREACTER/reaction_preparation/reaction_processor/utils.py
+++ b/AutoREACTER/reaction_preparation/reaction_processor/utils.py
@@ -24,14 +24,56 @@ def prepare_paths( cache, subdir):
os.makedirs(csv_cache, exist_ok=True)
return csv_cache
-def add_dict_as_new_columns(df_existing, data_dict, titles=("template_reactant_idx", "template_product_idx")):
- df_existing[titles[0]] = pd.Series(list(data_dict.keys())).astype("Int64")
- df_existing[titles[1]] = pd.Series(list(data_dict.values())).astype("Int64")
+def add_dict_as_new_columns(
+ df_existing,
+ data_dict,
+ titles=("template_reactant_idx", "template_product_idx"),
+):
+ reactant_values = list(data_dict.keys())
+ product_values = list(data_dict.values())
+
+ # These values represent dataframe row positions, not pandas index labels.
+ # Trim values longer than the dataframe and pad shorter values with pd.NA.
+ reactant_values = reactant_values[:len(df_existing)]
+ product_values = product_values[:len(df_existing)]
+
+ reactant_values += [pd.NA] * (
+ len(df_existing) - len(reactant_values)
+ )
+ product_values += [pd.NA] * (
+ len(df_existing) - len(product_values)
+ )
+
+ df_existing[titles[0]] = pd.array(
+ reactant_values,
+ dtype="Int64",
+ )
+ df_existing[titles[1]] = pd.array(
+ product_values,
+ dtype="Int64",
+ )
+
return df_existing
-def add_column_safe(df, list_data, column_name):
- df[column_name] = pd.Series(list_data).astype("Int64")
+def add_column_safe(
+ df,
+ list_data,
+ column_name,
+):
+ values = list(list_data)
+
+ # Assign by row position rather than pandas index-label alignment.
+ values = values[:len(df)]
+ values += [pd.NA] * (
+ len(df) - len(values)
+ )
+
+ df[column_name] = pd.array(
+ values,
+ dtype="Int64",
+ )
+
return df
@@ -107,7 +149,10 @@ def compare_set(reaction_metadata_list, _react2, _prod2):
return True
-def compare_rdkit_molecules_canonical(data_smiles_list, mol_smi_2):
+def compare_rdkit_molecules_canonical(
+ data_smiles_list,
+ mol_smi_2,
+):
"""
Compares two RDKit molecule SMILES strings to determine if they
represent the same chemical structure using canonical SMILES.
@@ -121,24 +166,42 @@ def compare_rdkit_molecules_canonical(data_smiles_list, mol_smi_2):
"""
if not mol_smi_2:
return data_smiles_list, False
+
mol2 = Chem.MolFromSmiles(mol_smi_2)
+
if mol2 is None:
return data_smiles_list, False
-
+
+ canonical_smi_2 = Chem.MolToSmiles(
+ mol2,
+ canonical=True,
+ )
+
for mol_smi_1 in data_smiles_list:
- mol1 = Chem.MolFromSmiles(mol_smi_1)
-
- # Handle cases where SMILES might be invalid
- if mol1 is None or mol2 is None:
- return data_smiles_list, False # or raise a ValueError
- # Generate canonical SMILES and compare them
- canonical_smi_1 = Chem.MolToSmiles(mol1, canonical=True)
- canonical_smi_2 = Chem.MolToSmiles(mol2, canonical=True)
-
- if canonical_smi_1 == canonical_smi_2:
+ mol1 = Chem.MolFromSmiles(
+ mol_smi_1
+ )
+
+ # A malformed existing cache entry should not prevent checking the
+ # remaining valid entries for an already-known molecule.
+ if mol1 is None:
+ continue
+
+ canonical_smi_1 = Chem.MolToSmiles(
+ mol1,
+ canonical=True,
+ )
+
+ if (
+ canonical_smi_1
+ == canonical_smi_2
+ ):
return data_smiles_list, True
-
- data_smiles_list.append(mol_smi_2)
+
+ data_smiles_list.append(
+ mol_smi_2
+ )
+
return data_smiles_list, False
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 00000000..468caecb
--- /dev/null
+++ b/AutoREACTER/reaction_preparation/reaction_processor/warning_asci.py
@@ -0,0 +1,26 @@
+
+# (unused import removed)
+
+def ascii_art(message: str) -> None:
+ message = message.upper()
+ print(f"WARNING: {message}")
+ print(
+r"""
+ ____ ____ _ _______ ____ _____ _____ ____ _____ ______ _ _ _
+|_ _| |_ _|/ \ |_ __ \ |_ \|_ _||_ _||_ \|_ _|.' ___ | | | | | | |
+ \ \ /\ / / / _ \ | |__) | | \ | | | | | \ | | / .' \_| | | | | | |
+ \ \/ \/ / / ___ \ | __ / | |\ \| | | | | |\ \| | | | ____ | | | | | |
+ \ /\ /_/ / \ \_ _| | \ \_ _| |_\ |_ _| |_ _| |_\ |_\ `.___] | |_| |_| |_|
+ \/ \/|____| |____||____| |___||_____|\____||_____||_____|\____|`._____.' (_) (_) (_)
+
+"""
+ )
+
+
+def print_warning() -> None:
+ message = (
+ "Entering the reaction progression loop is still in the beta phase. "
+ "Caution: results can be chemically inaccurate."
+ )
+ ascii_art(message)
+
diff --git a/AutoREACTER/session.py b/AutoREACTER/session.py
index c4100760..78c5c8e9 100644
--- a/AutoREACTER/session.py
+++ b/AutoREACTER/session.py
@@ -2,8 +2,8 @@
from typing import TYPE_CHECKING
import json
import shutil
+from dataclasses import dataclass, field
from pathlib import Path
-from dataclasses import dataclass
# Import internal modules
from AutoREACTER.initialization import Initialization
@@ -15,22 +15,76 @@
if TYPE_CHECKING:
from AutoREACTER.detectors.functional_groups_detector import MonomerRole
-@dataclass
+@dataclass(slots=True)
class Session:
"""
- Holds the validated inputs and directory paths for a single AutoREACTER run.
- This acts as the 'state object' passed through the pipeline.
+ Runtime state container for one AutoREACTER workflow.
+
+ A Session stores the validated input model, run directories, and all
+ intermediate/final objects generated as the pipeline progresses.
+
+ The object is intentionally passed through the full AutoREACTER pipeline so
+ each stage can attach its own outputs without requiring large return-value
+ chains.
+
+ Pipeline state
+ --------------
+ inputs:
+ Validated simulation setup parsed from the input JSON.
+
+ staging_dir:
+ Temporary working directory used for intermediate/cache files.
+
+ output_dir:
+ Final AutoREACTER output directory for this run.
+
+ images_dir:
+ Directory where molecule, functional-group, reaction, and template
+ visualization images are saved.
+
+ monomer_roles:
+ MonomerRole objects after functional-group classification.
+
+ reaction_instances:
+ Detected reaction candidates before full reaction preparation.
+
+ non_reactants:
+ MonomerRole objects selected as non-reactive species.
+
+ reaction_metadata:
+ Prepared reaction objects. Each ReactionMetadata object stores RDKit
+ mappings, template atoms, initiators, edge atoms, byproducts, generated
+ REACTER template files, map files, and activity status.
+
+ ff_files:
+ Raw force-field and LUNAR output files before REACTER-specific file
+ organization.
+
+ reacter_files:
+ Final REACTER file bundle. This should contain run-level files plus the
+ final monomer and reaction metadata lists used by LAMMPS writers.
"""
+
+ # Core run configuration
inputs: SimulationSetup
staging_dir: Path
output_dir: Path
- images_dir: Path
- monomer_roles: list["MonomerRole"] = None
- reaction_instances: list[ReactionInstance] = None
- non_reactants: list["MonomerRole"] = None
- reaction_metadata: list[ReactionMetadata] = None # Placeholder for actual ReactionMetadata type
+ images_dir: Path
+
+ # Pipeline-generated state
+ monomer_roles: list["MonomerRole"] = field(default_factory=list)
+ reaction_instances: list[ReactionInstance] = field(default_factory=list)
+ non_reactants: list["MonomerRole"] = field(default_factory=list)
+ reaction_metadata: list[ReactionMetadata] = field(default_factory=list)
+
+ # File bundles generated later in the pipeline
ff_files: FFFiles | None = None
- reacter_files: REACTERFiles = None # Placeholder for the actual REACTERFiles dataclass
+ reacter_files: REACTERFiles | None = None
+
+ # Runtime counters / sub-sessions attached during reaction preparation
+ reaction_id_counter: int = 0
+ reaction_progression_session: object | None = None
+
def _resolve_input_path(input_file_path: str) -> Path:
"""
@@ -59,19 +113,44 @@ def _clear_directory(path: Path):
elif item.is_dir():
shutil.rmtree(item)
-def _normalize_output_dir(raw_output_dir: str, input_path: Path) -> Path:
+def _resolve_output_dir(
+ raw_output_dir: str | None,
+ input_path: Path,
+ simulation_name: str,
+) -> Path:
"""
- Normalize output_dir from JSON.
+ Resolve the final AutoREACTER output directory.
+
+ Behavior:
+ - If output_dir is missing, None, or empty, use the default location beside
+ the input JSON:
+ input_json_folder / AutoREACTER_outputs / simulation_name
+
+ - If output_dir is relative, resolve it relative to the input JSON folder.
+
+ - If output_dir is a Linux/WSL absolute path, use it directly.
+
+ - If output_dir is a Windows-style path such as C:/Users/... or C:\\Users\\...,
+ convert it to the WSL form /mnt/c/Users/....
- Supports:
- - Linux/WSL absolute paths: /mnt/c/...
- - Windows paths: C:/Users/...
- - Relative paths: AutoREACTER_outputs/run_name
+ This function returns an absolute path. Directory creation/clearing happens
+ in read_input().
"""
+ if raw_output_dir is None or str(raw_output_dir).strip() == "":
+ return (
+ input_path.parent
+ / "AutoREACTER_outputs"
+ / simulation_name
+ ).resolve()
+
raw_output_dir = str(raw_output_dir).strip()
- # Handle Windows-style path when running in WSL/Linux.
- if len(raw_output_dir) >= 3 and raw_output_dir[1] == ":" and raw_output_dir[2] in {"/", "\\"}:
+ # Windows path while running from WSL/Linux.
+ if (
+ len(raw_output_dir) >= 3
+ and raw_output_dir[1] == ":"
+ and raw_output_dir[2] in {"/", "\\"}
+ ):
drive = raw_output_dir[0].lower()
rest = raw_output_dir[3:].replace("\\", "/")
return Path(f"/mnt/{drive}/{rest}").resolve()
@@ -118,12 +197,16 @@ def read_input(input_file_path: str, clear_staging: bool = True) -> Session:
raw_output_dir = input_data.get("output_dir", None)
- if raw_output_dir is not None:
- output_dir = _normalize_output_dir(raw_output_dir, input_path)
+ output_dir = _resolve_output_dir(
+ raw_output_dir=raw_output_dir,
+ input_path=input_path,
+ simulation_name=sim_name,
+ )
- else:
- # Backward-compatible default behavior.
- output_dir = input_path.parent / "AutoREACTER_outputs" / sim_name
+ if output_dir.exists() and not output_dir.is_dir():
+ raise ValueError(
+ f"Resolved output_dir exists but is not a directory: {output_dir}"
+ )
if output_dir.exists():
_clear_directory(output_dir)
@@ -133,14 +216,6 @@ def read_input(input_file_path: str, clear_staging: bool = True) -> Session:
images_dir = output_dir / "images"
images_dir.mkdir(parents=True, exist_ok=True)
- if output_dir.exists():
- _clear_directory(output_dir) # Only clear this specific run's old files
- output_dir.mkdir(parents=True, exist_ok=True)
-
- # Now create the images folder safely inside the simulation folder
- images_dir = Path((output_dir) / "images")
- images_dir.mkdir(parents=True, exist_ok=True)
-
# 6. Return the State Object
print(f"[INFO] Initialized AutoREACTER Session")
print(f"[INFO] Simulation Name: {validated_inputs.simulation_name}")
@@ -152,6 +227,6 @@ def read_input(input_file_path: str, clear_staging: bool = True) -> Session:
inputs=validated_inputs,
staging_dir=staging_dir,
output_dir=output_dir,
- images_dir=images_dir
+ images_dir=images_dir,
)
diff --git a/AutoREACTER/sim_setup/system_property_calculations.py b/AutoREACTER/sim_setup/system_property_calculations.py
index effe2123..1f8ce2c6 100644
--- a/AutoREACTER/sim_setup/system_property_calculations.py
+++ b/AutoREACTER/sim_setup/system_property_calculations.py
@@ -1,4 +1,5 @@
import math
+from rdkit import Chem
from rdkit.Chem import Descriptors
from AutoREACTER.input_parser import SimulationSetup
@@ -56,11 +57,17 @@ def process_all(self) -> SimulationSetup:
def _populate_monomer_properties(self) -> None:
"""
Populate each active monomer with basic structural properties derived from its RDKit molecule.
-
+
For every monomer whose status is True:
- - num_atoms is set to the heavy atom count.
- - molecular_weight is set using RDKit's Descriptors.MolWt (g/mol).
-
+ - num_atoms is set to the FULL atom count (heavy atoms + explicit hydrogens),
+ since this must match the atom count of the final built system, not just
+ the heavy-atom skeleton. Using AddHs() on a temporary copy avoids mutating
+ monomer.rdkit_mol, which other stages (e.g. functional group / reaction
+ detection) still expect in its original heavy-atom-only form.
+ - molecular_weight is set using RDKit's Descriptors.MolWt (g/mol). Unaffected
+ by explicit vs. implicit H representation -- MolWt already accounts for
+ implicit hydrogens correctly.
+
Raises:
NoneMonomerError: If a monomer is marked active but has no RDKit Mol object.
"""
@@ -75,8 +82,14 @@ def _populate_monomer_properties(self) -> None:
f"Monomer with ID {monomer.id} has no RDKit Mol object."
)
- monomer.num_atoms = monomer.rdkit_mol.GetNumAtoms()
+ # count on an AddHs'd copy so num_atoms reflects the true final
+ # atom count (heavy + explicit H), matching what total_atoms means
+ # to the rest of the simulation-setup pipeline. Original rdkit_mol
+ # is left untouched for downstream heavy-atom-based logic.
+ mol_with_hs = Chem.AddHs(monomer.rdkit_mol)
+ monomer.num_atoms = mol_with_hs.GetNumAtoms()
monomer.molecular_weight = Descriptors.MolWt(monomer.rdkit_mol)
+
def _calculate_replica_properties(self) -> None:
"""
diff --git a/AutoREACTER/sim_setup/writers/densification_writer.py b/AutoREACTER/sim_setup/writers/densification_writer.py
index e8847fdb..582eb97f 100644
--- a/AutoREACTER/sim_setup/writers/densification_writer.py
+++ b/AutoREACTER/sim_setup/writers/densification_writer.py
@@ -164,8 +164,9 @@ def write_lammps_densification_file(self, simulation: Simulation) -> str:
mol_ids = {}
for i, mol in enumerate(rf.molecule_files, 1):
m_id = f"mol_{i}"
- mol_ids[mol.id] = m_id
- lines.append(f"{'molecule':<16} {m_id} {mol.molecule_files.lmp_molecule_file.name}")
+ mol_name = mol.name or mol.data_id
+ mol_ids[mol_name] = m_id
+ lines.append(f"{'molecule':<16} {m_id} {mol.lmp_molecule_file.name}")
lines.append("\n#------------Randomly Insert Molecules------------")
for m_name, count in simulation.monomer_counts.items():
@@ -323,8 +324,8 @@ def _copy_required_files(self, dest_dir: Path) -> None:
shutil.copy2(rf.force_field_data, dest_dir / rf.force_field_data.name)
for mol in rf.molecule_files:
- if (mol.molecule_files and
- mol.molecule_files.lmp_molecule_file and
- mol.molecule_files.lmp_molecule_file.exists()):
- src = mol.molecule_files.lmp_molecule_file
+ if (mol.lmp_molecule_file and
+ mol.lmp_molecule_file and
+ mol.lmp_molecule_file.exists()):
+ src = mol.lmp_molecule_file
shutil.copy2(src, dest_dir / src.name)
diff --git a/AutoREACTER/sim_setup/writers/post_eq_writer.py b/AutoREACTER/sim_setup/writers/post_eq_writer.py
index ea68f4e7..e77cf74c 100644
--- a/AutoREACTER/sim_setup/writers/post_eq_writer.py
+++ b/AutoREACTER/sim_setup/writers/post_eq_writer.py
@@ -31,7 +31,14 @@ class PostEqWriter:
sim_name (str): Base simulation name used in file naming.
"""
- def __init__(self, out_dir: Path, settings: LammpsSettings, simulation: Simulation, sim_name: str):
+ def __init__(
+ self,
+ out_dir: Path,
+ settings: LammpsSettings,
+ simulation: Simulation,
+ sim_name: str,
+ write_second_reaction_stage: bool = True,
+ ):
"""
Initialize the writer and generate the post-equilibration input script.
@@ -46,9 +53,16 @@ def __init__(self, out_dir: Path, settings: LammpsSettings, simulation: Simulati
self.sim_name = sim_name
# Write the post-equilibration file immediately during construction.
- self.write_post_eq_file(simulation=simulation)
-
- def write_post_eq_file(self, simulation: Simulation) -> str:
+ self.write_post_eq_file(
+ simulation=simulation,
+ write_second_reaction_stage=write_second_reaction_stage,
+ )
+
+ def write_post_eq_file(
+ self,
+ simulation: Simulation,
+ write_second_reaction_stage: bool = True,
+ ) -> str:
"""
Create the LAMMPS input script for the post-equilibration stage.
@@ -74,8 +88,11 @@ def write_post_eq_file(self, simulation: Simulation) -> str:
s = self.settings
- # Input is the reacted structure produced by the second reaction stage.
- input_data = f"{tag}_reacted_1M-3.5_5.0A.data"
+ # Input is the reacted structure produced by the final reaction stage.
+ if write_second_reaction_stage:
+ input_data = f"{tag}_reacted_1M-3.5_5.0A.data"
+ else:
+ input_data = f"{tag}_reacted_0M-1M_3.5A.data"
# Output files written after equilibration completes.
output_xyz = f"{tag}_post_equilibration.xyz"
diff --git a/AutoREACTER/sim_setup/writers/pre_eq_writer.py b/AutoREACTER/sim_setup/writers/pre_eq_writer.py
index 6771f47e..18893dcb 100644
--- a/AutoREACTER/sim_setup/writers/pre_eq_writer.py
+++ b/AutoREACTER/sim_setup/writers/pre_eq_writer.py
@@ -126,18 +126,18 @@ def write_pre_eq_file(self, simulation: Simulation) -> str:
"#------------Stage 1: NVT Temperature Ramp------------",
"# (25,000 steps × 1 fs timestep)",
f"{'fix':<16} nvt_1 all nvt temp 298.15 {simulation.temperature} 100.0",
- f"{'run':<16} 25000",
+ f"{'run':<16} 50000",
f"{'unfix':<16} nvt_1",
"",
- "#------------Stage 2: NPT Equilibration------------",
- "# Isotropic pressure control at 0 atm while maintaining target temperature.",
- "# This allows the box volume to adjust to the correct density at the",
- "# desired temperature and pressure.",
- f"{'fix':<16} npt_2 all npt temp {simulation.temperature} {simulation.temperature} 100.0 iso 0.0 0.0 1000.0",
- f"{'run':<16} 25000",
- f"{'unfix':<16} npt_2",
- "",
- "#------------Stage 3: Final NVT Equilibration------------",
+ # "#------------Stage 2: NPT Equilibration------------",
+ # "# Isotropic pressure control at 0 atm while maintaining target temperature.",
+ # "# This allows the box volume to adjust to the correct density at the",
+ # "# desired temperature and pressure.",
+ # f"{'fix':<16} npt_2 all npt temp {simulation.temperature} {simulation.temperature} 100.0 iso 0.0 0.0 1000.0",
+ # f"{'run':<16} 25000",
+ # f"{'unfix':<16} npt_2",
+ # "",
+ "#------------Stage 2: Final NVT Equilibration------------",
"# Extended constant-volume equilibration at the final temperature.",
"# This stabilizes the system after the density adjustment in the NPT stage.",
f"{'fix':<16} nvt_3 all nvt temp {simulation.temperature} {simulation.temperature} 100.0",
diff --git a/AutoREACTER/sim_setup/writers/rxn_first_stage_writer.py b/AutoREACTER/sim_setup/writers/rxn_first_stage_writer.py
index 41bb2c3a..e7f99185 100644
--- a/AutoREACTER/sim_setup/writers/rxn_first_stage_writer.py
+++ b/AutoREACTER/sim_setup/writers/rxn_first_stage_writer.py
@@ -44,7 +44,9 @@ def __init__(
self.out_dir = out_dir
self.sim_name = sim_name
self.reacter_files = reacter_files
- self.first_stage_file_name = self.write_first_stage_reaction_files(simulation=simulation)
+ self.first_stage_file_name = self.write_first_stage_reaction_files(
+ simulation=simulation
+ )
# Public API
@@ -140,20 +142,34 @@ 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 [
+ t for t in rf.template_files
+ if getattr(t, "activity_stats", True)
+ ]:
# 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
+ pre_file = template.pre_reaction_file.name
+ post_file = template.post_reaction_file.name
+
+ # Use the active map generated by AutoREACTER. For reactions with
+ # deleted atoms, this map includes the required DeleteIDs section.
map_file = template.map_file.name
- lines.append(f"{'molecule':<16} {pre_id} {pre_file}")
- lines.append(f"{'molecule':<16} {post_id} {post_file}\n")
+ id = template.reaction_id
+ pre_id = f"mol_pre_{id}"
+ post_id = f"mol_post_{id}"
+
+ 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 +178,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",
@@ -196,6 +212,13 @@ def write_first_stage_reaction_files(self, simulation: Simulation) -> str:
def _copy_required_files(self, dest_dir: Path) -> None:
"""Copy every map and molecule file referenced by the reaction templates.
+ The standard RXN_N.map file is always copied and is the map used by
+ AutoREACTER's generated LAMMPS script.
+
+ When an optional RXN_N_with_delete_ids.map file is available, it is
+ also copied into the reaction directory for the user. AutoREACTER does
+ not automatically use the supplementary DeleteIDs map.
+
Parameters
----------
dest_dir : Path
@@ -204,18 +227,39 @@ def _copy_required_files(self, dest_dir: Path) -> None:
Raises
------
FileNotFoundError
- If any referenced file does not exist on disk.
+ If any required standard reaction file does not exist on disk.
"""
rf = self.reacter_files
- for template in rf.template_files:
+ for template in [
+ t for t in rf.template_files
+ if getattr(t, "activity_stats", True)
+ ]:
files: list[Path] = [
template.map_file,
- template.pre_reaction_file.lmp_molecule_file,
- template.post_reaction_file.lmp_molecule_file,
+ template.pre_reaction_file,
+ template.post_reaction_file,
]
+ # Supplementary DeleteIDs map is optional.
+ map_file_with_delete_ids = getattr(
+ template,
+ "map_file_with_delete_ids",
+ None,
+ )
+
+ if map_file_with_delete_ids is not None:
+ files.append(
+ map_file_with_delete_ids
+ )
+
for file in files:
if file is None or not file.exists():
- raise FileNotFoundError(f"Required reaction file not found: {file}")
- shutil.copy2(file, dest_dir / file.name)
+ raise FileNotFoundError(
+ f"Required reaction file not found: {file}"
+ )
+
+ shutil.copy2(
+ file,
+ dest_dir / file.name,
+ )
\ No newline at end of file
diff --git a/AutoREACTER/sim_setup/writers/rxn_second_stage_writer.py b/AutoREACTER/sim_setup/writers/rxn_second_stage_writer.py
index f0fdbb4e..70725840 100644
--- a/AutoREACTER/sim_setup/writers/rxn_second_stage_writer.py
+++ b/AutoREACTER/sim_setup/writers/rxn_second_stage_writer.py
@@ -10,10 +10,12 @@
import shutil
from pathlib import Path
from datetime import datetime
+
from AutoREACTER.reaction_preparation.ff_wrapper.REACTER_files_builder import REACTERFiles
from AutoREACTER.sim_setup.writers.lammps_settings import LammpsSettings
from AutoREACTER.input_parser import Simulation
+
# Generate current date string for file headers and timestamping
now = datetime.now().strftime("%Y-%m-%d")
@@ -21,67 +23,90 @@
class RxnSecondStageWriter:
"""
Generates LAMMPS input scripts for second-stage reaction simulations.
-
+
This class creates a complete LAMMPS input script for the second reaction stage,
configures inter-molecular reaction parameters, and manages the copying of required
reaction template files to the output directory.
-
+
Attributes:
- settings (LammpsSettings): LAMMPS simulation settings and parameters
- out_dir (Path): Output directory for generated files
- sim_name (str): Base name for the simulation
- reacter_files (REACTERFiles): Container for reaction template files
- second_stage_file_name (str): Name of the generated second-stage input file
+ settings (LammpsSettings): LAMMPS simulation settings and parameters.
+ out_dir (Path): Output directory for generated files.
+ sim_name (str): Base name for the simulation.
+ reacter_files (REACTERFiles): Container for reaction template files.
+ second_stage_file_name (str): Name of the generated second-stage input file.
"""
- def __init__(self, out_dir: Path, settings: LammpsSettings, reacter_files: REACTERFiles, simulation: Simulation, sim_name: str):
+ def __init__(
+ self,
+ out_dir: Path,
+ settings: LammpsSettings,
+ reacter_files: REACTERFiles,
+ simulation: Simulation,
+ sim_name: str,
+ ):
"""
Initialize the RxnSecondStageWriter and generate second-stage reaction files.
-
+
Args:
- out_dir (Path): Root output directory for simulation files
- settings (LammpsSettings): LAMMPS simulation settings and force field parameters
- reacter_files (REACTERFiles): Container with reaction template and molecule files
- simulation (Simulation): Simulation configuration with temperature and tag information
- sim_name (str): Base simulation name used for file naming
+ out_dir (Path): Root output directory for simulation files.
+ settings (LammpsSettings): LAMMPS simulation settings and force field parameters.
+ reacter_files (REACTERFiles): Container with reaction template and molecule files.
+ simulation (Simulation): Simulation configuration with temperature and tag information.
+ sim_name (str): Base simulation name used for file naming.
"""
self.settings = settings
self.out_dir = out_dir
self.sim_name = sim_name
self.reacter_files = reacter_files
- # Generate and store the second-stage input file name
- self.second_stage_file_name = self.write_second_stage_reaction_files(simulation=simulation)
+
+ # Generate and store the second-stage input file name.
+ self.second_stage_file_name = self.write_second_stage_reaction_files(
+ simulation=simulation,
+ )
def write_second_stage_reaction_files(self, simulation: Simulation) -> str:
"""
Generate LAMMPS input script for second-stage inter-molecular reactions.
-
+
Creates a complete LAMMPS input file with reaction definitions, simulation
parameters, and thermal dynamics settings. This stage simulates reactions
- between molecules at medium intermolecular distances (1M-3.5 Angstroms).
-
+ between molecules at medium intermolecular distances.
+
Args:
- simulation (Simulation): Simulation configuration containing temperature and unique tag
-
+ simulation (Simulation): Simulation configuration containing temperature and unique tag.
+
Returns:
- str: Filename of the generated second-stage input script
-
+ str: Filename of the generated second-stage input script.
+
Raises:
- FileNotFoundError: If required reaction template files are missing
+ FileNotFoundError: If required reaction template files are missing.
+ ValueError: If no active reaction templates are available.
"""
tag = f"{self.sim_name}_{simulation.tag}"
rxn_dir = self.out_dir / "4_reaction_second_stage"
rxn_dir.mkdir(parents=True, exist_ok=True)
-
- # Reference commonly used objects for cleaner code
+
+ # Reference commonly used objects for cleaner code.
s = self.settings
rf = self.reacter_files
+ now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
- # Define input and output data file names
+ # Define input and output data file names.
input_data = f"{tag}_reacted_0M-1M_3.5A.data"
output_base = f"{tag}_reacted_1M-3.5_5.0A"
-
- # Build LAMMPS script header and core simulation parameters
+
+ active_templates = [
+ template
+ for template in rf.template_files
+ if getattr(template, "activity_stats", True)
+ ]
+
+ if not active_templates:
+ raise ValueError(
+ "No active reaction templates available for the second reaction stage."
+ )
+
+ # ----- Header / Initialization ---------------------------------
lines = [
f"# {tag} Second Reaction Stage Script - Generated {now} by AutoREACTER\n",
"#------------Initialization------------",
@@ -90,130 +115,187 @@ def write_second_stage_reaction_files(self, simulation: Simulation) -> str:
f"{'boundary':<16} {s.boundary}",
f"{'atom_style':<16} {s.atom_style}",
"",
- # Force field style definitions
- "#------------Force Field Styles------------",
+ "# ------------Force Field Styles------------",
f"{'angle_style':<16} {s.angle_style}",
f"{'bond_style':<16} {s.bond_style}",
f"{'dihedral_style':<16} {s.dihedral_style}",
f"{'improper_style':<16} {s.improper_style}",
"",
- # Pair and electrostatic interaction styles
f"{'pair_style':<16} {s.pair_style}",
f"{'kspace_style':<16} {s.kspace_style}",
- f"{'pair_modify':<16} {s.pair_modify}"
+ f"{'pair_modify':<16} {s.pair_modify}",
]
-
- # Add optional neighbor list settings if specified
+
+ # Add optional neighbor list settings if specified.
if s.neighbor:
lines.append(f"{'neighbor':<16} {s.neighbor}")
+
if s.neigh_modify:
lines.append(f"{'neigh_modify':<16} {s.neigh_modify}")
- # Define data file reading with extra bonding capacity for reactions
+ # ----- Read first-stage reacted structure -----------------------
lines.extend([
"",
- "#------------Read First Stage reacted Box------------",
- f"{'read_data':<16} \"{input_data}\" &",
+ "#------------Read First Stage Reacted Box------------",
+ f"{'read_data':<16} {input_data} &",
f"{'':<16} extra/bond/per/atom 50 &",
f"{'':<16} extra/angle/per/atom 50 &",
f"{'':<16} extra/dihedral/per/atom 50 &",
f"{'':<16} extra/improper/per/atom 50 &",
f"{'':<16} extra/special/per/atom 50",
"",
- # Initial energy minimization and simulation setup
- "#------------Minimization and Velocity Initialization------------",
+ "#------------Minimization and Velocity------------",
f"{'minimize':<16} 1.0e-4 1.0e-6 1000 10000",
- f"{'velocity':<16} all create {simulation.temperature} {random.randint(10000, 9999999)} loop geom",
+ f"{'velocity':<16} all create {simulation.temperature} {random.randint(10_000, 9_999_999)} dist gaussian",
f"{'timestep':<16} 1.0",
f"{'thermo':<16} 100",
f"{'reset_timestep':<16} 1000000",
- ""
+ "",
])
- # Define reaction templates and build reaction fix commands
+ # ----- Reaction templates & fix bond/react ---------------------
lines.append("#------------Define Reaction Templates------------")
- rxn_commands = []
- for i, template in enumerate(rf.template_files, 1):
- # Create unique identifiers for pre- and post-reaction molecule templates
- pre_id = f"mol_pre_{i}"
- post_id = f"mol_post_{i}"
-
- # Extract molecule and mapping file names from template objects
- pre_file = template.pre_reaction_file.lmp_molecule_file.name
- post_file = template.post_reaction_file.lmp_molecule_file.name
+ rxn_commands: list[str] = []
+
+ for template in active_templates:
+ reaction_id = template.reaction_id
+
+ # Create unique identifiers for pre- and post-reaction molecule templates.
+ pre_id = f"mol_pre_{reaction_id}"
+ post_id = f"mol_post_{reaction_id}"
+
+ # Extract molecule and mapping file names from template objects.
+ pre_file = template.pre_reaction_file.name
+ post_file = template.post_reaction_file.name
+
+ # Use the active map generated by AutoREACTER. For reactions with
+ # deleted atoms, this map includes the required DeleteIDs section.
map_file = template.map_file.name
-
- # Register molecule templates in LAMMPS
- lines.append(f"{'molecule':<16} {pre_id} {pre_file}")
- lines.append(f"{'molecule':<16} {post_id} {post_file}")
-
- # Build reaction command with cutoff at 5.0 Angstroms for inter-molecular reactions
- # stabilize_steps: equilibration steps after reaction to prevent explosion
- rxn_str = f"react rxn_stp_{i} all 1 0.0 5.0 {pre_id} {post_id} {map_file} stabilize_steps 200 rescale_charges yes"
+
+ # Register molecule templates in LAMMPS.
+ lines.append(
+ f"{'molecule':<16} {pre_id:<16} {pre_file}"
+ )
+
+ lines.append(
+ f"{'molecule':<16} {post_id:<16} {post_file}\n"
+ )
+
+ # Build reaction command with cutoff at 5.0 Angstroms for inter-molecular reactions.
+ # stabilize_steps: equilibration steps after reaction to prevent explosion.
+ rxn_stp = f"rxn_stp_{reaction_id}"
+
+ rxn_str = (
+ f"react "
+ f"{rxn_stp:<15} "
+ f"all 1 0.0 5.0 "
+ f"{pre_id:<14} "
+ f"{post_id:<15} "
+ f"{map_file:<15} "
+ f"stabilize_steps 200 rescale_charges yes"
+ )
+
rxn_commands.append(rxn_str)
- # Combine all reaction commands with line continuation
+ # Combine all reaction commands with line continuation.
all_reactions = " & \n ".join(rxn_commands)
-
- # Configure bond/react fix with stabilization groups and define fixes for simulation
+
+ # Use explicit f_rxns[i] fields because some LAMMPS builds do not accept f_rxns[*].
+ rxn_thermo_values = " ".join(
+ f"f_rxns[{position}]"
+ for position in range(
+ 1,
+ len(rxn_commands) + 1
+ )
+ )
+
+ # Configure bond/react fix with stabilization groups and define fixes for simulation.
lines.extend([
"",
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",
- "# (e.g., RXN_i_with_delete_ids.map). ",
+ "# (e.g., RXN_i_with_delete_ids.map).",
"# NPT is recommended for deletion to account for density changes.\n",
- # NVT thermostat for stabilized group (uncomment NPT if density changes expected)
- f"{'fix':<16} 1 statted_grp_REACT nvt temp {simulation.temperature} {simulation.temperature} 100.0\n",
+ f"{'fix':<16} 1 statted_grp_REACT nvt temp {simulation.temperature} {simulation.temperature} 100.0\n",
f"#{'fix':<16} 1 statted_grp_REACT npt temp {simulation.temperature} {simulation.temperature} 100.0 iso 0.0 0.0 1000.0",
"",
- # Output configuration
- f"{'thermo_style':<16} custom step time temp f_rxns[*] press density vol pe ke etotal",
+ "#------------Output and Run------------",
+ f"{'thermo_style':<16} custom step time temp {rxn_thermo_values} press density vol pe ke etotal",
f"{'dump':<16} traj all xyz 1000 {output_base}.xyz",
f"{'dump_modify':<16} traj types labels",
"",
- # Run simulation for 2.5 million timesteps and save periodic backups
f"{'run':<16} 2500000",
f"{'restart':<16} 100 {output_base}_backup1.restart {output_base}_backup2.restart",
f"{'write_restart':<16} {output_base}.restart",
- f"{'write_data':<16} {output_base}.data nofix"
+ f"{'write_data':<16} {output_base}.data nofix",
])
- # Write assembled LAMMPS input script to file
+ # ----- Write the .in file --------------------------------------
in_file_path = rxn_dir / f"in.{tag}_reaction_stage_2"
- with open(in_file_path, 'w') as f:
+
+ with open(in_file_path, "w") as f:
f.write("\n".join(lines))
-
- # Copy all required reaction template files to output directory
- self._copy_required_files(dest_dir=rxn_dir)
+
+ # ----- Copy auxiliary files into the reaction directory ---------
+ self._copy_required_files(
+ dest_dir=rxn_dir
+ )
return in_file_path.name
def _copy_required_files(self, dest_dir: Path) -> None:
"""
Copy reaction template and molecule definition files to the output directory.
-
- Copies pre-reaction molecules, post-reaction molecules, and atom mapping files
- required by LAMMPS to the simulation directory for execution.
-
+
+ The standard RXN_N.map file is always copied and remains the map used
+ by AutoREACTER's generated LAMMPS input script.
+
+ If an optional RXN_N_with_delete_ids.map file exists, it is also copied
+ into the stage directory for the user. AutoREACTER does not automatically
+ use the supplementary DeleteIDs map.
+
Args:
- dest_dir (Path): Destination directory for copied files
-
+ dest_dir (Path): Destination directory for copied files.
+
Raises:
- FileNotFoundError: If any required reaction file is missing or cannot be accessed
+ FileNotFoundError: If any required reaction file is missing or cannot be accessed.
"""
rf = self.reacter_files
- for template in rf.template_files:
- # Collect all files associated with this reaction template
- files = [
+
+ for template in [
+ t for t in rf.template_files
+ if getattr(t, "activity_stats", True)
+ ]:
+ # Standard files required by the generated LAMMPS script.
+ files: list[Path] = [
template.map_file,
- template.pre_reaction_file.lmp_molecule_file,
- template.post_reaction_file.lmp_molecule_file
+ template.pre_reaction_file,
+ template.post_reaction_file,
]
-
- # Copy each file to the destination, preserving metadata
+
+ # Optional supplementary DeleteIDs map.
+ map_file_with_delete_ids = getattr(
+ template,
+ "map_file_with_delete_ids",
+ None,
+ )
+
+ if map_file_with_delete_ids is not None:
+ files.append(
+ map_file_with_delete_ids
+ )
+
+ # Copy each file to the destination, preserving metadata.
for file in files:
if file is None or not file.exists():
- raise FileNotFoundError(f"Required reaction file not found: {file}")
- shutil.copy2(file, dest_dir / file.name)
+ raise FileNotFoundError(
+ f"Required reaction file not found: {file}"
+ )
+
+ shutil.copy2(
+ file,
+ dest_dir / file.name,
+ )
\ No newline at end of file
diff --git a/AutoREACTER/sim_setup/writers/writer.py b/AutoREACTER/sim_setup/writers/writer.py
index 7e155bad..42716277 100644
--- a/AutoREACTER/sim_setup/writers/writer.py
+++ b/AutoREACTER/sim_setup/writers/writer.py
@@ -50,18 +50,22 @@ def write_all_files(self, run_dir: Path, simulation_setup: SimulationSetup) -> N
)
- RxnSecondStageWriter(
- out_dir=sub_dir,
- settings=self.settings,
- reacter_files=self.reacter_files,
- simulation=simulation,
- sim_name=sim_name
- )
+ if simulation_setup.write_second_reaction_stage:
+ RxnSecondStageWriter(
+ out_dir=sub_dir,
+ settings=self.settings,
+ reacter_files=self.reacter_files,
+ simulation=simulation,
+ sim_name=sim_name
+ )
PostEqWriter(
out_dir=sub_dir,
settings=self.settings,
simulation=simulation,
- sim_name=sim_name
+ sim_name=sim_name,
+ write_second_reaction_stage=(
+ simulation_setup.write_second_reaction_stage
+ )
)
\ No newline at end of file
diff --git a/README.md b/README.md
index 023f74a3..06dfacb7 100644
--- a/README.md
+++ b/README.md
@@ -1,11 +1,12 @@
+
-
Automated generation of LAMMPS/REACTER-ready reaction-template workflows.*
+
Automated generation of LAMMPS/REACTER-ready reaction-template workflows.
-> **Status:** AutoREACTER is currently in **v0.2.3** and under active development. APIs, configuration schemas, reaction libraries, and core functionality may change without notice.
-> Please refer to the [changelog](https://autoreacter.org/change_log.html) for the latest updates.
+> **Status:** AutoREACTER is currently in **v0.3** and under active development.
+> APIs, configuration schemas, reaction libraries, and core functionality may change.
## Documentation
@@ -13,19 +14,22 @@ Full documentation is available at:
**[autoreacter.org](https://autoreacter.org/)**
-The documentation includes installation instructions, input configuration, supported reactions, supported force fields, cleanup utilities, and developer API references.
-
-For detailed functional-group mapping and reaction rules, see the [supported reactions documentation](https://autoreacter.org/supported-reactions.html).
+The documentation covers installation, input configuration, supported reactions,
+force fields, workflow options, and API usage.
## Installation
-AutoREACTER can be installed directly from PyPI:
+Install AutoREACTER from PyPI:
```bash
python -m pip install AutoREACTER
-```
+````
+
+AutoREACTER also requires **LUNAR** for atom typing. See the
+[Getting Started documentation](https://autoreacter.org/getting-started.html)
+for setup instructions.
-For users who want to modify the source code or run the latest development version, AutoREACTER can also be installed from source:
+For development or source installation:
```bash
git clone https://github.com/NanoCIPHER-Lab/AutoREACTER.git
@@ -33,42 +37,52 @@ cd AutoREACTER
python -m pip install -e .
```
-AutoREACTER also requires **LUNAR** for atom typing. See the [getting started documentation](https://autoreacter.org/getting-started.html) for the full setup guide.
+## Quick Start
-## Quick start
+After installing AutoREACTER, create a Python script such as `run_autoreacter.py`:
-Run AutoREACTER with a JSON input file:
+```python
+import AutoREACTER as arx
-```bash
-python examples/example_1.py -i examples/example_1_inputs_count_mode.json
-```
+arx.run("input.json")
-or:
+arx.select_reactions()
+arx.select_non_reactants()
-```bash
-python examples/example_1.py --input examples/example_1_inputs_count_mode.json
+arx.prepare_reactions()
+
+session = arx.session()
+print(f"Review generated images in: {session.images_dir}")
+
+input("Press Enter to continue...")
+
+arx.process()
```
-View available commands and options:
+Run it with:
```bash
-python examples/example_1.py --help
+python run_autoreacter.py
```
-## Interactive notebook workflow
+Example JSON input files and complete workflows are available in the
+[`examples`](https://github.com/NanoCIPHER-Lab/AutoREACTER/tree/main/examples)
+directory.
-AutoREACTER can also be used through Jupyter notebooks for an interactive, visual, step-by-step workflow. This mode is recommended for inspecting monomers, functional groups, reaction templates, and generated LAMMPS setup files before running larger workflows.
+For users working directly from the source repository, the included example
+runner can also be used:
-See the examples directory for notebooks and usage notes:
-
-**[examples/README.md](https://autoreacter.org/getting_started_source_installation.html)**
+```bash
+python examples/example_1.py -i examples/polyamide_count_mode_basic.json
+```
-## Help and support
+## Help and Support
-If you find a bug, need a new reaction type, or want to request additional force-field support, please open an issue:
+For bugs, reaction requests, or force-field support requests, please open an issue:
**[AutoREACTER Issues](https://github.com/NanoCIPHER-Lab/AutoREACTER/issues)**
## License
-AutoREACTER is released under the **MIT License**. See [LICENSE](https://github.com/NanoCIPHER-Lab/AutoREACTER/blob/main/LICENSE.md) for details.
+AutoREACTER is released under the **MIT License**.
+See [LICENSE](https://github.com/NanoCIPHER-Lab/AutoREACTER/blob/main/LICENSE.md).
diff --git a/docs/source/Draft.md b/docs/drafts/Draft.md
similarity index 100%
rename from docs/source/Draft.md
rename to docs/drafts/Draft.md
diff --git a/docs/source/_static/Overview - Copy.png b/docs/source/_static/Overview - Copy.png
new file mode 100644
index 00000000..af88afae
Binary files /dev/null and b/docs/source/_static/Overview - Copy.png differ
diff --git a/docs/source/_static/Overview.png b/docs/source/_static/Overview.png
index af88afae..b9e454af 100644
Binary files a/docs/source/_static/Overview.png and b/docs/source/_static/Overview.png differ
diff --git a/docs/source/_static/deep_search_figures/edge_monomers.png b/docs/source/_static/deep_search_figures/edge_monomers.png
new file mode 100644
index 00000000..f9cb2bee
Binary files /dev/null and b/docs/source/_static/deep_search_figures/edge_monomers.png differ
diff --git a/docs/source/_static/deep_search_figures/edge_templates_template.png b/docs/source/_static/deep_search_figures/edge_templates_template.png
new file mode 100644
index 00000000..b9fbdbbc
Binary files /dev/null and b/docs/source/_static/deep_search_figures/edge_templates_template.png differ
diff --git a/docs/source/_static/deep_search_figures/input_monomers.png b/docs/source/_static/deep_search_figures/input_monomers.png
new file mode 100644
index 00000000..a5eec8da
Binary files /dev/null and b/docs/source/_static/deep_search_figures/input_monomers.png differ
diff --git a/docs/source/_static/deep_search_figures/missed_templates_template.png b/docs/source/_static/deep_search_figures/missed_templates_template.png
new file mode 100644
index 00000000..d66b9d0f
Binary files /dev/null and b/docs/source/_static/deep_search_figures/missed_templates_template.png differ
diff --git a/docs/source/_static/deep_search_figures/w_deep_search.png b/docs/source/_static/deep_search_figures/w_deep_search.png
new file mode 100644
index 00000000..297be9f8
Binary files /dev/null and b/docs/source/_static/deep_search_figures/w_deep_search.png differ
diff --git a/docs/source/_static/deep_search_figures/w_o_deep_search.png b/docs/source/_static/deep_search_figures/w_o_deep_search.png
new file mode 100644
index 00000000..99c46ab1
Binary files /dev/null and b/docs/source/_static/deep_search_figures/w_o_deep_search.png differ
diff --git a/docs/source/_static/loop_figures/glycine_templates_template.png b/docs/source/_static/loop_figures/glycine_templates_template.png
new file mode 100644
index 00000000..dda2d9b7
Binary files /dev/null and b/docs/source/_static/loop_figures/glycine_templates_template.png differ
diff --git a/docs/source/_static/loop_figures/loop_monomers.png b/docs/source/_static/loop_figures/loop_monomers.png
new file mode 100644
index 00000000..0b8d3960
Binary files /dev/null and b/docs/source/_static/loop_figures/loop_monomers.png differ
diff --git a/docs/source/_static/loop_figures/loop_templates_template.png b/docs/source/_static/loop_figures/loop_templates_template.png
new file mode 100644
index 00000000..55da3f72
Binary files /dev/null and b/docs/source/_static/loop_figures/loop_templates_template.png differ
diff --git a/docs/source/_static/loop_figures/wo_loop_templates_template.png b/docs/source/_static/loop_figures/wo_loop_templates_template.png
new file mode 100644
index 00000000..f99eca03
Binary files /dev/null and b/docs/source/_static/loop_figures/wo_loop_templates_template.png differ
diff --git a/docs/source/_static/nano_cipher_logo.JPG b/docs/source/_static/nano_cipher_logo.JPG
new file mode 100644
index 00000000..e8eb7496
Binary files /dev/null and b/docs/source/_static/nano_cipher_logo.JPG differ
diff --git a/docs/source/_static/wild_cards_figures/REACTER_wildcards_Diels-Alder-example_ed.avif b/docs/source/_static/wild_cards_figures/REACTER_wildcards_Diels-Alder-example_ed.avif
new file mode 100644
index 00000000..0f0dd516
Binary files /dev/null and b/docs/source/_static/wild_cards_figures/REACTER_wildcards_Diels-Alder-example_ed.avif differ
diff --git a/docs/source/_static/wild_cards_figures/prior_templates_template.png b/docs/source/_static/wild_cards_figures/prior_templates_template.png
new file mode 100644
index 00000000..85f396ee
Binary files /dev/null and b/docs/source/_static/wild_cards_figures/prior_templates_template.png differ
diff --git a/docs/source/_static/wild_cards_figures/wild_cards-templates_template.png b/docs/source/_static/wild_cards_figures/wild_cards-templates_template.png
new file mode 100644
index 00000000..2d6b0924
Binary files /dev/null and b/docs/source/_static/wild_cards_figures/wild_cards-templates_template.png differ
diff --git a/docs/source/advanced_options.md b/docs/source/advanced_options.md
new file mode 100644
index 00000000..27e62489
--- /dev/null
+++ b/docs/source/advanced_options.md
@@ -0,0 +1,266 @@
+# Advanced Options
+
+AutoREACTER gives users control over advanced workflow behavior through the input JSON file. These options are intended for users who want to control reaction-product iteration, LAMMPS wildcard template generation, and output-file placement.
+
+The following user-facing advanced options are currently available:
+
+
Controls where AutoREACTER writes generated output files.
+
+
+
+
+Default behavior:
+
+```json
+{
+ "reaction_iteration_depth": 5,
+ "wildcards": false
+}
+```
+
+By default, `output_dir` does not need to be provided. If it is omitted, AutoREACTER writes outputs next to the input JSON file using:
+
+```text
+AutoREACTER_outputs/
+```
+
+
Important: These options can change the number of generated reaction products, reaction templates, LAMMPS map files, and output locations.
+
+(reaction_iteration_depth)=
+## reaction_iteration_depth
+
+Inside AutoREACTER, reaction generation can be performed iteratively. First, AutoREACTER detects all possible reactions from the initial monomers and generates the corresponding products without looping. After that, if reaction iteration is enabled, AutoREACTER takes the generated products and checks whether they can react with any of the other monomers or products.
+
+This process continues until the maximum number of iterations is reached or until no new products are generated from any combination.
+
+The default value is `5`, which means AutoREACTER will attempt to generate products through up to five reaction iterations.
+
+This step is implemented because reaction templates are generated based on both monomers and products. In multistage reactions, products from the first stage can react with other monomers or products to generate new products and new reaction templates.
+
+If this option is disabled, AutoREACTER will only generate products from the initial monomers and will not check whether those products can react further. If products are not checked for further reactions, some reaction templates may be missed. In the case of small molecules or monomers, the length of the template can be limited by the size of the monomer, so polymerization propagation may be limited to dimers.
+
+You can set this variable to `false` so AutoREACTER will not enter the reaction-iteration loop. You can also use a non-negative integer to set the maximum number of iterations. This helps control reaction explosion. If five iterations are not enough to generate all possible products, you can increase the number of iterations.
+
+
Important: Setting reaction_iteration_depth too high can generate a large number of products and reaction templates.
+
+The examples below show this behavior for an epoxy-amine reaction and glycine polymerization.
+
+For the epoxy-amine example, the input monomers are:
+
+```json
+{
+ "monomers": [
+ {
+ "name": "bisphenol_A_diglycidyl_ether",
+ "smiles": "CC(C)(c1ccc(OCC2CO2)cc1)c1ccc(OCC2CO2)cc1"
+ },
+ {
+ "name": "1,5-diaminopentane",
+ "smiles": "NCCCCCN"
+ }
+ ]
+}
+```
+
+```{image} _static/loop_figures/loop_monomers.png
+:alt: reaction_iteration_epoxy_amine_monomers
+:width: 100%
+:align: center
+```
+
+Without `reaction_iteration_depth` enabled, the reaction templates will be generated as shown below.
+
+```{image} _static/loop_figures/wo_loop_templates_template.png
+:alt: reaction_iteration_without_loop_templates
+:width: 100%
+:align: center
+```
+
+With `reaction_iteration_depth` enabled, the reaction templates will be generated as shown below.
+
+```{image} _static/loop_figures/loop_templates_template.png
+:alt: reaction_iteration_with_loop_templates
+:width: 100%
+:align: center
+```
+
+For glycine polymerization, the input monomer is:
+
+```json
+{
+ "monomers": [
+ {
+ "name": "glycine",
+ "smiles": "NCC(=O)O"
+ }
+ ]
+}
+```
+
+```{image} _static/loop_figures/glycine_templates_template.png
+:alt: glycine_polymerization_reaction_templates
+:width: 100%
+:align: center
+```
+
+```html
+
Important: wildcards requires LAMMPS 22 July 2025 or later. The wildcard feature is not available in earlier versions of LAMMPS.
+
+```{image} _static/wild_cards_figures/REACTER_wildcards_Diels-Alder-example_ed.avif
+:alt: REACTER_wildcards_Diels-Alder-example
+:width: 100%
+:align: center
+```
+
+More information about REACTER wildcards can be found here:
+
+* [Type Label Framework for Bonded Force Fields in LAMMPS](https://doi.org/10.1021/acs.jpcb.3c08419)
+* [REACTER website](https://www.reacter.org/)
+
+In LAMMPS bond/react, wildcards can be used for atoms in a dihedral whose atom types are allowed to vary. If one or more atoms in the dihedral are represented by wildcards, LAMMPS infers the appropriate dihedral type from the resulting atom types during the reaction.
+
+When `wildcards` is enabled, AutoREACTER generates reaction templates that use wildcard atom labels in the map file where appropriate. Multiple atoms within a dihedral may be represented by wildcards. This can reduce the number of templates required for reactions in which the local atom-type environment varies.
+
+Because wildcard support requires a relatively recent version of LAMMPS, `wildcards` is disabled by default. Users with LAMMPS 22 July 2025 or later can enable it by setting:
+
+```json
+{
+ "wildcards": true
+}
+```
+
+Using wildcards can reduce the number of generated templates and simplify reactive simulations. However, templates without wildcards may be useful when explicit atom types are needed to control specific reactions or reaction sites.
+
+For normal vinyl reactions, AutoREACTER may need three templates without wildcards. With wildcard support enabled, the same simulation can often be performed using only two templates.
+
+See the styrene vinyl reaction example below.
+
+The following templates are generated without wildcards.
+
+```{image} _static/wild_cards_figures/prior_templates_template.png
+:alt: styrene_templates_without_wildcards
+:width: 100%
+:align: center
+```
+
+The following templates are generated with wildcards enabled.
+
+```{image} _static/wild_cards_figures/wild_cards-templates_template.png
+:alt: styrene_templates_with_wildcards
+:width: 100%
+:align: center
+```
+
+
+(output_dir)=
+## output_dir
+
+`output_dir` is an optional top-level input setting that controls where AutoREACTER writes generated output files.
+
+If `output_dir` is not provided, AutoREACTER writes outputs next to the input JSON file using the default folder structure:
+
+```text
+AutoREACTER_outputs/
+```
+
+Example:
+
+```json
+{
+ "simulation_name": "Polyamide_Count_Mode_Basic",
+ "force_field": "PCFF",
+ "output_dir": "my_outputs",
+ "simulations": [
+ {
+ "tag": "10k_300K",
+ "temperature": 300,
+ "density": 0.8,
+ "monomer_counts": {
+ "trimesoyl_chloride": 220,
+ "m_phenylenediamine": 330
+ }
+ }
+ ],
+ "monomers": [
+ {
+ "name": "trimesoyl_chloride",
+ "smiles": "ClC(=O)c1cc(cc(c1)C(Cl)=O)C(Cl)=O"
+ },
+ {
+ "name": "m_phenylenediamine",
+ "smiles": "C1=CC(=CC(=C1)N)N"
+ }
+ ]
+}
+```
+
+For this example, AutoREACTER writes output to:
+
+```text
+my_outputs
+```
+
+Relative paths are resolved relative to the input JSON file location. Absolute paths are used directly.
+
+Examples:
+
+```json
+{
+ "output_dir": "AutoREACTER_outputs/polyamide_test"
+}
+```
+
+```json
+{
+ "output_dir": "/home/user/AutoREACTER_runs/polyamide_test"
+}
+```
+
+On Windows or WSL, Windows-style paths can also be used:
+
+```json
+{
+ "output_dir": "C:/Users/Janitha/Documents/AutoREACTER_runs/polyamide_test"
+}
+```
+
+
Important: If the output directory already exists, AutoREACTER may overwrite or clear generated workflow files from the previous run.
\ No newline at end of file
diff --git a/docs/source/api_reference.md b/docs/source/api_reference.md
index 0552b4c5..023e0e99 100644
--- a/docs/source/api_reference.md
+++ b/docs/source/api_reference.md
@@ -8,7 +8,7 @@ AutoREACTER accepts an input file path, initializes the session, and exposes met
---
-### Import AutoREACTER
+## Import AutoREACTER
```python
import AutoREACTER as arx
@@ -20,7 +20,7 @@ Users can import AutoREACTER as `arx` so the code is shorter and the long module
## Required Functions in Order
-**Important**: Users must run these functions in the following order.
+Important: Users must run these functions in the following order.
### 1. Run AutoREACTER
@@ -63,6 +63,7 @@ Optional function available after `arx.select_reactions()`:
```python
arx.show_non_reactants()
```
+
---
### 3. Select Non-Reactants
@@ -80,7 +81,7 @@ A molecule can be treated as a non-reactant if:
* The molecule does not qualify as a monomer.
* The molecule qualifies as a monomer, but the user chooses not to include it in any selected reaction.
-
+---
### 4. Prepare Reactions
@@ -92,12 +93,12 @@ This function prepares reaction templates from the selected reactions for downst
This function marks the reaction-template preparation stage as complete.
-After this function, users can optionally visualize reaction templates.
+After this function, users can optionally visualize reaction templates before final processing.
Optional function available after `arx.prepare_reactions()`:
```python
-arx.show_reaction_templates()
+arx.show_reaction_templates("template")
```
---
@@ -108,9 +109,9 @@ arx.show_reaction_templates()
arx.process()
```
-This function executes the back-half of the AutoREACTER pipeline in one shot.
+This function executes the final AutoREACTER processing steps after reaction templates have been prepared.
-According to the docstring, this method runs reaction template preparation, 3D geometry setup, force-field generation through the LUNAR API, REACTER file building, and LAMMPS simulation writing.
+It performs 3D geometry setup, force-field generation through the LUNAR API, REACTER file building, and LAMMPS simulation writing.
This function should be run only after the required stages are completed:
@@ -137,6 +138,9 @@ arx.select_non_reactants()
arx.prepare_reactions()
+# Optional review step before final processing
+arx.show_reaction_templates("template")
+
arx.process()
```
@@ -264,20 +268,26 @@ Usage:
arx.show_reaction_templates()
```
-This function returns an image grid visualizing the reaction templates.
+This function returns an image grid visualizing the generated reaction templates.
+
+By default, AutoREACTER shows the full reaction template:
-The `highlight_type` parameter can be used to visualize different parts of the reaction templates. Default type is `"template"`
+```python
+arx.show_reaction_templates()
+```
+
+Users can also pass a template-highlight mode as a positional argument:
```python
-arx.show_reaction_templates(highlight_type="template")
-arx.show_reaction_templates(highlight_type="edge")
-arx.show_reaction_templates(highlight_type="delete")
-arx.show_reaction_templates(highlight_type="initiators")
+arx.show_reaction_templates("template")
+arx.show_reaction_templates("edge")
+arx.show_reaction_templates("delete")
+arx.show_reaction_templates("initiators")
```
#### Reaction Template Visualization Options
-Visualize reaction templates with different highlighting options by setting the `highlight_type` parameter to one of the following values:
+Visualize reaction templates with different highlighting options by passing one of the following values:
* `template`: Highlights all structural changes in the reaction templates.
* `edge`: Highlights edge atoms of the templates.
@@ -287,7 +297,7 @@ Visualize reaction templates with different highlighting options by setting the
The default value is:
```python
-highlight_type="template"
+"template"
```
Returns:
@@ -296,18 +306,20 @@ Returns:
Image
```
+---
## Summary of Public APIs
-| API | Required or Optional | When to Run |
-| ------------------------------- | -------------------: | ---------------------------------- |
-| `arx.run("input.json")` | Required | First |
-| `arx.select_reactions()` | Required | After `arx.run(...)` |
-| `arx.select_non_reactants()` | Required | After `arx.select_reactions()` |
-| `arx.prepare_reactions()` | Required | After `arx.select_non_reactants()` |
-| `arx.process()` | Required | Final required step |
-| `arx.show_molecules()` | Optional | After `arx.run(...)` |
-| `arx.show_functional_groups()` | Optional | After `arx.run(...)` |
-| `arx.show_reactions()` | Optional | After `arx.run(...)` |
-| `arx.show_non_reactants()` | Optional | After `arx.select_reactions()` |
-| `arx.show_reaction_templates()` | Optional | After `arx.prepare_reactions()` |
+| API | Required or Optional | When to Run |
+| ------------------------------- | -------------------: | ------------------------------------------------ |
+| `arx.run("input.json")` | Required | First |
+| `arx.select_reactions()` | Required | After `arx.run(...)` |
+| `arx.select_non_reactants()` | Required | After `arx.select_reactions()` |
+| `arx.prepare_reactions()` | Required | After `arx.select_non_reactants()` |
+| `arx.process()` | Required | Final required step |
+| `arx.show_molecules()` | Optional | After `arx.run(...)` |
+| `arx.show_functional_groups()` | Optional | After `arx.run(...)` |
+| `arx.show_reactions()` | Optional | After `arx.run(...)` |
+| `arx.show_non_reactants()` | Optional | After `arx.select_reactions()` |
+| `arx.show_reaction_templates()` | Optional | After `arx.prepare_reactions()`, before `process()` |
+
diff --git a/docs/source/change_log.md b/docs/source/change_log.md
index 62a703d5..34c1df9d 100644
--- a/docs/source/change_log.md
+++ b/docs/source/change_log.md
@@ -22,6 +22,62 @@ This serves two purposes:
At release time, you can move the Unreleased section changes into a new release version section.
-->
+# Change Log
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+---
+
+## [1.0.0] - 2026-09-10
+
+### Added
+
+- **Reaction progression workflow:** Added iterative reaction-product generation so AutoREACTER can generate templates from products formed in earlier reaction steps. This improves support for multistage reactions, copolymerizations, and small-molecule systems where the initial monomers alone are not sufficient to generate all required polymerization templates.
+- **Reaction iteration control:** Added `reaction_iteration_depth` to control how many reaction-product iterations are attempted. The default value is `5`, and the loop can be disabled by setting `reaction_iteration_depth` to `false`.
+- **Index-based functional-group detection:** Added index-based functional-group detection to track reactive atom positions more accurately during reaction progression.
+- **Index-based reaction detection:** Added index-based reaction detection so products generated in earlier reaction steps can be checked for additional valid reactions.
+- **RDKit/NetworkX reaction deduplication:** Added graph-based reaction deduplication to reduce redundant reaction pathways during reaction progression.
+- **LAMMPS template deduplication:** Added NetworkX-based LAMMPS template deduplication using generated pre-reaction templates, post-reaction templates, and map files.
+- **Wildcard template support:** Added support for LAMMPS wildcard map generation to reduce duplicate edge-template cases when using supported LAMMPS versions.
+- **Expanded reaction libraries:** Added and reorganized polymer reaction libraries for epoxy-amine, polyamide, polyester, polycarbonate, polysiloxane, polyurea, polyurethane, vinyl, and related polymerization chemistries.
+- **Expanded functional-group libraries:** Added modular functional-group libraries for nitrogen, oxygen, carboxyl/carbonyl, vinyl/alkene, sulfur, silicon, ring, active-center, and mixed AB-type groups.
+- **TFE/vinyl support:** Added support for tetrafluoroethylene and additional vinyl polymerization workflows.
+- **Public API session access:** Added `arx.session()` to expose the active AutoREACTER session for workflow inspection.
+- **Unit tests:** Added broader unit-test coverage for input parsing, ARX CLI behavior, reaction preparation, LUNAR client utilities, force-field wrapper components, walkers, and LAMMPS writers.
+- **Documentation pages:** Added new documentation pages for advanced options and template deduplication.
+
+### Changed
+
+- **Input parser:** Refactored input parsing and validation, including cleaner handling of workflow options, simulation setup fields, force-field aliases, and input schema checks.
+- **Reaction-library organization:** Refactored reaction libraries into dedicated modules and registries instead of relying on one monolithic reaction-library file.
+- **Functional-group organization:** Refactored functional-group definitions into modular registry-based libraries.
+- **Reaction preparation:** Refactored reaction preparation to support reaction progression, deduplication, inactive-template filtering, and clearer error handling.
+- **REACTER file handling:** Simplified REACTER file metadata storage by storing LAMMPS molecule paths directly on monomer entries and template/map paths directly on reaction metadata.
+- **LAMMPS writers:** Updated LAMMPS input writers to use the refactored REACTER metadata and filter inactive reaction templates.
+- **3D molecule preparation:** Improved 3D embedding and repair handling for congested or difficult polymer structures.
+- **Examples:** Replaced older test-style example JSON files with cleaner v0.3 example inputs.
+- **Documentation structure:** Reorganized documentation into clearer user-facing and developer-facing pages.
+
+### Fixed
+
+- Fixed reaction progression issues where products from earlier steps were not correctly reused for later reaction detection.
+- Fixed index-alignment issues during functional-group and reaction detection.
+- Fixed radical handling for vinyl polymerization products and reaction deduplication.
+- Fixed duplicate-template detection behavior for both RDKit-level reaction metadata and LAMMPS-level template files.
+- Fixed handling of inactive or duplicate reaction templates so they are skipped in later workflow stages.
+- Fixed empty-reaction cases so AutoREACTER raises clearer errors when no valid reaction instances are found.
+- Fixed LAMMPS molecule/template file path handling after the REACTER metadata refactor.
+- Fixed cache staging behavior during output directory preparation.
+- Fixed documentation heading and toctree issues for cleaner Sphinx builds.
+
+### Removed
+
+- Removed legacy compatibility shims.
+- Removed unused placeholder detector, fragment-comparison, and legacy library files.
+- Removed older cluttered example JSON files in favor of focused v0.3 examples.
+
## [0.2.3] - [2026-06-24]
### Added
diff --git a/docs/source/contact.md b/docs/source/contact.md
index e3904180..35ad4da1 100644
--- a/docs/source/contact.md
+++ b/docs/source/contact.md
@@ -1,4 +1,4 @@
-## Contact us
+# Contact us
For questions, suggestions, or bug reports:
@@ -6,4 +6,4 @@ For questions, suggestions, or bug reports:
- Email: [jmahanth@stevens.edu](mailto:jmahanth@stevens.edu)
- Website: [https://www.nanocipher.org/](https://www.nanocipher.org/)
-Please include relevant details when reporting issues.
\ No newline at end of file
+Please include relevant details when reporting issues.
diff --git a/docs/source/getting-started.md b/docs/source/getting-started.md
index f5d94453..9d6d4f89 100644
--- a/docs/source/getting-started.md
+++ b/docs/source/getting-started.md
@@ -2,7 +2,7 @@
Choose the installation guide that matches your setup.
-**Note:** Before starting, we highly recommend reviewing the **[API Reference](api_reference.md)** to understand the core functions and workflow of AutoREACTER. This will help you navigate the installation and usage process more effectively.
+**Note:** Before starting, it is highly recommended to review the **[API Reference](api_reference.md)** to understand the core functions and workflow of AutoREACTER. This will help you navigate the installation and usage process more effectively.
## {doc}`Getting Started - Source Installation Guide `
@@ -11,3 +11,12 @@ Use this guide if you want to clone the AutoREACTER GitHub repository, install t
## {doc}`Getting Started - Pip Installation Guide `
Use this guide if you want to install AutoREACTER directly from PyPI and run your first workflow.
+
+
+```{toctree}
+:hidden:
+
+getting_started_pip_installation
+getting_started_source_installation
+```
+
diff --git a/docs/source/getting_started_pip_installation.md b/docs/source/getting_started_pip_installation.md
index 123174cf..467e4156 100644
--- a/docs/source/getting_started_pip_installation.md
+++ b/docs/source/getting_started_pip_installation.md
@@ -1,10 +1,10 @@
-## Getting Started - Pip Installation Guide
+# Getting Started - Pip Installation Guide
This guide explains how to install AutoREACTER from PyPI and run your first workflow.
-### Step 1: Create a Python Virtual Environment
+## Step 1: Create a Python Virtual Environment
-We recommend using a virtual environment so AutoREACTER and its dependencies do not interfere with your system Python installation.
+Using a virtual environment is recommended so AutoREACTER and its dependencies do not interfere with your system Python installation.
```bash
python -m venv arx_env
@@ -24,7 +24,7 @@ On Windows:
arx_env\Scripts\activate
```
-### Step 2: Install AutoREACTER
+## Step 2: Install AutoREACTER
```bash
python -m pip install -U pip
@@ -33,7 +33,7 @@ python -m pip install AutoREACTER
This will install AutoREACTER and its required Python dependencies.
-#### Step 2.1: Download and Prepare LUNAR (Prerequisite)
+### Step 2.1: Download and Prepare LUNAR (Prerequisite)
AutoREACTER requires the LUNAR package to handle atom typing. You must have this downloaded before running any examples.
@@ -43,7 +43,7 @@ Note the Path: Keep track of the full directory path where LUNAR is saved on you
Keep track of the full directory path where LUNAR is saved on your computer. During your first run, AutoREACTER will prompt you to enter this path.
-### Step 3: Run AutoREACTER
+## Step 3: Run AutoREACTER
You can either run by downloading [run_AutoREACTER.py](https://github.com/NanoCIPHER-Lab/AutoREACTER/blob/main/examples/run_AutoREACTER.py) with [example_1_inputs_count_mode.json](https://github.com/NanoCIPHER-Lab/AutoREACTER/blob/main/examples/example_1_inputs_count_mode.json):
diff --git a/docs/source/getting_started_source_installation.md b/docs/source/getting_started_source_installation.md
index 304aff44..566c6f8f 100644
--- a/docs/source/getting_started_source_installation.md
+++ b/docs/source/getting_started_source_installation.md
@@ -1,13 +1,13 @@
-## Getting Started - Source Installation Guide
+# Getting Started - Source Installation Guide
This guide will guide you through setting up your environment, installing the required dependencies, and running reaction modeling setup for REACTER.
-AutoREACTER relies heavily on cheminformatics libraries like RDKit, and numeric computing libraries like Pandas, so we strongly recommend using **Conda** to manage your Python environment.
+AutoREACTER relies heavily on cheminformatics libraries like RDKit, and numeric computing libraries like Pandas, so using **Conda** is strongly recommended to manage your Python environment.
---
-### Step 1: Clone the Repository
+## Step 1: Clone the Repository
Download the AutoREACTER source code to your computer using Git. Open your terminal and run:
@@ -16,7 +16,7 @@ git clone https://github.com/NanoCIPHER-Lab/AutoREACTER.git
cd AutoREACTER
```
-### Step 2: Set Up the Conda Environment
+## Step 2: Set Up the Conda Environment
You need to create an environment containing Python 3.13(recommend) and the python libraries AutoREACTER needs to function.
@@ -41,7 +41,7 @@ python -m pip install -U pip
python -m pip install -r requirements.txt
```
-#### Step 2.1: Download and Prepare LUNAR (Prerequisite)
+### Step 2.1: Download and Prepare LUNAR (Prerequisite)
AutoREACTER requires the LUNAR package to handle atom typing. You must have this downloaded before running any examples.
@@ -49,14 +49,14 @@ Download LUNAR from: [https://github.com/CMMRLab/LUNAR](https://github.com/CMMRL
Note the Path: Keep track of the full directory path where LUNAR is saved on your computer (e.g., /home/user/software/LUNAR). During your first run, AutoREACTER will prompt you to enter this directory path. The path is then saved locally within the AutoREACTER.
-### Step 3: Run Your First Example
+## Step 3: Run Your First Example
AutoREACTER provides two different ways to build your LAMMPS reaction files:
- An interactive, visual Jupyter Notebook.
- A fast, Command-Line Interface (CLI)
-#### Option A: The Interactive Notebook
+### Option A: The Interactive Notebook
If you want to see exactly how AutoREACTER detects functional groups, maps templates, and handles non-reactive monomers, the Jupyter Notebook is the best place to start.
@@ -102,7 +102,7 @@ Run the cells sequentially. The notebook will guide you step-by-step.
**Interactive prompt note:** The notebook may ask what to do with monomers that do not participate in any detected reaction. For example:
-#### Option B: The Automated CLI
+### Option B: The Automated CLI
If want to generate the LAMMPS files quickly, you can run AutoREACTER directly from the terminal.
@@ -116,7 +116,7 @@ python examples/run_AutoREACTER.py -i examples/example_1_inputs_count_mode.json
AutoREACTER parses the JSON, processes the chemistry, and exports all LAMMPS scripts to a new directory named after your specific simulation.
-### Step 4: Run AutoREACTER
+## Step 4: Run AutoREACTER
You can either run by downloading [run_AutoREACTER.py](https://github.com/NanoCIPHER-Lab/AutoREACTER/blob/main/examples/run_AutoREACTER.py) with [`example_1_inputs_count_mode.json`](https://github.com/NanoCIPHER-Lab/AutoREACTER/blob/main/examples/example_1_inputs_count_mode.json):
diff --git a/docs/source/index.rst b/docs/source/index.rst
index d3c289ad..0fa82a99 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -7,15 +7,18 @@
:width: 750px
:align: center
-AutoREACTER documentation
+AutoREACTER Documentation
=========================
Welcome to the AutoREACTER documentation.
AutoREACTER is a tool for automated reaction-based molecular system generation.
-This guide covers setup, input configuration, and supported reactions.
+This documentation is organized into a user guide and a developer guide.
-**Note: AutoREACTER is currently in v0.2.2-beta and under active development. APIs, configuration schemas, and core functionality may change or break without notice as we expand reaction library and force field support**
+**Note: AutoREACTER is currently under active development. APIs, configuration schemas, and core functionality may change or break without notice as reaction-library and force-field support expand.**
+
+User Guide
+----------
.. toctree::
:maxdepth: 1
@@ -23,9 +26,19 @@ This guide covers setup, input configuration, and supported reactions.
overview.md
getting-started.md
- api_reference.md
input-configuration.md
+ advanced_options.md
supported-reactions.md
supported-force-fields.md
+ api_reference.md
+
+Developer Guide
+---------------
+
+.. toctree::
+ :maxdepth: 1
+ :caption: Developer Guide:
+
+ template-deduplication.md
change_log.md
- contact.md
+ contact.md
\ No newline at end of file
diff --git a/docs/source/input-configuration.md b/docs/source/input-configuration.md
index 2892d721..69b23385 100644
--- a/docs/source/input-configuration.md
+++ b/docs/source/input-configuration.md
@@ -1,4 +1,4 @@
-## Input Configuration.
+# Input Configuration.
The `input.json` file is the one and only input for your AutoREACTER workflow.
It tells the system:
@@ -15,7 +15,7 @@ There are two primary ways to define your system composition: **Ratio Mode** and
---
-### 1. Global Settings & Monomers
+## 1. Global Settings & Monomers
Regardless of which mode you use, every `input.json` needs global settings and a list of your chemical building blocks.
@@ -24,11 +24,11 @@ Regardless of which mode you use, every `input.json` needs global settings and a
---
-### 2. Defining Simulations: Ratio Mode vs. Count Mode
+## 2. Defining Simulations: Ratio Mode vs. Count Mode
When defining the `simulations` array, you have to decide how you want to calculate the number of molecules in the simulation box.
-#### Option A: Ratio Mode (Target Atom Count)
+### Option A: Ratio Mode (Target Atom Count)
Use Ratio Mode when you know the total size of the simulation you want to run (e.g., ~10,000 atoms) and the ratio of your molecules, but you don't want to calculate the exact number of individual molecules by hand.
@@ -80,7 +80,7 @@ AutoREACTER will automatically calculate the correct number of molecules to hit
}
```
-#### Option B: Count Mode (Exact Molecule Count)
+### Option B: Count Mode (Exact Molecule Count)
Use Count Mode when you know number of molecules instead of number of atoms. Instead of providing a total atom target, you explicitly define the exact number of each molecule using `monomer_counts`.
@@ -130,7 +130,7 @@ Use Count Mode when you know number of molecules instead of number of atoms. Ins
---
-### 3. Simulation Parameters Breakdown
+## 3. Simulation Parameters Breakdown
For each object inside the `simulations` list, you must define:
@@ -141,7 +141,7 @@ For each object inside the `simulations` list, you must define:
---
-### 4. Specifying the monomers or molecules.
+## 4. Specifying the monomers or molecules.
In the monomers section, you define each molecule as a dictionary entry.
Each monomer (or molecule) must include:
@@ -167,4 +167,12 @@ Each monomer (or molecule) must include:
**IMPORTANT**: The `monomers` section must remain consistent with the `monomer_counts` defined in each simulation. All name tags must match exactly, and every monomer listed must have a corresponding count in each simulation otherwise AutoREACTER will **raise an error** before proceeding with the chemistry.
+For advanced workflow controls such as reaction iteration depth, deep-search deduplication, wildcard template generation, duplicate-template removal, and optional second-stage reaction writing, see the {doc}`advanced_options` page.
+
+```{toctree}
+:hidden:
+
+advanced_options
+```
+
**Note:** You can use [SMILES Generator / Checker](https://www.cheminfo.org/flavor/malaria/Utilities/SMILES_generator___checker/index.html) to generate valid SMILES strings. If SMILES strings are incorrect AutoREACTER will **raise an error** before proceeding.
diff --git a/docs/source/overview.md b/docs/source/overview.md
index ef53e02d..8a21ff59 100644
--- a/docs/source/overview.md
+++ b/docs/source/overview.md
@@ -4,7 +4,7 @@
AutoREACTER is a Python-based toolkit for managing and automating reaction modeling in LAMMPS, for REACTER simulations. It provides a streamlined workflow for generating LAMMPS input files from simple chemical descriptions, eliminating the need for manual template preparation and atom typing.
-**Note: AutoREACTER is currently in v{{ autoreacter_version }}. It is under active development, and APIs or functionality may change as we continue to expand the reaction library and force field support.**
+**Note: AutoREACTER is currently in v{{ autoreacter_version }}. It is under active development, and APIs or functionality may change as the reaction library and force field support continue to expand.**
```{image} _static/Overview.png
:alt: AutoREACTER workflow overview
diff --git a/docs/source/supported-force-fields.md b/docs/source/supported-force-fields.md
index 2b5cebec..35617c48 100644
--- a/docs/source/supported-force-fields.md
+++ b/docs/source/supported-force-fields.md
@@ -1,4 +1,4 @@
-## Supported Force Fields
+# Supported Force Fields
All classical force fields integrated into **AutoREACTER** are processed via the [LUNAR][LUNAR_GITHUB] package.
@@ -11,4 +11,4 @@ All classical force fields integrated into **AutoREACTER** are processed via the
**Note: Proper installation and path configuration for [LUNAR][LUNAR_GITHUB] is required in `AutoREACTER` to utilize these force fields.**
-[LUNAR_GITHUB]: https://github.com/CMMRLab/LUNAR
\ No newline at end of file
+[LUNAR_GITHUB]: https://github.com/CMMRLab/LUNAR
diff --git a/docs/source/supported-reactions.md b/docs/source/supported-reactions.md
index bc2ff2f6..5af2e302 100644
--- a/docs/source/supported-reactions.md
+++ b/docs/source/supported-reactions.md
@@ -1,110 +1,224 @@
-## Supported Reactions
+# 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.
---
-### 1. Polyesterification
+## 1. Polyesterification
These reactions form ester linkages (`-COO-`) and typically release water (`H₂O`), alcohols (`R-OH`), or hydrogen halides (e.g., `HCl`) as byproducts.
-* **Hydroxy–Carboxylic Acid Polycondensation**
+* **Hydroxy Carboxylic Acid Polycondensation**
+* *Reactants:* `-OH` + `-COOH`
- * *Reactants:* `-OH` + `-COOH`
-* **Hydroxy Acid Halide Polycondensation**
+* **Hydroxy Carboxylic Acid and Hydroxy Carboxylic Acid Polycondensation**
+* *Reactants:* `-OH` + `-COOH` (Intermolecular)
- * *Reactants:* `-OH` + `-COX` where `X = Cl, Br, I`
-* **Diol + Di-Carboxylic Acid Polycondensation**
+* **Hydroxy Acid Halides Polycondensation**
+* *Reactants:* `-OH` + `-COX` where `X = Cl, Br, I`
- * *Reactants:* Two `-OH` groups + Two `-COOH` groups
-* **Diol + Di-Acid Halide Polycondensation**
+* **Hydroxy Acid Halides Hydroxy Acid Halides Polycondensation**
+* *Reactants:* `-OH` + `-COX` where `X = Cl, Br, I` (Intermolecular)
- * *Reactants:* Two `-OH` groups + Two `-COX` groups where `X = Cl, Br, I`
-* **Diol + Di-Carboxylic Ester Transesterification**
+* **Diol and Di-Carboxylic Acid Polycondensation**
+* *Reactants:* Two `-OH` groups + Two `-COOH` groups
+
+
+* **Diol and Di-Acid Halide Polycondensation**
+* *Reactants:* Two `-OH` groups + Two `-COX` groups where `X = Cl, Br, I`
+
+
+* **Diol and Di-Carboxylic Ester Polycondensation (Transesterification)**
+* *Reactants:* Two `-OH` groups + Two ester groups (`-COOR`)
+
- * *Reactants:* Two `-OH` groups + Two ester groups (`-COOR`)
---
-### 2. Polyamidation
+## 2. Polyamidation
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`
---
-### 3. Polyanhydride Formation
+## 3. Polyanhydride Formation
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`
---
-### 4. Polythioesterification
+## 4. Polythioesterification
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`
---
-### 5. Mixed Polyester/Polythioester Formation
+## 5. Mixed Polyester/Polythioester Formation
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).
diff --git a/docs/source/template-deduplication.md b/docs/source/template-deduplication.md
new file mode 100644
index 00000000..d17b5376
--- /dev/null
+++ b/docs/source/template-deduplication.md
@@ -0,0 +1,174 @@
+# Template Deduplication Options
+
+This page describes AutoREACTER options related to reaction-template comparison and duplicate-template removal. These options are mainly useful for developers or advanced users who need to understand how AutoREACTER decides whether two generated reaction templates are equivalent.
+
+The following template-deduplication options are available:
+
+
Important: These options affect reaction-template comparison and duplicate-template removal before LAMMPS files are written.
+
+---
+
+(deep_search)=
+## deep_search
+
+`deep_search` controls how strictly AutoREACTER compares reaction templates during RDKit/NetworkX-based deduplication.
+
+During early reaction-template generation, AutoREACTER has not yet assigned force-field atom types. At this stage, RDKit only knows the chemical graph, not the final force-field atom types. Because of this, two templates can look identical to RDKit even though they may later receive different atom types after force-field assignment.
+
+When `deep_search` is enabled, AutoREACTER extends the graph comparison by one additional neighbor beyond the normal template edge atoms. In practical terms, this means the comparison also checks the “5th atom” outside the normal reaction-template distance. This extra check helps prevent two templates from being incorrectly treated as duplicates when their immediate reaction core is the same but their outer chemical environment is different.
+
+This option is useful because atom typing is not performed during RDKit reaction detection. The extra graph environment acts as a substitute for missing atom-type information during deduplication.
+
+
Important: deep_search helps distinguish templates before atom typing, but it is not a full replacement for force-field atom typing.
+
+For example, two reaction templates may have the same reacting atoms and the same four-atom dihedral-distance template core, but differ at the edge, as shown in the example below.
+
+Using the following polyamidation reaction as an example:
+
+```json
+{
+ "monomers": [
+ {
+ "name": "4-methylheptanedioyl_dichloride",
+ "smiles": "CC(CCC(=O)Cl)CCC(=O)Cl"
+ },
+ {
+ "name": "heptanedioyl_dichloride",
+ "smiles": "O=C(Cl)CCCCCC(=O)Cl"
+ },
+ {
+ "name": "m-phenylenediamine",
+ "smiles": "C1=CC(=CC(=C1)N)N"
+ }
+ ]
+}
+```
+
+The input monomers are shown below.
+
+```{image} _static/deep_search_figures/input_monomers.png
+:alt: deep_search_demo_monomers
+:width: 100%
+:align: center
+```
+
+Without deep search, the reaction between `4-methylheptanedioyl_dichloride` and `m-phenylenediamine` can be treated as a duplicate of the reaction between `heptanedioyl_dichloride` and `m-phenylenediamine`, because the reaction core up to the normal dihedral-distance template region is identical.
+
+```{image} _static/deep_search_figures/w_o_deep_search.png
+:alt: without_deep_search_template_deduplication
+:width: 100%
+:align: center
+```
+
+With deep search enabled, the additional graph-environment check detects that the two templates are chemically different and does not treat them as duplicates.
+
+```{image} _static/deep_search_figures/w_deep_search.png
+:alt: with_deep_search_template_deduplication
+:width: 100%
+:align: center
+```
+
+However, there can still be edge cases, as shown in the example below.
+
+```json
+{
+ "monomers": [
+ {
+ "name": "branched_alkenyl_diacid_chloride",
+ "smiles": "C=CCC(CCCCCCC(CC=C)C(=O)Cl)C(=O)Cl"
+ },
+ {
+ "name": "m-phenylenediamine",
+ "smiles": "C1=CC(=CC(=C1)N)N"
+ },
+ {
+ "name": "extended_branched_alkenyl_diacid_chloride",
+ "smiles": "C/C=C/CC(CCCCCCC(C/C=C/C)C(=O)Cl)C(=O)Cl"
+ }
+ ]
+}
+```
+
+```{image} _static/deep_search_figures/edge_monomers.png
+:alt: deep_search_edge_case_monomers
+:width: 100%
+:align: center
+```
+
+In this case, the template contains double-bonded carbons. In one molecule, the double bond is part of a vinyl group, and the carbon bonded to the alkene chain will be typed as `c=1`, which is part of the template. In the other molecule, the corresponding carbon is bonded to another alkene chain. In this case, both carbons will be typed as `c=2`, including the carbon in the template.
+
+Even with deep search, AutoREACTER will only generate the template shown below.
+
+```{image} _static/deep_search_figures/edge_templates_template.png
+:alt: deep_search_edge_case_generated_template
+:width: 100%
+:align: center
+```
+
+The following template will be missed.
+
+```{image} _static/deep_search_figures/missed_templates_template.png
+:alt: deep_search_edge_case_missed_template
+:width: 100%
+:align: center
+```
+
+In this situation, deep search is not enough to detect the difference between the two templates, so the templates can still be treated as duplicates.
+
+With wildcard search enabled, this issue can be eliminated. See the {doc}`advanced_options` page.
+
+---
+
+(deduplicate_reaction_templates)=
+## deduplicate_reaction_templates
+
+AutoREACTER first generates all possible reactions and then generates LAMMPS reaction templates for those reactions. Some of these templates can be duplicates. The `deduplicate_reaction_templates` option controls whether AutoREACTER removes those duplicate templates.
+
+This deduplication step is one of the final steps in the AutoREACTER workflow. If `deduplicate_reaction_templates` is set to `false`, AutoREACTER will keep all generated templates, including duplicates.
+
+Keeping this option set to `true` is recommended so AutoREACTER only writes unique templates.
+
+
Important: If deduplicate_reaction_templates is set to false, AutoREACTER may write many redundant LAMMPS reaction templates.