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 @@ +

AutoREACTER logo

-

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: + + + + + + + + + + + + + + + + + + + + + + + + + + +
OptionDefaultPurpose
reaction_iteration_depth5Controls the maximum number of reaction-product iterations used during reaction progression.
wildcardstrueGenerates LAMMPS wildcard map sections to reduce redundant edge-template cases.
output_dirAutoREACTER_outputs/<simulation_name>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 + + wildcards + false + Generates LAMMPS wildcard map sections to reduce redundant edge-template cases. + +``` + +```json +{ + "reaction_iteration_depth": 5, + "wildcards": false +} +``` + +(wildcards)= +## wildcards + +

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: + + + + + + + + + + + + + + + + + + + + + +
OptionDefaultPurpose
deep_searchtrueControls stricter RDKit/NetworkX template deduplication before atom typing.
deduplicate_reaction_templatestrueRemoves duplicate reaction templates before writing LAMMPS files.
+ +Default JSON block: + +```json +{ + "deep_search": true, + "deduplicate_reaction_templates": true +} +``` + +

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.

+ +Example output: + +```text +Preparing templates and map file for reaction ID: 1 +Preparing templates and map file for reaction ID: 2 +Preparing templates and map file for reaction ID: 4 +Preparing templates and map file for reaction ID: 8 +Preparing templates and map file for reaction ID: 9 +Duplicate template disabled: RXN_8 +Duplicate template disabled: RXN_9 +``` diff --git a/examples/epoxy_amine_reaction_iteration.json b/examples/epoxy_amine_reaction_iteration.json new file mode 100644 index 00000000..8d200f4d --- /dev/null +++ b/examples/epoxy_amine_reaction_iteration.json @@ -0,0 +1,33 @@ +{ + "simulation_name": "Epoxy_Amine_Reaction_Iteration", + "force_field": "PCFF", + + "deep_search": true, + "reaction_iteration_depth": 5, + "wildcards": true, + "deduplicate_reaction_templates": true, + "write_second_reaction_stage": false, + + "simulations": [ + { + "tag": "10k_300K", + "temperature": 300, + "density": 1.0, + "monomer_counts": { + "bisphenol_A_diglycidyl_ether": 200, + "1_5_diaminopentane": 100 + } + } + ], + + "monomers": [ + { + "name": "bisphenol_A_diglycidyl_ether", + "smiles": "CC(C)(c1ccc(OCC2CO2)cc1)c1ccc(OCC2CO2)cc1" + }, + { + "name": "1_5_diaminopentane", + "smiles": "NCCCCCN" + } + ] +} diff --git a/examples/example_1.ipynb b/examples/example_1.ipynb index 76bc5593..9807a74b 100644 --- a/examples/example_1.ipynb +++ b/examples/example_1.ipynb @@ -42,7 +42,7 @@ "outputs": [], "source": [ "print(arx.__version__)\n", - "arx.run(\"example_1_inputs_count_mode.json\")" + "arx.run(\"polyamide_count_mode_basic.json\")" ] }, { @@ -189,43 +189,43 @@ }, { "cell_type": "markdown", - "id": "90b5747e", + "id": "459a32cd", "metadata": {}, "source": [ + "### Process System\n", "\n", - "### Show Reaction Templates\n", - "\n", - "Visualize the prepared reaction templates. Here, `\"template\"` highlights atoms or components removed during the reaction." + "Run the final AutoREACTER processing step to generate the simulation-ready outputs." ] }, { "cell_type": "code", "execution_count": null, - "id": "94018071", + "id": "3ba05aaf", "metadata": {}, "outputs": [], "source": [ - "arx.show_reaction_templates(\"template\")" + "arx.process()" ] }, { "cell_type": "markdown", - "id": "459a32cd", + "id": "e7f60c10", "metadata": {}, "source": [ - "### Process System\n", "\n", - "Run the final AutoREACTER processing step to generate the simulation-ready outputs." + "### Show Reaction Templates\n", + "\n", + "Visualize the prepared reaction templates. Here, `\"template\"` highlights atoms or components removed during the reaction." ] }, { "cell_type": "code", "execution_count": null, - "id": "3ba05aaf", + "id": "277fd06f", "metadata": {}, "outputs": [], "source": [ - "arx.process()" + "arx.show_reaction_templates(\"template\")" ] } ], diff --git a/examples/example_1.py b/examples/example_1.py index 51fe9dec..525486b4 100644 --- a/examples/example_1.py +++ b/examples/example_1.py @@ -1,107 +1,128 @@ -#!/usr/bin/env python3 -""" -AutoREACTER Workflow: Required Pipeline Example -=============================================== - -This script demonstrates the required AutoREACTER workflow only. -Visualization images are generated internally and saved in the session image directory. - -Usage: - python auto_reacter_workflow.py -i example_1_inputs_count_mode.json -""" - -import argparse -import sys - - -# --------------------------------------------------------------------------- -# 1. Command-line arguments -# --------------------------------------------------------------------------- -parser = argparse.ArgumentParser( - description="Run the required AutoREACTER workflow from an input JSON file." -) - -parser.add_argument( - "-i", - "--input", - "-in", - required=True, - help="Path to the AutoREACTER input JSON file." -) - -args = parser.parse_args() - - -# --------------------------------------------------------------------------- -# 2. Import AutoREACTER -# --------------------------------------------------------------------------- -try: - import AutoREACTER as arx - print("AutoREACTER imported successfully.") -except ImportError: - print( - "Failed to import AutoREACTER. " - "Ensure the package is installed in your environment." - ) - raise - - -# --------------------------------------------------------------------------- -# 3. Version check & input load -# --------------------------------------------------------------------------- -# Print the installed version for reproducibility and debugging. -print(arx.__version__) - -# Load the JSON input file and initialize the AutoREACTER session. -arx.run(args.input) -session = arx.session() - - -# --------------------------------------------------------------------------- -# 4. Select reactions and non-reactants -# --------------------------------------------------------------------------- -# Select which detected reactions should be processed. -arx.select_reactions() - -# Select non-reactant molecules, if any are detected. -arx.select_non_reactants() - - -# --------------------------------------------------------------------------- -# 5. Reaction template preparation -# --------------------------------------------------------------------------- -# Prepare reaction templates for downstream REACTER file generation. -arx.prepare_reactions() - - -# --------------------------------------------------------------------------- -# 6. Review checkpoint -# --------------------------------------------------------------------------- -# AutoREACTER saves visualization images internally in the session image directory. -print( - "\n[INFO] Reaction preparation completed." - "\n[INFO] Please review the generated images in the session image directory." -) - -# Try to print the image directory if it is exposed by AutoREACTER. -session = arx.session() -img_dir = getattr(session, "img_dir", None) or getattr(session, "images_dir", None) - -if img_dir is not None: - print(f"[INFO] Image directory: {img_dir}") - -ok_pass = input("\nType 'ok' to continue with final processing: ").strip().lower() - -if ok_pass != "ok": - print("[EXIT] Workflow stopped by user.") - sys.exit(0) - - -# --------------------------------------------------------------------------- -# 7. Final processing -# --------------------------------------------------------------------------- -# Run 3D geometry setup, force-field generation, REACTER file building, -# and LAMMPS simulation setup. -arx.process() - -print("\n[INFO] AutoREACTER workflow completed successfully.") +#!/usr/bin/env python3 +""" +AutoREACTER Workflow Example +============================ + +This script demonstrates the standard AutoREACTER workflow using a JSON +input file. + +It is intended for users running AutoREACTER from the source repository. + +Example +------- +From the AutoREACTER repository root: + + python examples/example_1.py \ + -i examples/polyamide_count_mode_basic.json + +AutoREACTER can also be installed from PyPI and used directly from a +user-created Python script: + + python -m pip install AutoREACTER +""" + +import argparse +import sys + + +def parse_arguments(): + """Parse command-line arguments.""" + parser = argparse.ArgumentParser( + description=( + "Run the standard AutoREACTER workflow " + "from an input JSON file." + ) + ) + + parser.add_argument( + "-i", + "--input", + "-in", + required=True, + help="Path to the AutoREACTER input JSON file.", + ) + + return parser.parse_args() + + +def main(): + """Run the standard AutoREACTER workflow.""" + + args = parse_arguments() + + # ------------------------------------------------------------------------- + # 1. Import AutoREACTER + # ------------------------------------------------------------------------- + + try: + import AutoREACTER as arx + except ImportError: + print( + "[ERROR] Failed to import AutoREACTER.\n" + "Install it with:\n\n" + " python -m pip install AutoREACTER\n" + ) + raise + + print("[OK] AutoREACTER imported successfully.") + print(f"[INFO] AutoREACTER version: {arx.__version__}") + + # ------------------------------------------------------------------------- + # 2. Initialize workflow + # ------------------------------------------------------------------------- + + arx.run(args.input) + + # ------------------------------------------------------------------------- + # 3. Select reactions + # ------------------------------------------------------------------------- + + arx.select_reactions() + + # ------------------------------------------------------------------------- + # 4. Select non-reactants + # ------------------------------------------------------------------------- + + arx.select_non_reactants() + + # ------------------------------------------------------------------------- + # 5. Prepare reaction templates + # ------------------------------------------------------------------------- + + arx.prepare_reactions() + + # ------------------------------------------------------------------------- + # 6. Review checkpoint + # ------------------------------------------------------------------------- + + session = arx.session() + + print( + "\n[INFO] Reaction preparation completed." + f"\n[INFO] Review generated images in: {session.images_dir}" + ) + + confirmation = input( + "\nType 'ok' to continue with final processing: " + ).strip().lower() + + if confirmation != "ok": + print("[EXIT] Workflow stopped by user.") + return 0 + + # ------------------------------------------------------------------------- + # 7. Final processing + # ------------------------------------------------------------------------- + + arx.process() + + print( + "\n[INFO] AutoREACTER workflow completed successfully." + f"\n[INFO] Output directory: {session.output_dir}" + ) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/examples/example_1_inputs_count_mode.json b/examples/example_1_inputs_count_mode.json deleted file mode 100644 index aa0f64b3..00000000 --- a/examples/example_1_inputs_count_mode.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "simulation_name": "Example_Count_Mode", - "simulations": [ - { - "tag": "10k", - "temperature": 300, - "density": 1.3, - "monomer_counts": { - "tmc": 220, - "mpd": 220, - "data_3": 110 - } - }, - { - "tag": "100k", - "temperature": 400, - "density": 0.8, - "monomer_counts": { - "tmc": 2200, - "mpd": 2200, - "data_3": 1100 - } - }, - { - "tag": "100k_high_temp", - "temperature": 500, - "density": 0.8, - "monomer_counts": { - "tmc": 2200, - "mpd": 2200, - "data_3": 1100 - } - } - ], - "monomers": [ - { - "name": "tmc", - "smiles": "ClC(=O)c1cc(cc(c1)C(Cl)=O)C(Cl)=O" - }, - { - "name": "mpd", - "smiles": "C1=CC(=CC(=C1)N)N" - }, - { - "name": "data_3", - "smiles": "CCO" - } - ] -} \ No newline at end of file diff --git a/examples/example_1_inputs_ratio_mode.json b/examples/example_1_inputs_ratio_mode.json deleted file mode 100644 index 69c1d1e3..00000000 --- a/examples/example_1_inputs_ratio_mode.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "simulation_name": "Example_Ratio_Mode", - "simulations": [ - { - "tag": "10k_base", - "temperature": 300, - "density": 0.8, - "total_atoms": 10000, - "monomer_ratios": { - "tmc": 1.0, - "mpd": 1.0, - "ethanol": 0.5 - } - }, - { - "tag": "100k_base", - "temperature": 400, - "density": 0.8, - "total_atoms": 100000, - "monomer_ratios": { - "tmc": 1.0, - "mpd": 1.0, - "ethanol": 0.5 - } - } - ], - "monomers": [ - { - "name": "tmc", - "smiles": "ClC(=O)c1cc(cc(c1)C(Cl)=O)C(Cl)=O" - }, - { - "name": "mpd", - "smiles": "C1=CC(=CC(=C1)N)N" - }, - { - "name": "ethanol", - "smiles": "CCO" - } - ] -} \ No newline at end of file diff --git a/examples/ipn_advanced_workflow.json b/examples/ipn_advanced_workflow.json new file mode 100644 index 00000000..5d2c8bf6 --- /dev/null +++ b/examples/ipn_advanced_workflow.json @@ -0,0 +1,43 @@ +{ + "simulation_name": "DGEBA_MMA_TEGDMA_IPN_Advanced_Workflow", + "force_field": "PCFF", + + "deep_search": true, + "reaction_iteration_depth": 5, + "wildcards": true, + "deduplicate_reaction_templates": true, + "write_second_reaction_stage": true, + + "simulations": [ + { + "tag": "100k_500K", + "temperature": 500, + "density": 1.23, + "monomer_counts": { + "bisphenol_A_diglycidyl_ether": 450, + "1_5_diaminopentane": 225, + "methyl_methacrylate": 3000, + "triethylene_glycol_dimethacrylate": 150 + } + } + ], + + "monomers": [ + { + "name": "bisphenol_A_diglycidyl_ether", + "smiles": "CC(C)(c1ccc(OCC2CO2)cc1)c1ccc(OCC2CO2)cc1" + }, + { + "name": "1_5_diaminopentane", + "smiles": "NCCCCCN" + }, + { + "name": "methyl_methacrylate", + "smiles": "COC(=O)C(C)=C" + }, + { + "name": "triethylene_glycol_dimethacrylate", + "smiles": "C=C(C)C(=O)OCCOCCOCCOC(=O)C(C)=C" + } + ] +} diff --git a/examples/legacy/example_1.ipynb b/examples/legacy/example_1.ipynb index 1778715a..d88419cf 100644 --- a/examples/legacy/example_1.ipynb +++ b/examples/legacy/example_1.ipynb @@ -1,427 +1,427 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "33f6dd27", - "metadata": {}, - "source": [ - "### Initialization and Environment Setup\n", - "\n", - "This cell initializes the AutoREACTER environment and prepares the working directory for the current run." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2dccf25a", - "metadata": {}, - "outputs": [], - "source": [ - "from AutoREACTER.session import read_input\n", - "from AutoREACTER.input_parser import InputParser\n", - "from AutoREACTER.detectors.functional_groups_detector import FunctionalGroupsDetector\n", - "from AutoREACTER.detectors.reaction_detector import ReactionDetector\n", - "from AutoREACTER.detectors.non_monomer_detector import NonReactantsDetector\n", - "from AutoREACTER.reaction_preparation.reaction_processor.prepare_reactions import PrepareReactions\n", - "from AutoREACTER.reaction_preparation.ff_wrapper.molecule_3d_preparation import Molecule3DPreparation\n", - "from AutoREACTER.reaction_preparation.ff_wrapper.ff_wrapper import FFWrapper\n", - "from AutoREACTER.reaction_preparation.ff_wrapper.REACTER_files_builder import REACTERFilesBuilder\n", - "from AutoREACTER.sim_setup.simulation_setup import SimulationSetupManager\n", - "\n", - "session = read_input(\"example_1_inputs_count_mode.json\")" - ] - }, - { - "cell_type": "markdown", - "id": "a48c7aee", - "metadata": {}, - "source": [ - "### Visualize Monomers (Optional)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "63c6e279", - "metadata": {}, - "outputs": [], - "source": [ - "# Generate and display the initial monomer structures.\n", - "input_parser = InputParser()\n", - "initial_molecules = input_parser.initial_molecules_image_grid(session.inputs)\n", - "initial_molecules" - ] - }, - { - "cell_type": "markdown", - "id": "65725a29", - "metadata": {}, - "source": [ - "### Functional Group Detection\n", - "\n", - "This cell runs the functional group detection step.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "04b6e642", - "metadata": {}, - "outputs": [], - "source": [ - "# Detect functional groups to get valid monomers\n", - "\n", - "functional_groups_detector = FunctionalGroupsDetector()\n", - "functional_groups_detector.functional_groups_detector(session)\n" - ] - }, - { - "cell_type": "markdown", - "id": "76ad6db0", - "metadata": {}, - "source": [ - "### Functional Group Visualization (Optional)\n", - "\n", - "This cell visualizes the detected functional groups on the molecules." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e295e3b3", - "metadata": {}, - "outputs": [], - "source": [ - "# The resulting image shows each molecule with the detected functional groups marked.\n", - "\n", - "img = functional_groups_detector.functional_group_highlighted_molecules_image_grid(session)\n", - "img" - ] - }, - { - "cell_type": "markdown", - "id": "80945c3f", - "metadata": {}, - "source": [ - "### Reaction Detection\n", - "\n", - "This cell identifies possible reactions between the detected functional groups.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "76817cd8", - "metadata": {}, - "outputs": [], - "source": [ - "# Detect possible reactions based on identified functional groups.\n", - "\n", - "reaction_detector = ReactionDetector()\n", - "reaction_detector.reaction_detector(session)" - ] - }, - { - "cell_type": "markdown", - "id": "0149d1d3", - "metadata": {}, - "source": [ - "### Reaction visualization (Optional)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "b443f92e", - "metadata": {}, - "outputs": [], - "source": [ - "# Visualize detected reactions.\n", - "\n", - "img = reaction_detector.available_reaction_image_grid(session)\n", - "img" - ] - }, - { - "cell_type": "markdown", - "id": "069aee8f", - "metadata": {}, - "source": [ - "### Reaction Selection\n", - "\n", - "This cell filters and selects the relevant reactions from the detected reaction instances.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "26d581e2", - "metadata": {}, - "outputs": [], - "source": [ - "# Select relevant reactions for template generation as user intened.\n", - "\n", - "reaction_detector.reaction_selection(session)" - ] - }, - { - "cell_type": "markdown", - "id": "09b1cb36", - "metadata": {}, - "source": [ - "### Non-Reactant (Non-Monomer) Detection\n", - "\n", - "This cell identifies molecules that do **not participate in any detected reactions**.\n", - "\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c811c031", - "metadata": {}, - "outputs": [], - "source": [ - "# Identify non-reactive molecules based on the selected reactions.\n", - "\n", - "non_monomer_detector = NonReactantsDetector()\n", - "\n", - "non_monomer_detector.non_monomer_detector(session)" - ] - }, - { - "cell_type": "markdown", - "id": "2e03f325", - "metadata": {}, - "source": [ - "### Non reactants molecules visualization" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "5040c430", - "metadata": {}, - "outputs": [], - "source": [ - "# Visualize non-reactive molecules (if any are detected).\n", - "\n", - "img_non_reactants = non_monomer_detector.non_reactants_to_visualization(session)\n", - "img_non_reactants" - ] - }, - { - "cell_type": "markdown", - "id": "561c06dd", - "metadata": {}, - "source": [ - "### Update Inputs After Non-Reactant Filtering\n", - "\n", - "This cell updates the validated inputs after identifying non-reactive molecules.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "00d75b1a", - "metadata": {}, - "outputs": [], - "source": [ - "# Update inputs by filtering out non-reactive molecules.\n", - "\n", - "non_monomer_detector.non_reactant_selection(session)" - ] - }, - { - "cell_type": "markdown", - "id": "6746d472", - "metadata": {}, - "source": [ - "### Prepare reaction templates from the selected reactions for downstream processing." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "d62efe2a", - "metadata": {}, - "outputs": [], - "source": [ - "prepare_reactions = PrepareReactions(session)\n", - "prepare_reactions.prepare_reactions(session)" - ] - }, - { - "cell_type": "markdown", - "id": "1b031c97", - "metadata": {}, - "source": [ - "### Reaction Template Visualization\n", - "\n", - "Visualize reaction templates with different highlighting options setting the `highlight_type` parameter to one of the following values:\n", - "\n", - "- **Default** or **template**: Highlights all structural changes in the reaction templates \n", - "- **edge**: Highlights edge atoms of the templates \n", - "- **delete**: Highlights removed components (if applicable) \n", - "- **initiators**: Highlights reaction initiator atoms " - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "a7c134e5", - "metadata": {}, - "outputs": [], - "source": [ - "img = prepare_reactions.reaction_templates_highlighted_image_grid(session, highlight_type=\"edge\")\n", - "img" - ] - }, - { - "cell_type": "markdown", - "id": "30207664", - "metadata": {}, - "source": [ - "### 3D Geometry Preparation\n", - "\n", - "Prepare 3D molecular geometries for both inputs molecules and reaction templates. \n", - "This step generates the structures required for the Lunar atom typing." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6603c504", - "metadata": {}, - "outputs": [], - "source": [ - "molecule3dpreparation = Molecule3DPreparation(session)\n", - "\n", - "molecule3dpreparation.prepare_molecule_3d_geometry(\n", - " session\n", - " )\n" - ] - }, - { - "cell_type": "markdown", - "id": "be30857a", - "metadata": {}, - "source": [ - "### Lunar Workflow\n", - "\n", - "Run the Lunar workflow to generate simulation-ready files from prepared 3D structures." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c6a74035", - "metadata": {}, - "outputs": [], - "source": [ - "ff_wrapper = FFWrapper(session)\n", - "\n", - "ff_wrapper.generate_force_field_files(\n", - " session\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "fff1fc61", - "metadata": {}, - "source": [ - "### REACTER File Generation and Finalization\n", - "\n", - "Generate REACTER-compatible files from the Lunar results and prepared reactions. \n", - "The generated files are then moved to the final run directory, and all internal paths are updated accordingly." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "52d28045", - "metadata": {}, - "outputs": [], - "source": [ - "from AutoREACTER.cache import RunDirectoryManager\n", - "\n", - "builder = REACTERFilesBuilder(\n", - " session=session,\n", - ")\n", - "\n", - "reacter_files = builder.molecule_template_preparation(\n", - " session=session,\n", - ")\n", - "\n", - "# Move generated files to final output directory using RunDirectoryManager\n", - "run_manager = RunDirectoryManager(session.output_dir.parent)\n", - "reacter_files = run_manager.move_reacter_files(\n", - " reacter_files,\n", - " staging_dir=session.staging_dir,\n", - " final_dir=session.output_dir\n", - ")\n", - "\n", - "\n", - "print(f\"[OK] REACTER files successfully moved to {session.output_dir}\")" - ] - }, - { - "cell_type": "markdown", - "id": "0a0add61", - "metadata": {}, - "source": [ - "### Simulation Setup and LAMMPS Script Generation\n", - "\n", - "Calculate physical system properties (e.g., box sizes, required monomer counts to reach target densities) and generate the complete suite of LAMMPS input scripts. \n", - "\n", - "This automatically writes the configuration files for all 5 stages:\n", - "1. Densification\n", - "2. Pre-reaction Equilibration\n", - "3. First Reaction Stage (3.5A)\n", - "4. Second Reaction Stage (5.0A)\n", - "5. Post-reaction Equilibration" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "bb8a48f5", - "metadata": {}, - "outputs": [], - "source": [ - "Simulation_setup_manager = SimulationSetupManager()\n", - "\n", - "updated_inputs_3d = Simulation_setup_manager.setup_and_write_simulation(\n", - " setup=updated_inputs_with_3d_mols,\n", - " reacter_files=reacter_files,\n", - " run_dir=session.output_dir\n", - ")\n", - "\n", - "print(\"\\n[INFO] AutoREACTER workflow completed successfully.\\n\")\n", - "print(f\"Final REACTER and LAMMPS files are located in: {session.output_dir}\")" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "arx_testpypi_pkg_test", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.13.13" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} +{ + "cells": [ + { + "cell_type": "markdown", + "id": "33f6dd27", + "metadata": {}, + "source": [ + "### Initialization and Environment Setup\n", + "\n", + "This cell initializes the AutoREACTER environment and prepares the working directory for the current run." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2dccf25a", + "metadata": {}, + "outputs": [], + "source": [ + "from AutoREACTER.session import read_input\n", + "from AutoREACTER.input_parser import InputParser\n", + "from AutoREACTER.detectors.functional_groups_detector import FunctionalGroupsDetector\n", + "from AutoREACTER.detectors.reaction_detector import ReactionDetector\n", + "from AutoREACTER.detectors.non_monomer_detector import NonReactantsDetector\n", + "from AutoREACTER.reaction_preparation.reaction_processor.prepare_reactions import PrepareReactions\n", + "from AutoREACTER.reaction_preparation.ff_wrapper.molecule_3d_preparation import Molecule3DPreparation\n", + "from AutoREACTER.reaction_preparation.ff_wrapper.ff_wrapper import FFWrapper\n", + "from AutoREACTER.reaction_preparation.ff_wrapper.REACTER_files_builder import REACTERFilesBuilder\n", + "from AutoREACTER.sim_setup.simulation_setup import SimulationSetupManager\n", + "\n", + "session = read_input(\"example_1_inputs_count_mode.json\")" + ] + }, + { + "cell_type": "markdown", + "id": "a48c7aee", + "metadata": {}, + "source": [ + "### Visualize Monomers (Optional)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "63c6e279", + "metadata": {}, + "outputs": [], + "source": [ + "# Generate and display the initial monomer structures.\n", + "input_parser = InputParser()\n", + "initial_molecules = input_parser.initial_molecules_image_grid(session.inputs)\n", + "initial_molecules" + ] + }, + { + "cell_type": "markdown", + "id": "65725a29", + "metadata": {}, + "source": [ + "### Functional Group Detection\n", + "\n", + "This cell runs the functional group detection step.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "04b6e642", + "metadata": {}, + "outputs": [], + "source": [ + "# Detect functional groups to get valid monomers\n", + "\n", + "functional_groups_detector = FunctionalGroupsDetector()\n", + "functional_groups_detector.functional_groups_detector(session)\n" + ] + }, + { + "cell_type": "markdown", + "id": "76ad6db0", + "metadata": {}, + "source": [ + "### Functional Group Visualization (Optional)\n", + "\n", + "This cell visualizes the detected functional groups on the molecules." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e295e3b3", + "metadata": {}, + "outputs": [], + "source": [ + "# The resulting image shows each molecule with the detected functional groups marked.\n", + "\n", + "img = functional_groups_detector.functional_group_highlighted_molecules_image_grid(session)\n", + "img" + ] + }, + { + "cell_type": "markdown", + "id": "80945c3f", + "metadata": {}, + "source": [ + "### Reaction Detection\n", + "\n", + "This cell identifies possible reactions between the detected functional groups.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "76817cd8", + "metadata": {}, + "outputs": [], + "source": [ + "# Detect possible reactions based on identified functional groups.\n", + "\n", + "reaction_detector = ReactionDetector()\n", + "reaction_detector.reaction_detector(session)" + ] + }, + { + "cell_type": "markdown", + "id": "0149d1d3", + "metadata": {}, + "source": [ + "### Reaction visualization (Optional)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b443f92e", + "metadata": {}, + "outputs": [], + "source": [ + "# Visualize detected reactions.\n", + "\n", + "img = reaction_detector.available_reaction_image_grid(session)\n", + "img" + ] + }, + { + "cell_type": "markdown", + "id": "069aee8f", + "metadata": {}, + "source": [ + "### Reaction Selection\n", + "\n", + "This cell filters and selects the relevant reactions from the detected reaction instances.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "26d581e2", + "metadata": {}, + "outputs": [], + "source": [ + "# Select relevant reactions for template generation as user intened.\n", + "\n", + "reaction_detector.reaction_selection(session)" + ] + }, + { + "cell_type": "markdown", + "id": "09b1cb36", + "metadata": {}, + "source": [ + "### Non-Reactant (Non-Monomer) Detection\n", + "\n", + "This cell identifies molecules that do **not participate in any detected reactions**.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c811c031", + "metadata": {}, + "outputs": [], + "source": [ + "# Identify non-reactive molecules based on the selected reactions.\n", + "\n", + "non_monomer_detector = NonReactantsDetector()\n", + "\n", + "non_monomer_detector.non_monomer_detector(session)" + ] + }, + { + "cell_type": "markdown", + "id": "2e03f325", + "metadata": {}, + "source": [ + "### Non reactants molecules visualization" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5040c430", + "metadata": {}, + "outputs": [], + "source": [ + "# Visualize non-reactive molecules (if any are detected).\n", + "\n", + "img_non_reactants = non_monomer_detector.non_reactants_to_visualization(session)\n", + "img_non_reactants" + ] + }, + { + "cell_type": "markdown", + "id": "561c06dd", + "metadata": {}, + "source": [ + "### Update Inputs After Non-Reactant Filtering\n", + "\n", + "This cell updates the validated inputs after identifying non-reactive molecules.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "00d75b1a", + "metadata": {}, + "outputs": [], + "source": [ + "# Update inputs by filtering out non-reactive molecules.\n", + "\n", + "non_monomer_detector.non_reactant_selection(session)" + ] + }, + { + "cell_type": "markdown", + "id": "6746d472", + "metadata": {}, + "source": [ + "### Prepare reaction templates from the selected reactions for downstream processing." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d62efe2a", + "metadata": {}, + "outputs": [], + "source": [ + "prepare_reactions = PrepareReactions(session)\n", + "prepare_reactions.prepare_reactions(session)" + ] + }, + { + "cell_type": "markdown", + "id": "1b031c97", + "metadata": {}, + "source": [ + "### Reaction Template Visualization\n", + "\n", + "Visualize reaction templates with different highlighting options setting the `highlight_type` parameter to one of the following values:\n", + "\n", + "- **Default** or **template**: Highlights all structural changes in the reaction templates \n", + "- **edge**: Highlights edge atoms of the templates \n", + "- **delete**: Highlights removed components (if applicable) \n", + "- **initiators**: Highlights reaction initiator atoms " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a7c134e5", + "metadata": {}, + "outputs": [], + "source": [ + "img = prepare_reactions.reaction_templates_highlighted_image_grid(session, highlight_type=\"edge\")\n", + "img" + ] + }, + { + "cell_type": "markdown", + "id": "30207664", + "metadata": {}, + "source": [ + "### 3D Geometry Preparation\n", + "\n", + "Prepare 3D molecular geometries for both inputs molecules and reaction templates. \n", + "This step generates the structures required for the Lunar atom typing." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6603c504", + "metadata": {}, + "outputs": [], + "source": [ + "molecule3dpreparation = Molecule3DPreparation(session)\n", + "\n", + "molecule3dpreparation.prepare_molecule_3d_geometry(\n", + " session\n", + " )\n" + ] + }, + { + "cell_type": "markdown", + "id": "be30857a", + "metadata": {}, + "source": [ + "### Lunar Workflow\n", + "\n", + "Run the Lunar workflow to generate simulation-ready files from prepared 3D structures." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c6a74035", + "metadata": {}, + "outputs": [], + "source": [ + "ff_wrapper = FFWrapper(session)\n", + "\n", + "ff_wrapper.generate_force_field_files(\n", + " session\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "fff1fc61", + "metadata": {}, + "source": [ + "### REACTER File Generation and Finalization\n", + "\n", + "Generate REACTER-compatible files from the Lunar results and prepared reactions. \n", + "The generated files are then moved to the final run directory, and all internal paths are updated accordingly." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "52d28045", + "metadata": {}, + "outputs": [], + "source": [ + "from AutoREACTER.cache import RunDirectoryManager\n", + "\n", + "builder = REACTERFilesBuilder(\n", + " session=session,\n", + ")\n", + "\n", + "reacter_files = builder.molecule_template_preparation(\n", + " session=session,\n", + ")\n", + "\n", + "# Move generated files to final output directory using RunDirectoryManager\n", + "run_manager = RunDirectoryManager(session.output_dir.parent)\n", + "reacter_files = run_manager.move_reacter_files(\n", + " reacter_files,\n", + " staging_dir=session.staging_dir,\n", + " final_dir=session.output_dir\n", + ")\n", + "\n", + "\n", + "print(f\"[OK] REACTER files successfully moved to {session.output_dir}\")" + ] + }, + { + "cell_type": "markdown", + "id": "0a0add61", + "metadata": {}, + "source": [ + "### Simulation Setup and LAMMPS Script Generation\n", + "\n", + "Calculate physical system properties (e.g., box sizes, required monomer counts to reach target densities) and generate the complete suite of LAMMPS input scripts. \n", + "\n", + "This automatically writes the configuration files for all 5 stages:\n", + "1. Densification\n", + "2. Pre-reaction Equilibration\n", + "3. First Reaction Stage (3.5A)\n", + "4. Second Reaction Stage (5.0A)\n", + "5. Post-reaction Equilibration" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bb8a48f5", + "metadata": {}, + "outputs": [], + "source": [ + "Simulation_setup_manager = SimulationSetupManager()\n", + "\n", + "updated_inputs_3d = Simulation_setup_manager.setup_and_write_simulation(\n", + " setup=updated_inputs_with_3d_mols,\n", + " reacter_files=reacter_files,\n", + " run_dir=session.output_dir\n", + ")\n", + "\n", + "print(\"\\n[INFO] AutoREACTER workflow completed successfully.\\n\")\n", + "print(f\"Final REACTER and LAMMPS files are located in: {session.output_dir}\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "arx_testpypi_pkg_test", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/legacy/run_AutoREACTER.py b/examples/legacy/run_AutoREACTER.py index 6c02cb6b..c0e126b6 100644 --- a/examples/legacy/run_AutoREACTER.py +++ b/examples/legacy/run_AutoREACTER.py @@ -1,215 +1,215 @@ -import sys -import os -import time -from PIL import Image - -# from AutoREACTER._compat import apply_legacy_patches -# apply_legacy_patches() -# this will be need when foyer integration is added back in, but for now it causes issues with the current foyer version. -# We can re-add it when we add foyer back in, and it should be compatible with the current version of foyer at that time. - -from AutoREACTER.session import read_input -from AutoREACTER.input_parser import InputParser -from AutoREACTER.detectors.functional_groups_detector import FunctionalGroupsDetector -from AutoREACTER.detectors.reaction_detector import ReactionDetector -from AutoREACTER.detectors.non_monomer_detector import NonReactantsDetector -from AutoREACTER.reaction_preparation.reaction_processor.prepare_reactions import PrepareReactions -from AutoREACTER.reaction_preparation.ff_wrapper.molecule_3d_preparation import Molecule3DPreparation -from AutoREACTER.reaction_preparation.ff_wrapper.ff_wrapper import FFWrapper -from AutoREACTER.reaction_preparation.ff_wrapper.REACTER_files_builder import REACTERFilesBuilder -from AutoREACTER.sim_setup.simulation_setup import SimulationSetupManager - - -def loading_message(message: str, duration: float = 3.0, interval: float = 0.5) -> None: - """ - Print a loading message with animated dots to indicate progress. - - Args: - message: The message to display - duration: Total duration of the animation in seconds - interval: Time between each dot in seconds - """ - print(f"[INFO] {message}", end="", flush=True) - steps = int(duration / interval) - - for _ in range(steps): - print(".", end="", flush=True) - time.sleep(interval) - - print() # newline - - -def save_image(img: Image.Image, path: str, label: str = "Image") -> None: - """ - Save a PIL Image to disk with robust error handling and user feedback. - - Args: - img: PIL Image object to save - path: Full path where the image should be saved - label: Human-readable label used in log messages - """ - if img is None: - print(f"[WARN] {label}: Image is None, skipping save.") - return - try: - if hasattr(img, "save"): - img.save(path) - print(f"\n[OK] {label} saved → {path}") - else: - print(f"[ERROR] {label}: Object has no .save() method") - except Exception as e: - print(f"[ERROR] Failed to save {label} to {path}: {e}") - -def help_message() -> None: - """ - Print usage instructions for the AutoREACTER script. - This function is called when the user runs the script without arguments or with incorrect options. - """ - print("Usage:") - print(" python AutoREACTER.py -i ") - print("\nOptions:") - print(" -i, --input Path to input JSON file") - -def AutoREACTER(input_file: str) -> None: - """ - Main function to run the AutoREACTER workflow. - This function orchestrates the entire process from reading inputs, detecting reactions, preparing files, and setting up the simulation. - Args: - input_file: Path to the input JSON file containing simulation parameters and monomer information. - - Workflow Steps: - 1. Initialize Session: Read and validate inputs, set up staging and output directories. - 2. Functional Group Detection: Identify functional groups in the monomers and generate visualizations - 3. Reaction Discovery and Selection: Detect possible reactions based on functional groups and allow user selection. - 4. Non-monomer (Additive) Detection: Identify any non-reactant additives and allow user selection. - 5. Reaction Template Preparation: Prepare reaction templates and generate visualizations for review. - 6. 3D Geometry Preparation: Generate 3D geometries for molecules and reactions. - 7. Lunar API Processing: Send data to the Lunar API and retrieve results. - 8. Build REACTER Input Files: Create the necessary input files for REACTER based on the processed data. - 9. Final Simulation Setup and Output: Organize all outputs into the final directory structure and provide user feedback. - """ - # === 1. Initialize Session === - session = read_input(input_file) - - # Generate initial visualization - try: - input_parser = InputParser() - img = input_parser.initial_molecules_image_grid(session) - # monomers = session.inputs.monomers # debug print to verify monomers are being processed - # print(monomers) - save_image(img, os.path.join(session.images_dir, "monomers.png"), "Monomers Grid") - except Exception: - print("[WARN] Failed to generate initial molecules image grid") - - # === 2. Functional Group Detection === - functional_groups_detector = FunctionalGroupsDetector() - functional_groups_detector.functional_groups_detector(session) - try: - img = functional_groups_detector.functional_group_highlighted_molecules_image_grid(session) - save_image(img, os.path.join(session.images_dir, "functional_groups.png"), "Functional Groups") - except Exception: - pass - - # === 3. Reaction Discovery and Selection === - reaction_detector = ReactionDetector() - reaction_detector.reaction_detector(session) - try: - img = reaction_detector.available_reaction_image_grid(session) - save_image(img, os.path.join(session.images_dir, "reactions.png"), "Available Reactions") - except Exception: - pass - - reaction_detector.reaction_selection(session) - - # === 4. Non-monomer (Additive) Detection === - non_monomer_detector = NonReactantsDetector() - non_monomer_detector.non_monomer_detector(session) - try: - img_non_reactants = non_monomer_detector.non_reactants_to_visualization(session) - save_image(img_non_reactants, os.path.join(session.images_dir, "non_reactants.png"), "Non-Reactants") - except Exception: - pass - - non_monomer_detector.non_reactant_selection(session) - - # === 5. Reaction Template Preparation === - prepare_reactions = PrepareReactions(session) - prepare_reactions.prepare_reactions(session) - - try: - for highlight_type, filename in [ - ("template", "templates_all.png"), - ("edge", "templates_edge.png"), - ("initiators", "templates_initiators.png"), - ("delete", "templates_delete.png") - ]: - img = prepare_reactions.reaction_templates_highlighted_image_grid( - session, highlight_type=highlight_type - ) - save_image(img, os.path.join(session.images_dir, filename)) - except Exception: - print("[WARN] Failed to generate one or more reaction template visualizations") - - print( - f"\n[INFO] Reaction preparation completed.\n" - f"All generated images have been saved in the {session.images_dir} directory.\n" - f"Please review the reaction templates before proceeding.\n" - ) - - # Interactive user confirmation - ok_pass = input("Type 'ok' to continue: ").strip().lower() - if ok_pass != "ok": - print("[EXIT] Workflow stopped by user.") - sys.exit(0) - - # === 6. 3D Geometry Preparation === - molecule3dpreparation = Molecule3DPreparation(session) - updated_inputs_with_3d_mols = molecule3dpreparation.prepare_molecule_3d_geometry(session) - - # === 7. Lunar API Processing === - ff_wrapper = FFWrapper(session) - ff_wrapper.generate_force_field_files(session) - - print("\n") - loading_message("Lunar API workflow completed. Proceeding to build REACTER files") - time.sleep(1.5) - print("\n") - - # === 8. Build REACTER Input Files === - builder = REACTERFilesBuilder(session=session) - builder.molecule_template_preparation(session=session) - - print(f"[OK] REACTER files successfully moved to {session.output_dir}") - - # === 9. Final simulation setup and output === - Simulation_setup_manager = SimulationSetupManager() - Simulation_setup_manager.setup_and_write_simulation( - session=session - ) - - print("\n[INFO] AutoREACTER workflow completed successfully.\n") - print(f"Final REACTER and LAMMPS files are located in: {session.output_dir}") - - -if __name__ == "__main__": - args = sys.argv[1:] - inpput_strs = ["-i", "--input", "-in"] - - if not args: - help_message() - sys.exit(1) - - if any(opt in args for opt in inpput_strs): - idx = next(idx for idx, arg in enumerate(args) if arg in inpput_strs) - if idx + 1 >= len(args): - print("Error: Missing input file.") - sys.exit(1) - - input_file = args[idx + 1] - if not os.path.isfile(input_file): - print(f"Error: Input file '{input_file}' does not exist.") - sys.exit(1) - - AutoREACTER(input_file) - else: +import sys +import os +import time +from PIL import Image + +# from AutoREACTER._compat import apply_legacy_patches +# apply_legacy_patches() +# this will be need when foyer integration is added back in, but for now it causes issues with the current foyer version. +# We can re-add it when we add foyer back in, and it should be compatible with the current version of foyer at that time. + +from AutoREACTER.session import read_input +from AutoREACTER.input_parser import InputParser +from AutoREACTER.detectors.functional_groups_detector import FunctionalGroupsDetector +from AutoREACTER.detectors.reaction_detector import ReactionDetector +from AutoREACTER.detectors.non_monomer_detector import NonReactantsDetector +from AutoREACTER.reaction_preparation.reaction_processor.prepare_reactions import PrepareReactions +from AutoREACTER.reaction_preparation.ff_wrapper.molecule_3d_preparation import Molecule3DPreparation +from AutoREACTER.reaction_preparation.ff_wrapper.ff_wrapper import FFWrapper +from AutoREACTER.reaction_preparation.ff_wrapper.REACTER_files_builder import REACTERFilesBuilder +from AutoREACTER.sim_setup.simulation_setup import SimulationSetupManager + + +def loading_message(message: str, duration: float = 3.0, interval: float = 0.5) -> None: + """ + Print a loading message with animated dots to indicate progress. + + Args: + message: The message to display + duration: Total duration of the animation in seconds + interval: Time between each dot in seconds + """ + print(f"[INFO] {message}", end="", flush=True) + steps = int(duration / interval) + + for _ in range(steps): + print(".", end="", flush=True) + time.sleep(interval) + + print() # newline + + +def save_image(img: Image.Image, path: str, label: str = "Image") -> None: + """ + Save a PIL Image to disk with robust error handling and user feedback. + + Args: + img: PIL Image object to save + path: Full path where the image should be saved + label: Human-readable label used in log messages + """ + if img is None: + print(f"[WARN] {label}: Image is None, skipping save.") + return + try: + if hasattr(img, "save"): + img.save(path) + print(f"\n[OK] {label} saved → {path}") + else: + print(f"[ERROR] {label}: Object has no .save() method") + except Exception as e: + print(f"[ERROR] Failed to save {label} to {path}: {e}") + +def help_message() -> None: + """ + Print usage instructions for the AutoREACTER script. + This function is called when the user runs the script without arguments or with incorrect options. + """ + print("Usage:") + print(" python AutoREACTER.py -i ") + print("\nOptions:") + print(" -i, --input Path to input JSON file") + +def AutoREACTER(input_file: str) -> None: + """ + Main function to run the AutoREACTER workflow. + This function orchestrates the entire process from reading inputs, detecting reactions, preparing files, and setting up the simulation. + Args: + input_file: Path to the input JSON file containing simulation parameters and monomer information. + + Workflow Steps: + 1. Initialize Session: Read and validate inputs, set up staging and output directories. + 2. Functional Group Detection: Identify functional groups in the monomers and generate visualizations + 3. Reaction Discovery and Selection: Detect possible reactions based on functional groups and allow user selection. + 4. Non-monomer (Additive) Detection: Identify any non-reactant additives and allow user selection. + 5. Reaction Template Preparation: Prepare reaction templates and generate visualizations for review. + 6. 3D Geometry Preparation: Generate 3D geometries for molecules and reactions. + 7. Lunar API Processing: Send data to the Lunar API and retrieve results. + 8. Build REACTER Input Files: Create the necessary input files for REACTER based on the processed data. + 9. Final Simulation Setup and Output: Organize all outputs into the final directory structure and provide user feedback. + """ + # === 1. Initialize Session === + session = read_input(input_file) + + # Generate initial visualization + try: + input_parser = InputParser() + img = input_parser.initial_molecules_image_grid(session) + # monomers = session.inputs.monomers # debug print to verify monomers are being processed + # print(monomers) + save_image(img, os.path.join(session.images_dir, "monomers.png"), "Monomers Grid") + except Exception: + print("[WARN] Failed to generate initial molecules image grid") + + # === 2. Functional Group Detection === + functional_groups_detector = FunctionalGroupsDetector() + functional_groups_detector.functional_groups_detector(session) + try: + img = functional_groups_detector.functional_group_highlighted_molecules_image_grid(session) + save_image(img, os.path.join(session.images_dir, "functional_groups.png"), "Functional Groups") + except Exception: + pass + + # === 3. Reaction Discovery and Selection === + reaction_detector = ReactionDetector() + reaction_detector.reaction_detector(session) + try: + img = reaction_detector.available_reaction_image_grid(session) + save_image(img, os.path.join(session.images_dir, "reactions.png"), "Available Reactions") + except Exception: + pass + + reaction_detector.reaction_selection(session) + + # === 4. Non-monomer (Additive) Detection === + non_monomer_detector = NonReactantsDetector() + non_monomer_detector.non_monomer_detector(session) + try: + img_non_reactants = non_monomer_detector.non_reactants_to_visualization(session) + save_image(img_non_reactants, os.path.join(session.images_dir, "non_reactants.png"), "Non-Reactants") + except Exception: + pass + + non_monomer_detector.non_reactant_selection(session) + + # === 5. Reaction Template Preparation === + prepare_reactions = PrepareReactions(session) + prepare_reactions.prepare_reactions(session) + + try: + for highlight_type, filename in [ + ("template", "templates_all.png"), + ("edge", "templates_edge.png"), + ("initiators", "templates_initiators.png"), + ("delete", "templates_delete.png") + ]: + img = prepare_reactions.reaction_templates_highlighted_image_grid( + session, highlight_type=highlight_type + ) + save_image(img, os.path.join(session.images_dir, filename)) + except Exception: + print("[WARN] Failed to generate one or more reaction template visualizations") + + print( + f"\n[INFO] Reaction preparation completed.\n" + f"All generated images have been saved in the {session.images_dir} directory.\n" + f"Please review the reaction templates before proceeding.\n" + ) + + # Interactive user confirmation + ok_pass = input("Type 'ok' to continue: ").strip().lower() + if ok_pass != "ok": + print("[EXIT] Workflow stopped by user.") + sys.exit(0) + + # === 6. 3D Geometry Preparation === + molecule3dpreparation = Molecule3DPreparation(session) + updated_inputs_with_3d_mols = molecule3dpreparation.prepare_molecule_3d_geometry(session) + + # === 7. Lunar API Processing === + ff_wrapper = FFWrapper(session) + ff_wrapper.generate_force_field_files(session) + + print("\n") + loading_message("Lunar API workflow completed. Proceeding to build REACTER files") + time.sleep(1.5) + print("\n") + + # === 8. Build REACTER Input Files === + builder = REACTERFilesBuilder(session=session) + builder.molecule_template_preparation(session=session) + + print(f"[OK] REACTER files successfully moved to {session.output_dir}") + + # === 9. Final simulation setup and output === + Simulation_setup_manager = SimulationSetupManager() + Simulation_setup_manager.setup_and_write_simulation( + session=session + ) + + print("\n[INFO] AutoREACTER workflow completed successfully.\n") + print(f"Final REACTER and LAMMPS files are located in: {session.output_dir}") + + +if __name__ == "__main__": + args = sys.argv[1:] + inpput_strs = ["-i", "--input", "-in"] + + if not args: + help_message() + sys.exit(1) + + if any(opt in args for opt in inpput_strs): + idx = next(idx for idx, arg in enumerate(args) if arg in inpput_strs) + if idx + 1 >= len(args): + print("Error: Missing input file.") + sys.exit(1) + + input_file = args[idx + 1] + if not os.path.isfile(input_file): + print(f"Error: Input file '{input_file}' does not exist.") + sys.exit(1) + + AutoREACTER(input_file) + else: help_message() \ No newline at end of file diff --git a/examples/legacy_examples/DGEBA_MMA_IPN.json b/examples/legacy_examples/DGEBA_MMA_IPN.json new file mode 100644 index 00000000..905f8e08 --- /dev/null +++ b/examples/legacy_examples/DGEBA_MMA_IPN.json @@ -0,0 +1,36 @@ +{ + "simulation_name": "DGEBA_MMA_ED900_TEGDMA_IPN_500K_100k", + "force_field": "PCFF", + "simulations": [ + { + "tag": "ipn_100k_500K", + "temperature": 500.0, + "density": 1.23, + "total_atoms": 100000, + "monomer_ratios": { + "dgeba": 2.0, + "jeffamine_ed900_rep": 1.0, + "mma": 14.0, + "tegdma": 0.7 + } + } + ], + "monomers": [ + { + "name": "dgeba", + "smiles": "CC(C)(c1ccc(OCC2CO2)cc1)c3ccc(OCC4CO4)cc3" + }, + { + "name": "jeffamine_ed900_rep", + "smiles": "CC(N)COCC(C)OCC(C)OCCOCCOCCOCCOCCOCCOCCOCCOCCOCCOCCOCCOCC(C)OCC(C)OCC(C)N" + }, + { + "name": "mma", + "smiles": "COC(=O)C(C)=C" + }, + { + "name": "tegdma", + "smiles": "CC(=C)C(=O)OCCOCCOCCOC(=O)C(=C)C" + } + ] +} \ No newline at end of file diff --git a/examples/example_1_inputs_count_mode_FF.json b/examples/legacy_examples/example_1_inputs_count_mode_FF.json similarity index 95% rename from examples/example_1_inputs_count_mode_FF.json rename to examples/legacy_examples/example_1_inputs_count_mode_FF.json index b97f5f09..90aab49b 100644 --- a/examples/example_1_inputs_count_mode_FF.json +++ b/examples/legacy_examples/example_1_inputs_count_mode_FF.json @@ -1,40 +1,40 @@ -{ - "simulation_name": "Example_Count_Mode", - "force_field": "PCFF", - "simulations": [ - { - "tag": "10k", - "temperature": 300, - "density": 0.8, - "monomer_counts": { - "tmc": 220, - "mpd": 220, - "ethanol": 110 - } - }, - { - "tag": "100k", - "temperature": 400, - "density": 0.8, - "monomer_counts": { - "tmc": 2200, - "mpd": 2200, - "ethanol": 1100 - } - } - ], - "monomers": [ - { - "name": "tmc", - "smiles": "ClC(=O)c1cc(cc(c1)C(Cl)=O)C(Cl)=O" - }, - { - "name": "mpd", - "smiles": "C1=CC(=CC(=C1)N)N" - }, - { - "name": "ethanol", - "smiles": "CCO" - } - ] +{ + "simulation_name": "Example_Count_Mode", + "force_field": "PCFF", + "simulations": [ + { + "tag": "10k", + "temperature": 300, + "density": 0.8, + "monomer_counts": { + "tmc": 220, + "mpd": 220, + "ethanol": 110 + } + }, + { + "tag": "100k", + "temperature": 400, + "density": 0.8, + "monomer_counts": { + "tmc": 2200, + "mpd": 2200, + "ethanol": 1100 + } + } + ], + "monomers": [ + { + "name": "tmc", + "smiles": "ClC(=O)c1cc(cc(c1)C(Cl)=O)C(Cl)=O" + }, + { + "name": "mpd", + "smiles": "C1=CC(=CC(=C1)N)N" + }, + { + "name": "ethanol", + "smiles": "CCO" + } + ] } \ No newline at end of file diff --git a/examples/legacy_examples/test_epoxy.json b/examples/legacy_examples/test_epoxy.json new file mode 100644 index 00000000..63dc9aac --- /dev/null +++ b/examples/legacy_examples/test_epoxy.json @@ -0,0 +1,31 @@ +{ + "simulation_name": "DGEBA_1,5-Diaminopentane_Epoxy_Curing_wo-loop", + + "reaction_iteration_depth": false, + + "simulations": [ + { + "tag": "DGEBA_1,5-diaminopentane", + + "temperature": 300, + + "density": 1.0, + + "monomer_counts": { + "bisphenol_A_diglycidyl_ether": 200, + "1,5_diaminopentane": 100 + } + } + ], + + "monomers": [ + { + "name": "bisphenol_A_diglycidyl_ether", + "smiles": "CC(C)(c1ccc(OCC2CO2)cc1)c1ccc(OCC2CO2)cc1" + }, + { + "name": "1,5_diaminopentane", + "smiles": "NCCCCCN" + } + ] +} \ No newline at end of file diff --git a/examples/legacy_examples/test_styrene.json b/examples/legacy_examples/test_styrene.json new file mode 100644 index 00000000..f26ac4c5 --- /dev/null +++ b/examples/legacy_examples/test_styrene.json @@ -0,0 +1,93 @@ +{ + "simulation_name": "Styrene2", + "force_field": "PCFF", + "wildcards": true, + "simulations": [ + { + "tag": "styrene_10k_298K", + "temperature": 298.15, + "density": 1.05, + "total_atoms": 10000, + "monomer_ratios": { + "styrene": 1.0 + }, + "comment": "Room-temperature reference system. The 10k system is intended for rapid testing, workflow validation, and establishing baseline structural and reaction behavior at ambient conditions." + }, + { + "tag": "styrene_100k_298K", + "temperature": 298.15, + "density": 1.05, + "total_atoms": 100000, + "monomer_ratios": { + "styrene": 1.0 + }, + "comment": "Large room-temperature reference system. It is used to evaluate finite-size effects and obtain more statistically representative structural and reaction data than the 10k system." + }, + { + "tag": "styrene_10k_373K", + "temperature": 373.15, + "density": 1.05, + "total_atoms": 10000, + "monomer_ratios": { + "styrene": 1.0 + }, + "comment": "Approximately 100 degrees Celsius. This small system probes accelerated polymerization behavior near a temperature where uncontrolled self-heating and runaway risks may become important, depending on initiators, inhibitors, and heat removal." + }, + { + "tag": "styrene_100k_373K", + "temperature": 373.15, + "density": 1.05, + "total_atoms": 100000, + "monomer_ratios": { + "styrene": 1.0 + }, + "comment": "Large system at approximately 100 degrees Celsius. It is included to examine system-size effects, heat-sensitive reaction behavior, and network development near the upper region of common styrene polymerization temperatures." + }, + { + "tag": "styrene_10k_523K", + "temperature": 523.15, + "density": 1.05, + "total_atoms": 10000, + "monomer_ratios": { + "styrene": 1.0 + }, + "comment": "Approximately 250 degrees Celsius. This system probes the transition toward strong thermal initiation, radical formation, oligomerization, and possible thermal degradation rather than normal controlled polystyrene production." + }, + { + "tag": "styrene_100k_523K", + "temperature": 523.15, + "density": 1.05, + "total_atoms": 100000, + "monomer_ratios": { + "styrene": 1.0 + }, + "comment": "Large system at approximately 250 degrees Celsius. It is intended to capture larger-scale thermal decomposition, branching, fragmentation, and spatially heterogeneous reaction behavior." + }, + { + "tag": "styrene_10k_800K", + "temperature": 800.0, + "density": 1.05, + "total_atoms": 10000, + "monomer_ratios": { + "styrene": 1.0 + }, + "comment": "High-temperature screening system at 800 K, equivalent to approximately 526.85 degrees Celsius. It probes extreme thermal chemistry and decomposition behavior and is not a conventional styrene polymerization condition." + }, + { + "tag": "styrene_100k_800K", + "temperature": 800.0, + "density": 1.05, + "total_atoms": 100000, + "monomer_ratios": { + "styrene": 1.0 + }, + "comment": "Large high-temperature system at 800 K. It is used to investigate finite-size effects and statistically significant reaction pathways under extreme thermal conditions." + } + ], + "monomers": [ + { + "name": "styrene", + "smiles": "C=Cc1ccccc1" + } + ] +} \ No newline at end of file diff --git a/examples/polyamide_count_mode_advanced_options.json b/examples/polyamide_count_mode_advanced_options.json new file mode 100644 index 00000000..68239d97 --- /dev/null +++ b/examples/polyamide_count_mode_advanced_options.json @@ -0,0 +1,42 @@ +{ + "simulation_name": "Polyamide_Count_Mode_Advanced", + "force_field": "PCFF", + + "deep_search": true, + "reaction_iteration_depth": 5, + "wildcards": true, + "deduplicate_reaction_templates": true, + "write_second_reaction_stage": false, + + "simulations": [ + { + "tag": "10k_300K", + "temperature": 300, + "density": 0.8, + "monomer_counts": { + "trimesoyl_chloride": 220, + "m_phenylenediamine": 330 + } + }, + { + "tag": "100k_400K", + "temperature": 400, + "density": 0.8, + "monomer_counts": { + "trimesoyl_chloride": 2200, + "m_phenylenediamine": 3300 + } + } + ], + + "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" + } + ] +} diff --git a/examples/polyamide_count_mode_basic.json b/examples/polyamide_count_mode_basic.json new file mode 100644 index 00000000..9413a121 --- /dev/null +++ b/examples/polyamide_count_mode_basic.json @@ -0,0 +1,36 @@ +{ + "simulation_name": "Polyamide_Count_Mode_Basic", + "force_field": "PCFF", + + "simulations": [ + { + "tag": "10k_300K", + "temperature": 300, + "density": 0.8, + "monomer_counts": { + "trimesoyl_chloride": 220, + "m_phenylenediamine": 330 + } + }, + { + "tag": "100k_400K", + "temperature": 400, + "density": 0.8, + "monomer_counts": { + "trimesoyl_chloride": 2200, + "m_phenylenediamine": 3300 + } + } + ], + + "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" + } + ] +} diff --git a/examples/polyamide_ratio_mode_basic.json b/examples/polyamide_ratio_mode_basic.json new file mode 100644 index 00000000..215ab807 --- /dev/null +++ b/examples/polyamide_ratio_mode_basic.json @@ -0,0 +1,38 @@ +{ + "simulation_name": "Polyamide_Ratio_Mode_Basic", + "force_field": "PCFF", + + "simulations": [ + { + "tag": "10k_300K", + "temperature": 300, + "density": 0.8, + "total_atoms": 10000, + "monomer_ratios": { + "trimesoyl_chloride": 2.0, + "m_phenylenediamine": 3.0 + } + }, + { + "tag": "100k_400K", + "temperature": 400, + "density": 0.8, + "total_atoms": 100000, + "monomer_ratios": { + "trimesoyl_chloride": 2.0, + "m_phenylenediamine": 3.0 + } + } + ], + + "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" + } + ] +} diff --git a/examples/styrene_wildcards.json b/examples/styrene_wildcards.json new file mode 100644 index 00000000..c30cbf17 --- /dev/null +++ b/examples/styrene_wildcards.json @@ -0,0 +1,28 @@ +{ + "simulation_name": "Styrene_Wildcards", + "force_field": "PCFF", + + "deep_search": true, + "reaction_iteration_depth": 5, + "wildcards": true, + "deduplicate_reaction_templates": true, + "write_second_reaction_stage": false, + + "simulations": [ + { + "tag": "10k_298K", + "temperature": 298, + "density": 0.95, + "monomer_counts": { + "styrene": 1000 + } + } + ], + + "monomers": [ + { + "name": "styrene", + "smiles": "C=CC1=CC=CC=C1" + } + ] +} diff --git a/test.ipynb b/test.ipynb new file mode 100644 index 00000000..b37645ba --- /dev/null +++ b/test.ipynb @@ -0,0 +1,59 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "7642e888", + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "import json\n", + "from importlib.resources import files\n", + "\n", + "\n", + "\n", + " return reaction_rules" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "8af2ad56", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[{'name': 'epoxy_polymerization', 'if_reactions': 'Amine Epoxy Addition First Stage', 'required_reactions': ['Amine Epoxy Addition Second Stage'], 'fg_additon': {'primary_amine': 'secondary_amine'}}]\n" + ] + } + ], + "source": [ + "action = _add_progessive_chemistries()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "base", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.5" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_input_parser.py b/tests/test_input_parser.py deleted file mode 100644 index f1df0780..00000000 --- a/tests/test_input_parser.py +++ /dev/null @@ -1,387 +0,0 @@ -import unittest -#this should import the main class from the code -from AutoREACTER.input_parser import ( - InputParser, - NumericFieldError, - InputSchemaError, - CompatibilityError, - DuplicateMonomerError, - InputConflictError, - SmilesValidationError -) - -class TestInputParser(unittest.TestCase) : - - def setUp(self): - """Runs automatically before every test to give us a fresh parser.""" - self.parser = InputParser() - #=============== - # Tests for: _validate_temperature - #=============== - - def test_validate_temperature_valid(self): - """Good Ending: A valid positive temp should return as a float.""" - result = self.parser._validate_temperature(300) - self.assertEqual(result, 300.0) - - def test_validate_temperature_negative(self): - """Bad Ending: A negative temp has to raise a NumericFieldError.""" - with self.assertRaises(NumericFieldError): - self.parser._validate_temperature(-150) - - def test_validate_temperature_zero(self): - """Bad Ending: Absolute zero has to raise a NumericFieldError.""" - with self.assertRaises(NumericFieldError): - self.parser._validate_temperature(0) - - def test_validate_temperature_wrong_type(self): - """Bad Ending: If a string or boolean is given instead of a number it has to fail.""" - with self.assertRaises(NumericFieldError): - self.parser._validate_temperature("room_temperature") - - #=============== - # Tests for: _validate_density - #=============== - def test_validate_density_valid(self): - """Good Ending: Valid positive density returns as float.""" - result = self.parser._validate_density(0.85) - self.assertEqual(result, 0.85) - - def test_validate_density_negative_or_zero(self): - """Bad Ending: Rejects zero or negative density values.""" - with self.assertRaises(NumericFieldError): - self.parser._validate_density(0) - with self.assertRaises(NumericFieldError): - self.parser._validate_density(-0.5) - - #================ - # Tests for: _validate_force_field - #================ - - def test_validate_force_field_default(self): - """Good Ending: If None then defaults to 'PCFF'.""" - result = self.parser._validate_force_field(None) - self.assertEqual(result, "PCFF") - - def test_validate_force_field_canonical(self): - """Good path: Normalizes force-field capitalization and aliases.""" - result = self.parser._validate_force_field("pcff-iff") - self.assertEqual(result, "PCFF-IFF") - - def test_validate_force_field_unsupported (self): - """Bad Ending: Passing a completely random name should raise an InputSchemaError.""" - with self.assertRaises(InputSchemaError): - self.parser._validate_force_field("NotAForceField") - - def test_validate_force_field_incompatible(self): - """Bad Ending: 'OPLSAA' is recognized but incompatible, so it should raise a CompatibilityError.""" - with self.assertRaises(CompatibilityError): - self.parser._validate_force_field("oplsaa") - - # ========================================== - # Tests for: validate_no_duplicate_smiles - # ========================================== - - def test_validate_no_duplicate_smiles_good_path(self): - """Good Ending: Adding a unique SMILES appends it to the tracker list.""" - tracker_list = ["CCO", "C1=CC(=CC(=C1)N)N"] - new_smiles = "ClC(=O)c1cc(cc(c1)C(Cl)=O)C(Cl)=O" - - result = self.parser.validate_no_duplicate_smiles(new_smiles, tracker_list) - - # Verify the list grew to 3 items and includes this new molecule - self.assertEqual(len(result), 3) - self.assertIn(new_smiles, result) - - def test_validate_no_duplicate_smiles_raises_error(self): - """Bad Ending: Adding an existing SMILES triggers DuplicateMonomerError.""" - tracker_list = ["CCO", "C1=CC(=CC(=C1)N)N"] - duplicate_smiles = "CCO" # Already present! - - with self.assertRaises(DuplicateMonomerError): - self.parser.validate_no_duplicate_smiles(duplicate_smiles, tracker_list) - - # ========================================== - # Tests for: _validate_smiles - # ========================================== - - def test_validate_smiles_valid(self): - """Good Ending: A valid SMILES should return canonical SMILES and an RDKit Mol.""" - smiles, mol = self.parser._validate_smiles("CCO") - - self.assertEqual(smiles, "CCO") - self.assertIsNotNone(mol) - - def test_validate_smiles_empty_raises_error(self): - """Bad Ending: Empty SMILES should raise SmilesValidationError.""" - with self.assertRaises(SmilesValidationError): - self.parser._validate_smiles("") - - def test_validate_smiles_invalid_raises_error(self): - """Bad Ending: Invalid SMILES should raise SmilesValidationError.""" - with self.assertRaises(SmilesValidationError): - self.parser._validate_smiles("not_a_smiles") - - # ========================================== - # Tests for: validate_basic_format - # ========================================== - - def test_validate_basic_format_valid(self): - """Good Ending: A minimal valid top-level input should pass basic format validation.""" - inputs = { - "simulation_name": "test_sim", - "simulations": [], - "monomers": [], - } - - self.assertIsNone(self.parser.validate_basic_format(inputs)) - - def test_validate_basic_format_missing_key(self): - """Bad Ending: Missing required top-level keys should raise InputSchemaError.""" - inputs = { - "simulation_name": "test_sim", - "simulations": [], - } - - with self.assertRaises(InputSchemaError): - self.parser.validate_basic_format(inputs) - - def test_validate_basic_format_wrong_type(self): - """Bad Ending: Non-dictionary input should raise InputSchemaError.""" - with self.assertRaises(InputSchemaError): - self.parser.validate_basic_format(["not", "a", "dict"]) - - # ========================================== - # Tests for: _get_inputs_mode - # ========================================== - - def test_get_inputs_mode_counts(self): - """Good Ending: Simulations using monomer_counts should be detected as counts mode.""" - simulations = [ - { - "tag": "10k", - "temperature": 300, - "density": 0.8, - "monomer_counts": {"tmc": 1}, - } - ] - - result = self.parser._get_inputs_mode(simulations) - - self.assertEqual(result, "counts") - - def test_get_inputs_mode_ratio(self): - """Good Ending: Simulations using monomer_ratios should be detected as ratio mode.""" - simulations = [ - { - "tag": "10k", - "temperature": 300, - "density": 0.8, - "total_atoms": 10000, - "monomer_ratios": {"tmc": 1.0}, - } - ] - - result = self.parser._get_inputs_mode(simulations) - - self.assertEqual(result, "ratio") - - def test_get_inputs_mode_mixed_modes_raises_error(self): - """Bad Ending: Mixing counts and ratio modes should raise InputConflictError.""" - simulations = [ - { - "tag": "counts_system", - "temperature": 300, - "density": 0.8, - "monomer_counts": {"tmc": 1}, - }, - { - "tag": "ratio_system", - "temperature": 300, - "density": 0.8, - "total_atoms": 10000, - "monomer_ratios": {"tmc": 1.0}, - }, - ] - - with self.assertRaises(InputConflictError): - self.parser._get_inputs_mode(simulations) - - def test_get_inputs_mode_both_counts_and_ratios_raises_error(self): - """Bad Ending: One simulation cannot contain both counts and ratios.""" - simulations = [ - { - "tag": "bad_system", - "temperature": 300, - "density": 0.8, - "monomer_counts": {"tmc": 1}, - "monomer_ratios": {"tmc": 1.0}, - } - ] - - with self.assertRaises(InputConflictError): - self.parser._get_inputs_mode(simulations) - - # ========================================== - # Tests for: validate_inputs - # ========================================== - - def test_validate_inputs_counts_mode_valid(self): - """Good Ending: Full valid counts-mode input should produce a SimulationSetup object.""" - inputs = { - "simulation_name": "test_counts", - "simulations": [ - { - "tag": "10k", - "temperature": 300, - "density": 0.8, - "monomer_counts": { - "tmc": 1, - "mpd": 1, - }, - } - ], - "monomers": [ - { - "name": "tmc", - "smiles": "ClC(=O)c1cc(cc(c1)C(Cl)=O)C(Cl)=O", - }, - { - "name": "mpd", - "smiles": "C1=CC(=CC(=C1)N)N", - }, - ], - } - - result = self.parser.validate_inputs(inputs) - - self.assertEqual(result.simulation_name, "test_counts") - self.assertEqual(result.composition_method, "counts") - self.assertEqual(result.force_field, "PCFF") - self.assertEqual(len(result.monomers), 2) - self.assertEqual(len(result.simulations), 1) - - def test_validate_inputs_ratio_mode_valid(self): - """Good Ending: Full valid ratio-mode input should produce a SimulationSetup object.""" - inputs = { - "simulation_name": "test_ratio", - "simulations": [ - { - "tag": "10k", - "temperature": 300, - "density": 0.8, - "total_atoms": 10000, - "monomer_ratios": { - "tmc": 1.0, - "mpd": 1.0, - }, - } - ], - "monomers": [ - { - "name": "tmc", - "smiles": "ClC(=O)c1cc(cc(c1)C(Cl)=O)C(Cl)=O", - }, - { - "name": "mpd", - "smiles": "C1=CC(=CC(=C1)N)N", - }, - ], - } - - result = self.parser.validate_inputs(inputs) - - self.assertEqual(result.simulation_name, "test_ratio") - self.assertEqual(result.composition_method, "ratio") - self.assertEqual(result.force_field, "PCFF") - self.assertEqual(len(result.monomers), 2) - self.assertEqual(len(result.simulations), 1) - - def test_validate_inputs_unknown_monomer_in_system_raises_error(self): - """Bad Ending: System composition cannot reference undefined monomers.""" - inputs = { - "simulation_name": "bad_unknown_monomer", - "simulations": [ - { - "tag": "10k", - "temperature": 300, - "density": 0.8, - "monomer_counts": { - "tmc": 1, - "unknown": 1, - }, - } - ], - "monomers": [ - { - "name": "tmc", - "smiles": "ClC(=O)c1cc(cc(c1)C(Cl)=O)C(Cl)=O", - }, - ], - } - - with self.assertRaises(InputSchemaError): - self.parser.validate_inputs(inputs) - - def test_validate_inputs_missing_monomer_in_system_raises_error(self): - """Bad Ending: Every defined monomer must appear in each system composition.""" - inputs = { - "simulation_name": "bad_missing_monomer", - "simulations": [ - { - "tag": "10k", - "temperature": 300, - "density": 0.8, - "monomer_counts": { - "tmc": 1, - }, - } - ], - "monomers": [ - { - "name": "tmc", - "smiles": "ClC(=O)c1cc(cc(c1)C(Cl)=O)C(Cl)=O", - }, - { - "name": "mpd", - "smiles": "C1=CC(=CC(=C1)N)N", - }, - ], - } - - with self.assertRaises(InputSchemaError): - self.parser.validate_inputs(inputs) - - def test_validate_inputs_duplicate_monomer_smiles_raises_error(self): - """Bad Ending: Duplicate monomer SMILES should raise DuplicateMonomerError.""" - inputs = { - "simulation_name": "bad_duplicate_smiles", - "simulations": [ - { - "tag": "10k", - "temperature": 300, - "density": 0.8, - "monomer_counts": { - "ethanol_a": 1, - "ethanol_b": 1, - }, - } - ], - "monomers": [ - { - "name": "ethanol_a", - "smiles": "CCO", - }, - { - "name": "ethanol_b", - "smiles": "CCO", - }, - ], - } - - with self.assertRaises(DuplicateMonomerError): - self.parser.validate_inputs(inputs) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_lunar_api_wrapper.py b/tests/test_lunar_api_wrapper.py deleted file mode 100644 index 20d938f4..00000000 --- a/tests/test_lunar_api_wrapper.py +++ /dev/null @@ -1,96 +0,0 @@ -""" -Lightweight tests for lunar_api_wrapper.py. - -Store this file at: - tests/test_lunar_api_wrapper.py - -These tests intentionally avoid running LUNAR itself. They test the wrapper's -path handling and merge_input.txt generation logic. -""" - -from pathlib import Path - -import pytest - -from AutoREACTER.reaction_preparation.ff_wrapper.lunar_client.lunar_executor import All2LMPResult -from AutoREACTER.reaction_preparation.ff_wrapper.lunar_client.lunar_utils import ( - get_ending_integer, - normalize_path, -) -from AutoREACTER.reaction_preparation.ff_wrapper.lunar_client.merge_builder import write_bond_react_merge_input - - -def test_get_ending_integer(tmp_path): - assert get_ending_integer("pre12") == 12 - assert get_ending_integer("post3") == 3 - assert get_ending_integer("data") is None - - -def test_normalize_windows_path_inside_wsl(tmp_path, monkeypatch): - monkeypatch.setattr( - "AutoREACTER.reaction_preparation.ff_wrapper.lunar_client.lunar_utils.is_wsl", - lambda: True, - ) - normalized = normalize_path(r"C:\Users\janit\Documents\file.data") - - assert normalized == "/mnt/c/Users/janit/Documents/file.data" - - -def test_write_bond_react_merge_input(tmp_path, monkeypatch): - cache_all2lmp = tmp_path / "lunar" / "all2lmp" - cache_bond_react_merge = tmp_path / "lunar" / "bond_react_merge" - cache_all2lmp.mkdir(parents=True) - cache_bond_react_merge.mkdir(parents=True) - - # Keep paths deterministic for this unit test. - monkeypatch.setattr( - "AutoREACTER.reaction_preparation.ff_wrapper.lunar_client.merge_builder.normalize_path", - lambda p: str(p), - ) - - monomer = Path("BisphenolA_typed_IFF.data") - pre = Path("pre1_typed_IFF.data") - post = Path("post1_typed_IFF.data") - - results = [ - All2LMPResult(id="BisphenolA", molecule=True, all2lmp_data_file=monomer), - All2LMPResult(id="pre1", molecule=False, all2lmp_data_file=pre), - All2LMPResult(id="post1", molecule=False, all2lmp_data_file=post), - ] - - merge_file = write_bond_react_merge_input( - cache_bond_react_merge=cache_bond_react_merge, - cache_all2lmp=cache_all2lmp, - all2lmp_results=results, - ) - text = merge_file.read_text(encoding="utf-8") - - assert merge_file.name == "merge_input.txt" - assert "data1" in text - assert "pre1" in text - assert "post1" in text - assert str(cache_all2lmp / monomer) in text - assert str(cache_all2lmp / pre) in text - assert str(cache_all2lmp / post) in text - - -def test_write_bond_react_merge_input_rejects_missing_post(tmp_path): - cache_all2lmp = tmp_path / "lunar" / "all2lmp" - cache_bond_react_merge = tmp_path / "lunar" / "bond_react_merge" - cache_all2lmp.mkdir(parents=True) - cache_bond_react_merge.mkdir(parents=True) - - results = [ - All2LMPResult( - id="pre1", - molecule=False, - all2lmp_data_file=Path("pre1_typed_IFF.data"), - ), - ] - - with pytest.raises(ValueError, match="Incomplete reaction pair"): - write_bond_react_merge_input( - cache_bond_react_merge=cache_bond_react_merge, - cache_all2lmp=cache_all2lmp, - all2lmp_results=results, - ) diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 00000000..76c137ec --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1,1013 @@ +from pathlib import Path +from types import SimpleNamespace + +import pytest + +import AutoREACTER as arx + + +# ============================================================================= +# Fixtures / helpers +# ============================================================================= + + +@pytest.fixture(autouse=True) +def reset_active_workflow(monkeypatch): + """ + Every test starts without an active global workflow. + + AutoREACTER intentionally keeps one package-level active workflow, so + isolating this state prevents tests from affecting one another. + """ + monkeypatch.setattr( + arx, + "_active_workflow", + None, + ) + + +class FakeWorkflow: + """ + Lightweight ARXCLI-like object used to verify public API delegation. + """ + + def __init__(self): + self.session = object() + self.calls = [] + + def show_molecules(self): + self.calls.append( + ("show_molecules",) + ) + return "molecules-image" + + def show_functional_groups(self): + self.calls.append( + ("show_functional_groups",) + ) + return "functional-groups-image" + + def show_reactions(self): + self.calls.append( + ("show_reactions",) + ) + return "reactions-image" + + def select_reactions(self): + self.calls.append( + ("select_reactions",) + ) + return None + + def show_non_reactants(self): + self.calls.append( + ("show_non_reactants",) + ) + return "non-reactants-image" + + def select_non_reactants(self): + self.calls.append( + ("select_non_reactants",) + ) + return None + + def prepare_reactions(self): + self.calls.append( + ("prepare_reactions",) + ) + return None + + def show_reaction_templates( + self, + highlight_type="template", + ): + self.calls.append( + ( + "show_reaction_templates", + highlight_type, + ) + ) + + return ( + f"templates-{highlight_type}" + ) + + def process(self): + self.calls.append( + ("process",) + ) + return None + + +def install_fake_workflow( + monkeypatch, +): + workflow = FakeWorkflow() + + monkeypatch.setattr( + arx, + "_active_workflow", + workflow, + ) + + return workflow + + +# ============================================================================= +# Package metadata +# ============================================================================= + + +def test_package_title(): + assert arx.__title__ == "AutoREACTER" + + +def test_package_version(): + assert arx.__version__ == "0.3" + + +def test_package_release_matches_version(): + assert ( + arx.__release__ + == arx.__version__ + ) + + +def test_package_license(): + assert arx.__license__ == "MIT" + + +def test_package_authors(): + assert arx.__authors__ == [ + "Janitha Mahanthe", + "Jacob Gissinger", + ] + + +def test_package_author_string_matches_authors(): + assert ( + arx.__author__ + == ", ".join(arx.__authors__) + ) + + +# ============================================================================= +# _ensure_workflow +# ============================================================================= + + +def test_ensure_workflow_raises_without_active_session(): + with pytest.raises( + RuntimeError, + match="No active session", + ): + arx._ensure_workflow() + + +def test_ensure_workflow_error_tells_user_to_call_run(): + with pytest.raises(RuntimeError) as exc_info: + arx._ensure_workflow() + + message = str(exc_info.value) + + assert "arx.run" in message + assert "your_file.json" in message + + +def test_ensure_workflow_returns_active_workflow( + monkeypatch, +): + workflow = object() + + monkeypatch.setattr( + arx, + "_active_workflow", + workflow, + ) + + assert ( + arx._ensure_workflow() + is workflow + ) + + +# ============================================================================= +# session() +# ============================================================================= + + +def test_session_requires_active_workflow(): + with pytest.raises( + RuntimeError, + match="No active session", + ): + arx.session() + + +def test_session_returns_active_workflow_session( + monkeypatch, +): + workflow = FakeWorkflow() + + monkeypatch.setattr( + arx, + "_active_workflow", + workflow, + ) + + assert ( + arx.session() + is workflow.session + ) + + +# ============================================================================= +# run() +# ============================================================================= + + +def test_run_rejects_missing_input_file( + tmp_path, + monkeypatch, +): + constructed = [] + + class FakeARXCLI: + def __init__( + self, + input_path, + ): + constructed.append( + input_path + ) + + monkeypatch.setattr( + arx, + "ARXCLI", + FakeARXCLI, + ) + + missing = ( + tmp_path + / "missing.json" + ) + + with pytest.raises( + FileNotFoundError, + match="Input file not found", + ): + arx.run(missing) + + assert constructed == [] + + assert ( + arx._active_workflow + is None + ) + + +def test_run_constructs_arxcli_with_resolved_path( + tmp_path, + monkeypatch, +): + input_file = ( + tmp_path / "input.json" + ) + + input_file.write_text( + "{}", + encoding="utf-8", + ) + + received = [] + + class FakeARXCLI: + def __init__( + self, + input_path, + ): + received.append( + input_path + ) + + monkeypatch.setattr( + arx, + "ARXCLI", + FakeARXCLI, + ) + + result = arx.run( + input_file + ) + + assert received == [ + input_file.resolve() + ] + + assert ( + result + is arx._active_workflow + ) + + +def test_run_accepts_string_path( + tmp_path, + monkeypatch, +): + input_file = ( + tmp_path / "input.json" + ) + + input_file.write_text( + "{}", + encoding="utf-8", + ) + + received = [] + + class FakeARXCLI: + def __init__( + self, + input_path, + ): + received.append( + input_path + ) + + monkeypatch.setattr( + arx, + "ARXCLI", + FakeARXCLI, + ) + + arx.run( + str(input_file) + ) + + assert received == [ + input_file.resolve() + ] + + assert isinstance( + received[0], + Path, + ) + + +def test_run_resolves_relative_path( + tmp_path, + monkeypatch, +): + input_file = ( + tmp_path / "input.json" + ) + + input_file.write_text( + "{}", + encoding="utf-8", + ) + + monkeypatch.chdir( + tmp_path + ) + + received = [] + + class FakeARXCLI: + def __init__( + self, + input_path, + ): + received.append( + input_path + ) + + monkeypatch.setattr( + arx, + "ARXCLI", + FakeARXCLI, + ) + + arx.run( + "input.json" + ) + + assert received == [ + input_file.resolve() + ] + + +def test_run_returns_created_workflow( + tmp_path, + monkeypatch, +): + input_file = ( + tmp_path / "input.json" + ) + + input_file.write_text( + "{}", + encoding="utf-8", + ) + + created = object() + + monkeypatch.setattr( + arx, + "ARXCLI", + lambda path: created, + ) + + result = arx.run( + input_file + ) + + assert result is created + + assert ( + arx._active_workflow + is created + ) + + +def test_run_replaces_existing_workflow( + tmp_path, + monkeypatch, +): + first_input = ( + tmp_path / "first.json" + ) + + second_input = ( + tmp_path / "second.json" + ) + + first_input.write_text( + "{}", + encoding="utf-8", + ) + + second_input.write_text( + "{}", + encoding="utf-8", + ) + + created = [] + + class FakeARXCLI: + def __init__( + self, + input_path, + ): + self.input_path = ( + input_path + ) + + created.append(self) + + monkeypatch.setattr( + arx, + "ARXCLI", + FakeARXCLI, + ) + + first = arx.run( + first_input + ) + + second = arx.run( + second_input + ) + + assert first is created[0] + assert second is created[1] + + assert ( + arx._active_workflow + is second + ) + + assert ( + first is not second + ) + + +def test_run_does_not_replace_existing_workflow_if_new_construction_fails( + tmp_path, + monkeypatch, +): + input_file = ( + tmp_path / "input.json" + ) + + input_file.write_text( + "{}", + encoding="utf-8", + ) + + existing = object() + + monkeypatch.setattr( + arx, + "_active_workflow", + existing, + ) + + class FailingARXCLI: + def __init__( + self, + input_path, + ): + raise RuntimeError( + "construction failed" + ) + + monkeypatch.setattr( + arx, + "ARXCLI", + FailingARXCLI, + ) + + with pytest.raises( + RuntimeError, + match="construction failed", + ): + arx.run(input_file) + + # Assignment happens only after successful ARXCLI construction. + assert ( + arx._active_workflow + is existing + ) + + +# ============================================================================= +# Delegation before run() +# ============================================================================= + + +@pytest.mark.parametrize( + "api_call", + [ + lambda: arx.show_molecules(), + lambda: arx.show_functional_groups(), + lambda: arx.show_reactions(), + lambda: arx.select_reactions(), + lambda: arx.show_non_reactants(), + lambda: arx.select_non_reactants(), + lambda: arx.prepare_reactions(), + lambda: arx.show_reaction_templates(), + lambda: arx.process(), + ], +) +def test_public_api_requires_run_first( + api_call, +): + with pytest.raises( + RuntimeError, + match="No active session", + ): + api_call() + + +# ============================================================================= +# Public API delegation +# ============================================================================= + + +def test_show_molecules_delegates( + monkeypatch, +): + workflow = install_fake_workflow( + monkeypatch + ) + + result = arx.show_molecules() + + assert ( + result + == "molecules-image" + ) + + assert workflow.calls == [ + ("show_molecules",) + ] + + +def test_show_functional_groups_delegates( + monkeypatch, +): + workflow = install_fake_workflow( + monkeypatch + ) + + result = ( + arx.show_functional_groups() + ) + + assert ( + result + == "functional-groups-image" + ) + + assert workflow.calls == [ + ("show_functional_groups",) + ] + + +def test_show_reactions_delegates( + monkeypatch, +): + workflow = install_fake_workflow( + monkeypatch + ) + + result = arx.show_reactions() + + assert ( + result + == "reactions-image" + ) + + assert workflow.calls == [ + ("show_reactions",) + ] + + +def test_select_reactions_delegates( + monkeypatch, +): + workflow = install_fake_workflow( + monkeypatch + ) + + result = arx.select_reactions() + + assert result is None + + assert workflow.calls == [ + ("select_reactions",) + ] + + +def test_show_non_reactants_delegates( + monkeypatch, +): + workflow = install_fake_workflow( + monkeypatch + ) + + result = ( + arx.show_non_reactants() + ) + + assert ( + result + == "non-reactants-image" + ) + + assert workflow.calls == [ + ("show_non_reactants",) + ] + + +def test_show_non_reactants_propagates_none( + monkeypatch, +): + workflow = FakeWorkflow() + + def return_none(): + workflow.calls.append( + ("show_non_reactants",) + ) + + return None + + workflow.show_non_reactants = ( + return_none + ) + + monkeypatch.setattr( + arx, + "_active_workflow", + workflow, + ) + + assert ( + arx.show_non_reactants() + is None + ) + + +def test_select_non_reactants_delegates( + monkeypatch, +): + workflow = install_fake_workflow( + monkeypatch + ) + + result = ( + arx.select_non_reactants() + ) + + assert result is None + + assert workflow.calls == [ + ("select_non_reactants",) + ] + + +def test_prepare_reactions_delegates( + monkeypatch, +): + workflow = install_fake_workflow( + monkeypatch + ) + + result = ( + arx.prepare_reactions() + ) + + assert result is None + + assert workflow.calls == [ + ("prepare_reactions",) + ] + + +def test_process_delegates( + monkeypatch, +): + workflow = install_fake_workflow( + monkeypatch + ) + + result = arx.process() + + assert result is None + + assert workflow.calls == [ + ("process",) + ] + + +# ============================================================================= +# show_reaction_templates() +# ============================================================================= + + +@pytest.mark.parametrize( + "highlight_type", + [ + "template", + "edge", + "initiators", + "delete", + ], +) +def test_show_reaction_templates_accepts_supported_types( + monkeypatch, + highlight_type, +): + workflow = install_fake_workflow( + monkeypatch + ) + + result = ( + arx.show_reaction_templates( + highlight_type + ) + ) + + assert result == ( + f"templates-{highlight_type}" + ) + + assert workflow.calls == [ + ( + "show_reaction_templates", + highlight_type, + ) + ] + + +@pytest.mark.parametrize( + "highlight_type, expected", + [ + ("TEMPLATE", "template"), + ("Edge", "edge"), + ("INITIATORS", "initiators"), + ("Delete", "delete"), + ], +) +def test_show_reaction_templates_is_case_insensitive( + monkeypatch, + highlight_type, + expected, +): + workflow = install_fake_workflow( + monkeypatch + ) + + result = ( + arx.show_reaction_templates( + highlight_type + ) + ) + + assert result == ( + f"templates-{expected}" + ) + + assert workflow.calls == [ + ( + "show_reaction_templates", + expected, + ) + ] + + +def test_show_reaction_templates_defaults_to_template( + monkeypatch, +): + workflow = install_fake_workflow( + monkeypatch + ) + + result = ( + arx.show_reaction_templates() + ) + + assert ( + result + == "templates-template" + ) + + assert workflow.calls == [ + ( + "show_reaction_templates", + "template", + ) + ] + + +def test_show_reaction_templates_none_defaults_to_template( + monkeypatch, +): + workflow = install_fake_workflow( + monkeypatch + ) + + result = ( + arx.show_reaction_templates( + None + ) + ) + + assert ( + result + == "templates-template" + ) + + assert workflow.calls == [ + ( + "show_reaction_templates", + "template", + ) + ] + + +@pytest.mark.parametrize( + "highlight_type", + [ + "", + "wrong", + "reaction", + "atoms", + "templates", + ], +) +def test_show_reaction_templates_rejects_invalid_type( + monkeypatch, + highlight_type, +): + workflow = install_fake_workflow( + monkeypatch + ) + + if highlight_type == "": + # Current API intentionally treats false-like/empty input + # as the default "template". + result = ( + arx.show_reaction_templates( + highlight_type + ) + ) + + assert ( + result + == "templates-template" + ) + + return + + with pytest.raises( + ValueError, + match="Invalid highlight_type", + ): + arx.show_reaction_templates( + highlight_type + ) + + # Validation occurs before delegation. + assert workflow.calls == [] + + +def test_show_reaction_templates_error_lists_allowed_values( + monkeypatch, +): + install_fake_workflow( + monkeypatch + ) + + with pytest.raises( + ValueError, + ) as exc_info: + arx.show_reaction_templates( + "bad" + ) + + message = str( + exc_info.value + ) + + assert "template" in message + assert "edge" in message + assert "initiators" in message + assert "delete" in message + + +# ============================================================================= +# __all__ / public export contract +# ============================================================================= + + +def test_public_all_contains_package_metadata(): + expected = { + "__title__", + "__version__", + "__release__", + "__authors__", + "__license__", + } + + assert expected.issubset( + set(arx.__all__) + ) + + +def test_public_all_contains_user_workflow_commands(): + expected = { + "run", + "show_molecules", + "show_functional_groups", + "show_reactions", + "select_reactions", + "show_non_reactants", + "select_non_reactants", + "prepare_reactions", + "show_reaction_templates", + "process", + } + + assert expected.issubset( + set(arx.__all__) + ) + + +def test_session_is_part_of_public_api(): + """ + session() is used directly by the documented/user-facing workflow: + + session = arx.session() + + Therefore it should be exported alongside the other public API helpers. + """ + assert "session" in arx.__all__ + + +def test_public_all_has_no_duplicates(): + assert len(arx.__all__) == len( + set(arx.__all__) + ) + + +def test_every_name_in_public_all_exists(): + for name in arx.__all__: + assert hasattr( + arx, + name, + ), ( + f"{name!r} appears in " + "__all__ but does not exist" + ) \ No newline at end of file diff --git a/tests/unit/detectors/__init__.py b/tests/unit/detectors/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/detectors/functional_groups_library/__init__.py b/tests/unit/detectors/functional_groups_library/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/detectors/functional_groups_library/test_active_centers.py b/tests/unit/detectors/functional_groups_library/test_active_centers.py new file mode 100644 index 00000000..f18a0c00 --- /dev/null +++ b/tests/unit/detectors/functional_groups_library/test_active_centers.py @@ -0,0 +1,119 @@ +from rdkit import Chem + +from AutoREACTER.detectors.functional_groups_detector import ( + FunctionalGroupsDetector, +) +from AutoREACTER.detectors.functional_groups_library.active_centers import ( + FUNCTIONAL_GROUPS, +) + + +def test_expected_active_groups(): + assert set(FUNCTIONAL_GROUPS) == { + "vinyl_chain_end_radical", + } + + +def test_vinyl_chain_end_radical_metadata(): + group = FUNCTIONAL_GROUPS["vinyl_chain_end_radical"] + + assert group["functionality_type"] == "vinyl" + assert group["group_name"] == "vinyl_chain_end_radical" + assert group["comments"] is None + + +def test_vinyl_chain_end_radical_smarts_compiles(): + group = FUNCTIONAL_GROUPS["vinyl_chain_end_radical"] + + pattern = Chem.MolFromSmarts(group["smarts_1"]) + + assert pattern is not None + + +def test_vinyl_chain_end_radical_smarts_matches_trivalent_non_ring_carbon(): + """ + A neutral trivalent carbon radical with three carbon neighbors + should match the active-center SMARTS. + """ + group = FUNCTIONAL_GROUPS["vinyl_chain_end_radical"] + + mol = Chem.MolFromSmiles("C[C](C)C") + pattern = Chem.MolFromSmarts(group["smarts_1"]) + + matches = mol.GetSubstructMatches(pattern) + + assert len(matches) == 1 + + +def test_vinyl_chain_end_radical_smarts_does_not_match_saturated_carbon(): + """ + A normal tetravalent saturated carbon should not be identified + as a chain-end radical. + """ + group = FUNCTIONAL_GROUPS["vinyl_chain_end_radical"] + + mol = Chem.MolFromSmiles("CC(C)C") + pattern = Chem.MolFromSmarts(group["smarts_1"]) + + assert mol.GetSubstructMatches(pattern) == () + + +def test_vinyl_chain_end_radical_smarts_does_not_match_alkene_carbon(): + group = FUNCTIONAL_GROUPS["vinyl_chain_end_radical"] + + mol = Chem.MolFromSmiles("C=C(C)C") + pattern = Chem.MolFromSmarts(group["smarts_1"]) + + assert mol.GetSubstructMatches(pattern) == () + + +def test_vinyl_chain_end_radical_smarts_excludes_ring_carbon(): + """ + The !R condition should prevent ring atoms from being classified + as vinyl chain-end radicals. + """ + group = FUNCTIONAL_GROUPS["vinyl_chain_end_radical"] + + mol = Chem.MolFromSmiles("[C]1(C)CCC1") + pattern = Chem.MolFromSmarts(group["smarts_1"]) + + assert mol.GetSubstructMatches(pattern) == () + + +def test_vinyl_chain_end_radical_qualifies_through_detector(): + detector = FunctionalGroupsDetector() + group = FUNCTIONAL_GROUPS["vinyl_chain_end_radical"] + + mol = Chem.MolFromSmiles("C[C](C)C") + + functionality_count, count_1, count_2, matches = ( + detector.detect_monomer_functionality( + mol, + group["functionality_type"], + group["smarts_1"], + ) + ) + + assert functionality_count == 1 + assert count_1 == 1 + assert count_2 is None + assert len(matches) == 1 + + +def test_saturated_carbon_does_not_qualify_through_detector(): + detector = FunctionalGroupsDetector() + group = FUNCTIONAL_GROUPS["vinyl_chain_end_radical"] + + mol = Chem.MolFromSmiles("CC(C)C") + + functionality_count, count_1, count_2, matches = ( + detector.detect_monomer_functionality( + mol, + group["functionality_type"], + group["smarts_1"], + ) + ) + + assert functionality_count == 0 + assert count_1 == 0 + assert count_2 is None \ No newline at end of file diff --git a/tests/unit/detectors/functional_groups_library/test_carboxyl_and_carbonyl_groups.py b/tests/unit/detectors/functional_groups_library/test_carboxyl_and_carbonyl_groups.py new file mode 100644 index 00000000..069b49af --- /dev/null +++ b/tests/unit/detectors/functional_groups_library/test_carboxyl_and_carbonyl_groups.py @@ -0,0 +1,284 @@ +import pytest +from rdkit import Chem + +from AutoREACTER.detectors.functional_groups_detector import ( + FunctionalGroupsDetector, +) +from AutoREACTER.detectors.functional_groups_library.carboxyl_and_carbonyl_groups import ( + FUNCTIONAL_GROUPS, +) + + +EXPECTED_GROUPS = { + "di_carboxylic_acid_monomer", + "di_carboxylic_acid_halide_monomer", + "di_carboxylic_ester_monomer", + "phosgene_monomer", + "diphenyl_carbonate_monomer", +} + + +def detect(group_key, smiles): + detector = FunctionalGroupsDetector() + group = FUNCTIONAL_GROUPS[group_key] + mol = Chem.MolFromSmiles(smiles) + + return detector.detect_monomer_functionality( + mol, + group["functionality_type"], + group["smarts_1"], + ) + + +# ============================================================================ +# Structure +# ============================================================================ + +def test_expected_active_groups(): + assert set(FUNCTIONAL_GROUPS) == EXPECTED_GROUPS + + +@pytest.mark.parametrize("group_key", sorted(EXPECTED_GROUPS)) +def test_required_fields_exist(group_key): + group = FUNCTIONAL_GROUPS[group_key] + + assert "functionality_type" in group + assert "smarts_1" in group + assert "group_name" in group + assert "comments" in group + + +@pytest.mark.parametrize("group_key", sorted(EXPECTED_GROUPS)) +def test_all_smarts_compile(group_key): + pattern = Chem.MolFromSmarts( + FUNCTIONAL_GROUPS[group_key]["smarts_1"] + ) + + assert pattern is not None + + +# ============================================================================ +# Dicarboxylic acid +# ============================================================================ + +def test_dicarboxylic_acid_metadata(): + group = FUNCTIONAL_GROUPS["di_carboxylic_acid_monomer"] + + assert group["functionality_type"] == "di_identical" + assert group["group_name"] == "di_carboxylic_acid" + + +def test_adipic_acid_has_two_carboxylic_acid_sites(): + result = detect( + "di_carboxylic_acid_monomer", + "O=C(O)CCCCC(=O)O", + ) + + functionality_count, count_1, count_2, matches = result + + assert functionality_count == 2 + assert count_1 == 2 + assert count_2 is None + assert len(matches) == 2 + + +def test_monocarboxylic_acid_is_rejected_as_dicarboxylic(): + result = detect( + "di_carboxylic_acid_monomer", + "CC(=O)O", + ) + + functionality_count, count_1, _, matches = result + + assert functionality_count == 0 + assert count_1 == 1 + assert len(matches) == 1 + + +def test_ester_does_not_match_carboxylic_acid(): + result = detect( + "di_carboxylic_acid_monomer", + "CC(=O)OC", + ) + + assert result == (0, 0, None, ()) + + +# ============================================================================ +# Dicarboxylic acid halide +# ============================================================================ + +def test_dicarboxylic_acid_halide_metadata(): + group = FUNCTIONAL_GROUPS[ + "di_carboxylic_acid_halide_monomer" + ] + + assert group["functionality_type"] == "di_identical" + assert group["group_name"] == "di_carboxylic_acid_halide" + + +def test_terephthaloyl_chloride_has_two_acid_halide_sites(): + result = detect( + "di_carboxylic_acid_halide_monomer", + "O=C(Cl)c1ccc(C(=O)Cl)cc1", + ) + + functionality_count, count_1, count_2, matches = result + + assert functionality_count == 2 + assert count_1 == 2 + assert count_2 is None + assert len(matches) == 2 + + +def test_single_acid_chloride_is_rejected_as_di_acid_halide(): + result = detect( + "di_carboxylic_acid_halide_monomer", + "CC(=O)Cl", + ) + + functionality_count, count_1, _, matches = result + + assert functionality_count == 0 + assert count_1 == 1 + assert len(matches) == 1 + + +# ============================================================================ +# Dicarboxylic ester +# ============================================================================ + +def test_dicarboxylic_ester_metadata(): + group = FUNCTIONAL_GROUPS["di_carboxylic_ester_monomer"] + + assert group["functionality_type"] == "di_identical" + assert group["group_name"] == "di_carboxylic_ester" + + +def test_diester_has_two_ester_sites(): + result = detect( + "di_carboxylic_ester_monomer", + "COC(=O)CCCCC(=O)OC", + ) + + functionality_count, count_1, count_2, matches = result + + assert functionality_count == 2 + assert count_1 == 2 + assert count_2 is None + assert len(matches) == 2 + + +def test_single_ester_is_rejected_as_diester(): + result = detect( + "di_carboxylic_ester_monomer", + "CC(=O)OC", + ) + + functionality_count, count_1, _, matches = result + + assert functionality_count == 0 + assert count_1 == 1 + assert len(matches) == 1 + + +def test_carboxylic_acid_does_not_match_ester_pattern(): + result = detect( + "di_carboxylic_ester_monomer", + "CC(=O)O", + ) + + assert result == (0, 0, None, ()) + + +# ============================================================================ +# Phosgene +# ============================================================================ + +def test_phosgene_metadata(): + group = FUNCTIONAL_GROUPS["phosgene_monomer"] + + assert group["functionality_type"] == "mono" + assert group["group_name"] == "phosgene" + + +def test_phosgene_pattern_matches_phosgene_once(): + result = detect( + "phosgene_monomer", + "O=C(Cl)Cl", + ) + + functionality_count, count_1, count_2, matches = result + + assert functionality_count == 1 + assert count_1 == 1 + assert count_2 is None + assert len(matches) == 1 + + +@pytest.mark.parametrize( + "smiles", + [ + "CC(=O)Cl", + "O=C(O)O", + "O=C(OC)OC", + ], +) +def test_phosgene_pattern_rejects_non_phosgene_carbonyls(smiles): + result = detect( + "phosgene_monomer", + smiles, + ) + + assert result == (0, 0, None, ()) + + +# ============================================================================ +# Diphenyl carbonate +# ============================================================================ + +def test_diphenyl_carbonate_metadata(): + group = FUNCTIONAL_GROUPS["diphenyl_carbonate_monomer"] + + assert group["functionality_type"] == "di_identical" + assert group["group_name"] == "diphenyl_carbonate" + + +def test_diphenyl_carbonate_has_two_aryl_carbonate_matches(): + result = detect( + "diphenyl_carbonate_monomer", + "O=C(Oc1ccccc1)Oc1ccccc1", + ) + + functionality_count, count_1, count_2, matches = result + + assert functionality_count == 2 + assert count_1 == 2 + assert count_2 is None + assert len(matches) == 2 + + +def test_single_aryl_carbonate_side_is_rejected(): + """ + Methyl phenyl carbonate contains only one O-aryl carbonate match, + so it cannot satisfy the di_identical >= 2 rule. + """ + result = detect( + "diphenyl_carbonate_monomer", + "O=C(Oc1ccccc1)OC", + ) + + functionality_count, count_1, _, matches = result + + assert functionality_count == 0 + assert count_1 == 1 + assert len(matches) == 1 + + +def test_dimethyl_carbonate_does_not_match_diphenyl_carbonate(): + result = detect( + "diphenyl_carbonate_monomer", + "O=C(OC)OC", + ) + + assert result == (0, 0, None, ()) \ No newline at end of file diff --git a/tests/unit/detectors/functional_groups_library/test_heterocumulene_groups.py b/tests/unit/detectors/functional_groups_library/test_heterocumulene_groups.py new file mode 100644 index 00000000..acb16e32 --- /dev/null +++ b/tests/unit/detectors/functional_groups_library/test_heterocumulene_groups.py @@ -0,0 +1,121 @@ +from rdkit import Chem + +from AutoREACTER.detectors.functional_groups_detector import ( + FunctionalGroupsDetector, +) +from AutoREACTER.detectors.functional_groups_library.heterocumulene_groups import ( + FUNCTIONAL_GROUPS, +) + + +def detect(smiles): + detector = FunctionalGroupsDetector() + group = FUNCTIONAL_GROUPS["di_isocyanate_monomer"] + + return detector.detect_monomer_functionality( + Chem.MolFromSmiles(smiles), + group["functionality_type"], + group["smarts_1"], + ) + + +# ============================================================================ +# Structure / metadata +# ============================================================================ + +def test_expected_active_groups(): + assert set(FUNCTIONAL_GROUPS) == { + "di_isocyanate_monomer", + } + + +def test_di_isocyanate_metadata(): + group = FUNCTIONAL_GROUPS["di_isocyanate_monomer"] + + assert group["functionality_type"] == "di_identical" + assert group["group_name"] == "di_isocyanate" + assert group["comments"] is None + + +def test_di_isocyanate_smarts_compiles(): + group = FUNCTIONAL_GROUPS["di_isocyanate_monomer"] + + pattern = Chem.MolFromSmarts(group["smarts_1"]) + + assert pattern is not None + + +# ============================================================================ +# SMARTS behavior +# ============================================================================ + +def test_diisocyanate_contains_two_isocyanate_sites(): + group = FUNCTIONAL_GROUPS["di_isocyanate_monomer"] + + mol = Chem.MolFromSmiles("O=C=NCCCCCCN=C=O") + pattern = Chem.MolFromSmarts(group["smarts_1"]) + + matches = mol.GetSubstructMatches(pattern) + + assert len(matches) == 2 + + +def test_monoisocyanate_contains_one_isocyanate_site(): + group = FUNCTIONAL_GROUPS["di_isocyanate_monomer"] + + mol = Chem.MolFromSmiles("CN=C=O") + pattern = Chem.MolFromSmarts(group["smarts_1"]) + + matches = mol.GetSubstructMatches(pattern) + + assert len(matches) == 1 + + +def test_amine_does_not_match_isocyanate(): + group = FUNCTIONAL_GROUPS["di_isocyanate_monomer"] + + mol = Chem.MolFromSmiles("NCCCCCCN") + pattern = Chem.MolFromSmarts(group["smarts_1"]) + + assert mol.GetSubstructMatches(pattern) == () + + +def test_carbon_dioxide_does_not_match_isocyanate(): + group = FUNCTIONAL_GROUPS["di_isocyanate_monomer"] + + mol = Chem.MolFromSmiles("O=C=O") + pattern = Chem.MolFromSmarts(group["smarts_1"]) + + assert mol.GetSubstructMatches(pattern) == () + + +# ============================================================================ +# Detector behavior +# ============================================================================ + +def test_diisocyanate_qualifies(): + functionality_count, count_1, count_2, matches = detect( + "O=C=NCCCCCCN=C=O" + ) + + assert functionality_count == 2 + assert count_1 == 2 + assert count_2 is None + assert len(matches) == 2 + + +def test_monoisocyanate_is_rejected_as_diisocyanate(): + functionality_count, count_1, count_2, matches = detect( + "CN=C=O" + ) + + assert functionality_count == 0 + assert count_1 == 1 + assert count_2 is None + assert len(matches) == 1 + + +def test_non_isocyanate_is_rejected(): + result = detect("NCCCCCCN") + + assert result == (0, 0, None, ()) \ No newline at end of file diff --git a/tests/unit/detectors/functional_groups_library/test_mixed_ab_groups.py b/tests/unit/detectors/functional_groups_library/test_mixed_ab_groups.py new file mode 100644 index 00000000..a8232079 --- /dev/null +++ b/tests/unit/detectors/functional_groups_library/test_mixed_ab_groups.py @@ -0,0 +1,323 @@ +import pytest +from rdkit import Chem + +from AutoREACTER.detectors.functional_groups_detector import ( + FunctionalGroupsDetector, +) +from AutoREACTER.detectors.functional_groups_library.mixed_ab_groups import ( + FUNCTIONAL_GROUPS, +) + + +EXPECTED_GROUPS = { + "hydroxy_carboxylic_acid_monomer", + "hydroxy_acid_halides_monomer", + "amino_acid_monomer", + "carboxylic_acid_acid_halide_monomer", + "hydroxy_thiol_monomer", +} + + +def detect(group_key, smiles): + detector = FunctionalGroupsDetector() + group = FUNCTIONAL_GROUPS[group_key] + + return detector.detect_monomer_functionality( + Chem.MolFromSmiles(smiles), + group["functionality_type"], + group["smarts_1"], + group["smarts_2"], + ) + + +# ============================================================================ +# Structure +# ============================================================================ + +def test_expected_active_groups(): + assert set(FUNCTIONAL_GROUPS) == EXPECTED_GROUPS + + +@pytest.mark.parametrize("group_key", sorted(EXPECTED_GROUPS)) +def test_all_groups_are_di_different(group_key): + assert ( + FUNCTIONAL_GROUPS[group_key]["functionality_type"] + == "di_different" + ) + + +@pytest.mark.parametrize("group_key", sorted(EXPECTED_GROUPS)) +def test_required_fields_exist(group_key): + group = FUNCTIONAL_GROUPS[group_key] + + assert "functionality_type" in group + assert "smarts_1" in group + assert "smarts_2" in group + assert "group_name" in group + assert "comments" in group + + +@pytest.mark.parametrize("group_key", sorted(EXPECTED_GROUPS)) +def test_both_smarts_patterns_compile(group_key): + group = FUNCTIONAL_GROUPS[group_key] + + assert Chem.MolFromSmarts(group["smarts_1"]) is not None + assert Chem.MolFromSmarts(group["smarts_2"]) is not None + + +# ============================================================================ +# Hydroxy carboxylic acid +# ============================================================================ + +def test_hydroxy_carboxylic_acid_metadata(): + group = FUNCTIONAL_GROUPS[ + "hydroxy_carboxylic_acid_monomer" + ] + + assert group["group_name"] == "hydroxy_carboxylic_acid" + + +def test_lactic_acid_qualifies_as_hydroxy_carboxylic_acid(): + functionality_count, count_1, count_2, matches = detect( + "hydroxy_carboxylic_acid_monomer", + "CC(O)C(=O)O", + ) + + assert functionality_count == 2 + assert count_1 == 1 + assert count_2 == 1 + assert len(matches) == 2 + + +def test_carboxylic_acid_without_alcohol_is_rejected(): + functionality_count, count_1, count_2, matches = detect( + "hydroxy_carboxylic_acid_monomer", + "CC(=O)O", + ) + + assert functionality_count == 0 + assert count_1 == 0 + assert count_2 == 1 + assert len(matches) == 1 + + +def test_diol_without_carboxylic_acid_is_rejected(): + functionality_count, count_1, count_2, matches = detect( + "hydroxy_carboxylic_acid_monomer", + "OCCO", + ) + + assert functionality_count == 0 + assert count_1 == 2 + assert count_2 == 0 + assert len(matches) == 2 + + +# ============================================================================ +# Hydroxy acid halide +# ============================================================================ + +def test_hydroxy_acid_halide_metadata(): + group = FUNCTIONAL_GROUPS[ + "hydroxy_acid_halides_monomer" + ] + + assert group["group_name"] == "hydroxy_acid_halide" + + +def test_hydroxy_acid_chloride_qualifies(): + functionality_count, count_1, count_2, matches = detect( + "hydroxy_acid_halides_monomer", + "OCCC(=O)Cl", + ) + + assert functionality_count == 2 + assert count_1 == 1 + assert count_2 == 1 + assert len(matches) == 2 + + +def test_acid_chloride_without_hydroxyl_is_rejected(): + functionality_count, count_1, count_2, matches = detect( + "hydroxy_acid_halides_monomer", + "CCC(=O)Cl", + ) + + assert functionality_count == 0 + assert count_1 == 0 + assert count_2 == 1 + assert len(matches) == 1 + + +def test_alcohol_without_acid_halide_is_rejected(): + functionality_count, count_1, count_2, matches = detect( + "hydroxy_acid_halides_monomer", + "CCCO", + ) + + assert functionality_count == 0 + assert count_1 == 1 + assert count_2 == 0 + assert len(matches) == 1 + + +# ============================================================================ +# Amino acid +# ============================================================================ + +def test_amino_acid_metadata(): + group = FUNCTIONAL_GROUPS["amino_acid_monomer"] + + assert group["group_name"] == "amino_acid" + + +def test_glycine_qualifies_as_amino_acid(): + functionality_count, count_1, count_2, matches = detect( + "amino_acid_monomer", + "NCC(=O)O", + ) + + assert functionality_count == 2 + assert count_1 == 1 + assert count_2 == 1 + assert len(matches) == 2 + + +def test_amine_without_carboxylic_acid_is_rejected(): + functionality_count, count_1, count_2, matches = detect( + "amino_acid_monomer", + "CCN", + ) + + assert functionality_count == 0 + assert count_1 == 1 + assert count_2 == 0 + assert len(matches) == 1 + + +def test_carboxylic_acid_without_amine_is_rejected(): + functionality_count, count_1, count_2, matches = detect( + "amino_acid_monomer", + "CC(=O)O", + ) + + assert functionality_count == 0 + assert count_1 == 0 + assert count_2 == 1 + assert len(matches) == 1 + + +# ============================================================================ +# Carboxylic acid + acid halide +# ============================================================================ + +def test_carboxylic_acid_acid_halide_metadata(): + group = FUNCTIONAL_GROUPS[ + "carboxylic_acid_acid_halide_monomer" + ] + + assert group["group_name"] == "carboxylic_acid_acid_halide" + + +def test_acid_acid_chloride_qualifies(): + functionality_count, count_1, count_2, matches = detect( + "carboxylic_acid_acid_halide_monomer", + "O=C(O)CCC(=O)Cl", + ) + + assert functionality_count == 2 + assert count_1 == 1 + assert count_2 == 1 + assert len(matches) == 2 + + +def test_dicarboxylic_acid_without_halide_is_rejected(): + functionality_count, count_1, count_2, matches = detect( + "carboxylic_acid_acid_halide_monomer", + "O=C(O)CCC(=O)O", + ) + + assert functionality_count == 0 + assert count_1 == 2 + assert count_2 == 0 + assert len(matches) == 2 + + +def test_diacid_chloride_without_acid_is_rejected(): + functionality_count, count_1, count_2, matches = detect( + "carboxylic_acid_acid_halide_monomer", + "O=C(Cl)CCC(=O)Cl", + ) + + assert functionality_count == 0 + assert count_1 == 0 + assert count_2 == 2 + assert len(matches) == 2 + + +# ============================================================================ +# Hydroxy thiol +# ============================================================================ + +def test_hydroxy_thiol_metadata(): + group = FUNCTIONAL_GROUPS["hydroxy_thiol_monomer"] + + assert group["group_name"] == "hydroxy_thiol" + + +def test_hydroxy_thiol_qualifies(): + functionality_count, count_1, count_2, matches = detect( + "hydroxy_thiol_monomer", + "OCCS", + ) + + assert functionality_count == 2 + assert count_1 == 1 + assert count_2 == 1 + assert len(matches) == 2 + + +def test_alcohol_without_thiol_is_rejected(): + functionality_count, count_1, count_2, matches = detect( + "hydroxy_thiol_monomer", + "CCO", + ) + + assert functionality_count == 0 + assert count_1 == 1 + assert count_2 == 0 + assert len(matches) == 1 + + +def test_thiol_without_hydroxyl_is_rejected(): + functionality_count, count_1, count_2, matches = detect( + "hydroxy_thiol_monomer", + "CCS", + ) + + assert functionality_count == 0 + assert count_1 == 0 + assert count_2 == 1 + assert len(matches) == 1 + + +# ============================================================================ +# Multiplicity preservation +# ============================================================================ + +def test_di_different_preserves_unequal_site_counts(): + """ + A di_different monomer only requires >=1 of each type. + + Additional sites must remain visible in the returned counts because + downstream logic may use them for branching / reaction enumeration. + """ + functionality_count, count_1, count_2, matches = detect( + "hydroxy_carboxylic_acid_monomer", + "O=C(O)CC(O)(CC(=O)O)C(=O)O", + ) + + assert functionality_count == 2 + assert count_1 == 1 + assert count_2 == 3 + assert len(matches) == 4 \ No newline at end of file diff --git a/tests/unit/detectors/functional_groups_library/test_nitrogen_groups.py b/tests/unit/detectors/functional_groups_library/test_nitrogen_groups.py new file mode 100644 index 00000000..70c51c3f --- /dev/null +++ b/tests/unit/detectors/functional_groups_library/test_nitrogen_groups.py @@ -0,0 +1,297 @@ +import pytest +from rdkit import Chem + +from AutoREACTER.detectors.functional_groups_detector import ( + FunctionalGroupsDetector, +) +from AutoREACTER.detectors.functional_groups_library.nitrogen_groups import ( + FUNCTIONAL_GROUPS, +) + + +EXPECTED_GROUPS = { + "primary_amine_monomer", + "secondary_amine_monomer", + "di_amine_monomer", + "di_primary_amine_monomer", +} + + +def detect(group_key, smiles): + detector = FunctionalGroupsDetector() + group = FUNCTIONAL_GROUPS[group_key] + + return detector.detect_monomer_functionality( + Chem.MolFromSmiles(smiles), + group["functionality_type"], + group["smarts_1"], + ) + + +# ============================================================================ +# Structure / metadata +# ============================================================================ + +def test_expected_active_groups(): + assert set(FUNCTIONAL_GROUPS) == EXPECTED_GROUPS + + +@pytest.mark.parametrize("group_key", sorted(EXPECTED_GROUPS)) +def test_required_fields_exist(group_key): + group = FUNCTIONAL_GROUPS[group_key] + + assert "functionality_type" in group + assert "smarts_1" in group + assert "group_name" in group + assert "comments" in group + + +@pytest.mark.parametrize("group_key", sorted(EXPECTED_GROUPS)) +def test_all_smarts_compile(group_key): + pattern = Chem.MolFromSmarts( + FUNCTIONAL_GROUPS[group_key]["smarts_1"] + ) + + assert pattern is not None + + +def test_primary_amine_metadata(): + group = FUNCTIONAL_GROUPS["primary_amine_monomer"] + + assert group["functionality_type"] == "mono" + assert group["group_name"] == "primary_amine" + assert group["comments"] is None + + +def test_secondary_amine_metadata(): + group = FUNCTIONAL_GROUPS["secondary_amine_monomer"] + + assert group["functionality_type"] == "mono" + assert group["group_name"] == "secondary_amine" + assert group["comments"] is None + + +def test_di_amine_metadata(): + group = FUNCTIONAL_GROUPS["di_amine_monomer"] + + assert group["functionality_type"] == "di_identical" + assert group["group_name"] == "di_amine" + + +def test_di_primary_amine_metadata(): + group = FUNCTIONAL_GROUPS["di_primary_amine_monomer"] + + assert group["functionality_type"] == "di_identical" + assert group["group_name"] == "di_primary_amine" + + +# ============================================================================ +# Primary amine +# ============================================================================ + +def test_primary_amine_matches_ethylamine(): + functionality_count, count_1, count_2, matches = detect( + "primary_amine_monomer", + "CCN", + ) + + assert functionality_count == 1 + assert count_1 == 1 + assert count_2 is None + assert len(matches) == 1 + + +def test_primary_amine_matches_glycine_nitrogen(): + functionality_count, count_1, _, matches = detect( + "primary_amine_monomer", + "NCC(=O)O", + ) + + assert functionality_count == 1 + assert count_1 == 1 + assert len(matches) == 1 + + +def test_primary_amine_does_not_match_secondary_amine(): + result = detect( + "primary_amine_monomer", + "CNC", + ) + + assert result == (0, 0, None, ()) + + +def test_primary_amine_does_not_match_amide(): + """ + The SMARTS explicitly excludes N-C(=O) environments. + """ + result = detect( + "primary_amine_monomer", + "CC(=O)N", + ) + + assert result == (0, 0, None, ()) + + +# ============================================================================ +# Secondary amine +# ============================================================================ + +def test_secondary_amine_matches_dimethylamine(): + functionality_count, count_1, count_2, matches = detect( + "secondary_amine_monomer", + "CNC", + ) + + assert functionality_count == 1 + assert count_1 == 1 + assert count_2 is None + assert len(matches) == 1 + + +def test_secondary_amine_does_not_match_primary_amine(): + result = detect( + "secondary_amine_monomer", + "CCN", + ) + + assert result == (0, 0, None, ()) + + +def test_secondary_amine_does_not_match_tertiary_amine(): + result = detect( + "secondary_amine_monomer", + "CN(C)C", + ) + + assert result == (0, 0, None, ()) + + +def test_secondary_amine_does_not_match_secondary_amide(): + result = detect( + "secondary_amine_monomer", + "CC(=O)NC", + ) + + assert result == (0, 0, None, ()) + + +# ============================================================================ +# Di-amine +# ============================================================================ + +def test_ethylenediamine_qualifies_as_diamine(): + functionality_count, count_1, count_2, matches = detect( + "di_amine_monomer", + "NCCN", + ) + + assert functionality_count == 2 + assert count_1 == 2 + assert count_2 is None + assert len(matches) == 2 + + +def test_hexamethylenediamine_qualifies_as_diamine(): + functionality_count, count_1, _, matches = detect( + "di_amine_monomer", + "NCCCCCCN", + ) + + assert functionality_count == 2 + assert count_1 == 2 + assert len(matches) == 2 + + +def test_monoamine_is_rejected_as_diamine(): + functionality_count, count_1, count_2, matches = detect( + "di_amine_monomer", + "CCN", + ) + + assert functionality_count == 0 + assert count_1 == 1 + assert count_2 is None + assert len(matches) == 1 + + +def test_mixed_primary_secondary_diamine_qualifies_as_diamine(): + """ + di_amine permits both H2 and H1 nitrogens. + + N-methylethylenediamine has one primary and one secondary amine, + so both sites should be counted. + """ + functionality_count, count_1, _, matches = detect( + "di_amine_monomer", + "NCCNC", + ) + + assert functionality_count == 2 + assert count_1 == 2 + assert len(matches) == 2 + + +# ============================================================================ +# Di-primary amine +# ============================================================================ + +def test_ethylenediamine_qualifies_as_di_primary_amine(): + functionality_count, count_1, count_2, matches = detect( + "di_primary_amine_monomer", + "NCCN", + ) + + assert functionality_count == 2 + assert count_1 == 2 + assert count_2 is None + assert len(matches) == 2 + + +def test_hexamethylenediamine_qualifies_as_di_primary_amine(): + functionality_count, count_1, _, matches = detect( + "di_primary_amine_monomer", + "NCCCCCCN", + ) + + assert functionality_count == 2 + assert count_1 == 2 + assert len(matches) == 2 + + +def test_single_primary_amine_is_rejected_as_di_primary(): + functionality_count, count_1, _, matches = detect( + "di_primary_amine_monomer", + "CCN", + ) + + assert functionality_count == 0 + assert count_1 == 1 + assert len(matches) == 1 + + +def test_primary_secondary_pair_is_not_di_primary(): + """ + N-methylethylenediamine contains only one primary amine. + + The secondary nitrogen must not count toward di_primary_amine. + """ + functionality_count, count_1, _, matches = detect( + "di_primary_amine_monomer", + "NCCNC", + ) + + assert functionality_count == 0 + assert count_1 == 1 + assert len(matches) == 1 + + +def test_amide_nitrogen_does_not_count_toward_di_primary(): + functionality_count, count_1, _, matches = detect( + "di_primary_amine_monomer", + "NCCC(=O)N", + ) + + assert functionality_count == 0 + assert count_1 == 1 + assert len(matches) == 1 \ No newline at end of file diff --git a/tests/unit/detectors/functional_groups_library/test_oxygen_groups.py b/tests/unit/detectors/functional_groups_library/test_oxygen_groups.py new file mode 100644 index 00000000..9af0c41b --- /dev/null +++ b/tests/unit/detectors/functional_groups_library/test_oxygen_groups.py @@ -0,0 +1,291 @@ +import pytest +from rdkit import Chem + +from AutoREACTER.detectors.functional_groups_detector import ( + FunctionalGroupsDetector, +) +from AutoREACTER.detectors.functional_groups_library.oxygen_groups import ( + FUNCTIONAL_GROUPS, +) + + +# ============================================================================ +# Library structure +# ============================================================================ + +def test_functional_groups_is_dictionary(): + assert isinstance(FUNCTIONAL_GROUPS, dict) + + +def test_expected_active_oxygen_groups(): + assert set(FUNCTIONAL_GROUPS.keys()) == { + "diol_monomer", + "water_monomer", + } + + +@pytest.mark.parametrize( + "group_name", + [ + "diol_monomer", + "water_monomer", + ], +) +def test_required_fields_exist(group_name): + group = FUNCTIONAL_GROUPS[group_name] + + assert "functionality_type" in group + assert "smarts_1" in group + assert "group_name" in group + assert "comments" in group + + +# ============================================================================ +# Metadata +# ============================================================================ + +def test_diol_metadata(): + group = FUNCTIONAL_GROUPS["diol_monomer"] + + assert group["functionality_type"] == "di_identical" + assert group["group_name"] == "diol" + assert group["comments"] is None + + +def test_water_metadata(): + group = FUNCTIONAL_GROUPS["water_monomer"] + + assert group["functionality_type"] == "mono" + assert group["group_name"] == "water" + assert group["comments"] is None + + +# ============================================================================ +# SMARTS validity +# ============================================================================ + +@pytest.mark.parametrize( + "group_name", + [ + "diol_monomer", + "water_monomer", + ], +) +def test_smarts_is_valid_rdkit_pattern(group_name): + smarts = FUNCTIONAL_GROUPS[group_name]["smarts_1"] + + pattern = Chem.MolFromSmarts(smarts) + + assert pattern is not None + + +# ============================================================================ +# Diol SMARTS behavior +# ============================================================================ + +def test_diol_smarts_finds_two_sites_in_ethylene_glycol(): + group = FUNCTIONAL_GROUPS["diol_monomer"] + + mol = Chem.MolFromSmiles("OCCO") + pattern = Chem.MolFromSmarts(group["smarts_1"]) + + matches = mol.GetSubstructMatches(pattern) + + assert len(matches) == 2 + + +def test_diol_smarts_finds_three_sites_in_glycerol(): + group = FUNCTIONAL_GROUPS["diol_monomer"] + + mol = Chem.MolFromSmiles("OCC(O)CO") + pattern = Chem.MolFromSmarts(group["smarts_1"]) + + matches = mol.GetSubstructMatches(pattern) + + assert len(matches) == 3 + + +def test_diol_smarts_finds_only_one_site_in_ethanol(): + """ + The SMARTS identifies hydroxyl sites. + + Ethanol contains one valid hydroxyl site, but the detector should later + reject it as a di_identical monomer because at least two sites are required. + """ + group = FUNCTIONAL_GROUPS["diol_monomer"] + + mol = Chem.MolFromSmiles("CCO") + pattern = Chem.MolFromSmarts(group["smarts_1"]) + + matches = mol.GetSubstructMatches(pattern) + + assert len(matches) == 1 + + +def test_diol_smarts_does_not_match_ether(): + group = FUNCTIONAL_GROUPS["diol_monomer"] + + mol = Chem.MolFromSmiles("COC") + pattern = Chem.MolFromSmarts(group["smarts_1"]) + + matches = mol.GetSubstructMatches(pattern) + + assert len(matches) == 0 + + +def test_diol_smarts_excludes_carboxylic_acid_oh(): + """ + The diol SMARTS explicitly excludes oxygen bonded to a carbonyl carbon. + """ + group = FUNCTIONAL_GROUPS["diol_monomer"] + + mol = Chem.MolFromSmiles("CC(=O)O") + pattern = Chem.MolFromSmarts(group["smarts_1"]) + + matches = mol.GetSubstructMatches(pattern) + + assert len(matches) == 0 + + +# ============================================================================ +# Diol behavior through FunctionalGroupsDetector +# ============================================================================ + +def test_ethylene_glycol_qualifies_as_diol(): + detector = FunctionalGroupsDetector() + group = FUNCTIONAL_GROUPS["diol_monomer"] + + mol = Chem.MolFromSmiles("OCCO") + + functionality_count, count_1, count_2, matches = ( + detector.detect_monomer_functionality( + mol, + group["functionality_type"], + group["smarts_1"], + ) + ) + + assert functionality_count == 2 + assert count_1 == 2 + assert count_2 is None + assert len(matches) == 2 + + +def test_ethanol_does_not_qualify_as_diol(): + detector = FunctionalGroupsDetector() + group = FUNCTIONAL_GROUPS["diol_monomer"] + + mol = Chem.MolFromSmiles("CCO") + + functionality_count, count_1, count_2, matches = ( + detector.detect_monomer_functionality( + mol, + group["functionality_type"], + group["smarts_1"], + ) + ) + + assert functionality_count == 0 + assert count_1 == 1 + assert count_2 is None + assert len(matches) == 1 + + +def test_glycerol_qualifies_and_preserves_three_site_count(): + detector = FunctionalGroupsDetector() + group = FUNCTIONAL_GROUPS["diol_monomer"] + + mol = Chem.MolFromSmiles("OCC(O)CO") + + functionality_count, count_1, count_2, matches = ( + detector.detect_monomer_functionality( + mol, + group["functionality_type"], + group["smarts_1"], + ) + ) + + assert functionality_count == 2 + assert count_1 == 3 + assert count_2 is None + assert len(matches) == 3 + + +# ============================================================================ +# Water SMARTS behavior +# ============================================================================ + +def test_water_smarts_matches_water(): + group = FUNCTIONAL_GROUPS["water_monomer"] + + mol = Chem.MolFromSmiles("O") + pattern = Chem.MolFromSmarts(group["smarts_1"]) + + matches = mol.GetSubstructMatches(pattern) + + assert len(matches) == 1 + + +@pytest.mark.parametrize( + "smiles", + [ + "CO", # methanol + "CCO", # ethanol + "OCCO", # ethylene glycol + "COC", # dimethyl ether + "CC(=O)O", # acetic acid + ], +) +def test_water_smarts_does_not_match_non_water_oxygen_compounds(smiles): + group = FUNCTIONAL_GROUPS["water_monomer"] + + mol = Chem.MolFromSmiles(smiles) + pattern = Chem.MolFromSmarts(group["smarts_1"]) + + matches = mol.GetSubstructMatches(pattern) + + assert len(matches) == 0 + + +# ============================================================================ +# Water behavior through FunctionalGroupsDetector +# ============================================================================ + +def test_water_qualifies_as_mono_functional(): + detector = FunctionalGroupsDetector() + group = FUNCTIONAL_GROUPS["water_monomer"] + + mol = Chem.MolFromSmiles("O") + + functionality_count, count_1, count_2, matches = ( + detector.detect_monomer_functionality( + mol, + group["functionality_type"], + group["smarts_1"], + ) + ) + + assert functionality_count == 1 + assert count_1 == 1 + assert count_2 is None + assert len(matches) == 1 + + +def test_ethanol_does_not_match_water_functionality(): + detector = FunctionalGroupsDetector() + group = FUNCTIONAL_GROUPS["water_monomer"] + + mol = Chem.MolFromSmiles("CCO") + + functionality_count, count_1, count_2, matches = ( + detector.detect_monomer_functionality( + mol, + group["functionality_type"], + group["smarts_1"], + ) + ) + + assert functionality_count == 0 + assert count_1 == 0 + assert count_2 is None + assert matches == () \ No newline at end of file diff --git a/tests/unit/detectors/functional_groups_library/test_registry.py b/tests/unit/detectors/functional_groups_library/test_registry.py new file mode 100644 index 00000000..8cff03b3 --- /dev/null +++ b/tests/unit/detectors/functional_groups_library/test_registry.py @@ -0,0 +1,190 @@ +import pytest + +from AutoREACTER.detectors.functional_groups_library import registry + + +def test_load_functional_groups_returns_dict(): + """Registry should return a flat dictionary.""" + groups = registry.load_functional_groups() + + assert isinstance(groups, dict) + + +def test_load_functional_groups_is_not_empty(): + """The active functional-group registry should contain entries.""" + groups = registry.load_functional_groups() + + assert groups + + +def test_load_functional_groups_merges_modules(monkeypatch): + """Entries from multiple modules should be merged into one dictionary.""" + module_1 = { + "group_a": { + "functionality_type": "mono", + "smarts_1": "[O]", + "group_name": "group_a_name", + "comments": None, + } + } + + module_2 = { + "group_b": { + "functionality_type": "mono", + "smarts_1": "[N]", + "group_name": "group_b_name", + "comments": None, + } + } + + monkeypatch.setattr( + registry, + "_FUNCTIONAL_GROUP_MODULES", + [module_1, module_2], + ) + + groups = registry.load_functional_groups() + + assert groups == { + "group_a": module_1["group_a"], + "group_b": module_2["group_b"], + } + + +def test_duplicate_entry_key_raises_value_error(monkeypatch): + """Duplicate dictionary keys across modules must be rejected.""" + module_1 = { + "duplicate_key": { + "functionality_type": "mono", + "smarts_1": "[O]", + "group_name": "oxygen_group", + "comments": None, + } + } + + module_2 = { + "duplicate_key": { + "functionality_type": "mono", + "smarts_1": "[N]", + "group_name": "nitrogen_group", + "comments": None, + } + } + + monkeypatch.setattr( + registry, + "_FUNCTIONAL_GROUP_MODULES", + [module_1, module_2], + ) + + with pytest.raises( + ValueError, + match="Duplicate functional-group key: duplicate_key", + ): + registry.load_functional_groups() + + +def test_duplicate_group_name_raises_value_error(monkeypatch): + """Different keys must not share the same group_name.""" + module_1 = { + "key_a": { + "functionality_type": "mono", + "smarts_1": "[O]", + "group_name": "same_group", + "comments": None, + } + } + + module_2 = { + "key_b": { + "functionality_type": "mono", + "smarts_1": "[N]", + "group_name": "same_group", + "comments": None, + } + } + + monkeypatch.setattr( + registry, + "_FUNCTIONAL_GROUP_MODULES", + [module_1, module_2], + ) + + with pytest.raises( + ValueError, + match="Duplicate group_name", + ): + registry.load_functional_groups() + + +def test_duplicate_group_name_error_identifies_both_keys(monkeypatch): + """Duplicate-name error should identify both conflicting entries.""" + module_1 = { + "first_key": { + "functionality_type": "mono", + "smarts_1": "[O]", + "group_name": "duplicate_name", + "comments": None, + } + } + + module_2 = { + "second_key": { + "functionality_type": "mono", + "smarts_1": "[N]", + "group_name": "duplicate_name", + "comments": None, + } + } + + monkeypatch.setattr( + registry, + "_FUNCTIONAL_GROUP_MODULES", + [module_1, module_2], + ) + + with pytest.raises(ValueError) as exc_info: + registry.load_functional_groups() + + message = str(exc_info.value) + + assert "duplicate_name" in message + assert "first_key" in message + assert "second_key" in message + + +def test_empty_module_list_returns_empty_dict(monkeypatch): + """An empty module collection should produce an empty registry.""" + monkeypatch.setattr( + registry, + "_FUNCTIONAL_GROUP_MODULES", + [], + ) + + assert registry.load_functional_groups() == {} + + +def test_module_level_functional_groups_matches_loader(): + """The exported FUNCTIONAL_GROUPS registry should match a fresh load.""" + assert registry.FUNCTIONAL_GROUPS == registry.load_functional_groups() + + +def test_functional_groups_library_exposes_monomer_types(): + """Backward-compatible class should expose the merged registry.""" + library = registry.FunctionalGroupsLibrary() + + assert hasattr(library, "monomer_types") + assert isinstance(library.monomer_types, dict) + assert library.monomer_types == registry.load_functional_groups() + + +def test_real_registry_has_unique_group_names(): + """Active production registry should contain no duplicate group_name values.""" + groups = registry.load_functional_groups() + + group_names = [ + entry["group_name"] + for entry in groups.values() + ] + + assert len(group_names) == len(set(group_names)) \ No newline at end of file diff --git a/tests/unit/detectors/functional_groups_library/test_ring_groups.py b/tests/unit/detectors/functional_groups_library/test_ring_groups.py new file mode 100644 index 00000000..e10aed07 --- /dev/null +++ b/tests/unit/detectors/functional_groups_library/test_ring_groups.py @@ -0,0 +1,146 @@ +from rdkit import Chem + +from AutoREACTER.detectors.functional_groups_detector import ( + FunctionalGroupsDetector, +) +from AutoREACTER.detectors.functional_groups_library.ring_groups import ( + FUNCTIONAL_GROUPS, +) + + +GROUP_KEY = "di_epoxy_monomer" + + +def detect(smiles): + detector = FunctionalGroupsDetector() + group = FUNCTIONAL_GROUPS[GROUP_KEY] + + return detector.detect_monomer_functionality( + Chem.MolFromSmiles(smiles), + group["functionality_type"], + group["smarts_1"], + ) + + +# ============================================================================ +# Structure / metadata +# ============================================================================ + +def test_expected_active_groups(): + assert set(FUNCTIONAL_GROUPS) == { + "di_epoxy_monomer", + } + + +def test_di_epoxy_metadata(): + group = FUNCTIONAL_GROUPS[GROUP_KEY] + + assert group["functionality_type"] == "di_identical" + assert group["group_name"] == "di_epoxide" + assert group["comments"] is None + + +def test_di_epoxy_smarts_compiles(): + group = FUNCTIONAL_GROUPS[GROUP_KEY] + + pattern = Chem.MolFromSmarts(group["smarts_1"]) + + assert pattern is not None + + +# ============================================================================ +# Epoxide SMARTS +# ============================================================================ + +def test_single_epoxide_ring_produces_one_match(): + """ + Ethylene oxide contains one three-membered C-O-C epoxide ring. + """ + group = FUNCTIONAL_GROUPS[GROUP_KEY] + + mol = Chem.MolFromSmiles("C1CO1") + pattern = Chem.MolFromSmarts(group["smarts_1"]) + + matches = mol.GetSubstructMatches(pattern) + + assert len(matches) == 1 + + +def test_two_separate_epoxide_rings_produce_two_matches(): + """ + Molecule containing two independent oxirane rings should expose + two epoxide reactive sites. + """ + group = FUNCTIONAL_GROUPS[GROUP_KEY] + + mol = Chem.MolFromSmiles("C1OC1CC2CO2") + pattern = Chem.MolFromSmarts(group["smarts_1"]) + + matches = mol.GetSubstructMatches(pattern) + + assert len(matches) == 2 + + +def test_acyclic_ether_does_not_match_epoxide(): + group = FUNCTIONAL_GROUPS[GROUP_KEY] + + mol = Chem.MolFromSmiles("COC") + pattern = Chem.MolFromSmarts(group["smarts_1"]) + + assert mol.GetSubstructMatches(pattern) == () + + +def test_tetrahydrofuran_does_not_match_epoxide(): + """ + A five-membered cyclic ether must not be mistaken for an epoxide. + """ + group = FUNCTIONAL_GROUPS[GROUP_KEY] + + mol = Chem.MolFromSmiles("C1CCOC1") + pattern = Chem.MolFromSmarts(group["smarts_1"]) + + assert mol.GetSubstructMatches(pattern) == () + + +def test_cyclopropane_does_not_match_epoxide(): + """ + Ring size alone is insufficient; the epoxide oxygen must be present. + """ + group = FUNCTIONAL_GROUPS[GROUP_KEY] + + mol = Chem.MolFromSmiles("C1CC1") + pattern = Chem.MolFromSmarts(group["smarts_1"]) + + assert mol.GetSubstructMatches(pattern) == () + + +# ============================================================================ +# Detector behavior +# ============================================================================ + +def test_single_epoxide_is_rejected_as_di_epoxide(): + functionality_count, count_1, count_2, matches = detect( + "C1CO1" + ) + + assert functionality_count == 0 + assert count_1 == 1 + assert count_2 is None + assert len(matches) == 1 + + +def test_two_epoxide_sites_qualify_as_di_epoxide(): + functionality_count, count_1, count_2, matches = detect( + "C1OC1CC2CO2" + ) + + assert functionality_count == 2 + assert count_1 == 2 + assert count_2 is None + assert len(matches) == 2 + + +def test_non_epoxide_is_rejected(): + result = detect("COC") + + assert result == (0, 0, None, ()) \ No newline at end of file diff --git a/tests/unit/detectors/functional_groups_library/test_silicon_groups.py b/tests/unit/detectors/functional_groups_library/test_silicon_groups.py new file mode 100644 index 00000000..8ddbb8f3 --- /dev/null +++ b/tests/unit/detectors/functional_groups_library/test_silicon_groups.py @@ -0,0 +1,129 @@ +import pytest +from rdkit import Chem + +from AutoREACTER.detectors.functional_groups_detector import ( + FunctionalGroupsDetector, +) +from AutoREACTER.detectors.functional_groups_library.silicon_groups import ( + FUNCTIONAL_GROUPS, +) + + +EXPECTED_GROUPS = { + "dichlorosilane_monomer", + "silanediol_monomer", +} + + +def detect(group_key, smiles): + detector = FunctionalGroupsDetector() + group = FUNCTIONAL_GROUPS[group_key] + + return detector.detect_monomer_functionality( + Chem.MolFromSmiles(smiles), + group["functionality_type"], + group["smarts_1"], + ) + + +def test_expected_active_groups(): + assert set(FUNCTIONAL_GROUPS) == EXPECTED_GROUPS + + +@pytest.mark.parametrize("group_key", sorted(EXPECTED_GROUPS)) +def test_required_fields_exist(group_key): + group = FUNCTIONAL_GROUPS[group_key] + + assert "functionality_type" in group + assert "smarts_1" in group + assert "group_name" in group + assert "comments" in group + + +@pytest.mark.parametrize("group_key", sorted(EXPECTED_GROUPS)) +def test_all_smarts_compile(group_key): + assert Chem.MolFromSmarts( + FUNCTIONAL_GROUPS[group_key]["smarts_1"] + ) is not None + + +def test_dichlorosilane_metadata(): + group = FUNCTIONAL_GROUPS["dichlorosilane_monomer"] + + assert group["functionality_type"] == "di_identical" + assert group["group_name"] == "dichlorosilane" + assert group["comments"] is None + + +def test_silanediol_metadata(): + group = FUNCTIONAL_GROUPS["silanediol_monomer"] + + assert group["functionality_type"] == "di_identical" + assert group["group_name"] == "silanediol" + assert group["comments"] is None + + +def test_dichlorosilane_has_two_si_cl_sites(): + functionality_count, count_1, count_2, matches = detect( + "dichlorosilane_monomer", + "Cl[Si](C)(C)Cl", + ) + + assert functionality_count == 2 + assert count_1 == 2 + assert count_2 is None + assert len(matches) == 2 + + +def test_monochlorosilane_is_rejected(): + functionality_count, count_1, count_2, matches = detect( + "dichlorosilane_monomer", + "C[Si](C)(C)Cl", + ) + + assert functionality_count == 0 + assert count_1 == 1 + assert count_2 is None + assert len(matches) == 1 + + +def test_nonchlorinated_silane_does_not_match(): + result = detect( + "dichlorosilane_monomer", + "C[Si](C)(C)C", + ) + + assert result == (0, 0, None, ()) + + +def test_silanediol_has_two_si_oh_sites(): + functionality_count, count_1, count_2, matches = detect( + "silanediol_monomer", + "O[Si](C)(C)O", + ) + + assert functionality_count == 2 + assert count_1 == 2 + assert count_2 is None + assert len(matches) == 2 + + +def test_single_silanol_is_rejected_as_silanediol(): + functionality_count, count_1, count_2, matches = detect( + "silanediol_monomer", + "C[Si](C)(C)O", + ) + + assert functionality_count == 0 + assert count_1 == 1 + assert count_2 is None + assert len(matches) == 1 + + +def test_regular_alcohol_does_not_match_silanediol(): + result = detect( + "silanediol_monomer", + "CCO", + ) + + assert result == (0, 0, None, ()) \ No newline at end of file diff --git a/tests/unit/detectors/functional_groups_library/test_sulfur_groups.py b/tests/unit/detectors/functional_groups_library/test_sulfur_groups.py new file mode 100644 index 00000000..6a2a6cbc --- /dev/null +++ b/tests/unit/detectors/functional_groups_library/test_sulfur_groups.py @@ -0,0 +1,88 @@ +from rdkit import Chem + +from AutoREACTER.detectors.functional_groups_detector import ( + FunctionalGroupsDetector, +) +from AutoREACTER.detectors.functional_groups_library.sulfur_groups import ( + FUNCTIONAL_GROUPS, +) + + +GROUP_KEY = "dithiol_monomer" + + +def detect(smiles): + detector = FunctionalGroupsDetector() + group = FUNCTIONAL_GROUPS[GROUP_KEY] + + return detector.detect_monomer_functionality( + Chem.MolFromSmiles(smiles), + group["functionality_type"], + group["smarts_1"], + ) + + +def test_expected_active_groups(): + assert set(FUNCTIONAL_GROUPS) == { + "dithiol_monomer", + } + + +def test_dithiol_metadata(): + group = FUNCTIONAL_GROUPS[GROUP_KEY] + + assert group["functionality_type"] == "di_identical" + assert group["group_name"] == "dithiol" + assert group["comments"] is None + + +def test_dithiol_smarts_compiles(): + assert Chem.MolFromSmarts( + FUNCTIONAL_GROUPS[GROUP_KEY]["smarts_1"] + ) is not None + + +def test_dithiol_has_two_thiol_sites(): + functionality_count, count_1, count_2, matches = detect( + "SCCS" + ) + + assert functionality_count == 2 + assert count_1 == 2 + assert count_2 is None + assert len(matches) == 2 + + +def test_monothiol_is_rejected_as_dithiol(): + functionality_count, count_1, count_2, matches = detect( + "CCS" + ) + + assert functionality_count == 0 + assert count_1 == 1 + assert count_2 is None + assert len(matches) == 1 + + +def test_thioether_does_not_match_thiol(): + result = detect( + "CSC" + ) + + assert result == (0, 0, None, ()) + + +def test_disulfide_does_not_match_thiol(): + result = detect( + "CSSC" + ) + + assert result == (0, 0, None, ()) + + +def test_carbonyl_attached_sulfur_is_excluded(): + result = detect( + "CC(=O)S" + ) + + assert result == (0, 0, None, ()) \ No newline at end of file diff --git a/tests/unit/detectors/functional_groups_library/test_vinyl_and_alkene_groups.py b/tests/unit/detectors/functional_groups_library/test_vinyl_and_alkene_groups.py new file mode 100644 index 00000000..6732ab64 --- /dev/null +++ b/tests/unit/detectors/functional_groups_library/test_vinyl_and_alkene_groups.py @@ -0,0 +1,208 @@ +import pytest +from rdkit import Chem + +from AutoREACTER.detectors.functional_groups_detector import ( + FunctionalGroupsDetector, +) +from AutoREACTER.detectors.functional_groups_library.vinyl_and_alkene_groups import ( + FUNCTIONAL_GROUPS, +) + + +EXPECTED_GROUPS = { + "vinyl_monomer", + "diene_monomer", + "tetrafluoroethylene_monomer", +} + + +def detect(group_key, smiles): + detector = FunctionalGroupsDetector() + group = FUNCTIONAL_GROUPS[group_key] + + return detector.detect_monomer_functionality( + Chem.MolFromSmiles(smiles), + group["functionality_type"], + group["smarts_1"], + ) + + +# ============================================================================ +# Structure / metadata +# ============================================================================ + +def test_expected_active_groups(): + assert set(FUNCTIONAL_GROUPS) == EXPECTED_GROUPS + + +@pytest.mark.parametrize("group_key", sorted(EXPECTED_GROUPS)) +def test_required_fields_exist(group_key): + group = FUNCTIONAL_GROUPS[group_key] + + assert "functionality_type" in group + assert "smarts_1" in group + assert "group_name" in group + assert "comments" in group + + +@pytest.mark.parametrize("group_key", sorted(EXPECTED_GROUPS)) +def test_all_smarts_compile(group_key): + assert Chem.MolFromSmarts( + FUNCTIONAL_GROUPS[group_key]["smarts_1"] + ) is not None + + +def test_vinyl_metadata(): + group = FUNCTIONAL_GROUPS["vinyl_monomer"] + + assert group["functionality_type"] == "vinyl" + assert group["group_name"] == "vinyl" + + +def test_diene_metadata(): + group = FUNCTIONAL_GROUPS["diene_monomer"] + + assert group["functionality_type"] == "di_identical" + assert group["group_name"] == "diene" + + +def test_tetrafluoroethylene_metadata(): + group = FUNCTIONAL_GROUPS["tetrafluoroethylene_monomer"] + + assert group["functionality_type"] == "vinyl" + assert group["group_name"] == "tetrafluoroethylene" + + +# ============================================================================ +# Vinyl +# ============================================================================ + +@pytest.mark.parametrize( + "smiles", + [ + "C=C", # ethylene + "C=CC", # propene + "C=Cc1ccccc1", # styrene + "C=C(C)C", # substituted terminal alkene + ], +) +def test_terminal_vinyl_groups_qualify(smiles): + functionality_count, count_1, count_2, matches = detect( + "vinyl_monomer", + smiles, + ) + + assert functionality_count == 1 + assert count_1 >= 1 + assert count_2 is None + assert len(matches) == count_1 + + +def test_internal_alkene_does_not_match_terminal_vinyl(): + result = detect( + "vinyl_monomer", + "CC=CC", + ) + + assert result == (0, 0, None, ()) + + +def test_saturated_molecule_does_not_match_vinyl(): + result = detect( + "vinyl_monomer", + "CCCC", + ) + + assert result == (0, 0, None, ()) + + +def test_ring_double_bond_does_not_match_vinyl(): + result = detect( + "vinyl_monomer", + "C1=CCCCC1", + ) + + assert result == (0, 0, None, ()) + + +# ============================================================================ +# Diene +# ============================================================================ + +def test_butadiene_has_two_terminal_vinyl_sites(): + functionality_count, count_1, count_2, matches = detect( + "diene_monomer", + "C=CC=C", + ) + + assert functionality_count == 2 + assert count_1 == 2 + assert count_2 is None + assert len(matches) == 2 + + +def test_single_terminal_alkene_is_rejected_as_diene(): + functionality_count, count_1, count_2, matches = detect( + "diene_monomer", + "C=CCC", + ) + + assert functionality_count == 0 + assert count_1 == 1 + assert count_2 is None + assert len(matches) == 1 + + +def test_internal_diene_without_terminal_ch2_is_rejected(): + result = detect( + "diene_monomer", + "CC=CC=CC", + ) + + assert result == (0, 0, None, ()) + + +def test_molecule_with_more_than_two_terminal_vinyl_sites_preserves_count(): + functionality_count, count_1, count_2, matches = detect( + "diene_monomer", + "C=C(C=C)C=C", + ) + + assert functionality_count == 2 + assert count_1 >= 2 + assert count_2 is None + assert len(matches) == count_1 + + +# ============================================================================ +# Tetrafluoroethylene +# ============================================================================ + +def test_tetrafluoroethylene_qualifies(): + functionality_count, count_1, count_2, matches = detect( + "tetrafluoroethylene_monomer", + "FC(F)=C(F)F", + ) + + assert functionality_count == 1 + assert count_1 == 1 + assert count_2 is None + assert len(matches) == 1 + + +@pytest.mark.parametrize( + "smiles", + [ + "C=C", + "C=CC", + "C=Cc1ccccc1", + "FC(F)=CC", + ], +) +def test_non_tetrafluoroethylene_alkenes_do_not_match(smiles): + result = detect( + "tetrafluoroethylene_monomer", + smiles, + ) + + assert result == (0, 0, None, ()) \ No newline at end of file diff --git a/tests/unit/detectors/reactions_library/test_epoxy_polymers.py b/tests/unit/detectors/reactions_library/test_epoxy_polymers.py new file mode 100644 index 00000000..4dd5d098 --- /dev/null +++ b/tests/unit/detectors/reactions_library/test_epoxy_polymers.py @@ -0,0 +1,338 @@ +import pytest +from rdkit import Chem +from rdkit.Chem import rdChemReactions + +from AutoREACTER.detectors.reactions_library import registry +from AutoREACTER.detectors.reactions_library.epoxy_polymers import ( + REACTIONS, +) + + +PRIMARY = ( + "Primary Amine and Epoxide Polyaddition " + "(Epoxy-Amine, First Addition)" +) + +SECONDARY = ( + "Secondary Amine and Epoxide Polyaddition " + "(Epoxy-Amine, Second Addition / Crosslink)" +) + + +EXPECTED_REACTIONS = { + PRIMARY, + SECONDARY, +} + + +def run_reaction(reaction_name, smiles_1, smiles_2): + reaction_info = REACTIONS[reaction_name] + + rxn = rdChemReactions.ReactionFromSmarts( + reaction_info["reaction"] + ) + + assert rxn is not None + + mol_1 = Chem.AddHs( + Chem.MolFromSmiles(smiles_1) + ) + + mol_2 = Chem.AddHs( + Chem.MolFromSmiles(smiles_2) + ) + + return rxn.RunReactants( + ( + mol_1, + mol_2, + ) + ) + + +# ============================================================================= +# Structure +# ============================================================================= + +def test_expected_active_reactions(): + assert set(REACTIONS) == EXPECTED_REACTIONS + + +@pytest.mark.parametrize( + "reaction_name", + sorted(EXPECTED_REACTIONS), +) +def test_required_fields_exist(reaction_name): + reaction = REACTIONS[reaction_name] + + required = { + "same_reactants", + "reactant_1", + "reactant_2", + "product", + "delete_atom", + "reaction", + "reference", + "comments", + } + + assert required.issubset( + reaction.keys() + ) + + +@pytest.mark.parametrize( + "reaction_name", + sorted(EXPECTED_REACTIONS), +) +def test_reaction_smarts_parses(reaction_name): + reaction = REACTIONS[reaction_name] + + rxn = rdChemReactions.ReactionFromSmarts( + reaction["reaction"] + ) + + assert rxn is not None + + +@pytest.mark.parametrize( + "reaction_name", + sorted(EXPECTED_REACTIONS), +) +def test_reaction_passes_registry_validation(reaction_name): + errors = registry._validate_reaction_smarts( + reaction_name, + REACTIONS[reaction_name], + ) + + assert errors == [] + + +# ============================================================================= +# Metadata +# ============================================================================= + +def test_primary_epoxy_amine_metadata(): + reaction = REACTIONS[PRIMARY] + + assert reaction["same_reactants"] is False + assert reaction["reactant_1"] == "primary_amine" + assert reaction["reactant_2"] == "di_epoxide" + + assert ( + reaction["product"] + == "secondary_amine_hydroxyl_product" + ) + + assert reaction["delete_atom"] is False + assert reaction["comments"] is None + + +def test_secondary_epoxy_amine_metadata(): + reaction = REACTIONS[SECONDARY] + + assert reaction["same_reactants"] is False + assert reaction["reactant_1"] == "secondary_amine" + assert reaction["reactant_2"] == "di_epoxide" + + assert ( + reaction["product"] + == "tertiary_amine_crosslink_product" + ) + + assert reaction["delete_atom"] is False + assert reaction["comments"] is None + + +def test_primary_and_secondary_reactions_use_different_amine_queries(): + primary = REACTIONS[PRIMARY]["reaction"] + secondary = REACTIONS[SECONDARY]["reaction"] + + assert "[NX3H2:1]" in primary + assert "[NX3H1:1]" in secondary + + +# ============================================================================= +# Initiator-map convention +# ============================================================================= + +@pytest.mark.parametrize( + "reaction_name", + sorted(EXPECTED_REACTIONS), +) +def test_reaction_contains_initiator_maps_1_and_2( + reaction_name, +): + reaction = rdChemReactions.ReactionFromSmarts( + REACTIONS[reaction_name]["reaction"] + ) + + reactants = [ + Chem.Mol( + reaction.GetReactantTemplate(i) + ) + for i in range( + reaction.GetNumReactantTemplates() + ) + ] + + products = [ + Chem.Mol( + reaction.GetProductTemplate(i) + ) + for i in range( + reaction.GetNumProductTemplates() + ) + ] + + assert { + 1, + 2, + }.issubset( + registry._atom_maps_in_templates( + reactants + ) + ) + + assert { + 1, + 2, + }.issubset( + registry._atom_maps_in_templates( + products + ) + ) + + +@pytest.mark.parametrize( + "reaction_name", + sorted(EXPECTED_REACTIONS), +) +def test_product_contains_new_initiator_bond( + reaction_name, +): + reaction = rdChemReactions.ReactionFromSmarts( + REACTIONS[reaction_name]["reaction"] + ) + + products = [ + Chem.Mol( + reaction.GetProductTemplate(i) + ) + for i in range( + reaction.GetNumProductTemplates() + ) + ] + + assert registry._has_bond_between_atom_maps( + products, + 1, + 2, + ) is True + + +# ============================================================================= +# Primary amine + epoxide chemistry +# ============================================================================= + +def test_primary_amine_reaction_executes(): + """ + Ethylamine contains a primary amine. + + Propylene oxide provides the CH2/CH epoxide environment required by + this reaction SMARTS. + """ + products = run_reaction( + PRIMARY, + "CCN", + "CC1CO1", + ) + + assert len(products) > 0 + + +def test_primary_reaction_products_are_sanitizable(): + products = run_reaction( + PRIMARY, + "CCN", + "CC1CO1", + ) + + first_product_set = products[0] + + assert first_product_set + + for product in first_product_set: + Chem.SanitizeMol(product) + + +def test_primary_reaction_does_not_accept_secondary_amine(): + products = run_reaction( + PRIMARY, + "CNC", + "CC1CO1", + ) + + assert len(products) == 0 + + +def test_primary_reaction_does_not_accept_nonamine(): + products = run_reaction( + PRIMARY, + "CCO", + "CC1CO1", + ) + + assert len(products) == 0 + + +# ============================================================================= +# Secondary amine + epoxide chemistry +# ============================================================================= + +def test_secondary_amine_reaction_executes(): + """ + Dimethylamine contains one N-H bond and therefore matches NX3H1. + """ + products = run_reaction( + SECONDARY, + "CNC", + "CC1CO1", + ) + + assert len(products) > 0 + + +def test_secondary_reaction_products_are_sanitizable(): + products = run_reaction( + SECONDARY, + "CNC", + "CC1CO1", + ) + + first_product_set = products[0] + + assert first_product_set + + for product in first_product_set: + Chem.SanitizeMol(product) + + +def test_secondary_reaction_does_not_accept_primary_amine(): + products = run_reaction( + SECONDARY, + "CCN", + "CC1CO1", + ) + + assert len(products) == 0 + + +def test_secondary_reaction_does_not_accept_tertiary_amine(): + products = run_reaction( + SECONDARY, + "CN(C)C", + "CC1CO1", + ) + + assert len(products) == 0 \ No newline at end of file diff --git a/tests/unit/detectors/reactions_library/test_polyamides.py b/tests/unit/detectors/reactions_library/test_polyamides.py new file mode 100644 index 00000000..41a151a7 --- /dev/null +++ b/tests/unit/detectors/reactions_library/test_polyamides.py @@ -0,0 +1,456 @@ +import pytest +from rdkit import Chem +from rdkit.Chem import rdChemReactions + +from AutoREACTER.detectors.reactions_library import registry +from AutoREACTER.detectors.reactions_library.polyamides import ( + REACTIONS, +) + + +AMINO_HOMO = ( + "Amino Acid Polycondensation (Polyamidation)" +) + +AMINO_CO = ( + "Amino Acid and Amino Acid " + "Polycondensation (Polyamidation)" +) + +DIAMINE_ACID = ( + "Di-Amine and Di-Carboxylic Acid " + "Polycondensation (Polyamidation)" +) + +DIAMINE_HALIDE = ( + "Di-Amine and Di-Carboxylic Acid Halide " + "Polycondensation (Polyamidation)" +) + +CAPROLACTAM_HYDROLYSIS = ( + "Hydrolytic Initiation of Caprolactam" +) + + +EXPECTED_REACTIONS = { + AMINO_HOMO, + AMINO_CO, + DIAMINE_ACID, + DIAMINE_HALIDE, + CAPROLACTAM_HYDROLYSIS, +} + + +def run_reaction(reaction_name, smiles_1, smiles_2): + reaction_info = REACTIONS[reaction_name] + + rxn = rdChemReactions.ReactionFromSmarts( + reaction_info["reaction"] + ) + + assert rxn is not None + + mol_1 = Chem.AddHs( + Chem.MolFromSmiles(smiles_1) + ) + + mol_2 = Chem.AddHs( + Chem.MolFromSmiles(smiles_2) + ) + + return rxn.RunReactants( + ( + mol_1, + mol_2, + ) + ) + + +# ============================================================================= +# Structure +# ============================================================================= + +def test_expected_active_reactions(): + assert set(REACTIONS) == EXPECTED_REACTIONS + + +@pytest.mark.parametrize( + "reaction_name", + sorted(EXPECTED_REACTIONS), +) +def test_required_fields_exist(reaction_name): + reaction = REACTIONS[reaction_name] + + required = { + "same_reactants", + "reactant_1", + "product", + "delete_atom", + "reaction", + "reference", + "comments", + } + + assert required.issubset( + reaction.keys() + ) + + +@pytest.mark.parametrize( + "reaction_name", + sorted(EXPECTED_REACTIONS), +) +def test_reaction_smarts_parses(reaction_name): + rxn = rdChemReactions.ReactionFromSmarts( + REACTIONS[reaction_name]["reaction"] + ) + + assert rxn is not None + + +@pytest.mark.parametrize( + "reaction_name", + sorted(EXPECTED_REACTIONS), +) +def test_reaction_passes_registry_validation( + reaction_name, +): + errors = registry._validate_reaction_smarts( + reaction_name, + REACTIONS[reaction_name], + ) + + assert errors == [] + + +# ============================================================================= +# Metadata +# ============================================================================= + +def test_amino_acid_homopolymerization_metadata(): + reaction = REACTIONS[AMINO_HOMO] + + assert reaction["same_reactants"] is True + assert reaction["reactant_1"] == "amino_acid" + + assert reaction.get( + "reactant_2" + ) is None + + assert reaction["product"] == "polyamide_chain" + assert reaction["delete_atom"] is True + + +def test_amino_acid_copolymerization_metadata(): + reaction = REACTIONS[AMINO_CO] + + assert reaction["same_reactants"] is False + assert reaction["reactant_1"] == "amino_acid" + assert reaction["reactant_2"] == "amino_acid" + assert reaction["product"] == "polyamide_chain" + assert reaction["delete_atom"] is True + + +def test_diamine_diacid_metadata(): + reaction = REACTIONS[DIAMINE_ACID] + + assert reaction["same_reactants"] is False + assert reaction["reactant_1"] == "di_amine" + + assert ( + reaction["reactant_2"] + == "di_carboxylic_acid" + ) + + assert reaction["product"] == "polyamide_chain" + assert reaction["delete_atom"] is True + + +def test_diamine_diacid_halide_metadata(): + reaction = REACTIONS[DIAMINE_HALIDE] + + assert reaction["same_reactants"] is False + assert reaction["reactant_1"] == "di_amine" + + assert ( + reaction["reactant_2"] + == "di_carboxylic_acid_halide" + ) + + assert reaction["product"] == "polyamide_chain" + assert reaction["delete_atom"] is True + + +def test_caprolactam_hydrolysis_metadata(): + reaction = REACTIONS[ + CAPROLACTAM_HYDROLYSIS + ] + + assert reaction["same_reactants"] is False + assert reaction["reactant_1"] == "water" + assert reaction["reactant_2"] == "lactam" + assert reaction["product"] == "polyamide_chain" + assert reaction["delete_atom"] is False + + +# ============================================================================= +# Initiator maps +# ============================================================================= + +@pytest.mark.parametrize( + "reaction_name", + sorted(EXPECTED_REACTIONS), +) +def test_reactions_contain_initiator_maps_1_and_2( + reaction_name, +): + reaction = rdChemReactions.ReactionFromSmarts( + REACTIONS[reaction_name]["reaction"] + ) + + reactants = [ + Chem.Mol( + reaction.GetReactantTemplate(i) + ) + for i in range( + reaction.GetNumReactantTemplates() + ) + ] + + products = [ + Chem.Mol( + reaction.GetProductTemplate(i) + ) + for i in range( + reaction.GetNumProductTemplates() + ) + ] + + reactant_maps = ( + registry._atom_maps_in_templates( + reactants + ) + ) + + product_maps = ( + registry._atom_maps_in_templates( + products + ) + ) + + assert { + 1, + 2, + }.issubset( + reactant_maps + ) + + assert { + 1, + 2, + }.issubset( + product_maps + ) + + +@pytest.mark.parametrize( + "reaction_name", + sorted(EXPECTED_REACTIONS), +) +def test_products_contain_initiator_bond( + reaction_name, +): + reaction = rdChemReactions.ReactionFromSmarts( + REACTIONS[reaction_name]["reaction"] + ) + + products = [ + Chem.Mol( + reaction.GetProductTemplate(i) + ) + for i in range( + reaction.GetNumProductTemplates() + ) + ] + + assert registry._has_bond_between_atom_maps( + products, + 1, + 2, + ) is True + + +# ============================================================================= +# Amino-acid polycondensation +# ============================================================================= + +def test_glycine_homopolycondensation_executes(): + products = run_reaction( + AMINO_HOMO, + "NCC(=O)O", + "NCC(=O)O", + ) + + assert len(products) > 0 + + +def test_glycine_condensation_produces_two_product_fragments(): + """ + The SMARTS generates the amide-containing product plus the + eliminated water fragment. + """ + products = run_reaction( + AMINO_HOMO, + "NCC(=O)O", + "NCC(=O)O", + ) + + assert len( + products[0] + ) == 2 + + +def test_glycine_condensation_products_are_sanitizable(): + products = run_reaction( + AMINO_HOMO, + "NCC(=O)O", + "NCC(=O)O", + ) + + for product in products[0]: + Chem.SanitizeMol(product) + + +# ============================================================================= +# Diamine + dicarboxylic acid +# ============================================================================= + +def test_diamine_diacid_polycondensation_executes(): + products = run_reaction( + DIAMINE_ACID, + "NCCN", + "O=C(O)CCC(=O)O", + ) + + assert len(products) > 0 + + +def test_diamine_diacid_products_are_sanitizable(): + products = run_reaction( + DIAMINE_ACID, + "NCCN", + "O=C(O)CCC(=O)O", + ) + + for product in products[0]: + Chem.SanitizeMol(product) + + +def test_diamine_diacid_reaction_produces_byproduct(): + products = run_reaction( + DIAMINE_ACID, + "NCCN", + "O=C(O)CCC(=O)O", + ) + + assert len( + products[0] + ) == 2 + + +# ============================================================================= +# Diamine + acid halide +# ============================================================================= + +def test_diamine_acid_halide_polycondensation_executes(): + products = run_reaction( + DIAMINE_HALIDE, + "NCCN", + "O=C(Cl)CCC(=O)Cl", + ) + + assert len(products) > 0 + + +def test_diamine_acid_halide_products_are_sanitizable(): + products = run_reaction( + DIAMINE_HALIDE, + "NCCN", + "O=C(Cl)CCC(=O)Cl", + ) + + for product in products[0]: + Chem.SanitizeMol(product) + + +def test_diamine_acid_halide_reaction_produces_byproduct(): + products = run_reaction( + DIAMINE_HALIDE, + "NCCN", + "O=C(Cl)CCC(=O)Cl", + ) + + assert len( + products[0] + ) == 2 + + +# ============================================================================= +# Hydrolytic initiation of caprolactam +# ============================================================================= + +def test_caprolactam_hydrolysis_executes(): + products = run_reaction( + CAPROLACTAM_HYDROLYSIS, + "O", + "O=C1CCCCCN1", + ) + + assert len(products) > 0 + + +def test_caprolactam_hydrolysis_product_is_sanitizable(): + products = run_reaction( + CAPROLACTAM_HYDROLYSIS, + "O", + "O=C1CCCCCN1", + ) + + for product in products[0]: + Chem.SanitizeMol(product) + + +def test_caprolactam_hydrolysis_produces_single_open_chain_product(): + products = run_reaction( + CAPROLACTAM_HYDROLYSIS, + "O", + "O=C1CCCCCN1", + ) + + assert len( + products[0] + ) == 1 + + +# ============================================================================= +# Negative chemistry tests +# ============================================================================= + +def test_diamine_diacid_reaction_does_not_accept_simple_alcohol(): + products = run_reaction( + DIAMINE_ACID, + "CCO", + "O=C(O)CCC(=O)O", + ) + + assert len(products) == 0 + + +def test_diamine_acid_halide_reaction_does_not_accept_dicarboxylic_acid(): + products = run_reaction( + DIAMINE_HALIDE, + "NCCN", + "O=C(O)CCC(=O)O", + ) + + assert len(products) == 0 \ No newline at end of file diff --git a/tests/unit/detectors/reactions_library/test_polyanhydrides.py b/tests/unit/detectors/reactions_library/test_polyanhydrides.py new file mode 100644 index 00000000..a80aee90 --- /dev/null +++ b/tests/unit/detectors/reactions_library/test_polyanhydrides.py @@ -0,0 +1,242 @@ +import pytest +from rdkit import Chem +from rdkit.Chem import rdChemReactions + +from AutoREACTER.detectors.reactions_library import registry +from AutoREACTER.detectors.reactions_library.polyanhydrides import ( + REACTIONS, +) + + +HOMO = ( + "Carboxylic Acid and Acid Halide Polycondensation " + "(Polyanhydride Formation)" +) + +CO = ( + "Carboxylic Acid and Acid Halide Copolycondensation " + "(Polyanhydride Copolymerization)" +) + + +EXPECTED_REACTIONS = { + HOMO, + CO, +} + + +def run_reaction(reaction_name, smiles_1, smiles_2): + rxn = rdChemReactions.ReactionFromSmarts( + REACTIONS[reaction_name]["reaction"] + ) + + assert rxn is not None + + mol_1 = Chem.AddHs( + Chem.MolFromSmiles(smiles_1) + ) + + mol_2 = Chem.AddHs( + Chem.MolFromSmiles(smiles_2) + ) + + return rxn.RunReactants( + ( + mol_1, + mol_2, + ) + ) + + +def test_expected_active_reactions(): + assert set(REACTIONS) == EXPECTED_REACTIONS + + +@pytest.mark.parametrize( + "reaction_name", + sorted(EXPECTED_REACTIONS), +) +def test_required_fields_exist(reaction_name): + reaction = REACTIONS[reaction_name] + + required = { + "same_reactants", + "reactant_1", + "product", + "delete_atom", + "reaction", + "reference", + } + + assert required.issubset( + reaction.keys() + ) + + +@pytest.mark.parametrize( + "reaction_name", + sorted(EXPECTED_REACTIONS), +) +def test_smarts_parses(reaction_name): + rxn = rdChemReactions.ReactionFromSmarts( + REACTIONS[reaction_name]["reaction"] + ) + + assert rxn is not None + + +@pytest.mark.parametrize( + "reaction_name", + sorted(EXPECTED_REACTIONS), +) +def test_registry_validation_passes(reaction_name): + errors = registry._validate_reaction_smarts( + reaction_name, + REACTIONS[reaction_name], + ) + + assert errors == [] + + +def test_homopolymerization_metadata(): + reaction = REACTIONS[HOMO] + + assert reaction["same_reactants"] is True + assert ( + reaction["reactant_1"] + == "carboxylic_acid_acid_halide" + ) + assert reaction.get("reactant_2") is None + assert reaction["product"] == "polyanhydride_chain" + assert reaction["delete_atom"] is True + + +def test_copolymerization_metadata(): + reaction = REACTIONS[CO] + + assert reaction["same_reactants"] is False + + assert ( + reaction["reactant_1"] + == "carboxylic_acid_acid_halide" + ) + + assert ( + reaction["reactant_2"] + == "carboxylic_acid_acid_halide" + ) + + assert reaction["product"] == "polyanhydride_chain" + assert reaction["delete_atom"] is True + + +@pytest.mark.parametrize( + "reaction_name", + sorted(EXPECTED_REACTIONS), +) +def test_initiator_maps_are_present(reaction_name): + rxn = rdChemReactions.ReactionFromSmarts( + REACTIONS[reaction_name]["reaction"] + ) + + reactants = [ + Chem.Mol( + rxn.GetReactantTemplate(i) + ) + for i in range( + rxn.GetNumReactantTemplates() + ) + ] + + products = [ + Chem.Mol( + rxn.GetProductTemplate(i) + ) + for i in range( + rxn.GetNumProductTemplates() + ) + ] + + assert {1, 2}.issubset( + registry._atom_maps_in_templates( + reactants + ) + ) + + assert {1, 2}.issubset( + registry._atom_maps_in_templates( + products + ) + ) + + +@pytest.mark.parametrize( + "reaction_name", + sorted(EXPECTED_REACTIONS), +) +def test_product_contains_initiator_bond( + reaction_name, +): + rxn = rdChemReactions.ReactionFromSmarts( + REACTIONS[reaction_name]["reaction"] + ) + + products = [ + Chem.Mol( + rxn.GetProductTemplate(i) + ) + for i in range( + rxn.GetNumProductTemplates() + ) + ] + + assert registry._has_bond_between_atom_maps( + products, + 1, + 2, + ) is True + + +def test_homopolymerization_smarts_executes(): + """ + HOOC-(CH2)3-COCl contains both groups required by the + heterobifunctional polyanhydride definition. + """ + products = run_reaction( + HOMO, + "O=C(O)CCC(=O)Cl", + "O=C(O)CCC(=O)Cl", + ) + + assert len(products) > 0 + + +def test_homopolymerization_produces_byproduct(): + products = run_reaction( + HOMO, + "O=C(O)CCC(=O)Cl", + "O=C(O)CCC(=O)Cl", + ) + + assert len(products[0]) == 2 + + +def test_products_are_sanitizable(): + products = run_reaction( + HOMO, + "O=C(O)CCC(=O)Cl", + "O=C(O)CCC(=O)Cl", + ) + + for product in products[0]: + Chem.SanitizeMol(product) + + +def test_reaction_fails_without_acid_halide(): + products = run_reaction( + HOMO, + "O=C(O)CCC(=O)O", + "O=C(O)CCC(=O)O", + ) + + assert len(products) == 0 \ No newline at end of file diff --git a/tests/unit/detectors/reactions_library/test_polycarbonates.py b/tests/unit/detectors/reactions_library/test_polycarbonates.py new file mode 100644 index 00000000..a80549c4 --- /dev/null +++ b/tests/unit/detectors/reactions_library/test_polycarbonates.py @@ -0,0 +1,220 @@ +import pytest +from rdkit import Chem +from rdkit.Chem import rdChemReactions + +from AutoREACTER.detectors.reactions_library import registry +from AutoREACTER.detectors.reactions_library.polycarbonates import ( + REACTIONS, +) + + +PHOSGENE = ( + "Diol and Phosgene " + "Polycondensation(Polycarbonate Formation)" +) + +DIPHENYL = ( + "Diol and Diphenyl Carbonate " + "Polycondensation(Transcarbonation)" +) + + +EXPECTED_REACTIONS = { + PHOSGENE, + DIPHENYL, +} + + +def run_reaction(reaction_name, smiles_1, smiles_2): + rxn = rdChemReactions.ReactionFromSmarts( + REACTIONS[reaction_name]["reaction"] + ) + + assert rxn is not None + + mol_1 = Chem.AddHs( + Chem.MolFromSmiles(smiles_1) + ) + + mol_2 = Chem.AddHs( + Chem.MolFromSmiles(smiles_2) + ) + + return rxn.RunReactants( + ( + mol_1, + mol_2, + ) + ) + + +def test_expected_active_reactions(): + assert set(REACTIONS) == EXPECTED_REACTIONS + + +@pytest.mark.parametrize( + "reaction_name", + sorted(EXPECTED_REACTIONS), +) +def test_required_fields_exist(reaction_name): + reaction = REACTIONS[reaction_name] + + required = { + "same_reactants", + "reactant_1", + "reactant_2", + "product", + "delete_atom", + "reaction", + "reference", + "comments", + } + + assert required.issubset( + reaction.keys() + ) + + +@pytest.mark.parametrize( + "reaction_name", + sorted(EXPECTED_REACTIONS), +) +def test_smarts_parses(reaction_name): + assert rdChemReactions.ReactionFromSmarts( + REACTIONS[reaction_name]["reaction"] + ) is not None + + +@pytest.mark.parametrize( + "reaction_name", + sorted(EXPECTED_REACTIONS), +) +def test_registry_validation_passes(reaction_name): + assert registry._validate_reaction_smarts( + reaction_name, + REACTIONS[reaction_name], + ) == [] + + +def test_phosgene_reaction_metadata(): + reaction = REACTIONS[PHOSGENE] + + assert reaction["same_reactants"] is False + assert reaction["reactant_1"] == "diol" + assert reaction["reactant_2"] == "phosgene" + assert reaction["product"] == "polycarbonate_chain" + assert reaction["delete_atom"] is True + + +def test_diphenyl_carbonate_metadata(): + reaction = REACTIONS[DIPHENYL] + + assert reaction["same_reactants"] is False + assert reaction["reactant_1"] == "diol" + + assert ( + reaction["reactant_2"] + == "diphenyl_carbonate" + ) + + assert reaction["product"] == "polycarbonate_chain" + assert reaction["delete_atom"] is True + + +@pytest.mark.parametrize( + "reaction_name", + sorted(EXPECTED_REACTIONS), +) +def test_product_contains_initiator_bond( + reaction_name, +): + rxn = rdChemReactions.ReactionFromSmarts( + REACTIONS[reaction_name]["reaction"] + ) + + products = [ + Chem.Mol( + rxn.GetProductTemplate(i) + ) + for i in range( + rxn.GetNumProductTemplates() + ) + ] + + assert registry._has_bond_between_atom_maps( + products, + 1, + 2, + ) is True + + +def test_diol_phosgene_reaction_executes(): + products = run_reaction( + PHOSGENE, + "OCCO", + "O=C(Cl)Cl", + ) + + assert len(products) > 0 + + +def test_diol_phosgene_produces_hcl_byproduct(): + products = run_reaction( + PHOSGENE, + "OCCO", + "O=C(Cl)Cl", + ) + + assert len(products[0]) == 2 + + +def test_diol_phosgene_products_are_sanitizable(): + products = run_reaction( + PHOSGENE, + "OCCO", + "O=C(Cl)Cl", + ) + + for product in products[0]: + Chem.SanitizeMol(product) + + +def test_diol_diphenyl_carbonate_reaction_executes(): + products = run_reaction( + DIPHENYL, + "OCCO", + "O=C(Oc1ccccc1)Oc1ccccc1", + ) + + assert len(products) > 0 + + +def test_transcarbonation_produces_two_product_fragments(): + products = run_reaction( + DIPHENYL, + "OCCO", + "O=C(Oc1ccccc1)Oc1ccccc1", + ) + + assert len(products[0]) == 2 + + +def test_transcarbonation_products_are_sanitizable(): + products = run_reaction( + DIPHENYL, + "OCCO", + "O=C(Oc1ccccc1)Oc1ccccc1", + ) + + for product in products[0]: + Chem.SanitizeMol(product) + + +def test_phosgene_reaction_rejects_non_diol_alcohol_free_molecule(): + products = run_reaction( + PHOSGENE, + "CCC", + "O=C(Cl)Cl", + ) + + assert len(products) == 0 \ No newline at end of file diff --git a/tests/unit/detectors/reactions_library/test_polyesters.py b/tests/unit/detectors/reactions_library/test_polyesters.py new file mode 100644 index 00000000..97bddbc8 --- /dev/null +++ b/tests/unit/detectors/reactions_library/test_polyesters.py @@ -0,0 +1,399 @@ +import pytest +from rdkit import Chem +from rdkit.Chem import rdChemReactions + +from AutoREACTER.detectors.reactions_library import registry +from AutoREACTER.detectors.reactions_library.polyesters import ( + REACTIONS, +) + + +HYDROXY_ACID_HOMO = ( + "Hydroxy Carboxylic Acid " + "Polycondensation(Polyesterification)" +) + +HYDROXY_ACID_CO = ( + "Hydroxy Carboxylic and Hydroxy Carboxylic " + "Polycondensation(Polyesterification)" +) + +HYDROXY_HALIDE_HOMO = ( + "Hydroxy Acid Halides " + "Polycondensation(Polyesterification)" +) + +HYDROXY_HALIDE_CO = ( + "Hydroxy Acid Halides Hydroxy Acid Halides " + "Polycondensation(Polyesterification)" +) + +DIOL_DIACID = ( + "Diol and Di-Carboxylic Acid " + "Polycondensation(Polyesterification)" +) + +DIOL_DIHALIDE = ( + "Diol and Di-Acid Halide " + "Polycondensation(Polyesterification)" +) + + +EXPECTED_REACTIONS = { + HYDROXY_ACID_HOMO, + HYDROXY_ACID_CO, + HYDROXY_HALIDE_HOMO, + HYDROXY_HALIDE_CO, + DIOL_DIACID, + DIOL_DIHALIDE, +} + + +def run_reaction(reaction_name, smiles_1, smiles_2): + rxn = rdChemReactions.ReactionFromSmarts( + REACTIONS[reaction_name]["reaction"] + ) + + assert rxn is not None + + mol_1 = Chem.AddHs( + Chem.MolFromSmiles(smiles_1) + ) + + mol_2 = Chem.AddHs( + Chem.MolFromSmiles(smiles_2) + ) + + return rxn.RunReactants( + ( + mol_1, + mol_2, + ) + ) + + +def test_expected_active_reactions(): + assert set(REACTIONS) == EXPECTED_REACTIONS + + +@pytest.mark.parametrize( + "reaction_name", + sorted(EXPECTED_REACTIONS), +) +def test_required_fields_exist(reaction_name): + reaction = REACTIONS[reaction_name] + + required = { + "same_reactants", + "reactant_1", + "product", + "delete_atom", + "reaction", + "reference", + "comments", + } + + assert required.issubset( + reaction.keys() + ) + + +@pytest.mark.parametrize( + "reaction_name", + sorted(EXPECTED_REACTIONS), +) +def test_smarts_parses(reaction_name): + assert rdChemReactions.ReactionFromSmarts( + REACTIONS[reaction_name]["reaction"] + ) is not None + + +@pytest.mark.parametrize( + "reaction_name", + sorted(EXPECTED_REACTIONS), +) +def test_registry_validation_passes(reaction_name): + assert registry._validate_reaction_smarts( + reaction_name, + REACTIONS[reaction_name], + ) == [] + + +# ============================================================================= +# Metadata +# ============================================================================= + +def test_hydroxy_acid_homo_metadata(): + reaction = REACTIONS[ + HYDROXY_ACID_HOMO + ] + + assert reaction["same_reactants"] is True + + assert ( + reaction["reactant_1"] + == "hydroxy_carboxylic_acid" + ) + + assert reaction.get("reactant_2") is None + assert reaction["product"] == "polyester_chain" + assert reaction["delete_atom"] is True + + +def test_hydroxy_acid_co_metadata(): + reaction = REACTIONS[ + HYDROXY_ACID_CO + ] + + assert reaction["same_reactants"] is False + + assert ( + reaction["reactant_1"] + == "hydroxy_carboxylic_acid" + ) + + assert ( + reaction["reactant_2"] + == "hydroxy_carboxylic_acid" + ) + + +def test_hydroxy_halide_homo_metadata(): + reaction = REACTIONS[ + HYDROXY_HALIDE_HOMO + ] + + assert reaction["same_reactants"] is True + + assert ( + reaction["reactant_1"] + == "hydroxy_acid_halide" + ) + + +def test_hydroxy_halide_co_metadata(): + reaction = REACTIONS[ + HYDROXY_HALIDE_CO + ] + + assert reaction["same_reactants"] is False + + assert ( + reaction["reactant_1"] + == "hydroxy_acid_halide" + ) + + assert ( + reaction["reactant_2"] + == "hydroxy_acid_halide" + ) + + +def test_diol_diacid_metadata(): + reaction = REACTIONS[DIOL_DIACID] + + assert reaction["same_reactants"] is False + assert reaction["reactant_1"] == "diol" + + assert ( + reaction["reactant_2"] + == "di_carboxylic_acid" + ) + + assert reaction["product"] == "polyester_chain" + + +def test_diol_diacid_halide_metadata(): + reaction = REACTIONS[DIOL_DIHALIDE] + + assert reaction["same_reactants"] is False + assert reaction["reactant_1"] == "diol" + + assert ( + reaction["reactant_2"] + == "di_carboxylic_acid_halide" + ) + + +# ============================================================================= +# Initiator bond +# ============================================================================= + +@pytest.mark.parametrize( + "reaction_name", + sorted(EXPECTED_REACTIONS), +) +def test_product_contains_initiator_bond( + reaction_name, +): + rxn = rdChemReactions.ReactionFromSmarts( + REACTIONS[reaction_name]["reaction"] + ) + + products = [ + Chem.Mol( + rxn.GetProductTemplate(i) + ) + for i in range( + rxn.GetNumProductTemplates() + ) + ] + + assert registry._has_bond_between_atom_maps( + products, + 1, + 2, + ) is True + + +# ============================================================================= +# Hydroxy carboxylic acid +# ============================================================================= + +def test_hydroxy_acid_polycondensation_executes(): + products = run_reaction( + HYDROXY_ACID_HOMO, + "CC(O)C(=O)O", + "CC(O)C(=O)O", + ) + + assert len(products) > 0 + + +def test_hydroxy_acid_polycondensation_has_byproduct(): + products = run_reaction( + HYDROXY_ACID_HOMO, + "CC(O)C(=O)O", + "CC(O)C(=O)O", + ) + + assert len(products[0]) == 2 + + +def test_hydroxy_acid_products_are_sanitizable(): + products = run_reaction( + HYDROXY_ACID_HOMO, + "CC(O)C(=O)O", + "CC(O)C(=O)O", + ) + + for product in products[0]: + Chem.SanitizeMol(product) + + +# ============================================================================= +# Hydroxy acid halide +# ============================================================================= + +def test_hydroxy_acid_halide_polycondensation_executes(): + products = run_reaction( + HYDROXY_HALIDE_HOMO, + "OCCC(=O)Cl", + "OCCC(=O)Cl", + ) + + assert len(products) > 0 + + +def test_hydroxy_acid_halide_has_byproduct(): + products = run_reaction( + HYDROXY_HALIDE_HOMO, + "OCCC(=O)Cl", + "OCCC(=O)Cl", + ) + + assert len(products[0]) == 2 + + +# ============================================================================= +# Diol + diacid +# ============================================================================= + +def test_diol_diacid_polycondensation_executes(): + products = run_reaction( + DIOL_DIACID, + "O=C(O)CCC(=O)O", + "OCCO", + ) + + assert len(products) > 0 + + +def test_diol_diacid_products_are_sanitizable(): + products = run_reaction( + DIOL_DIACID, + "O=C(O)CCC(=O)O", + "OCCO", + ) + + for product in products[0]: + Chem.SanitizeMol(product) + + +def test_diol_diacid_has_byproduct(): + products = run_reaction( + DIOL_DIACID, + "O=C(O)CCC(=O)O", + "OCCO", + ) + + assert len(products[0]) == 2 + + +# ============================================================================= +# Diol + diacid halide +# ============================================================================= + +def test_diol_diacid_halide_polycondensation_executes(): + products = run_reaction( + DIOL_DIHALIDE, + "O=C(Cl)CCC(=O)Cl", + "OCCO", + ) + + assert len(products) > 0 + + +def test_diol_diacid_halide_products_are_sanitizable(): + products = run_reaction( + DIOL_DIHALIDE, + "O=C(Cl)CCC(=O)Cl", + "OCCO", + ) + + for product in products[0]: + Chem.SanitizeMol(product) + + +def test_diol_diacid_halide_has_byproduct(): + products = run_reaction( + DIOL_DIHALIDE, + "O=C(Cl)CCC(=O)Cl", + "OCCO", + ) + + assert len(products[0]) == 2 + + +# ============================================================================= +# Negative tests +# ============================================================================= + +def test_hydroxy_acid_reaction_rejects_molecule_without_hydroxyl(): + products = run_reaction( + HYDROXY_ACID_HOMO, + "CC(=O)O", + "CC(=O)O", + ) + + assert len(products) == 0 + + +def test_diol_diacid_reaction_rejects_hydrocarbon(): + products = run_reaction( + DIOL_DIACID, + "O=C(O)CCC(=O)O", + "CCCC", + ) + + assert len(products) == 0 \ No newline at end of file diff --git a/tests/unit/detectors/reactions_library/test_polysiloxanes.py b/tests/unit/detectors/reactions_library/test_polysiloxanes.py new file mode 100644 index 00000000..3a6f707b --- /dev/null +++ b/tests/unit/detectors/reactions_library/test_polysiloxanes.py @@ -0,0 +1,126 @@ +import pytest +from rdkit import Chem +from rdkit.Chem import rdChemReactions + +from AutoREACTER.detectors.reactions_library import registry +from AutoREACTER.detectors.reactions_library.polysiloxanes import REACTIONS + + +HYDROLYSIS = "Dichlorosilane Hydrolysis to Silanol" +HOMO = "Silanediol Polycondensation(Polysiloxane Formation)" +CO = "Silanediol and Silanediol Copolycondensation(Polysiloxane Formation)" + +EXPECTED_REACTIONS = { + HYDROLYSIS, + HOMO, + CO, +} + + +def run_reaction(name, smiles_1, smiles_2): + rxn = rdChemReactions.ReactionFromSmarts( + REACTIONS[name]["reaction"] + ) + assert rxn is not None + + mol_1 = Chem.AddHs(Chem.MolFromSmiles(smiles_1)) + mol_2 = Chem.AddHs(Chem.MolFromSmiles(smiles_2)) + + return rxn.RunReactants((mol_1, mol_2)) + + +def test_expected_active_reactions(): + assert set(REACTIONS) == EXPECTED_REACTIONS + + +@pytest.mark.parametrize("name", sorted(EXPECTED_REACTIONS)) +def test_smarts_parses(name): + assert rdChemReactions.ReactionFromSmarts( + REACTIONS[name]["reaction"] + ) is not None + + +@pytest.mark.parametrize("name", sorted(EXPECTED_REACTIONS)) +def test_registry_validation_passes(name): + assert registry._validate_reaction_smarts( + name, + REACTIONS[name], + ) == [] + + +def test_hydrolysis_metadata(): + reaction = REACTIONS[HYDROLYSIS] + + assert reaction["same_reactants"] is False + assert reaction["reactant_1"] == "dichlorosilane" + assert reaction["reactant_2"] == "water" + assert reaction["product"] == "silanediol" + assert reaction["delete_atom"] is True + + +def test_silanediol_homopolycondensation_metadata(): + reaction = REACTIONS[HOMO] + + assert reaction["same_reactants"] is True + assert reaction["reactant_1"] == "silanediol" + assert reaction.get("reactant_2") is None + assert reaction["product"] == "polysiloxane_chain" + assert reaction["delete_atom"] is True + + +def test_silanediol_copolycondensation_metadata(): + reaction = REACTIONS[CO] + + assert reaction["same_reactants"] is False + assert reaction["reactant_1"] == "silanediol" + assert reaction["reactant_2"] == "silanediol" + + +def test_dichlorosilane_hydrolysis_executes(): + products = run_reaction( + HYDROLYSIS, + "Cl[Si](C)(C)Cl", + "O", + ) + + assert len(products) > 0 + + +def test_dichlorosilane_hydrolysis_has_two_products(): + products = run_reaction( + HYDROLYSIS, + "Cl[Si](C)(C)Cl", + "O", + ) + + assert len(products[0]) == 2 + + +def test_silanediol_condensation_executes(): + products = run_reaction( + HOMO, + "O[Si](C)(C)O", + "O[Si](C)(C)O", + ) + + assert len(products) > 0 + + +def test_silanediol_condensation_has_water_fragment(): + products = run_reaction( + HOMO, + "O[Si](C)(C)O", + "O[Si](C)(C)O", + ) + + assert len(products[0]) == 2 + + +def test_hydrolysis_rejects_non_water_second_reactant(): + products = run_reaction( + HYDROLYSIS, + "Cl[Si](C)(C)Cl", + "CCO", + ) + + assert len(products) == 0 \ No newline at end of file diff --git a/tests/unit/detectors/reactions_library/test_polythioesters.py b/tests/unit/detectors/reactions_library/test_polythioesters.py new file mode 100644 index 00000000..2af4462e --- /dev/null +++ b/tests/unit/detectors/reactions_library/test_polythioesters.py @@ -0,0 +1,144 @@ +import pytest +from rdkit import Chem +from rdkit.Chem import rdChemReactions + +from AutoREACTER.detectors.reactions_library import registry +from AutoREACTER.detectors.reactions_library.polythioesters import REACTIONS + + +THIOL_HALIDE = ( + "Dithiol and Di-Carboxylic Acid Halide " + "Polycondensation(Polythioesterification)" +) + +THIOL_ACID = ( + "Dithiol and Di-Carboxylic Acid " + "Polycondensation(Polythioesterification)" +) + +HYDROXY_PATH = ( + "Hydroxy-Thiol and Di-Carboxylic Acid Halide " + "Polycondensation through Hydroxy Group" +) + +THIOL_PATH = ( + "Hydroxy-Thiol and Di-Carboxylic Acid Halide " + "Polycondensation through Thiol Group" +) + +EXPECTED_REACTIONS = { + THIOL_HALIDE, + THIOL_ACID, + HYDROXY_PATH, + THIOL_PATH, +} + + +def run_reaction(name, smiles_1, smiles_2): + rxn = rdChemReactions.ReactionFromSmarts( + REACTIONS[name]["reaction"] + ) + assert rxn is not None + + return rxn.RunReactants( + ( + Chem.AddHs(Chem.MolFromSmiles(smiles_1)), + Chem.AddHs(Chem.MolFromSmiles(smiles_2)), + ) + ) + + +def test_expected_active_reactions(): + assert set(REACTIONS) == EXPECTED_REACTIONS + + +@pytest.mark.parametrize("name", sorted(EXPECTED_REACTIONS)) +def test_smarts_parses(name): + assert rdChemReactions.ReactionFromSmarts( + REACTIONS[name]["reaction"] + ) is not None + + +@pytest.mark.parametrize("name", sorted(EXPECTED_REACTIONS)) +def test_registry_validation_passes(name): + assert registry._validate_reaction_smarts( + name, + REACTIONS[name], + ) == [] + + +@pytest.mark.parametrize("name", sorted(EXPECTED_REACTIONS)) +def test_reactions_delete_leaving_group(name): + assert REACTIONS[name]["delete_atom"] is True + + +def test_dithiol_acid_halide_metadata(): + reaction = REACTIONS[THIOL_HALIDE] + + assert reaction["reactant_1"] == "dithiol" + assert reaction["reactant_2"] == "di_carboxylic_acid_halide" + assert reaction["product"] == "polythioester_chain" + + +def test_dithiol_acid_metadata(): + reaction = REACTIONS[THIOL_ACID] + + assert reaction["reactant_1"] == "dithiol" + assert reaction["reactant_2"] == "di_carboxylic_acid" + assert reaction["product"] == "polythioester_chain" + + +def test_hydroxy_thiol_has_two_distinct_reaction_routes(): + assert "OX2H1" in REACTIONS[HYDROXY_PATH]["reaction"] + assert "SX2H1" in REACTIONS[THIOL_PATH]["reaction"] + + +def test_dithiol_acid_halide_reaction_executes(): + # SMARTS order = acid halide first, dithiol second + products = run_reaction( + THIOL_HALIDE, + "O=C(Cl)CCC(=O)Cl", + "SCCS", + ) + + assert len(products) > 0 + + +def test_dithiol_acid_reaction_executes(): + products = run_reaction( + THIOL_ACID, + "O=C(O)CCC(=O)O", + "SCCS", + ) + + assert len(products) > 0 + + +def test_hydroxy_thiol_hydroxy_route_executes(): + products = run_reaction( + HYDROXY_PATH, + "O=C(Cl)CCC(=O)Cl", + "OCCS", + ) + + assert len(products) > 0 + + +def test_hydroxy_thiol_thiol_route_executes(): + products = run_reaction( + THIOL_PATH, + "O=C(Cl)CCC(=O)Cl", + "OCCS", + ) + + assert len(products) > 0 + + +def test_dithiol_acid_halide_produces_byproduct(): + products = run_reaction( + THIOL_HALIDE, + "O=C(Cl)CCC(=O)Cl", + "SCCS", + ) + + assert len(products[0]) == 2 \ No newline at end of file diff --git a/tests/unit/detectors/reactions_library/test_polyureas.py b/tests/unit/detectors/reactions_library/test_polyureas.py new file mode 100644 index 00000000..eb231500 --- /dev/null +++ b/tests/unit/detectors/reactions_library/test_polyureas.py @@ -0,0 +1,76 @@ +from rdkit import Chem +from rdkit.Chem import rdChemReactions + +from AutoREACTER.detectors.reactions_library import registry +from AutoREACTER.detectors.reactions_library.polyureas import REACTIONS + + +NAME = "Di-Amine and Di-Isocyanate Polyaddition(Polyurea Formation)" + + +def run_reaction(smiles_1, smiles_2): + rxn = rdChemReactions.ReactionFromSmarts( + REACTIONS[NAME]["reaction"] + ) + + return rxn.RunReactants( + ( + Chem.AddHs(Chem.MolFromSmiles(smiles_1)), + Chem.AddHs(Chem.MolFromSmiles(smiles_2)), + ) + ) + + +def test_expected_active_reaction(): + assert set(REACTIONS) == {NAME} + + +def test_metadata(): + reaction = REACTIONS[NAME] + + assert reaction["same_reactants"] is False + assert reaction["reactant_1"] == "di_amine" + assert reaction["reactant_2"] == "di_isocyanate" + assert reaction["product"] == "polyurea_chain" + assert reaction["delete_atom"] is False + + +def test_smarts_parses(): + assert rdChemReactions.ReactionFromSmarts( + REACTIONS[NAME]["reaction"] + ) is not None + + +def test_registry_validation_passes(): + assert registry._validate_reaction_smarts( + NAME, + REACTIONS[NAME], + ) == [] + + +def test_diamine_diisocyanate_reaction_executes(): + products = run_reaction( + "NCCN", + "O=C=NCCCCCCN=C=O", + ) + + assert len(products) > 0 + + +def test_products_are_sanitizable(): + products = run_reaction( + "NCCN", + "O=C=NCCCCCCN=C=O", + ) + + for product in products[0]: + Chem.SanitizeMol(product) + + +def test_nonamine_does_not_react(): + products = run_reaction( + "OCCO", + "O=C=NCCCCCCN=C=O", + ) + + assert len(products) == 0 \ No newline at end of file diff --git a/tests/unit/detectors/reactions_library/test_polyurethanes.py b/tests/unit/detectors/reactions_library/test_polyurethanes.py new file mode 100644 index 00000000..78edef99 --- /dev/null +++ b/tests/unit/detectors/reactions_library/test_polyurethanes.py @@ -0,0 +1,108 @@ +import pytest +from rdkit import Chem +from rdkit.Chem import rdChemReactions + +from AutoREACTER.detectors.reactions_library import registry +from AutoREACTER.detectors.reactions_library.polyurethanes import REACTIONS + + +URETHANE = "Diol and Di-Isocyanate Polyaddition(Polyurethane Formation)" +THIOURETHANE = ( + "Dithiol and Di-Isocyanate " + "Polyaddition(Polythiourethane Formation)" +) + +EXPECTED_REACTIONS = { + URETHANE, + THIOURETHANE, +} + + +def run_reaction(name, smiles_1, smiles_2): + rxn = rdChemReactions.ReactionFromSmarts( + REACTIONS[name]["reaction"] + ) + + return rxn.RunReactants( + ( + Chem.AddHs(Chem.MolFromSmiles(smiles_1)), + Chem.AddHs(Chem.MolFromSmiles(smiles_2)), + ) + ) + + +def test_expected_active_reactions(): + assert set(REACTIONS) == EXPECTED_REACTIONS + + +@pytest.mark.parametrize("name", sorted(EXPECTED_REACTIONS)) +def test_smarts_parses(name): + assert rdChemReactions.ReactionFromSmarts( + REACTIONS[name]["reaction"] + ) is not None + + +@pytest.mark.parametrize("name", sorted(EXPECTED_REACTIONS)) +def test_registry_validation_passes(name): + assert registry._validate_reaction_smarts( + name, + REACTIONS[name], + ) == [] + + +def test_polyurethane_metadata(): + reaction = REACTIONS[URETHANE] + + assert reaction["reactant_1"] == "diol" + assert reaction["reactant_2"] == "di_isocyanate" + assert reaction["product"] == "polyurethane_chain" + assert reaction["delete_atom"] is False + + +def test_polythiourethane_metadata(): + reaction = REACTIONS[THIOURETHANE] + + assert reaction["reactant_1"] == "dithiol" + assert reaction["reactant_2"] == "di_isocyanate" + assert reaction["product"] == "polythiourethane_chain" + assert reaction["delete_atom"] is False + + +def test_diol_diisocyanate_reaction_executes(): + products = run_reaction( + URETHANE, + "OCCO", + "O=C=NCCCCCCN=C=O", + ) + + assert len(products) > 0 + + +def test_dithiol_diisocyanate_reaction_executes(): + products = run_reaction( + THIOURETHANE, + "SCCS", + "O=C=NCCCCCCN=C=O", + ) + + assert len(products) > 0 + + +def test_polyurethane_rejects_dithiol(): + products = run_reaction( + URETHANE, + "SCCS", + "O=C=NCCCCCCN=C=O", + ) + + assert len(products) == 0 + + +def test_polythiourethane_rejects_diol(): + products = run_reaction( + THIOURETHANE, + "OCCO", + "O=C=NCCCCCCN=C=O", + ) + + assert len(products) == 0 \ No newline at end of file diff --git a/tests/unit/detectors/reactions_library/test_registry.py b/tests/unit/detectors/reactions_library/test_registry.py new file mode 100644 index 00000000..b8cd4383 --- /dev/null +++ b/tests/unit/detectors/reactions_library/test_registry.py @@ -0,0 +1,768 @@ +import pytest +from rdkit import Chem +from rdkit.Chem import rdChemReactions + +from AutoREACTER.detectors.reactions_library import registry + + +# ============================================================================= +# Helpers +# ============================================================================= + +def make_valid_reaction( + *, + smarts="[C:1].[O:2]>>[C:1]-[O:2]", + **extra, +): + """Create a minimal valid AutoREACTER reaction-library entry.""" + reaction = { + "reaction": smarts, + } + + reaction.update(extra) + + return reaction + + +def templates_from_smarts(smarts): + """ + Convert reaction SMARTS into independent reactant/product Mol copies. + + RDKit's GetReactantTemplate() and GetProductTemplate() return objects + owned by the ChemicalReaction. Therefore, the returned templates must + be copied before the ChemicalReaction goes out of scope. + + Without the Chem.Mol copies below, tests may retain dangling C++ object + references and can eventually cause a segmentation fault. + """ + reaction = rdChemReactions.ReactionFromSmarts(smarts) + + assert reaction is not None + + reactants = [ + Chem.Mol( + reaction.GetReactantTemplate(i) + ) + for i in range( + reaction.GetNumReactantTemplates() + ) + ] + + products = [ + Chem.Mol( + reaction.GetProductTemplate(i) + ) + for i in range( + reaction.GetNumProductTemplates() + ) + ] + + return reactants, products + + +# ============================================================================= +# ReactionLibraryValidationError +# ============================================================================= + +def test_validation_error_is_value_error(): + assert issubclass( + registry.ReactionLibraryValidationError, + ValueError, + ) + + +# ============================================================================= +# _atom_maps_in_templates() +# ============================================================================= + +def test_atom_maps_in_templates_collects_all_nonzero_maps(): + reactants, _ = templates_from_smarts( + "[C:1].[O:2].[N:7]>>[C:1]-[O:2]-[N:7]" + ) + + result = registry._atom_maps_in_templates( + reactants + ) + + assert result == { + 1, + 2, + 7, + } + + +def test_atom_maps_in_templates_ignores_unmapped_atoms(): + reactants, _ = templates_from_smarts( + "[C:1].O>>[C:1]-O" + ) + + result = registry._atom_maps_in_templates( + reactants + ) + + assert result == { + 1, + } + + +def test_atom_maps_in_templates_empty_templates_returns_empty_set(): + result = registry._atom_maps_in_templates( + [] + ) + + assert result == set() + + +def test_atom_maps_in_templates_deduplicates_map_numbers(): + reactants, _ = templates_from_smarts( + "[C:1].[O:1]>>[C:1]-[O:1]" + ) + + result = registry._atom_maps_in_templates( + reactants + ) + + assert result == { + 1, + } + + +# ============================================================================= +# _has_bond_between_atom_maps() +# ============================================================================= + +def test_has_bond_between_atom_maps_returns_true_when_bond_exists(): + _, products = templates_from_smarts( + "[C:1].[O:2]>>[C:1]-[O:2]" + ) + + result = registry._has_bond_between_atom_maps( + products, + 1, + 2, + ) + + assert result is True + + +def test_has_bond_between_atom_maps_is_order_independent(): + _, products = templates_from_smarts( + "[C:1].[O:2]>>[C:1]-[O:2]" + ) + + result = registry._has_bond_between_atom_maps( + products, + 2, + 1, + ) + + assert result is True + + +def test_has_bond_between_atom_maps_returns_false_without_bond(): + _, products = templates_from_smarts( + "[C:1].[O:2]>>[C:1].[O:2]" + ) + + result = registry._has_bond_between_atom_maps( + products, + 1, + 2, + ) + + assert result is False + + +def test_has_bond_between_atom_maps_returns_false_for_missing_map(): + _, products = templates_from_smarts( + "[C:1].[O:2]>>[C:1]-[O:2]" + ) + + result = registry._has_bond_between_atom_maps( + products, + 1, + 99, + ) + + assert result is False + + +def test_has_bond_between_atom_maps_empty_templates_returns_false(): + result = registry._has_bond_between_atom_maps( + [], + 1, + 2, + ) + + assert result is False + + +# ============================================================================= +# _validate_reaction_smarts() +# ============================================================================= + +def test_valid_reaction_smarts_has_no_errors(): + reaction = make_valid_reaction() + + errors = registry._validate_reaction_smarts( + "test_reaction", + reaction, + ) + + assert errors == [] + + +def test_missing_reaction_key_is_reported(): + errors = registry._validate_reaction_smarts( + "test_reaction", + {}, + ) + + assert errors == [ + "test_reaction: missing required key 'reaction'" + ] + + +def test_empty_reaction_smarts_is_reported_as_missing(): + errors = registry._validate_reaction_smarts( + "test_reaction", + { + "reaction": "", + }, + ) + + assert errors == [ + "test_reaction: missing required key 'reaction'" + ] + + +def test_validation_can_be_explicitly_disabled(): + """ + Special reactions can intentionally bypass the initiator-bond check. + """ + reaction = { + "reaction": "THIS DOES NOT NEED TO BE VALIDATED", + "validate_initiator_bond": False, + } + + errors = registry._validate_reaction_smarts( + "special_reaction", + reaction, + ) + + assert errors == [] + + +def test_missing_rdkit_is_reported(monkeypatch): + monkeypatch.setattr( + registry, + "rdChemReactions", + None, + ) + + errors = registry._validate_reaction_smarts( + "test_reaction", + make_valid_reaction(), + ) + + assert errors == [ + "test_reaction: RDKit is required to validate reaction SMARTS" + ] + + +@pytest.mark.parametrize( + "initiator_maps", + [ + (), + (1,), + (1, 2, 3), + [1], + [1, 2, 3], + ], +) +def test_initiator_atom_maps_must_contain_exactly_two_maps( + initiator_maps, +): + reaction = make_valid_reaction( + initiator_atom_maps=initiator_maps, + ) + + errors = registry._validate_reaction_smarts( + "test_reaction", + reaction, + ) + + assert errors == [ + "test_reaction: initiator_atom_maps must contain exactly two atom maps" + ] + + +def test_invalid_reaction_smarts_is_reported(): + reaction = { + "reaction": "not_a_reaction_smarts", + } + + errors = registry._validate_reaction_smarts( + "bad_reaction", + reaction, + ) + + assert errors + + assert errors[0].startswith( + "bad_reaction:" + ) + + assert ( + "invalid reaction SMARTS" + in errors[0] + or + "could not parse reaction SMARTS" + in errors[0] + ) + + +def test_missing_initiator_map_from_reactants_is_reported(): + reaction = { + "reaction": "[C:1].O>>[C:1]-[O:2]", + } + + errors = registry._validate_reaction_smarts( + "test_reaction", + reaction, + ) + + assert any( + "initiator atom maps missing from reactants: [2]" + in error + for error in errors + ) + + +def test_missing_initiator_map_from_products_is_reported(): + reaction = { + "reaction": "[C:1].[O:2]>>[C:1]-O", + } + + errors = registry._validate_reaction_smarts( + "test_reaction", + reaction, + ) + + assert any( + "initiator atom maps missing from products: [2]" + in error + for error in errors + ) + + +def test_missing_initiator_bond_is_reported(): + reaction = { + "reaction": "[C:1].[O:2]>>[C:1].[O:2]", + } + + errors = registry._validate_reaction_smarts( + "test_reaction", + reaction, + ) + + assert ( + "test_reaction: product does not contain required " + "AutoREACTER initiator bond between atom maps 1 and 2" + in errors + ) + + +def test_missing_product_map_can_generate_multiple_validation_errors(): + """ + If a mapped initiator disappears from the product, both the missing-map + error and missing-bond error should be preserved. + """ + reaction = { + "reaction": "[C:1].[O:2]>>[C:1]-O", + } + + errors = registry._validate_reaction_smarts( + "test_reaction", + reaction, + ) + + assert any( + "missing from products" + in error + for error in errors + ) + + assert any( + "product does not contain required" + in error + for error in errors + ) + + +def test_custom_initiator_atom_maps_are_supported(): + reaction = { + "reaction": "[C:7].[O:9]>>[C:7]-[O:9]", + "initiator_atom_maps": ( + 7, + 9, + ), + } + + errors = registry._validate_reaction_smarts( + "custom_maps", + reaction, + ) + + assert errors == [] + + +def test_custom_maps_still_require_product_bond(): + reaction = { + "reaction": "[C:7].[O:9]>>[C:7].[O:9]", + "initiator_atom_maps": ( + 7, + 9, + ), + } + + errors = registry._validate_reaction_smarts( + "custom_maps", + reaction, + ) + + assert any( + "between atom maps 7 and 9" + in error + for error in errors + ) + + +def test_initiator_atom_maps_are_converted_to_int(): + """ + String map numbers are accepted because registry converts them using int(). + """ + reaction = { + "reaction": "[C:7].[O:9]>>[C:7]-[O:9]", + "initiator_atom_maps": ( + "7", + "9", + ), + } + + errors = registry._validate_reaction_smarts( + "custom_maps", + reaction, + ) + + assert errors == [] + + +# ============================================================================= +# validate_reactions() +# ============================================================================= + +def test_validate_reactions_accepts_valid_dictionary(): + reactions = { + "reaction_1": make_valid_reaction(), + "reaction_2": make_valid_reaction( + smarts="[N:1].[C:2]>>[N:1]-[C:2]" + ), + } + + result = registry.validate_reactions( + reactions + ) + + assert result is None + + +def test_validate_reactions_rejects_non_dictionary_entry(): + reactions = { + "bad_reaction": "not a dictionary", + } + + with pytest.raises( + registry.ReactionLibraryValidationError, + match="reaction entry must be a dictionary", + ): + registry.validate_reactions( + reactions + ) + + +def test_validate_reactions_rejects_missing_reaction_smarts(): + reactions = { + "bad_reaction": {}, + } + + with pytest.raises( + registry.ReactionLibraryValidationError, + match="missing required key 'reaction'", + ): + registry.validate_reactions( + reactions + ) + + +def test_validate_reactions_aggregates_multiple_reaction_errors(): + reactions = { + "bad_1": {}, + "bad_2": { + "reaction": "[C:1].[O:2]>>[C:1].[O:2]" + }, + } + + with pytest.raises( + registry.ReactionLibraryValidationError + ) as exc_info: + registry.validate_reactions( + reactions + ) + + message = str( + exc_info.value + ) + + assert "bad_1" in message + assert "bad_2" in message + assert "missing required key 'reaction'" in message + assert "product does not contain required" in message + + +def test_validation_error_message_has_expected_header(): + reactions = { + "bad": {}, + } + + with pytest.raises( + registry.ReactionLibraryValidationError + ) as exc_info: + registry.validate_reactions( + reactions + ) + + assert str( + exc_info.value + ).startswith( + "Reaction library validation failed:" + ) + + +def test_empty_reaction_dictionary_is_valid(): + result = registry.validate_reactions( + {} + ) + + assert result is None + + +# ============================================================================= +# load_reactions() +# ============================================================================= + +def test_load_reactions_merges_modules(monkeypatch): + module_1 = { + "reaction_a": make_valid_reaction(), + } + + module_2 = { + "reaction_b": make_valid_reaction( + smarts="[N:1].[C:2]>>[N:1]-[C:2]" + ), + } + + monkeypatch.setattr( + registry, + "_REACTION_MODULES", + [ + module_1, + module_2, + ], + ) + + result = registry.load_reactions() + + assert set( + result + ) == { + "reaction_a", + "reaction_b", + } + + assert ( + result["reaction_a"] + is module_1["reaction_a"] + ) + + assert ( + result["reaction_b"] + is module_2["reaction_b"] + ) + + +def test_load_reactions_rejects_duplicate_reaction_names( + monkeypatch, +): + module_1 = { + "duplicate": make_valid_reaction(), + } + + module_2 = { + "duplicate": make_valid_reaction(), + } + + monkeypatch.setattr( + registry, + "_REACTION_MODULES", + [ + module_1, + module_2, + ], + ) + + with pytest.raises( + ValueError, + match="Duplicate reaction name: duplicate", + ): + registry.load_reactions() + + +def test_load_reactions_empty_modules_returns_empty_dict( + monkeypatch, +): + monkeypatch.setattr( + registry, + "_REACTION_MODULES", + [], + ) + + result = registry.load_reactions() + + assert result == {} + + +def test_load_reactions_calls_validation(monkeypatch): + module = { + "reaction_a": { + "reaction": "anything" + } + } + + monkeypatch.setattr( + registry, + "_REACTION_MODULES", + [ + module, + ], + ) + + captured = {} + + def fake_validate(reactions): + captured["reactions"] = reactions + + monkeypatch.setattr( + registry, + "validate_reactions", + fake_validate, + ) + + result = registry.load_reactions() + + assert ( + captured["reactions"] + == result + ) + + assert "reaction_a" in result + + +def test_load_reactions_propagates_validation_failure( + monkeypatch, +): + module = { + "bad": {}, + } + + monkeypatch.setattr( + registry, + "_REACTION_MODULES", + [ + module, + ], + ) + + with pytest.raises( + registry.ReactionLibraryValidationError + ): + registry.load_reactions() + + +# ============================================================================= +# ReactionLibrary backward compatibility +# ============================================================================= + +def test_reaction_library_exposes_reactions( + monkeypatch, +): + expected = { + "test": make_valid_reaction(), + } + + monkeypatch.setattr( + registry, + "load_reactions", + lambda: expected, + ) + + library = registry.ReactionLibrary() + + assert library.reactions is expected + + +# ============================================================================= +# Production registry sanity checks +# ============================================================================= + +def test_module_level_reactions_is_dictionary(): + assert isinstance( + registry.REACTIONS, + dict, + ) + + +def test_module_level_reactions_is_not_empty(): + assert registry.REACTIONS + + +def test_production_reaction_names_are_unique(): + names = list( + registry.REACTIONS.keys() + ) + + assert ( + len(names) + == len(set(names)) + ) + + +def test_production_registry_passes_validation(): + """ + All currently enabled production reactions must satisfy the + registry validation rules. + """ + result = registry.validate_reactions( + registry.REACTIONS + ) + + assert result is None + + +def test_fresh_production_load_matches_exported_registry(): + freshly_loaded = registry.load_reactions() + + assert ( + freshly_loaded + == registry.REACTIONS + ) \ No newline at end of file diff --git a/tests/unit/detectors/reactions_library/test_thiol_ene_polymers.py b/tests/unit/detectors/reactions_library/test_thiol_ene_polymers.py new file mode 100644 index 00000000..253df4ed --- /dev/null +++ b/tests/unit/detectors/reactions_library/test_thiol_ene_polymers.py @@ -0,0 +1,87 @@ +from rdkit import Chem +from rdkit.Chem import rdChemReactions + +from AutoREACTER.detectors.reactions_library import registry +from AutoREACTER.detectors.reactions_library.thiol_ene_polymers import ( + REACTIONS, +) + + +NAME = "Dithiol and Diene Thiol-Ene Click Polymerization" + + +def run_reaction(smiles_1, smiles_2): + rxn = rdChemReactions.ReactionFromSmarts( + REACTIONS[NAME]["reaction"] + ) + + return rxn.RunReactants( + ( + Chem.AddHs(Chem.MolFromSmiles(smiles_1)), + Chem.AddHs(Chem.MolFromSmiles(smiles_2)), + ) + ) + + +def test_expected_active_reaction(): + assert set(REACTIONS) == {NAME} + + +def test_metadata(): + reaction = REACTIONS[NAME] + + assert reaction["same_reactants"] is False + assert reaction["reactant_1"] == "dithiol" + assert reaction["reactant_2"] == "diene" + assert reaction["product"] == "poly_thioether_chain" + assert reaction["delete_atom"] is False + + +def test_smarts_parses(): + assert rdChemReactions.ReactionFromSmarts( + REACTIONS[NAME]["reaction"] + ) is not None + + +def test_registry_validation_passes(): + assert registry._validate_reaction_smarts( + NAME, + REACTIONS[NAME], + ) == [] + + +def test_dithiol_diene_reaction_executes(): + products = run_reaction( + "SCCS", + "C=CC=C", + ) + + assert len(products) > 0 + + +def test_products_are_sanitizable(): + products = run_reaction( + "SCCS", + "C=CC=C", + ) + + for product in products[0]: + Chem.SanitizeMol(product) + + +def test_dithiol_diene_reaction_rejects_saturated_hydrocarbon(): + products = run_reaction( + "SCCS", + "CCCC", + ) + + assert len(products) == 0 + + +def test_reaction_rejects_thioether_without_sh(): + products = run_reaction( + "CSC", + "C=CC=C", + ) + + assert len(products) == 0 \ No newline at end of file diff --git a/tests/unit/detectors/reactions_library/test_vinyl_polymers.py b/tests/unit/detectors/reactions_library/test_vinyl_polymers.py new file mode 100644 index 00000000..d926c498 --- /dev/null +++ b/tests/unit/detectors/reactions_library/test_vinyl_polymers.py @@ -0,0 +1,457 @@ +import pytest +from rdkit import Chem +from rdkit.Chem import rdChemReactions + +from AutoREACTER.detectors.reactions_library import registry +from AutoREACTER.detectors.reactions_library.vinyl_polymers import ( + REACTIONS, +) + + +INITIATION = "Vinyl Addition Polymerization Initiation" +PROPAGATION = "Vinyl Addition Polymerization Propagation" + +SAME_CHAIN_TERMINATION = ( + "Vinyl Radical Coupling Termination (Same Chain)" +) + +CROSS_CHAIN_TERMINATION = ( + "Vinyl Radical Coupling Termination (Cross Chain)" +) + +CO_INITIATION = "Vinyl Copolymerization Initiation" +TFE_INITIATION = "Tetrafluoroethylene Initiation" +TFE_PROPAGATION = "Tetrafluoroethylene Propagation" + + +EXPECTED_REACTIONS = { + INITIATION, + PROPAGATION, + SAME_CHAIN_TERMINATION, + CROSS_CHAIN_TERMINATION, + CO_INITIATION, + TFE_INITIATION, + TFE_PROPAGATION, +} + + +def reaction_templates(name): + rxn = rdChemReactions.ReactionFromSmarts( + REACTIONS[name]["reaction"] + ) + + assert rxn is not None + + reactants = [ + Chem.Mol( + rxn.GetReactantTemplate(i) + ) + for i in range( + rxn.GetNumReactantTemplates() + ) + ] + + products = [ + Chem.Mol( + rxn.GetProductTemplate(i) + ) + for i in range( + rxn.GetNumProductTemplates() + ) + ] + + return reactants, products + + +# ============================================================================= +# Structure +# ============================================================================= + +def test_expected_active_reactions(): + """ + This deliberately catches accidental dictionary nesting. + + All seven currently enabled vinyl/TFE reactions are intended to be + top-level reaction-library entries. + """ + assert set(REACTIONS) == EXPECTED_REACTIONS + + +@pytest.mark.parametrize( + "name", + sorted(EXPECTED_REACTIONS), +) +def test_each_reaction_is_top_level(name): + assert name in REACTIONS + assert isinstance(REACTIONS[name], dict) + + +@pytest.mark.parametrize( + "name", + sorted(EXPECTED_REACTIONS), +) +def test_smarts_parses(name): + assert rdChemReactions.ReactionFromSmarts( + REACTIONS[name]["reaction"] + ) is not None + + +@pytest.mark.parametrize( + "name", + sorted(EXPECTED_REACTIONS), +) +def test_registry_validation_passes(name): + assert registry._validate_reaction_smarts( + name, + REACTIONS[name], + ) == [] + + +# ============================================================================= +# Metadata +# ============================================================================= + +def test_vinyl_initiation_metadata(): + reaction = REACTIONS[INITIATION] + + assert reaction["same_reactants"] is True + assert reaction["reactant_1"] == "vinyl" + assert reaction.get("reactant_2") is None + assert reaction["product"] == "vinyl_chain_end_radical" + assert reaction["delete_atom"] is False + + +def test_vinyl_propagation_metadata(): + reaction = REACTIONS[PROPAGATION] + + assert reaction["same_reactants"] is False + assert reaction["reactant_1"] == "vinyl" + + assert ( + reaction["reactant_2"] + == "vinyl_chain_end_radical" + ) + + assert reaction["product"] == "vinyl_chain_end_radical" + + +def test_same_chain_termination_metadata(): + reaction = REACTIONS[SAME_CHAIN_TERMINATION] + + assert reaction["same_reactants"] is True + + assert ( + reaction["reactant_1"] + == "vinyl_chain_end_radical" + ) + + assert reaction.get("reactant_2") is None + + assert ( + reaction["product"] + == "vinyl_terminated_chain" + ) + + assert reaction["delete_atom"] is False + + +def test_cross_chain_termination_metadata(): + reaction = REACTIONS[CROSS_CHAIN_TERMINATION] + + assert reaction["same_reactants"] is False + + assert ( + reaction["reactant_1"] + == "vinyl_chain_end_radical" + ) + + assert ( + reaction["reactant_2"] + == "vinyl_chain_end_radical" + ) + + assert ( + reaction["product"] + == "vinyl_terminated_chain" + ) + + assert reaction["delete_atom"] is False + + +def test_copolymerization_initiation_metadata(): + reaction = REACTIONS[CO_INITIATION] + + assert reaction["same_reactants"] is False + assert reaction["reactant_1"] == "vinyl" + assert reaction["reactant_2"] == "vinyl" + assert reaction["product"] == "vinyl_chain_end_radical" + + +def test_tfe_initiation_metadata(): + reaction = REACTIONS[TFE_INITIATION] + + assert reaction["reactant_1"] == "tetrafluoroethylene" + assert reaction["reactant_2"] == "tetrafluoroethylene" + assert reaction["product"] == "vinyl_chain_end_radical" + + +def test_tfe_propagation_metadata(): + reaction = REACTIONS[TFE_PROPAGATION] + + assert reaction["reactant_1"] == "vinyl_chain_end_radical" + assert reaction["reactant_2"] == "tetrafluoroethylene" + assert reaction["product"] == "vinyl_chain_end_radical" + + +# ============================================================================= +# H-T vinyl initiation topology +# ============================================================================= + +def test_vinyl_initiation_forms_new_bond_between_maps_1_and_2(): + reactants, products = reaction_templates( + INITIATION + ) + + assert registry._has_bond_between_atom_maps( + reactants, + 1, + 2, + ) is False + + assert registry._has_bond_between_atom_maps( + products, + 1, + 2, + ) is True + + +def test_vinyl_initiation_caps_map_3_as_ch3(): + _, products = reaction_templates( + INITIATION + ) + + map_3_atoms = [ + atom + for mol in products + for atom in mol.GetAtoms() + if atom.GetAtomMapNum() == 3 + ] + + assert len(map_3_atoms) == 1 + + atom = map_3_atoms[0] + + assert atom.GetSymbol() == "C" + + +# ============================================================================= +# Vinyl propagation topology +# ============================================================================= + +def test_vinyl_propagation_forms_new_1_2_bond(): + reactants, products = reaction_templates( + PROPAGATION + ) + + assert registry._has_bond_between_atom_maps( + reactants, + 1, + 2, + ) is False + + assert registry._has_bond_between_atom_maps( + products, + 1, + 2, + ) is True + + +def test_vinyl_propagation_retains_new_active_map_3(): + _, products = reaction_templates( + PROPAGATION + ) + + product_maps = registry._atom_maps_in_templates( + products + ) + + assert 3 in product_maps + + +# ============================================================================= +# Vinyl radical coupling termination +# ============================================================================= + + +@pytest.mark.parametrize( + "name", + [ + SAME_CHAIN_TERMINATION, + CROSS_CHAIN_TERMINATION, + ], +) +def test_vinyl_termination_forms_new_1_2_bond( + name, +): + reactants, products = reaction_templates( + name + ) + + assert registry._has_bond_between_atom_maps( + reactants, + 1, + 2, + ) is False + + assert registry._has_bond_between_atom_maps( + products, + 1, + 2, + ) is True + + +@pytest.mark.parametrize( + "name", + [ + SAME_CHAIN_TERMINATION, + CROSS_CHAIN_TERMINATION, + ], +) +def test_vinyl_termination_retains_maps_1_and_2( + name, +): + _, products = reaction_templates( + name + ) + + product_maps = registry._atom_maps_in_templates( + products + ) + + assert 1 in product_maps + assert 2 in product_maps + + +# ============================================================================= +# Vinyl copolymerization initiation +# ============================================================================= + +def test_copolymerization_initiation_forms_new_1_2_bond(): + reactants, products = reaction_templates( + CO_INITIATION + ) + + assert registry._has_bond_between_atom_maps( + reactants, + 1, + 2, + ) is False + + assert registry._has_bond_between_atom_maps( + products, + 1, + 2, + ) is True + + +def test_copolymerization_product_retains_radical_map_4(): + _, products = reaction_templates( + CO_INITIATION + ) + + assert 4 in registry._atom_maps_in_templates( + products + ) + + +# ============================================================================= +# TFE initiation +# ============================================================================= + +def test_tfe_initiation_forms_new_1_2_bond(): + reactants, products = reaction_templates( + TFE_INITIATION + ) + + assert registry._has_bond_between_atom_maps( + reactants, + 1, + 2, + ) is False + + assert registry._has_bond_between_atom_maps( + products, + 1, + 2, + ) is True + + +def test_tfe_initiation_preserves_all_four_fluorines(): + _, products = reaction_templates( + TFE_INITIATION + ) + + maps = registry._atom_maps_in_templates( + products + ) + + assert { + 4, + 5, + 6, + 7, + }.issubset(maps) + + +# ============================================================================= +# TFE propagation topology +# ============================================================================= + +def test_tfe_propagation_forms_new_bond_between_maps_2_and_3(): + """ + In the actual SMARTS, 1-2 already exists in the growing chain. + + The propagation step forms the new connection between maps 2 and 3. + """ + reactants, products = reaction_templates( + TFE_PROPAGATION + ) + + assert registry._has_bond_between_atom_maps( + reactants, + 2, + 3, + ) is False + + assert registry._has_bond_between_atom_maps( + products, + 2, + 3, + ) is True + + +def test_tfe_propagation_maps_1_and_2_are_already_bonded_before_reaction(): + reactants, _ = reaction_templates( + TFE_PROPAGATION + ) + + assert registry._has_bond_between_atom_maps( + reactants, + 1, + 2, + ) is True + + +def test_tfe_propagation_should_declare_new_initiator_maps(): + """ + The registry defaults initiator_atom_maps to (1, 2). + + But TFE propagation's actual NEW bond is 2-3, while 1-2 already + exists before the reaction. Therefore this reaction should explicitly + override the default initiator mapping. + """ + reaction = REACTIONS[TFE_PROPAGATION] + + assert reaction.get( + "initiator_atom_maps" + ) == (2, 3) \ No newline at end of file diff --git a/tests/unit/detectors/test_functional_groups_detector.py b/tests/unit/detectors/test_functional_groups_detector.py new file mode 100644 index 00000000..ec5b3d86 --- /dev/null +++ b/tests/unit/detectors/test_functional_groups_detector.py @@ -0,0 +1,769 @@ +from types import SimpleNamespace + +import pytest +from rdkit import Chem + +from AutoREACTER.detectors.functional_groups_detector import ( + FunctionalGroupInfo, + FunctionalGroupsDetector, + FunctionalGroupVisualization, + MonomerRole, +) + + +# ============================================================================ +# Helpers +# ============================================================================ + +def make_monomer(name: str, smiles: str): + """Create a minimal monomer-like object for detector unit tests.""" + return SimpleNamespace( + name=name, + smiles=smiles, + rdkit_mol=Chem.MolFromSmiles(smiles), + ) + + +def make_session(monomers): + """Create the minimal Session interface required by the detector.""" + return SimpleNamespace( + inputs=SimpleNamespace(monomers=monomers), + monomer_roles=None, + ) + + +def make_index_role( + name, + smiles, + indexes, + *, + is_looped=False, +): + """Create a minimal role-like object for index-based detection.""" + return SimpleNamespace( + name=name, + smiles=smiles, + rdkit_mol=Chem.MolFromSmiles(smiles), + indexes_in_template=indexes, + is_looped=is_looped, + ) + + +# ============================================================================ +# detect_monomer_functionality() +# ============================================================================ + +def test_mono_single_match_qualifies(): + detector = FunctionalGroupsDetector() + mol = Chem.MolFromSmiles("CCO") + + result = detector.detect_monomer_functionality( + mol, + "mono", + "[OX2H1]", + ) + + functionality_count, count_1, count_2, matches = result + + assert functionality_count == 1 + assert count_1 == 1 + assert count_2 is None + assert len(matches) == 1 + + +def test_mono_no_match_is_rejected(): + detector = FunctionalGroupsDetector() + mol = Chem.MolFromSmiles("CCC") + + result = detector.detect_monomer_functionality( + mol, + "mono", + "[OX2H1]", + ) + + assert result == (0, 0, None, ()) + + +def test_vinyl_single_match_qualifies(): + detector = FunctionalGroupsDetector() + mol = Chem.MolFromSmiles("C=C") + + functionality_count, count_1, count_2, matches = ( + detector.detect_monomer_functionality( + mol, + "vinyl", + "[CH2]=[CH2]", + ) + ) + + assert functionality_count == 1 + assert count_1 == 1 + assert count_2 is None + assert len(matches) == 1 + + +def test_vinyl_multiple_sites_still_returns_presence_based_functionality(): + detector = FunctionalGroupsDetector() + mol = Chem.MolFromSmiles("C=CC=C") + + functionality_count, count_1, count_2, matches = ( + detector.detect_monomer_functionality( + mol, + "vinyl", + "[CH2]=[C]", + ) + ) + + assert functionality_count == 1 + assert count_1 >= 1 + assert count_2 is None + assert len(matches) == count_1 + + +def test_di_identical_two_sites_qualifies(): + detector = FunctionalGroupsDetector() + mol = Chem.MolFromSmiles("OCCO") + + functionality_count, count_1, count_2, matches = ( + detector.detect_monomer_functionality( + mol, + "di_identical", + "[OX2H1]", + ) + ) + + assert functionality_count == 2 + assert count_1 == 2 + assert count_2 is None + assert len(matches) == 2 + + +def test_di_identical_one_site_is_rejected(): + detector = FunctionalGroupsDetector() + mol = Chem.MolFromSmiles("CCO") + + functionality_count, count_1, count_2, matches = ( + detector.detect_monomer_functionality( + mol, + "di_identical", + "[OX2H1]", + ) + ) + + assert functionality_count == 0 + assert count_1 == 1 + assert count_2 is None + assert len(matches) == 1 + + +def test_di_identical_more_than_two_sites_qualifies_and_preserves_count(): + detector = FunctionalGroupsDetector() + mol = Chem.MolFromSmiles("OCC(O)CO") + + functionality_count, count_1, count_2, matches = ( + detector.detect_monomer_functionality( + mol, + "di_identical", + "[OX2H1]", + ) + ) + + assert functionality_count == 2 + assert count_1 == 3 + assert count_2 is None + assert len(matches) == 3 + + +def test_di_different_requires_both_patterns(): + detector = FunctionalGroupsDetector() + mol = Chem.MolFromSmiles("NCC(=O)O") + + functionality_count, count_1, count_2, matches = ( + detector.detect_monomer_functionality( + mol, + "di_different", + "[NX3H2]", + "[CX3](=[O])[OX2H1]", + ) + ) + + assert functionality_count == 2 + assert count_1 == 1 + assert count_2 == 1 + assert len(matches) == 2 + + +def test_di_different_missing_second_pattern_is_rejected(): + detector = FunctionalGroupsDetector() + mol = Chem.MolFromSmiles("CCN") + + functionality_count, count_1, count_2, matches = ( + detector.detect_monomer_functionality( + mol, + "di_different", + "[NX3H2]", + "[CX3](=[O])[OX2H1]", + ) + ) + + assert functionality_count == 0 + assert count_1 == 1 + assert count_2 == 0 + assert len(matches) == 1 + + +def test_invalid_primary_smarts_is_rejected(): + detector = FunctionalGroupsDetector() + mol = Chem.MolFromSmiles("CCO") + + result = detector.detect_monomer_functionality( + mol, + "mono", + "[THIS_IS_INVALID", + ) + + assert result == (0, None, None, None) + + +def test_invalid_secondary_smarts_is_rejected(): + detector = FunctionalGroupsDetector() + mol = Chem.MolFromSmiles("NCC(=O)O") + + result = detector.detect_monomer_functionality( + mol, + "di_different", + "[NX3H2]", + "[THIS_IS_INVALID", + ) + + assert result == (0, None, None, None) + + +def test_none_molecule_is_rejected(): + detector = FunctionalGroupsDetector() + + result = detector.detect_monomer_functionality( + None, + "mono", + "[OX2H1]", + ) + + assert result == (0, None, None, None) + + +# ============================================================================ +# functional_groups_detector() +# ============================================================================ + +def test_detector_stores_detected_roles_in_session(): + detector = FunctionalGroupsDetector() + + detector.monomer_types = { + "test_diol": { + "functionality_type": "di_identical", + "smarts_1": "[OX2H1]", + "group_name": "diol", + "comments": None, + } + } + + monomer = make_monomer("ethylene_glycol", "OCCO") + session = make_session([monomer]) + + result = detector.functional_groups_detector(session) + + assert result is None + assert len(session.monomer_roles) == 1 + + role = session.monomer_roles[0] + + assert isinstance(role, MonomerRole) + assert role.name == "ethylene_glycol" + assert role.smiles == "OCCO" + assert role.is_monomer is True + assert len(role.functionalities) == 1 + + +def test_detector_records_functional_group_information(): + detector = FunctionalGroupsDetector() + + detector.monomer_types = { + "test_diol": { + "functionality_type": "di_identical", + "smarts_1": "[OX2H1]", + "group_name": "diol", + "comments": None, + } + } + + session = make_session( + [make_monomer("ethylene_glycol", "OCCO")] + ) + + detector.functional_groups_detector(session) + + fg = session.monomer_roles[0].functionalities[0] + + assert isinstance(fg, FunctionalGroupInfo) + assert fg.functionality_type == "di_identical" + assert fg.fg_name == "diol" + assert fg.fg_smarts_1 == "[OX2H1]" + assert fg.fg_count_1 == 2 + assert fg.fg_smarts_2 is None + assert fg.fg_count_2 is None + + +def test_detector_can_record_multiple_functionalities_for_same_monomer(): + detector = FunctionalGroupsDetector() + + detector.monomer_types = { + "amine": { + "functionality_type": "mono", + "smarts_1": "[NX3H2]", + "group_name": "primary_amine", + "comments": None, + }, + "acid": { + "functionality_type": "mono", + "smarts_1": "[CX3](=[O])[OX2H1]", + "group_name": "carboxylic_acid", + "comments": None, + }, + } + + session = make_session( + [make_monomer("glycine", "NCC(=O)O")] + ) + + detector.functional_groups_detector(session) + + functionalities = session.monomer_roles[0].functionalities + + assert len(functionalities) == 2 + + names = {fg.fg_name for fg in functionalities} + + assert names == { + "primary_amine", + "carboxylic_acid", + } + + +def test_detector_rejects_di_identical_monomer_with_only_one_site(): + detector = FunctionalGroupsDetector() + + detector.monomer_types = { + "test_diol": { + "functionality_type": "di_identical", + "smarts_1": "[OX2H1]", + "group_name": "diol", + "comments": None, + } + } + + session = make_session( + [make_monomer("ethanol", "CCO")] + ) + + with pytest.raises( + RuntimeError, + match="No functional groups were detected", + ): + detector.functional_groups_detector(session) + + +def test_detector_raises_when_no_monomer_has_supported_functionality(): + detector = FunctionalGroupsDetector() + + detector.monomer_types = { + "test_amine": { + "functionality_type": "mono", + "smarts_1": "[NX3H2]", + "group_name": "primary_amine", + "comments": None, + } + } + + session = make_session( + [ + make_monomer("propane", "CCC"), + make_monomer("butane", "CCCC"), + ] + ) + + with pytest.raises( + RuntimeError, + match="No functional groups were detected", + ): + detector.functional_groups_detector(session) + + +# ============================================================================ +# _functional_groups_detector_for_visualization() +# ============================================================================ + +def test_visualization_detector_returns_detected_monomer(): + detector = FunctionalGroupsDetector() + + detector.monomer_types = { + "alcohol": { + "functionality_type": "mono", + "smarts_1": "[OX2H1]", + "group_name": "alcohol", + "comments": None, + } + } + + session = make_session( + [make_monomer("ethanol", "CCO")] + ) + + result = detector._functional_groups_detector_for_visualization( + session + ) + + assert len(result) == 1 + assert isinstance(result[0], FunctionalGroupVisualization) + assert result[0].name == "ethanol" + assert len(result[0].indexes_to_highlight) == 1 + + +def test_visualization_detector_ignores_nonmatching_monomer(): + detector = FunctionalGroupsDetector() + + detector.monomer_types = { + "amine": { + "functionality_type": "mono", + "smarts_1": "[NX3H2]", + "group_name": "amine", + "comments": None, + } + } + + session = make_session( + [make_monomer("propane", "CCC")] + ) + + result = detector._functional_groups_detector_for_visualization( + session + ) + + assert result == [] + + +# ============================================================================ +# _detect_functional_groups_by_index() +# ============================================================================ + +def test_detect_by_index_returns_true_for_overlapping_atom(): + detector = FunctionalGroupsDetector() + mol = Chem.MolFromSmiles("CCO") + + oxygen_index = 2 + + assert detector._detect_functional_groups_by_index( + mol, + "[OX2H1]", + [oxygen_index], + ) is True + + +def test_detect_by_index_returns_false_when_indices_do_not_overlap(): + detector = FunctionalGroupsDetector() + mol = Chem.MolFromSmiles("CCO") + + assert detector._detect_functional_groups_by_index( + mol, + "[OX2H1]", + [0], + ) is False + + +def test_detect_by_index_returns_false_for_empty_indices(): + detector = FunctionalGroupsDetector() + mol = Chem.MolFromSmiles("CCO") + + assert detector._detect_functional_groups_by_index( + mol, + "[OX2H1]", + [], + ) is False + + +def test_detect_by_index_invalid_smarts_returns_false(): + detector = FunctionalGroupsDetector() + mol = Chem.MolFromSmiles("CCO") + + assert detector._detect_functional_groups_by_index( + mol, + "[INVALID", + [2], + ) is False + + +# ============================================================================ +# index_based_functional_groups_detector() +# ============================================================================ + +def test_index_detector_allows_single_di_identical_site(): + """ + Index-based detection intentionally ignores the normal >=2 rule for + di_identical groups. + + One matching functional group touching the target index is sufficient. + """ + detector = FunctionalGroupsDetector() + + detector.monomer_types = { + "diol": { + "functionality_type": "di_identical", + "smarts_1": "[OX2H1]", + "group_name": "diol", + "comments": None, + } + } + + role = make_index_role( + "ethanol_product", + "CCO", + [2], + ) + + result = detector.index_based_functional_groups_detector( + [role] + ) + + assert result is not False + assert len(result) == 1 + + output_role = result[0] + + assert output_role.is_monomer is False + assert len(output_role.functionalities) == 1 + + fg = output_role.functionalities[0] + + assert fg.functionality_type == "di_identical" + assert fg.fg_count_1 == 1 + assert fg.fg_1_indexes is not None + + +def test_index_detector_requires_overlap(): + detector = FunctionalGroupsDetector() + + detector.monomer_types = { + "alcohol": { + "functionality_type": "mono", + "smarts_1": "[OX2H1]", + "group_name": "alcohol", + "comments": None, + } + } + + role = make_index_role( + "ethanol_product", + "CCO", + [0], + ) + + result = detector.index_based_functional_groups_detector( + [role] + ) + + assert result is False + + +def test_index_detector_di_different_requires_hit_for_both_patterns(): + detector = FunctionalGroupsDetector() + + detector.monomer_types = { + "amino_acid": { + "functionality_type": "di_different", + "smarts_1": "[N]", + "smarts_2": "[OX2H1]", + "group_name": "amino_acid", + "comments": None, + } + } + + mol = Chem.MolFromSmiles("NCC(=O)O") + + nitrogen_index = next( + atom.GetIdx() + for atom in mol.GetAtoms() + if atom.GetSymbol() == "N" + ) + + hydroxyl_oxygen_index = next( + atom.GetIdx() + for atom in mol.GetAtoms() + if atom.GetSymbol() == "O" + and atom.GetTotalNumHs() == 1 + ) + + role = SimpleNamespace( + name="glycine_product", + smiles="NCC(=O)O", + rdkit_mol=mol, + indexes_in_template=[ + nitrogen_index, + hydroxyl_oxygen_index, + ], + is_looped=False, + ) + + result = detector.index_based_functional_groups_detector( + [role] + ) + + assert result is not False + assert len(result) == 1 + + fg = result[0].functionalities[0] + + assert fg.fg_count_1 == 1 + assert fg.fg_count_2 == 1 + assert fg.fg_1_indexes is not None + assert fg.fg_2_indexes is not None + + +def test_index_detector_di_different_fails_when_only_one_pattern_overlaps(): + detector = FunctionalGroupsDetector() + + detector.monomer_types = { + "amino_acid": { + "functionality_type": "di_different", + "smarts_1": "[N]", + "smarts_2": "[OX2H1]", + "group_name": "amino_acid", + "comments": None, + } + } + + mol = Chem.MolFromSmiles("NCC(=O)O") + + nitrogen_index = next( + atom.GetIdx() + for atom in mol.GetAtoms() + if atom.GetSymbol() == "N" + ) + + role = SimpleNamespace( + name="glycine_product", + smiles="NCC(=O)O", + rdkit_mol=mol, + indexes_in_template=[nitrogen_index], + is_looped=False, + ) + + result = detector.index_based_functional_groups_detector( + [role] + ) + + assert result is False + + +def test_index_detector_skips_looped_monomers(): + detector = FunctionalGroupsDetector() + + detector.monomer_types = { + "alcohol": { + "functionality_type": "mono", + "smarts_1": "[OX2H1]", + "group_name": "alcohol", + "comments": None, + } + } + + role = make_index_role( + "ethanol_product", + "CCO", + [2], + is_looped=True, + ) + + result = detector.index_based_functional_groups_detector( + [role] + ) + + assert result is False + + +def test_index_detector_preserves_template_indices(): + detector = FunctionalGroupsDetector() + + detector.monomer_types = { + "alcohol": { + "functionality_type": "mono", + "smarts_1": "[OX2H1]", + "group_name": "alcohol", + "comments": None, + } + } + + role = make_index_role( + "ethanol_product", + "CCO", + [2], + ) + + result = detector.index_based_functional_groups_detector( + [role] + ) + + assert result[0].indexes_in_template == [2] + + +# ============================================================================ +# functional_group_highlighted_molecules_image_grid() +# ============================================================================ + +def test_image_grid_flattens_highlighted_atom_indices(monkeypatch): + detector = FunctionalGroupsDetector() + + mol = Chem.MolFromSmiles("CCO") + + visualization = FunctionalGroupVisualization( + monomer=mol, + name="ethanol", + indexes_to_highlight=((1, 2), (2,)), + ) + + monkeypatch.setattr( + detector, + "_functional_groups_detector_for_visualization", + lambda session: [visualization], + ) + + captured = {} + + def fake_grid( + molecules, + molsPerRow, + legends, + subImgSize, + highlightAtomLists, + ): + captured["molecules"] = molecules + captured["molsPerRow"] = molsPerRow + captured["legends"] = legends + captured["subImgSize"] = subImgSize + captured["highlightAtomLists"] = highlightAtomLists + + return "fake-image" + + monkeypatch.setattr( + "AutoREACTER.detectors.functional_groups_detector.Draw.MolsToGridImage", + fake_grid, + ) + + result = detector.functional_group_highlighted_molecules_image_grid( + SimpleNamespace() + ) + + assert result == "fake-image" + assert captured["molecules"] == [mol] + assert captured["legends"] == ["ethanol"] + assert captured["highlightAtomLists"] == [[1, 2]] + assert captured["molsPerRow"] == 3 + assert captured["subImgSize"] == (500, 500) diff --git a/tests/unit/detectors/test_non_monomer_detector.py b/tests/unit/detectors/test_non_monomer_detector.py new file mode 100644 index 00000000..dcd36c15 --- /dev/null +++ b/tests/unit/detectors/test_non_monomer_detector.py @@ -0,0 +1,1156 @@ +from dataclasses import dataclass +from types import SimpleNamespace + +import pytest +from rdkit import Chem + +from AutoREACTER.detectors.non_monomer_detector import ( + NonReactantsDetector, +) + + +# ============================================================================= +# Test helpers +# ============================================================================= + +@dataclass +class DummyMonomer: + """ + Minimal dataclass matching what NonReactantsDetector needs. + + A dataclass is intentionally used because non_reactant_selection() + calls dataclasses.replace(). + """ + id: int + name: str + smiles: str + rdkit_mol: object + status: bool = True + + +def make_monomer( + monomer_id: int, + name: str, + smiles: str, + *, + status=True, +): + return DummyMonomer( + id=monomer_id, + name=name, + smiles=smiles, + rdkit_mol=Chem.MolFromSmiles(smiles), + status=status, + ) + + +def make_reaction(smiles_1, smiles_2=None): + monomer_1 = SimpleNamespace(smiles=smiles_1) + + monomer_2 = ( + SimpleNamespace(smiles=smiles_2) + if smiles_2 is not None + else None + ) + + return SimpleNamespace( + monomer_1=monomer_1, + monomer_2=monomer_2, + ) + + +def make_session( + monomers=None, + reaction_instances=None, + non_reactants=None, +): + return SimpleNamespace( + inputs=SimpleNamespace(monomers=monomers or []), + reaction_instances=reaction_instances or [], + non_reactants=non_reactants or [], + ) + + +# ============================================================================= +# _same_molecule() +# ============================================================================= + +def test_same_molecule_adds_new_molecule(): + detector = NonReactantsDetector() + + reactants = [] + + result = detector._same_molecule( + reactants, + "CCO", + ) + + assert result == ["CCO"] + + +def test_same_molecule_does_not_duplicate_identical_smiles(): + detector = NonReactantsDetector() + + reactants = ["CCO"] + + result = detector._same_molecule( + reactants, + "CCO", + ) + + assert result == ["CCO"] + + +def test_same_molecule_uses_canonical_smiles(): + """ + OCC and CCO represent the same ethanol molecule. + + They should therefore not be stored twice. + """ + detector = NonReactantsDetector() + + reactants = ["CCO"] + + result = detector._same_molecule( + reactants, + "OCC", + ) + + assert result == ["CCO"] + + +def test_same_molecule_adds_structurally_different_molecule(): + detector = NonReactantsDetector() + + reactants = ["CCO"] + + result = detector._same_molecule( + reactants, + "CCN", + ) + + assert result == [ + "CCO", + "CCN", + ] + + +def test_same_molecule_invalid_smiles_is_not_added(): + detector = NonReactantsDetector() + + reactants = ["CCO"] + + result = detector._same_molecule( + reactants, + "this_is_not_smiles", + ) + + assert result == ["CCO"] + + +def test_same_molecule_modifies_original_list(): + """ + Current implementation appends directly to reactants_list. + + The returned object should therefore be the same list object. + """ + detector = NonReactantsDetector() + + reactants = [] + + result = detector._same_molecule( + reactants, + "CCO", + ) + + assert result is reactants + assert reactants == ["CCO"] + + +# ============================================================================= +# _same_molecule_for_initaials() +# ============================================================================= + +def test_same_molecule_for_initials_finds_identical_molecule(): + detector = NonReactantsDetector() + + reactants = ["CCO"] + mol = Chem.MolFromSmiles("CCO") + + result = detector._same_molecule_for_initaials( + reactants, + mol, + ) + + assert result is True + + +def test_same_molecule_for_initials_uses_canonical_structure(): + detector = NonReactantsDetector() + + reactants = ["CCO"] + mol = Chem.MolFromSmiles("OCC") + + result = detector._same_molecule_for_initaials( + reactants, + mol, + ) + + assert result is True + + +def test_same_molecule_for_initials_ignores_explicit_hydrogens(): + """ + Explicit hydrogens should not prevent structural equality. + """ + detector = NonReactantsDetector() + + reactants = ["CCO"] + + mol = Chem.AddHs( + Chem.MolFromSmiles("CCO") + ) + + result = detector._same_molecule_for_initaials( + reactants, + mol, + ) + + assert result is True + + +def test_same_molecule_for_initials_returns_false_for_different_molecule(): + detector = NonReactantsDetector() + + reactants = ["CCO"] + mol = Chem.MolFromSmiles("CCN") + + result = detector._same_molecule_for_initaials( + reactants, + mol, + ) + + assert result is False + + +def test_same_molecule_for_initials_returns_false_for_none(): + detector = NonReactantsDetector() + + result = detector._same_molecule_for_initaials( + ["CCO"], + None, + ) + + assert result is False + + +def test_same_molecule_for_initials_ignores_invalid_smiles_in_reactant_list(): + detector = NonReactantsDetector() + + reactants = [ + "invalid_smiles", + "CCO", + ] + + mol = Chem.MolFromSmiles("CCO") + + result = detector._same_molecule_for_initaials( + reactants, + mol, + ) + + assert result is True + + +def test_same_molecule_for_initials_empty_reactant_list_returns_false(): + detector = NonReactantsDetector() + + mol = Chem.MolFromSmiles("CCO") + + result = detector._same_molecule_for_initaials( + [], + mol, + ) + + assert result is False + + +# ============================================================================= +# non_monomer_detector() +# ============================================================================= + +def test_non_monomer_detector_identifies_unused_monomer(): + detector = NonReactantsDetector() + + ethanol = make_monomer( + 1, + "ethanol", + "CCO", + ) + + amine = make_monomer( + 2, + "ethylamine", + "CCN", + ) + + propane = make_monomer( + 3, + "propane", + "CCC", + ) + + reaction = make_reaction( + "CCO", + "CCN", + ) + + session = make_session( + monomers=[ + ethanol, + amine, + propane, + ], + reaction_instances=[reaction], + ) + + result = detector.non_monomer_detector(session) + + assert result is None + assert session.non_reactants == [ + propane, + ] + + +def test_non_monomer_detector_all_monomers_react(): + detector = NonReactantsDetector() + + ethanol = make_monomer( + 1, + "ethanol", + "CCO", + ) + + amine = make_monomer( + 2, + "ethylamine", + "CCN", + ) + + session = make_session( + monomers=[ + ethanol, + amine, + ], + reaction_instances=[ + make_reaction( + "CCO", + "CCN", + ) + ], + ) + + detector.non_monomer_detector(session) + + assert session.non_reactants == [] + + +def test_non_monomer_detector_no_reactions_marks_every_monomer_nonreactant(): + detector = NonReactantsDetector() + + monomers = [ + make_monomer( + 1, + "ethanol", + "CCO", + ), + make_monomer( + 2, + "ethylamine", + "CCN", + ), + ] + + session = make_session( + monomers=monomers, + reaction_instances=[], + ) + + detector.non_monomer_detector(session) + + assert session.non_reactants == monomers + + +def test_non_monomer_detector_handles_homopolymerization(): + """ + A ReactionInstance may contain only monomer_1. + + That molecule should still be recognized as a reactant. + """ + detector = NonReactantsDetector() + + styrene = make_monomer( + 1, + "styrene", + "C=Cc1ccccc1", + ) + + solvent = make_monomer( + 2, + "ethanol", + "CCO", + ) + + session = make_session( + monomers=[ + styrene, + solvent, + ], + reaction_instances=[ + make_reaction( + "C=Cc1ccccc1", + ) + ], + ) + + detector.non_monomer_detector(session) + + assert session.non_reactants == [ + solvent, + ] + + +def test_non_monomer_detector_uses_structural_equivalence_not_raw_string(): + detector = NonReactantsDetector() + + ethanol = make_monomer( + 1, + "ethanol", + "OCC", + ) + + session = make_session( + monomers=[ethanol], + reaction_instances=[ + make_reaction("CCO") + ], + ) + + detector.non_monomer_detector(session) + + assert session.non_reactants == [] + + +def test_non_monomer_detector_deduplicates_reactants_across_reactions(): + detector = NonReactantsDetector() + + ethanol = make_monomer( + 1, + "ethanol", + "CCO", + ) + + propane = make_monomer( + 2, + "propane", + "CCC", + ) + + session = make_session( + monomers=[ + ethanol, + propane, + ], + reaction_instances=[ + make_reaction("CCO"), + make_reaction("OCC"), + make_reaction("CCO"), + ], + ) + + detector.non_monomer_detector(session) + + assert session.non_reactants == [ + propane, + ] + + +# ============================================================================= +# non_reactants_to_visualization() +# ============================================================================= + +def test_non_reactants_visualization_returns_none_for_empty_list(): + detector = NonReactantsDetector() + + session = make_session( + non_reactants=[], + ) + + result = detector.non_reactants_to_visualization( + session + ) + + assert result is None + + +def test_non_reactants_visualization_passes_correct_data_to_rdkit( + monkeypatch, +): + detector = NonReactantsDetector() + + monomer_1 = make_monomer( + 1, + "ethanol", + "CCO", + ) + + monomer_2 = make_monomer( + 2, + "ethylamine", + "CCN", + ) + + session = make_session( + non_reactants=[ + monomer_1, + monomer_2, + ] + ) + + captured = {} + + fake_image = object() + + def fake_grid( + molecules, + legends, + molsPerRow, + subImgSize, + ): + captured["molecules"] = molecules + captured["legends"] = legends + captured["molsPerRow"] = molsPerRow + captured["subImgSize"] = subImgSize + + return fake_image + + monkeypatch.setattr( + "AutoREACTER.detectors.non_monomer_detector." + "Draw.MolsToGridImage", + fake_grid, + ) + + result = detector.non_reactants_to_visualization( + session + ) + + assert result is fake_image + + assert captured["molecules"] == [ + monomer_1.rdkit_mol, + monomer_2.rdkit_mol, + ] + + assert captured["legends"] == [ + "ethanol", + "ethylamine", + ] + + assert captured["molsPerRow"] == 3 + assert captured["subImgSize"] == ( + 400, + 400, + ) + + +# ============================================================================= +# non_reactant_selection() - no non-reactants +# ============================================================================= + +def test_selection_does_nothing_when_no_nonreactants(): + detector = NonReactantsDetector() + + monomer = make_monomer( + 1, + "ethanol", + "CCO", + ) + + session = make_session( + monomers=[monomer], + non_reactants=[], + ) + + result = detector.non_reactant_selection( + session + ) + + assert result is None + assert session.inputs.monomers == [ + monomer, + ] + + +# ============================================================================= +# non_reactant_selection() - one non-reactant +# ============================================================================= + +def test_single_nonreactant_N_discards_monomer( + monkeypatch, +): + detector = NonReactantsDetector() + + monomer = make_monomer( + 1, + "solvent", + "CCO", + ) + + session = make_session( + monomers=[monomer], + non_reactants=[monomer], + ) + + monkeypatch.setattr( + "builtins.input", + lambda prompt: "N", + ) + + detector.non_reactant_selection(session) + + assert session.inputs.monomers[0].status is False + + +def test_single_nonreactant_A_keeps_monomer( + monkeypatch, +): + detector = NonReactantsDetector() + + monomer = make_monomer( + 1, + "solvent", + "CCO", + ) + + session = make_session( + monomers=[monomer], + non_reactants=[monomer], + ) + + monkeypatch.setattr( + "builtins.input", + lambda prompt: "A", + ) + + detector.non_reactant_selection(session) + + assert session.inputs.monomers[0].status is True + + +def test_single_nonreactant_reprompts_invalid_input( + monkeypatch, +): + detector = NonReactantsDetector() + + monomer = make_monomer( + 1, + "solvent", + "CCO", + ) + + session = make_session( + monomers=[monomer], + non_reactants=[monomer], + ) + + responses = iter( + [ + "wrong", + "N", + ] + ) + + monkeypatch.setattr( + "builtins.input", + lambda prompt: next(responses), + ) + + detector.non_reactant_selection(session) + + assert session.inputs.monomers[0].status is False + + +# ============================================================================= +# non_reactant_selection() - multiple non-reactants +# ============================================================================= + +def test_multiple_nonreactants_N_discards_all( + monkeypatch, +): + detector = NonReactantsDetector() + + m1 = make_monomer( + 1, + "M1", + "CCO", + ) + + m2 = make_monomer( + 2, + "M2", + "CCN", + ) + + reactive = make_monomer( + 3, + "reactive", + "CCC", + ) + + session = make_session( + monomers=[ + m1, + m2, + reactive, + ], + non_reactants=[ + m1, + m2, + ], + ) + + monkeypatch.setattr( + "builtins.input", + lambda prompt: "N", + ) + + detector.non_reactant_selection(session) + + status_by_id = { + m.id: m.status + for m in session.inputs.monomers + } + + assert status_by_id == { + 1: False, + 2: False, + 3: True, + } + + +def test_multiple_nonreactants_A_keeps_all( + monkeypatch, +): + detector = NonReactantsDetector() + + m1 = make_monomer( + 1, + "M1", + "CCO", + ) + + m2 = make_monomer( + 2, + "M2", + "CCN", + ) + + session = make_session( + monomers=[ + m1, + m2, + ], + non_reactants=[ + m1, + m2, + ], + ) + + monkeypatch.setattr( + "builtins.input", + lambda prompt: "A", + ) + + detector.non_reactant_selection(session) + + assert all( + m.status is True + for m in session.inputs.monomers + ) + + +def test_multiple_nonreactants_S_retains_selected_and_discards_others( + monkeypatch, +): + detector = NonReactantsDetector() + + m1 = make_monomer( + 1, + "M1", + "CCO", + ) + + m2 = make_monomer( + 2, + "M2", + "CCN", + ) + + m3 = make_monomer( + 3, + "M3", + "CCC", + ) + + session = make_session( + monomers=[ + m1, + m2, + m3, + ], + non_reactants=[ + m1, + m2, + m3, + ], + ) + + responses = iter( + [ + "S", + "1,3", + ] + ) + + monkeypatch.setattr( + "builtins.input", + lambda prompt: next(responses), + ) + + detector.non_reactant_selection(session) + + status_by_id = { + m.id: m.status + for m in session.inputs.monomers + } + + assert status_by_id == { + 1: True, + 2: False, + 3: True, + } + + +def test_selective_mode_accepts_whitespace_and_duplicate_ids( + monkeypatch, +): + detector = NonReactantsDetector() + + m1 = make_monomer( + 1, + "M1", + "CCO", + ) + + m2 = make_monomer( + 2, + "M2", + "CCN", + ) + + session = make_session( + monomers=[ + m1, + m2, + ], + non_reactants=[ + m1, + m2, + ], + ) + + responses = iter( + [ + "S", + " 1, 1 ", + ] + ) + + monkeypatch.setattr( + "builtins.input", + lambda prompt: next(responses), + ) + + detector.non_reactant_selection(session) + + status_by_id = { + m.id: m.status + for m in session.inputs.monomers + } + + assert status_by_id == { + 1: True, + 2: False, + } + + +def test_selective_mode_reprompts_for_noninteger_input( + monkeypatch, +): + detector = NonReactantsDetector() + + m1 = make_monomer( + 1, + "M1", + "CCO", + ) + + m2 = make_monomer( + 2, + "M2", + "CCN", + ) + + session = make_session( + monomers=[ + m1, + m2, + ], + non_reactants=[ + m1, + m2, + ], + ) + + responses = iter( + [ + "S", + "abc", + "S", + "1", + ] + ) + + monkeypatch.setattr( + "builtins.input", + lambda prompt: next(responses), + ) + + detector.non_reactant_selection(session) + + status_by_id = { + m.id: m.status + for m in session.inputs.monomers + } + + assert status_by_id == { + 1: True, + 2: False, + } + + +def test_selective_mode_reprompts_for_unknown_id( + monkeypatch, +): + detector = NonReactantsDetector() + + m1 = make_monomer( + 1, + "M1", + "CCO", + ) + + m2 = make_monomer( + 2, + "M2", + "CCN", + ) + + session = make_session( + monomers=[ + m1, + m2, + ], + non_reactants=[ + m1, + m2, + ], + ) + + responses = iter( + [ + "S", + "99", + "S", + "2", + ] + ) + + monkeypatch.setattr( + "builtins.input", + lambda prompt: next(responses), + ) + + detector.non_reactant_selection(session) + + status_by_id = { + m.id: m.status + for m in session.inputs.monomers + } + + assert status_by_id == { + 1: False, + 2: True, + } + + +def test_selective_mode_reprompts_for_empty_id_list( + monkeypatch, +): + detector = NonReactantsDetector() + + m1 = make_monomer( + 1, + "M1", + "CCO", + ) + + m2 = make_monomer( + 2, + "M2", + "CCN", + ) + + session = make_session( + monomers=[ + m1, + m2, + ], + non_reactants=[ + m1, + m2, + ], + ) + + responses = iter( + [ + "S", + "", + "S", + "1", + ] + ) + + monkeypatch.setattr( + "builtins.input", + lambda prompt: next(responses), + ) + + detector.non_reactant_selection(session) + + status_by_id = { + m.id: m.status + for m in session.inputs.monomers + } + + assert status_by_id == { + 1: True, + 2: False, + } + + +def test_multiple_nonreactants_reprompts_invalid_main_option( + monkeypatch, +): + detector = NonReactantsDetector() + + m1 = make_monomer( + 1, + "M1", + "CCO", + ) + + m2 = make_monomer( + 2, + "M2", + "CCN", + ) + + session = make_session( + monomers=[ + m1, + m2, + ], + non_reactants=[ + m1, + m2, + ], + ) + + responses = iter( + [ + "wrong", + "N", + ] + ) + + monkeypatch.setattr( + "builtins.input", + lambda prompt: next(responses), + ) + + detector.non_reactant_selection(session) + + assert all( + m.status is False + for m in session.inputs.monomers + ) + + +def test_selection_preserves_reactive_monomers( + monkeypatch, +): + """ + Discarding non-reactants must not accidentally change a monomer that + participates in a reaction. + """ + detector = NonReactantsDetector() + + nonreactant = make_monomer( + 1, + "solvent", + "CCO", + ) + + reactive = make_monomer( + 2, + "reactive", + "C=C", + ) + + session = make_session( + monomers=[ + nonreactant, + reactive, + ], + non_reactants=[ + nonreactant, + ], + ) + + monkeypatch.setattr( + "builtins.input", + lambda prompt: "N", + ) + + detector.non_reactant_selection(session) + + status_by_id = { + m.id: m.status + for m in session.inputs.monomers + } + + assert status_by_id == { + 1: False, + 2: True, + } \ No newline at end of file diff --git a/tests/unit/detectors/test_reaction_detector.py b/tests/unit/detectors/test_reaction_detector.py new file mode 100644 index 00000000..4b000acd --- /dev/null +++ b/tests/unit/detectors/test_reaction_detector.py @@ -0,0 +1,937 @@ +from types import SimpleNamespace + +import pytest +from PIL import Image +from rdkit import Chem + +from AutoREACTER.detectors.functional_groups_detector import ( + FunctionalGroupInfo, + MonomerRole, +) +from AutoREACTER.detectors.reaction_detector import ( + EmptyReactionListError, + ReactionDetector, + ReactionInstance, + SMARTSerror, +) + + +# ============================================================================= +# Helpers +# ============================================================================= + +def make_fg(name: str) -> FunctionalGroupInfo: + return FunctionalGroupInfo( + functionality_type="test", + fg_name=name, + fg_smarts_1="[*]", + fg_count_1=1, + ) + + +def make_role( + name: str, + smiles: str, + fg_names, + *, + is_looped=False, +) -> MonomerRole: + return MonomerRole( + name=name, + smiles=smiles, + functionalities=tuple(make_fg(fg) for fg in fg_names), + is_looped=is_looped, + ) + + +def make_session(monomer_roles=None, reaction_instances=None): + return SimpleNamespace( + monomer_roles=monomer_roles or [], + reaction_instances=reaction_instances or [], + ) + + +def homo_reaction( + reactant="vinyl", + *, + reaction_name="vinyl_polymerization", +): + return { + reaction_name: { + "reactant_1": reactant, + "reactant_2": None, + "same_reactants": True, + "reaction": "[*:1]>>[*:1]", + "delete_atom": False, + "reference": {"test": "reference"}, + } + } + + +def co_reaction( + reactant_1="A", + reactant_2="B", + *, + reaction_name="A_B_polymerization", +): + return { + reaction_name: { + "reactant_1": reactant_1, + "reactant_2": reactant_2, + "same_reactants": False, + "reaction": "[*:1].[*:2]>>[*:1]-[*:2]", + "delete_atom": True, + "reference": {"test": "reference"}, + } + } + + +# ============================================================================= +# ReactionInstance +# ============================================================================= + +def test_reaction_instance_stores_required_data(): + monomer = make_role("styrene", "C=Cc1ccccc1", ["vinyl"]) + fg = monomer.functionalities[0] + + instance = ReactionInstance( + reaction_name="vinyl_polymerization", + reaction_smarts="[*:1]>>[*:1]", + delete_atom=False, + references={"paper": "test"}, + same_reactants=True, + monomer_1=monomer, + functional_group_1=fg, + ) + + assert instance.reaction_name == "vinyl_polymerization" + assert instance.reaction_smarts == "[*:1]>>[*:1]" + assert instance.delete_atom is False + assert instance.references == {"paper": "test"} + assert instance.same_reactants is True + assert instance.monomer_1 is monomer + assert instance.functional_group_1 is fg + assert instance.monomer_2 is None + assert instance.functional_group_2 is None + + +# ============================================================================= +# _matching_fgs() +# ============================================================================= + +def test_matching_fgs_returns_matching_functionality(): + detector = ReactionDetector() + + monomer = make_role( + "test", + "CC", + ["vinyl", "diol", "primary_amine"], + ) + + result = detector._matching_fgs(monomer, "diol") + + assert len(result) == 1 + assert result[0].fg_name == "diol" + + +def test_matching_fgs_returns_all_matching_entries(): + detector = ReactionDetector() + + fg1 = make_fg("vinyl") + fg2 = make_fg("vinyl") + + monomer = MonomerRole( + name="test", + smiles="C=C", + functionalities=(fg1, fg2), + ) + + result = detector._matching_fgs(monomer, "vinyl") + + assert result == [fg1, fg2] + + +def test_matching_fgs_returns_empty_list_when_no_match(): + detector = ReactionDetector() + + monomer = make_role("test", "CCO", ["diol"]) + + assert detector._matching_fgs(monomer, "vinyl") == [] + + +# ============================================================================= +# _seen_pair_key() +# ============================================================================= + +def test_seen_pair_key_for_homopolymerization(): + detector = ReactionDetector() + + monomer = make_role("styrene", "C=Cc1ccccc1", ["vinyl"]) + fg = monomer.functionalities[0] + + key = detector._seen_pair_key( + "vinyl_polymerization", + monomer, + fg, + ) + + assert key == ( + "vinyl_polymerization", + "C=Cc1ccccc1", + "vinyl", + ) + + +def test_seen_pair_key_for_copolymerization_is_order_independent(): + detector = ReactionDetector() + + monomer_a = make_role("A", "CCO", ["A"]) + monomer_b = make_role("B", "CCN", ["B"]) + + key_ab = detector._seen_pair_key( + "reaction", + monomer_a, + monomer_a.functionalities[0], + monomer_b, + monomer_b.functionalities[0], + ) + + key_ba = detector._seen_pair_key( + "reaction", + monomer_b, + monomer_b.functionalities[0], + monomer_a, + monomer_a.functionalities[0], + ) + + assert key_ab == key_ba + + +def test_seen_pair_key_distinguishes_reaction_names(): + detector = ReactionDetector() + + monomer_a = make_role("A", "CCO", ["A"]) + monomer_b = make_role("B", "CCN", ["B"]) + + key_1 = detector._seen_pair_key( + "reaction_1", + monomer_a, + monomer_a.functionalities[0], + monomer_b, + monomer_b.functionalities[0], + ) + + key_2 = detector._seen_pair_key( + "reaction_2", + monomer_a, + monomer_a.functionalities[0], + monomer_b, + monomer_b.functionalities[0], + ) + + assert key_1 != key_2 + + +# ============================================================================= +# reaction_detector() - homopolymerization +# ============================================================================= + +def test_homopolymerization_is_detected(): + detector = ReactionDetector() + detector.reactions = homo_reaction() + + monomer = make_role( + "styrene", + "C=Cc1ccccc1", + ["vinyl"], + ) + + session = make_session([monomer]) + + result = detector.reaction_detector(session) + + assert result is None + assert len(session.reaction_instances) == 1 + + rxn = session.reaction_instances[0] + + assert rxn.reaction_name == "vinyl_polymerization" + assert rxn.same_reactants is True + assert rxn.monomer_1 is monomer + assert rxn.functional_group_1.fg_name == "vinyl" + assert rxn.monomer_2 is None + assert rxn.functional_group_2 is None + + +def test_homopolymerization_preserves_reaction_metadata(): + detector = ReactionDetector() + + detector.reactions = { + "test_reaction": { + "reactant_1": "vinyl", + "reactant_2": None, + "same_reactants": True, + "reaction": "TEST_SMARTS", + "delete_atom": True, + "reference": {"doi": "123"}, + } + } + + session = make_session( + [make_role("M", "C=C", ["vinyl"])] + ) + + detector.reaction_detector(session) + + rxn = session.reaction_instances[0] + + assert rxn.reaction_smarts == "TEST_SMARTS" + assert rxn.delete_atom is True + assert rxn.references == {"doi": "123"} + + +def test_duplicate_homo_functionalities_do_not_create_duplicate_reactions(): + detector = ReactionDetector() + detector.reactions = homo_reaction() + + monomer = make_role( + "test", + "C=C", + ["vinyl", "vinyl"], + ) + + session = make_session([monomer]) + + detector.reaction_detector(session) + + assert len(session.reaction_instances) == 1 + + +def test_multiple_different_homo_monomers_create_multiple_instances(): + detector = ReactionDetector() + detector.reactions = homo_reaction() + + monomer_1 = make_role("M1", "C=C", ["vinyl"]) + monomer_2 = make_role("M2", "C=CC", ["vinyl"]) + + session = make_session([monomer_1, monomer_2]) + + detector.reaction_detector(session) + + assert len(session.reaction_instances) == 2 + + +# ============================================================================= +# reaction_detector() - copolymerization +# ============================================================================= + +def test_copolymerization_detects_A_plus_B(): + detector = ReactionDetector() + detector.reactions = co_reaction() + + monomer_a = make_role("A", "CCO", ["A"]) + monomer_b = make_role("B", "CCN", ["B"]) + + session = make_session([monomer_a, monomer_b]) + + detector.reaction_detector(session) + + assert len(session.reaction_instances) == 1 + + rxn = session.reaction_instances[0] + + assert rxn.monomer_1 is monomer_a + assert rxn.monomer_2 is monomer_b + assert rxn.functional_group_1.fg_name == "A" + assert rxn.functional_group_2.fg_name == "B" + assert rxn.same_reactants is False + + +def test_copolymerization_reverse_scan_does_not_duplicate_pair(): + detector = ReactionDetector() + detector.reactions = co_reaction() + + monomer_a = make_role("A", "CCO", ["A"]) + monomer_b = make_role("B", "CCN", ["B"]) + + session = make_session( + [monomer_b, monomer_a] + ) + + detector.reaction_detector(session) + + assert len(session.reaction_instances) == 1 + + +def test_unrelated_monomer_is_ignored(): + detector = ReactionDetector() + detector.reactions = co_reaction() + + monomer_a = make_role("A", "CCO", ["A"]) + monomer_b = make_role("B", "CCN", ["B"]) + unrelated = make_role("X", "CCC", ["X"]) + + session = make_session( + [monomer_a, monomer_b, unrelated] + ) + + detector.reaction_detector(session) + + assert len(session.reaction_instances) == 1 + + +# ============================================================================= +# Same molecule carrying A + B +# ============================================================================= + +def test_single_monomer_with_both_functional_groups_can_react(): + """ + A heterobifunctional monomer containing both A and B should be detected + by the special same-monomer A+B branch. + """ + detector = ReactionDetector() + detector.reactions = co_reaction() + + monomer = make_role( + "AB", + "NCC(=O)O", + ["A", "B"], + ) + + session = make_session([monomer]) + + detector.reaction_detector(session) + + assert len(session.reaction_instances) == 1 + + rxn = session.reaction_instances[0] + + assert rxn.monomer_1 is monomer + assert rxn.monomer_2 is monomer + assert rxn.functional_group_1.fg_name == "A" + assert rxn.functional_group_2.fg_name == "B" + + +def test_same_monomer_AB_reaction_not_duplicated(): + detector = ReactionDetector() + detector.reactions = co_reaction() + + monomer = make_role( + "AB", + "NCC(=O)O", + ["A", "B"], + ) + + session = make_session([monomer]) + + detector.reaction_detector(session) + + assert len(session.reaction_instances) == 1 + + +# ============================================================================= +# Empty detection +# ============================================================================= + +def test_no_matching_reaction_raises_empty_reaction_error(): + detector = ReactionDetector() + detector.reactions = co_reaction() + + session = make_session( + [make_role("X", "CCC", ["X"])] + ) + + with pytest.raises( + EmptyReactionListError, + match="No reaction instances found", + ): + detector.reaction_detector(session) + + +def test_empty_monomer_role_list_raises_empty_reaction_error(): + detector = ReactionDetector() + detector.reactions = homo_reaction() + + session = make_session([]) + + with pytest.raises(EmptyReactionListError): + detector.reaction_detector(session) + + +# ============================================================================= +# index_based_reaction_detector() - homo +# ============================================================================= + +def test_index_homo_fresh_role_is_detected(): + detector = ReactionDetector() + detector.reactions = homo_reaction() + + monomer = make_role( + "M", + "C=C", + ["vinyl"], + is_looped=False, + ) + + result = detector.index_based_reaction_detector([monomer]) + + assert len(result) == 1 + + +def test_index_homo_looped_role_is_skipped(): + detector = ReactionDetector() + detector.reactions = homo_reaction() + + monomer = make_role( + "M", + "C=C", + ["vinyl"], + is_looped=True, + ) + + result = detector.index_based_reaction_detector([monomer]) + + assert result == [] + + +# ============================================================================= +# index_based_reaction_detector() - co +# ============================================================================= + +def test_index_co_two_fresh_roles_are_detected(): + detector = ReactionDetector() + detector.reactions = co_reaction() + + a = make_role("A", "CCO", ["A"]) + b = make_role("B", "CCN", ["B"]) + + result = detector.index_based_reaction_detector([a, b]) + + assert len(result) == 1 + + +@pytest.mark.parametrize( + "a_looped,b_looped", + [ + (True, False), + (False, True), + ], +) +def test_index_co_pair_is_kept_when_only_one_role_is_looped( + a_looped, + b_looped, +): + detector = ReactionDetector() + detector.reactions = co_reaction() + + a = make_role( + "A", + "CCO", + ["A"], + is_looped=a_looped, + ) + + b = make_role( + "B", + "CCN", + ["B"], + is_looped=b_looped, + ) + + result = detector.index_based_reaction_detector([a, b]) + + assert len(result) == 1 + + +def test_index_co_pair_is_skipped_when_both_roles_are_looped(): + detector = ReactionDetector() + detector.reactions = co_reaction() + + a = make_role( + "A", + "CCO", + ["A"], + is_looped=True, + ) + + b = make_role( + "B", + "CCN", + ["B"], + is_looped=True, + ) + + result = detector.index_based_reaction_detector([a, b]) + + assert result == [] + + +def test_index_same_monomer_AB_fresh_role_is_detected(): + detector = ReactionDetector() + detector.reactions = co_reaction() + + monomer = make_role( + "AB", + "NCC(=O)O", + ["A", "B"], + is_looped=False, + ) + + result = detector.index_based_reaction_detector([monomer]) + + assert len(result) == 1 + + assert result[0].monomer_1 is monomer + assert result[0].monomer_2 is monomer + + +def test_index_same_monomer_AB_looped_role_is_skipped(): + detector = ReactionDetector() + detector.reactions = co_reaction() + + monomer = make_role( + "AB", + "NCC(=O)O", + ["A", "B"], + is_looped=True, + ) + + result = detector.index_based_reaction_detector([monomer]) + + assert result == [] + + +def test_index_detector_deduplicates_reaction_pairs(): + detector = ReactionDetector() + detector.reactions = co_reaction() + + a = make_role("A", "CCO", ["A", "A"]) + b = make_role("B", "CCN", ["B", "B"]) + + result = detector.index_based_reaction_detector([a, b]) + + assert len(result) == 1 + + +# ============================================================================= +# create_reaction_image() +# ============================================================================= + +def test_create_reaction_image_returns_generated_image(monkeypatch): + detector = ReactionDetector() + + product = Chem.MolFromSmiles("CO") + + class FakeEngine: + def RunReactants(self, reactants): + return ((product,),) + + monkeypatch.setattr( + "AutoREACTER.detectors.reaction_detector." + "rdChemReactions.ReactionFromSmarts", + lambda smarts: FakeEngine(), + ) + + fake_image = object() + + monkeypatch.setattr( + "AutoREACTER.detectors.reaction_detector." + "Draw.ReactionToImage", + lambda reaction, subImgSize: fake_image, + ) + + result = detector.create_reaction_image( + "C", + "O", + "fake_smarts", + "test_reaction", + ) + + assert result is fake_image + + +def test_create_reaction_image_tries_reverse_reactant_order(monkeypatch): + detector = ReactionDetector() + + product = Chem.MolFromSmiles("CO") + + class FakeEngine: + def __init__(self): + self.calls = 0 + + def RunReactants(self, reactants): + self.calls += 1 + + if self.calls == 1: + return () + + return ((product,),) + + engine = FakeEngine() + + monkeypatch.setattr( + "AutoREACTER.detectors.reaction_detector." + "rdChemReactions.ReactionFromSmarts", + lambda smarts: engine, + ) + + monkeypatch.setattr( + "AutoREACTER.detectors.reaction_detector." + "Draw.ReactionToImage", + lambda reaction, subImgSize: "image", + ) + + result = detector.create_reaction_image( + "C", + "O", + "fake_smarts", + "test", + ) + + assert result == "image" + assert engine.calls == 2 + + +def test_create_reaction_image_raises_when_both_orders_fail( + monkeypatch, +): + detector = ReactionDetector() + + class FakeEngine: + def RunReactants(self, reactants): + return () + + monkeypatch.setattr( + "AutoREACTER.detectors.reaction_detector." + "rdChemReactions.ReactionFromSmarts", + lambda smarts: FakeEngine(), + ) + + with pytest.raises( + SMARTSerror, + match="failed", + ): + detector.create_reaction_image( + "C", + "O", + "bad_reaction", + "test", + ) + + +# ============================================================================= +# available_reaction_image_grid() +# ============================================================================= + +def test_available_reaction_image_grid_stacks_images_vertically( + monkeypatch, +): + detector = ReactionDetector() + + m1 = make_role("A", "CCO", ["A"]) + m2 = make_role("B", "CCN", ["B"]) + + fg1 = m1.functionalities[0] + fg2 = m2.functionalities[0] + + reaction_1 = ReactionInstance( + reaction_name="R1", + reaction_smarts="x", + delete_atom=False, + references={}, + same_reactants=False, + monomer_1=m1, + functional_group_1=fg1, + monomer_2=m2, + functional_group_2=fg2, + ) + + reaction_2 = ReactionInstance( + reaction_name="R2", + reaction_smarts="x", + delete_atom=False, + references={}, + same_reactants=False, + monomer_1=m1, + functional_group_1=fg1, + monomer_2=m2, + functional_group_2=fg2, + ) + + monkeypatch.setattr( + detector, + "create_reaction_image", + lambda *args, **kwargs: Image.new( + "RGB", + (100, 50), + "white", + ), + ) + + session = make_session( + reaction_instances=[reaction_1, reaction_2] + ) + + result = detector.available_reaction_image_grid(session) + + assert isinstance(result, Image.Image) + assert result.size == (180, 100) + + +def test_available_reaction_image_grid_returns_none_when_empty(): + detector = ReactionDetector() + + session = make_session(reaction_instances=[]) + + result = detector.available_reaction_image_grid(session) + + assert result is None + + +def test_available_reaction_image_grid_skips_failed_visualizations( + monkeypatch, +): + detector = ReactionDetector() + + monomer = make_role("M", "C=C", ["vinyl"]) + + reaction = ReactionInstance( + reaction_name="R", + reaction_smarts="bad", + delete_atom=False, + references={}, + same_reactants=True, + monomer_1=monomer, + functional_group_1=monomer.functionalities[0], + ) + + def fail(*args, **kwargs): + raise SMARTSerror("bad SMARTS") + + monkeypatch.setattr( + detector, + "create_reaction_image", + fail, + ) + + session = make_session(reaction_instances=[reaction]) + + result = detector.available_reaction_image_grid(session) + + assert result is None + + +# ============================================================================= +# reaction_selection() +# ============================================================================= + +def test_reaction_selection_raises_for_empty_list(): + detector = ReactionDetector() + + session = make_session(reaction_instances=[]) + + with pytest.raises( + EmptyReactionListError, + match="Cannot proceed", + ): + detector.reaction_selection(session) + + +def test_reaction_selection_automatically_keeps_single_reaction(): + detector = ReactionDetector() + + monomer = make_role("M", "C=C", ["vinyl"]) + + reaction = ReactionInstance( + reaction_name="R", + reaction_smarts="x", + delete_atom=False, + references={}, + same_reactants=True, + monomer_1=monomer, + functional_group_1=monomer.functionalities[0], + ) + + session = make_session(reaction_instances=[reaction]) + + detector.reaction_selection(session) + + assert session.reaction_instances == [reaction] + + +def test_reaction_selection_selects_requested_reactions(monkeypatch): + detector = ReactionDetector() + + monomer = make_role("M", "C=C", ["vinyl"]) + fg = monomer.functionalities[0] + + reactions = [ + ReactionInstance( + reaction_name=f"R{i}", + reaction_smarts="x", + delete_atom=False, + references={}, + same_reactants=True, + monomer_1=monomer, + functional_group_1=fg, + ) + for i in range(1, 4) + ] + + session = make_session(reaction_instances=reactions) + + monkeypatch.setattr( + "builtins.input", + lambda prompt: "3,1,3", + ) + + detector.reaction_selection(session) + + assert session.reaction_instances == [ + reactions[0], + reactions[2], + ] + + +def test_reaction_selection_reprompts_after_invalid_input( + monkeypatch, +): + detector = ReactionDetector() + + monomer = make_role("M", "C=C", ["vinyl"]) + fg = monomer.functionalities[0] + + reactions = [ + ReactionInstance( + reaction_name=f"R{i}", + reaction_smarts="x", + delete_atom=False, + references={}, + same_reactants=True, + monomer_1=monomer, + functional_group_1=fg, + ) + for i in range(1, 3) + ] + + session = make_session(reaction_instances=reactions) + + responses = iter( + [ + "", + "abc", + "9", + "2", + ] + ) + + monkeypatch.setattr( + "builtins.input", + lambda prompt: next(responses), + ) + + detector.reaction_selection(session) + + assert session.reaction_instances == [ + reactions[1] + ] \ No newline at end of file diff --git a/tests/unit/reaction_preparation/ff_wrapper/lunar_client/test_config.py b/tests/unit/reaction_preparation/ff_wrapper/lunar_client/test_config.py new file mode 100644 index 00000000..79211575 --- /dev/null +++ b/tests/unit/reaction_preparation/ff_wrapper/lunar_client/test_config.py @@ -0,0 +1,14 @@ +from AutoREACTER.reaction_preparation.ff_wrapper.lunar_client.config import ( + LUNAR_ROOT_DIR, +) + + +def test_lunar_root_dir_is_string(): + assert isinstance( + LUNAR_ROOT_DIR, + str, + ) + + +def test_lunar_root_dir_is_not_empty(): + assert LUNAR_ROOT_DIR.strip() \ No newline at end of file diff --git a/tests/unit/reaction_preparation/ff_wrapper/lunar_client/test_locate_lunar.py b/tests/unit/reaction_preparation/ff_wrapper/lunar_client/test_locate_lunar.py new file mode 100644 index 00000000..5257759a --- /dev/null +++ b/tests/unit/reaction_preparation/ff_wrapper/lunar_client/test_locate_lunar.py @@ -0,0 +1,1385 @@ +from pathlib import Path +from types import SimpleNamespace + +import pytest + +import AutoREACTER.reaction_preparation.ff_wrapper.lunar_client.locate_lunar as locate_lunar + + +# ============================================================================= +# Helpers +# ============================================================================= + + +def make_valid_lunar_root( + root: Path, +) -> Path: + root.mkdir( + parents=True, + exist_ok=True, + ) + + for filename in ( + "LUNAR.py", + "atom_typing.py", + "all2lmp.py", + "bond_react_merge.py", + ): + ( + root / filename + ).write_text( + "# test\n", + encoding="utf-8", + ) + + ( + root / "src" + ).mkdir( + exist_ok=True, + ) + + ( + root / "frc_files" + ).mkdir( + exist_ok=True, + ) + + return root + + +def fake_config( + tmp_path, + lunar_root=None, +): + config_file = ( + tmp_path / "config.py" + ) + + config_file.write_text( + "LUNAR_ROOT_DIR = None\n", + encoding="utf-8", + ) + + return SimpleNamespace( + __file__=str(config_file), + LUNAR_ROOT_DIR=lunar_root, + ) + + +# ============================================================================= +# _normalize_path +# ============================================================================= + + +def test_normalize_path_none_returns_none(): + assert ( + locate_lunar._normalize_path( + None + ) + is None + ) + + +def test_normalize_path_empty_string_returns_none(): + assert ( + locate_lunar._normalize_path( + "" + ) + is None + ) + + +def test_normalize_path_strips_whitespace( + tmp_path, + monkeypatch, +): + monkeypatch.setattr( + locate_lunar.os.path, + "exists", + lambda path: ( + False + if path == "/mnt" + else Path(path).exists() + ), + ) + + expected = ( + tmp_path / "folder" + ).resolve() + + result = ( + locate_lunar._normalize_path( + f" {expected} " + ) + ) + + assert result == str( + expected + ) + + +def test_normalize_path_strips_double_quotes( + tmp_path, + monkeypatch, +): + monkeypatch.setattr( + locate_lunar.os.path, + "exists", + lambda path: ( + False + if path == "/mnt" + else Path(path).exists() + ), + ) + + expected = ( + tmp_path / "folder" + ).resolve() + + result = ( + locate_lunar._normalize_path( + f'"{expected}"' + ) + ) + + assert result == str( + expected + ) + + +def test_normalize_path_strips_single_quotes( + tmp_path, + monkeypatch, +): + monkeypatch.setattr( + locate_lunar.os.path, + "exists", + lambda path: ( + False + if path == "/mnt" + else Path(path).exists() + ), + ) + + expected = ( + tmp_path / "folder" + ).resolve() + + result = ( + locate_lunar._normalize_path( + f"'{expected}'" + ) + ) + + assert result == str( + expected + ) + + +def test_normalize_path_expands_environment_variable( + tmp_path, + monkeypatch, +): + monkeypatch.setenv( + "AUTOREACTER_LUNAR_TEST", + str(tmp_path), + ) + + monkeypatch.setattr( + locate_lunar.os.path, + "exists", + lambda path: ( + False + if path == "/mnt" + else Path(path).exists() + ), + ) + + result = ( + locate_lunar._normalize_path( + "$AUTOREACTER_LUNAR_TEST/lunar" + ) + ) + + assert result == str( + ( + tmp_path / "lunar" + ).resolve() + ) + + +def test_normalize_path_expands_user_home( + tmp_path, + monkeypatch, +): + monkeypatch.setenv( + "HOME", + str(tmp_path), + ) + + monkeypatch.setattr( + locate_lunar.os.path, + "exists", + lambda path: ( + False + if path == "/mnt" + else Path(path).exists() + ), + ) + + result = ( + locate_lunar._normalize_path( + "~/LUNAR" + ) + ) + + assert result == str( + ( + tmp_path / "LUNAR" + ).resolve() + ) + + +def test_normalize_path_converts_windows_path_to_wsl_when_mnt_exists( + monkeypatch, +): + monkeypatch.setattr( + locate_lunar.os.path, + "exists", + lambda path: ( + path == "/mnt" + ), + ) + + result = ( + locate_lunar._normalize_path( + r"C:\Users\Janitha\LUNAR" + ) + ) + + assert result == ( + "/mnt/c/Users/Janitha/LUNAR" + ) + + +def test_normalize_path_wsl_drive_is_lowercase( + monkeypatch, +): + monkeypatch.setattr( + locate_lunar.os.path, + "exists", + lambda path: ( + path == "/mnt" + ), + ) + + result = ( + locate_lunar._normalize_path( + r"D:\Research\LUNAR" + ) + ) + + assert result == ( + "/mnt/d/Research/LUNAR" + ) + + +def test_normalize_path_does_not_convert_windows_drive_without_mnt( + tmp_path, + monkeypatch, +): + monkeypatch.chdir( + tmp_path + ) + + monkeypatch.setattr( + locate_lunar.os.path, + "exists", + lambda path: False, + ) + + result = ( + locate_lunar._normalize_path( + r"C:\Users\Test" + ) + ) + + # Characterizes current non-WSL behavior. + assert result == str( + Path( + r"C:\Users\Test" + ).resolve() + ) + + +# ============================================================================= +# _is_valid_dir +# ============================================================================= + + +def test_is_valid_dir_none_is_false(): + assert ( + locate_lunar._is_valid_dir( + None + ) + is False + ) + + +def test_is_valid_dir_missing_path_is_false( + tmp_path, +): + assert ( + locate_lunar._is_valid_dir( + tmp_path / "missing" + ) + is False + ) + + +def test_is_valid_dir_file_is_false( + tmp_path, +): + path = ( + tmp_path / "not_directory" + ) + + path.write_text( + "test", + encoding="utf-8", + ) + + assert ( + locate_lunar._is_valid_dir( + path + ) + is False + ) + + +def test_is_valid_dir_accepts_complete_lunar_root( + tmp_path, +): + root = make_valid_lunar_root( + tmp_path / "LUNAR" + ) + + assert ( + locate_lunar._is_valid_dir( + root + ) + is True + ) + + +@pytest.mark.parametrize( + "missing_file", + [ + "LUNAR.py", + "atom_typing.py", + "all2lmp.py", + "bond_react_merge.py", + ], +) +def test_is_valid_dir_requires_each_required_file( + tmp_path, + missing_file, +): + root = make_valid_lunar_root( + tmp_path / "LUNAR" + ) + + ( + root / missing_file + ).unlink() + + assert ( + locate_lunar._is_valid_dir( + root + ) + is False + ) + + +@pytest.mark.parametrize( + "missing_directory", + [ + "src", + "frc_files", + ], +) +def test_is_valid_dir_requires_each_required_directory( + tmp_path, + missing_directory, +): + root = make_valid_lunar_root( + tmp_path / "LUNAR" + ) + + ( + root / missing_directory + ).rmdir() + + assert ( + locate_lunar._is_valid_dir( + root + ) + is False + ) + + +# ============================================================================= +# _write_config_py +# ============================================================================= + + +def test_write_config_py_writes_string_path( + tmp_path, + monkeypatch, +): + cfg = fake_config( + tmp_path + ) + + monkeypatch.setattr( + locate_lunar, + "config", + cfg, + ) + + locate_lunar._write_config_py( + "/tmp/LUNAR" + ) + + text = Path( + cfg.__file__ + ).read_text( + encoding="utf-8", + ) + + assert text == ( + "LUNAR_ROOT_DIR = '/tmp/LUNAR'\n" + ) + + +def test_write_config_py_writes_none( + tmp_path, + monkeypatch, +): + cfg = fake_config( + tmp_path + ) + + monkeypatch.setattr( + locate_lunar, + "config", + cfg, + ) + + locate_lunar._write_config_py( + None + ) + + text = Path( + cfg.__file__ + ).read_text( + encoding="utf-8", + ) + + assert text == ( + "LUNAR_ROOT_DIR = None\n" + ) + + +def test_write_config_py_repr_handles_quotes( + tmp_path, + monkeypatch, +): + cfg = fake_config( + tmp_path + ) + + monkeypatch.setattr( + locate_lunar, + "config", + cfg, + ) + + locate_lunar._write_config_py( + "/tmp/user's/LUNAR" + ) + + text = Path( + cfg.__file__ + ).read_text( + encoding="utf-8", + ) + + namespace = {} + + exec( + text, + namespace, + ) + + assert ( + namespace[ + "LUNAR_ROOT_DIR" + ] + == "/tmp/user's/LUNAR" + ) + + +# ============================================================================= +# set_LUNAR_loc +# ============================================================================= + + +def test_set_lunar_loc_valid_path( + tmp_path, + monkeypatch, +): + root = make_valid_lunar_root( + tmp_path / "LUNAR" + ) + + cfg = fake_config( + tmp_path + ) + + monkeypatch.setattr( + locate_lunar, + "config", + cfg, + ) + + result = ( + locate_lunar.set_LUNAR_loc( + str(root) + ) + ) + + assert result == str( + root.resolve() + ) + + assert ( + cfg.LUNAR_ROOT_DIR + == str(root.resolve()) + ) + + text = Path( + cfg.__file__ + ).read_text( + encoding="utf-8", + ) + + assert str( + root.resolve() + ) in text + + +def test_set_lunar_loc_invalid_path_raises( + tmp_path, + monkeypatch, +): + cfg = fake_config( + tmp_path + ) + + monkeypatch.setattr( + locate_lunar, + "config", + cfg, + ) + + with pytest.raises( + ValueError, + match="Not a valid directory", + ): + locate_lunar.set_LUNAR_loc( + str( + tmp_path / "missing" + ) + ) + + +def test_set_lunar_loc_invalid_path_does_not_change_config( + tmp_path, + monkeypatch, +): + original = "/old/LUNAR" + + cfg = fake_config( + tmp_path, + lunar_root=original, + ) + + monkeypatch.setattr( + locate_lunar, + "config", + cfg, + ) + + with pytest.raises( + ValueError + ): + locate_lunar.set_LUNAR_loc( + str( + tmp_path / "missing" + ) + ) + + assert ( + cfg.LUNAR_ROOT_DIR + == original + ) + + +# ============================================================================= +# reset_LUNAR_loc +# ============================================================================= + + +def test_reset_lunar_loc_sets_config_to_none( + tmp_path, + monkeypatch, +): + cfg = fake_config( + tmp_path, + lunar_root="/old/LUNAR", + ) + + monkeypatch.setattr( + locate_lunar, + "config", + cfg, + ) + + result = ( + locate_lunar.reset_LUNAR_loc() + ) + + assert result is None + + assert ( + cfg.LUNAR_ROOT_DIR + is None + ) + + +def test_reset_lunar_loc_persists_none( + tmp_path, + monkeypatch, +): + cfg = fake_config( + tmp_path, + lunar_root="/old/LUNAR", + ) + + monkeypatch.setattr( + locate_lunar, + "config", + cfg, + ) + + locate_lunar.reset_LUNAR_loc() + + assert ( + Path( + cfg.__file__ + ).read_text( + encoding="utf-8", + ) + == "LUNAR_ROOT_DIR = None\n" + ) + + +# ============================================================================= +# _ask_cli +# ============================================================================= + + +def test_ask_cli_returns_normalized_path( + monkeypatch, +): + monkeypatch.setattr( + "builtins.input", + lambda prompt: ( + " /tmp/LUNAR " + ), + ) + + monkeypatch.setattr( + locate_lunar, + "_normalize_path", + lambda path: ( + f"normalized:{path}" + ), + ) + + result = ( + locate_lunar._ask_cli() + ) + + assert result == ( + "normalized:/tmp/LUNAR" + ) + + +def test_ask_cli_empty_input_returns_none( + monkeypatch, +): + monkeypatch.setattr( + "builtins.input", + lambda prompt: " ", + ) + + assert ( + locate_lunar._ask_cli() + is None + ) + + +# ============================================================================= +# _ask_gui +# ============================================================================= + + +def test_ask_gui_returns_normalized_selected_folder( + monkeypatch, +): + events = [] + + class FakeRoot: + def withdraw(self): + events.append( + "withdraw" + ) + + def destroy(self): + events.append( + "destroy" + ) + + monkeypatch.setattr( + locate_lunar.tk, + "Tk", + lambda: FakeRoot(), + ) + + monkeypatch.setattr( + locate_lunar.filedialog, + "askdirectory", + lambda title: + "/tmp/LUNAR", + ) + + monkeypatch.setattr( + locate_lunar, + "_normalize_path", + lambda path: + "normalized", + ) + + result = ( + locate_lunar._ask_gui() + ) + + assert result == "normalized" + + assert events == [ + "withdraw", + "destroy", + ] + + +def test_ask_gui_cancel_returns_none( + monkeypatch, +): + events = [] + + class FakeRoot: + def withdraw(self): + events.append( + "withdraw" + ) + + def destroy(self): + events.append( + "destroy" + ) + + monkeypatch.setattr( + locate_lunar.tk, + "Tk", + lambda: FakeRoot(), + ) + + monkeypatch.setattr( + locate_lunar.filedialog, + "askdirectory", + lambda title: "", + ) + + result = ( + locate_lunar._ask_gui() + ) + + assert result is None + + assert events == [ + "withdraw", + "destroy", + ] + + +# ============================================================================= +# get_LUNAR_loc - auto detection +# ============================================================================= + + +def test_get_lunar_loc_auto_detects_parent( + tmp_path, + monkeypatch, +): + lunar_root = make_valid_lunar_root( + tmp_path / "LUNAR" + ) + + module_dir = ( + lunar_root + / "some" + / "nested" + ) + + module_dir.mkdir( + parents=True, + ) + + fake_module_file = ( + module_dir + / "locate_lunar.py" + ) + + fake_module_file.write_text( + "", + encoding="utf-8", + ) + + cfg = fake_config( + tmp_path + ) + + monkeypatch.setattr( + locate_lunar, + "config", + cfg, + ) + + monkeypatch.setattr( + locate_lunar, + "__file__", + str(fake_module_file), + ) + + result = ( + locate_lunar.get_LUNAR_loc( + force_prompt=False, + ) + ) + + assert result == str( + lunar_root + ) + + assert ( + cfg.LUNAR_ROOT_DIR + == str(lunar_root) + ) + + +def test_get_lunar_loc_auto_detection_precedes_saved_config( + tmp_path, + monkeypatch, +): + auto_root = make_valid_lunar_root( + tmp_path + / "auto" + / "LUNAR" + ) + + saved_root = make_valid_lunar_root( + tmp_path + / "saved" + / "LUNAR" + ) + + nested = ( + auto_root / "pkg" + ) + + nested.mkdir() + + fake_module = ( + nested / "locate_lunar.py" + ) + + fake_module.write_text( + "", + encoding="utf-8", + ) + + cfg = fake_config( + tmp_path, + lunar_root=str(saved_root), + ) + + monkeypatch.setattr( + locate_lunar, + "config", + cfg, + ) + + monkeypatch.setattr( + locate_lunar, + "__file__", + str(fake_module), + ) + + result = ( + locate_lunar.get_LUNAR_loc() + ) + + # Characterizes current precedence: + # auto-detection occurs before saved configuration. + assert result == str( + auto_root + ) + + assert ( + cfg.LUNAR_ROOT_DIR + == str(auto_root) + ) + + +# ============================================================================= +# get_LUNAR_loc - saved configuration +# ============================================================================= + + +def test_get_lunar_loc_returns_saved_valid_path( + tmp_path, + monkeypatch, + capsys, +): + saved_root = make_valid_lunar_root( + tmp_path / "saved_lunar" + ) + + cfg = fake_config( + tmp_path, + lunar_root=str(saved_root), + ) + + monkeypatch.setattr( + locate_lunar, + "config", + cfg, + ) + + original_validator = ( + locate_lunar._is_valid_dir + ) + + def validator(path): + return ( + Path(path) == saved_root + and original_validator(path) + ) + + monkeypatch.setattr( + locate_lunar, + "_is_valid_dir", + validator, + ) + + result = ( + locate_lunar.get_LUNAR_loc( + force_prompt=False, + ) + ) + + assert result == str( + saved_root + ) + + assert ( + "Using saved LUNAR root directory" + in capsys.readouterr().out + ) + + +def test_get_lunar_loc_force_prompt_ignores_saved_config( + tmp_path, + monkeypatch, +): + cfg = fake_config( + tmp_path, + lunar_root="/saved/LUNAR", + ) + + monkeypatch.setattr( + locate_lunar, + "config", + cfg, + ) + + monkeypatch.setattr( + locate_lunar, + "_ask_cli", + lambda: None, + ) + + result = ( + locate_lunar.get_LUNAR_loc( + force_prompt=True, + use_gui=False, + ) + ) + + assert result is None + + +# ============================================================================= +# get_LUNAR_loc - CLI prompting +# ============================================================================= + + +def test_get_lunar_loc_cli_accepts_valid_prompted_path( + tmp_path, + monkeypatch, +): + lunar_root = make_valid_lunar_root( + tmp_path / "LUNAR" + ) + + cfg = fake_config( + tmp_path + ) + + monkeypatch.setattr( + locate_lunar, + "config", + cfg, + ) + + monkeypatch.setattr( + locate_lunar, + "_ask_cli", + lambda: str( + lunar_root + ), + ) + + result = ( + locate_lunar.get_LUNAR_loc( + force_prompt=True, + use_gui=False, + ) + ) + + assert result == str( + lunar_root + ) + + assert ( + cfg.LUNAR_ROOT_DIR + == str(lunar_root) + ) + + +def test_get_lunar_loc_cli_cancel_returns_none( + tmp_path, + monkeypatch, +): + cfg = fake_config( + tmp_path + ) + + monkeypatch.setattr( + locate_lunar, + "config", + cfg, + ) + + monkeypatch.setattr( + locate_lunar, + "_ask_cli", + lambda: None, + ) + + assert ( + locate_lunar.get_LUNAR_loc( + force_prompt=True, + use_gui=False, + ) + is None + ) + + +def test_get_lunar_loc_cli_retries_after_invalid_path( + tmp_path, + monkeypatch, + capsys, +): + valid_root = make_valid_lunar_root( + tmp_path / "valid_lunar" + ) + + invalid_root = ( + tmp_path / "invalid_lunar" + ) + + invalid_root.mkdir() + + cfg = fake_config( + tmp_path + ) + + monkeypatch.setattr( + locate_lunar, + "config", + cfg, + ) + + answers = iter( + [ + str(invalid_root), + str(valid_root), + ] + ) + + monkeypatch.setattr( + locate_lunar, + "_ask_cli", + lambda: next( + answers + ), + ) + + result = ( + locate_lunar.get_LUNAR_loc( + force_prompt=True, + use_gui=False, + ) + ) + + assert result == str( + valid_root + ) + + output = ( + capsys.readouterr().out + ) + + assert ( + "Invalid LUNAR directory" + in output + ) + + assert ( + "Expected files:" + in output + ) + + assert ( + "Expected directory:" + in output + ) + + +# ============================================================================= +# get_LUNAR_loc - GUI prompting +# ============================================================================= + + +def test_get_lunar_loc_gui_accepts_valid_path( + tmp_path, + monkeypatch, +): + lunar_root = make_valid_lunar_root( + tmp_path / "LUNAR" + ) + + cfg = fake_config( + tmp_path + ) + + monkeypatch.setattr( + locate_lunar, + "config", + cfg, + ) + + monkeypatch.setattr( + locate_lunar, + "_ask_gui", + lambda: str( + lunar_root + ), + ) + + result = ( + locate_lunar.get_LUNAR_loc( + force_prompt=True, + use_gui=True, + ) + ) + + assert result == str( + lunar_root + ) + + +def test_get_lunar_loc_gui_invalid_path_shows_error_then_cancel( + tmp_path, + monkeypatch, +): + invalid_root = ( + tmp_path / "invalid" + ) + + invalid_root.mkdir() + + cfg = fake_config( + tmp_path + ) + + monkeypatch.setattr( + locate_lunar, + "config", + cfg, + ) + + answers = iter( + [ + str(invalid_root), + None, + ] + ) + + monkeypatch.setattr( + locate_lunar, + "_ask_gui", + lambda: next( + answers + ), + ) + + events = [] + + class FakeRoot: + def withdraw(self): + events.append( + "withdraw" + ) + + def destroy(self): + events.append( + "destroy" + ) + + monkeypatch.setattr( + locate_lunar.tk, + "Tk", + lambda: FakeRoot(), + ) + + monkeypatch.setattr( + locate_lunar.messagebox, + "showerror", + lambda title, message: + events.append( + ( + title, + message, + ) + ), + ) + + result = ( + locate_lunar.get_LUNAR_loc( + force_prompt=True, + use_gui=True, + ) + ) + + assert result is None + + assert any( + isinstance(event, tuple) + and event[0] == "Invalid Folder" + for event in events + ) + + +def test_get_lunar_loc_none_use_gui_uses_global_setting( + tmp_path, + monkeypatch, +): + lunar_root = make_valid_lunar_root( + tmp_path / "LUNAR" + ) + + cfg = fake_config( + tmp_path + ) + + monkeypatch.setattr( + locate_lunar, + "config", + cfg, + ) + + monkeypatch.setattr( + locate_lunar, + "USE_GUI", + True, + ) + + calls = [] + + monkeypatch.setattr( + locate_lunar, + "_ask_gui", + lambda: ( + calls.append( + "gui" + ) + or str(lunar_root) + ), + ) + + monkeypatch.setattr( + locate_lunar, + "_ask_cli", + lambda: pytest.fail( + "CLI prompt should not be used" + ), + ) + + result = ( + locate_lunar.get_LUNAR_loc( + force_prompt=True, + use_gui=None, + ) + ) + + assert result == str( + lunar_root + ) + + assert calls == [ + "gui" + ] \ No newline at end of file diff --git a/tests/unit/reaction_preparation/ff_wrapper/lunar_client/test_lunar_api_wrapper.py b/tests/unit/reaction_preparation/ff_wrapper/lunar_client/test_lunar_api_wrapper.py new file mode 100644 index 00000000..0eb49536 --- /dev/null +++ b/tests/unit/reaction_preparation/ff_wrapper/lunar_client/test_lunar_api_wrapper.py @@ -0,0 +1,1312 @@ +from pathlib import Path +from types import SimpleNamespace + +import pytest + +import AutoREACTER.reaction_preparation.ff_wrapper.lunar_client.lunar_api_wrapper as lunar_api +from AutoREACTER.reaction_preparation.ff_wrapper.lunar_client.lunar_api_wrapper import ( + LunarAPIWrapper, +) + + +# ============================================================================= +# Helpers +# ============================================================================= + + +def make_session( + tmp_path, + *, + force_field="PCFF", +): + inputs = SimpleNamespace( + force_field=force_field, + ) + + return SimpleNamespace( + inputs=inputs, + staging_dir=tmp_path / "staging", + ) + + +class FakeExecutor: + def __init__( + self, + lunar_location, + cache_dir, + ): + self.lunar_location = Path( + lunar_location + ) + + self.cache_dir = Path( + cache_dir + ) + + self.cache_atom_typing = ( + self.cache_dir + / "atom_typing" + ) + + self.cache_all2lmp = ( + self.cache_dir + / "all2lmp" + ) + + self.cache_bond_react_merge = ( + self.cache_dir + / "bond_react_merge" + ) + + self.calls = [] + + def run_atom_typing( + self, + updated_inputs, + prepared_reactions, + force_field, + ): + self.calls.append( + ( + "atom_typing", + updated_inputs, + prepared_reactions, + force_field, + ) + ) + + return [ + "atom-typing-result" + ] + + def run_all2lmp( + self, + atom_typing_results, + frc_file, + ): + self.calls.append( + ( + "all2lmp", + atom_typing_results, + frc_file, + ) + ) + + return [ + "all2lmp-result" + ] + + def run_bond_react_merge( + self, + merge_input_file_path, + all2lmp_results, + ): + self.calls.append( + ( + "bond_react_merge", + merge_input_file_path, + all2lmp_results, + ) + ) + + return "final-files" + + +# ============================================================================= +# Constructor +# ============================================================================= + + +def test_constructor_stores_session( + tmp_path, + monkeypatch, +): + session = make_session( + tmp_path + ) + + monkeypatch.setattr( + lunar_api, + "get_LUNAR_loc", + lambda use_gui=False: + "/tmp/LUNAR", + ) + + monkeypatch.setattr( + lunar_api, + "LunarExecutor", + FakeExecutor, + ) + + wrapper = LunarAPIWrapper( + session + ) + + assert wrapper.session is session + assert wrapper.inputs is session.inputs + + +def test_constructor_creates_lunar_cache_directory( + tmp_path, + monkeypatch, +): + session = make_session( + tmp_path + ) + + monkeypatch.setattr( + lunar_api, + "get_LUNAR_loc", + lambda use_gui=False: + "/tmp/LUNAR", + ) + + monkeypatch.setattr( + lunar_api, + "LunarExecutor", + FakeExecutor, + ) + + wrapper = LunarAPIWrapper( + session + ) + + expected = ( + tmp_path + / "staging" + / "lunar" + ) + + assert wrapper.cache_dir == expected + + assert expected.is_dir() + + +def test_constructor_requests_lunar_location_without_gui( + tmp_path, + monkeypatch, +): + session = make_session( + tmp_path + ) + + calls = [] + + def fake_get_lunar_loc( + use_gui=None, + ): + calls.append( + use_gui + ) + + return "/tmp/LUNAR" + + monkeypatch.setattr( + lunar_api, + "get_LUNAR_loc", + fake_get_lunar_loc, + ) + + monkeypatch.setattr( + lunar_api, + "LunarExecutor", + FakeExecutor, + ) + + LunarAPIWrapper( + session + ) + + assert calls == [ + False + ] + + +def test_constructor_stores_lunar_location_as_path( + tmp_path, + monkeypatch, +): + session = make_session( + tmp_path + ) + + monkeypatch.setattr( + lunar_api, + "get_LUNAR_loc", + lambda use_gui=False: + "/tmp/LUNAR", + ) + + monkeypatch.setattr( + lunar_api, + "LunarExecutor", + FakeExecutor, + ) + + wrapper = LunarAPIWrapper( + session + ) + + assert isinstance( + wrapper.LUNAR_LOCATION, + Path, + ) + + assert wrapper.LUNAR_LOCATION == ( + Path("/tmp/LUNAR") + ) + + +def test_constructor_initializes_executor_with_expected_paths( + tmp_path, + monkeypatch, +): + session = make_session( + tmp_path + ) + + captured = [] + + class CapturingExecutor: + def __init__( + self, + lunar_location, + cache_dir, + ): + captured.append( + ( + lunar_location, + cache_dir, + ) + ) + + monkeypatch.setattr( + lunar_api, + "get_LUNAR_loc", + lambda use_gui=False: + "/tmp/LUNAR", + ) + + monkeypatch.setattr( + lunar_api, + "LunarExecutor", + CapturingExecutor, + ) + + wrapper = LunarAPIWrapper( + session + ) + + assert captured == [ + ( + Path("/tmp/LUNAR"), + tmp_path + / "staging" + / "lunar", + ) + ] + + assert isinstance( + wrapper.executor, + CapturingExecutor, + ) + + +# ============================================================================= +# lunar_workflow - loading screen +# ============================================================================= + + +def test_lunar_workflow_calls_loading_screen( + tmp_path, + monkeypatch, +): + session = make_session( + tmp_path + ) + + monkeypatch.setattr( + lunar_api, + "get_LUNAR_loc", + lambda use_gui=False: + "/tmp/LUNAR", + ) + + monkeypatch.setattr( + lunar_api, + "LunarExecutor", + FakeExecutor, + ) + + loading_calls = [] + + monkeypatch.setattr( + lunar_api, + "loading_screen", + lambda name: + loading_calls.append( + name + ), + ) + + monkeypatch.setattr( + lunar_api, + "get_force_field_file", + lambda **kwargs: + Path("/tmp/pcff.frc"), + ) + + monkeypatch.setattr( + lunar_api, + "write_bond_react_merge_input", + lambda **kwargs: + Path("/tmp/merge_input.txt"), + ) + + wrapper = LunarAPIWrapper( + session + ) + + wrapper.lunar_workflow( + session.inputs, + [], + ) + + assert loading_calls == [ + "LUNAR Workflow" + ] + + +# ============================================================================= +# Force-field lookup +# ============================================================================= + + +def test_lunar_workflow_uses_force_field_from_updated_inputs( + tmp_path, + monkeypatch, +): + session = make_session( + tmp_path, + force_field="PCFF", + ) + + updated_inputs = SimpleNamespace( + force_field="COMPASS", + ) + + monkeypatch.setattr( + lunar_api, + "get_LUNAR_loc", + lambda use_gui=False: + "/tmp/LUNAR", + ) + + monkeypatch.setattr( + lunar_api, + "LunarExecutor", + FakeExecutor, + ) + + monkeypatch.setattr( + lunar_api, + "loading_screen", + lambda name: None, + ) + + ff_calls = [] + + def fake_get_force_field_file( + *, + force_field, + lunar_location, + ): + ff_calls.append( + ( + force_field, + lunar_location, + ) + ) + + return Path( + "/tmp/compass.frc" + ) + + monkeypatch.setattr( + lunar_api, + "get_force_field_file", + fake_get_force_field_file, + ) + + monkeypatch.setattr( + lunar_api, + "write_bond_react_merge_input", + lambda **kwargs: + Path("/tmp/merge_input.txt"), + ) + + wrapper = LunarAPIWrapper( + session + ) + + wrapper.lunar_workflow( + updated_inputs, + [], + ) + + assert ff_calls == [ + ( + "COMPASS", + Path("/tmp/LUNAR"), + ) + ] + + +# ============================================================================= +# Stage 1 - atom typing +# ============================================================================= + + +def test_lunar_workflow_passes_inputs_and_reactions_to_atom_typing( + tmp_path, + monkeypatch, +): + session = make_session( + tmp_path + ) + + updated_inputs = SimpleNamespace( + force_field="PCFF", + ) + + prepared_reactions = [ + object(), + object(), + ] + + monkeypatch.setattr( + lunar_api, + "get_LUNAR_loc", + lambda use_gui=False: + "/tmp/LUNAR", + ) + + monkeypatch.setattr( + lunar_api, + "LunarExecutor", + FakeExecutor, + ) + + monkeypatch.setattr( + lunar_api, + "loading_screen", + lambda name: None, + ) + + monkeypatch.setattr( + lunar_api, + "get_force_field_file", + lambda **kwargs: + Path("/tmp/pcff.frc"), + ) + + monkeypatch.setattr( + lunar_api, + "write_bond_react_merge_input", + lambda **kwargs: + Path("/tmp/merge_input.txt"), + ) + + wrapper = LunarAPIWrapper( + session + ) + + wrapper.lunar_workflow( + updated_inputs, + prepared_reactions, + ) + + assert ( + wrapper.executor.calls[0] + == ( + "atom_typing", + updated_inputs, + prepared_reactions, + "PCFF", + ) + ) + + +# ============================================================================= +# Stage 2 - all2lmp +# ============================================================================= + + +def test_lunar_workflow_passes_atom_typing_results_to_all2lmp( + tmp_path, + monkeypatch, +): + session = make_session( + tmp_path + ) + + monkeypatch.setattr( + lunar_api, + "get_LUNAR_loc", + lambda use_gui=False: + "/tmp/LUNAR", + ) + + monkeypatch.setattr( + lunar_api, + "LunarExecutor", + FakeExecutor, + ) + + monkeypatch.setattr( + lunar_api, + "loading_screen", + lambda name: None, + ) + + ff_file = Path( + "/tmp/pcff.frc" + ) + + monkeypatch.setattr( + lunar_api, + "get_force_field_file", + lambda **kwargs: + ff_file, + ) + + monkeypatch.setattr( + lunar_api, + "write_bond_react_merge_input", + lambda **kwargs: + Path("/tmp/merge_input.txt"), + ) + + wrapper = LunarAPIWrapper( + session + ) + + wrapper.lunar_workflow( + session.inputs, + [], + ) + + assert ( + wrapper.executor.calls[1] + == ( + "all2lmp", + [ + "atom-typing-result" + ], + ff_file, + ) + ) + + +# ============================================================================= +# Stage 3 - merge input builder +# ============================================================================= + + +def test_lunar_workflow_builds_merge_input_with_executor_caches( + tmp_path, + monkeypatch, +): + session = make_session( + tmp_path + ) + + monkeypatch.setattr( + lunar_api, + "get_LUNAR_loc", + lambda use_gui=False: + "/tmp/LUNAR", + ) + + monkeypatch.setattr( + lunar_api, + "LunarExecutor", + FakeExecutor, + ) + + monkeypatch.setattr( + lunar_api, + "loading_screen", + lambda name: None, + ) + + monkeypatch.setattr( + lunar_api, + "get_force_field_file", + lambda **kwargs: + Path("/tmp/pcff.frc"), + ) + + merge_calls = [] + + def fake_write_merge( + *, + cache_bond_react_merge, + cache_all2lmp, + all2lmp_results, + ): + merge_calls.append( + ( + cache_bond_react_merge, + cache_all2lmp, + all2lmp_results, + ) + ) + + return Path( + "/tmp/merge_input.txt" + ) + + monkeypatch.setattr( + lunar_api, + "write_bond_react_merge_input", + fake_write_merge, + ) + + wrapper = LunarAPIWrapper( + session + ) + + wrapper.lunar_workflow( + session.inputs, + [], + ) + + assert merge_calls == [ + ( + wrapper.executor.cache_bond_react_merge, + wrapper.executor.cache_all2lmp, + [ + "all2lmp-result" + ], + ) + ] + + +# ============================================================================= +# Stage 4 - bond/react merge +# ============================================================================= + + +def test_lunar_workflow_passes_merge_file_and_all2lmp_results_to_final_stage( + tmp_path, + monkeypatch, +): + session = make_session( + tmp_path + ) + + monkeypatch.setattr( + lunar_api, + "get_LUNAR_loc", + lambda use_gui=False: + "/tmp/LUNAR", + ) + + monkeypatch.setattr( + lunar_api, + "LunarExecutor", + FakeExecutor, + ) + + monkeypatch.setattr( + lunar_api, + "loading_screen", + lambda name: None, + ) + + monkeypatch.setattr( + lunar_api, + "get_force_field_file", + lambda **kwargs: + Path("/tmp/pcff.frc"), + ) + + merge_input = Path( + "/tmp/merge_input.txt" + ) + + monkeypatch.setattr( + lunar_api, + "write_bond_react_merge_input", + lambda **kwargs: + merge_input, + ) + + wrapper = LunarAPIWrapper( + session + ) + + wrapper.lunar_workflow( + session.inputs, + [], + ) + + assert ( + wrapper.executor.calls[2] + == ( + "bond_react_merge", + merge_input, + [ + "all2lmp-result" + ], + ) + ) + + +# ============================================================================= +# Workflow result +# ============================================================================= + + +def test_lunar_workflow_returns_final_files( + tmp_path, + monkeypatch, +): + session = make_session( + tmp_path + ) + + monkeypatch.setattr( + lunar_api, + "get_LUNAR_loc", + lambda use_gui=False: + "/tmp/LUNAR", + ) + + monkeypatch.setattr( + lunar_api, + "LunarExecutor", + FakeExecutor, + ) + + monkeypatch.setattr( + lunar_api, + "loading_screen", + lambda name: None, + ) + + monkeypatch.setattr( + lunar_api, + "get_force_field_file", + lambda **kwargs: + Path("/tmp/pcff.frc"), + ) + + monkeypatch.setattr( + lunar_api, + "write_bond_react_merge_input", + lambda **kwargs: + Path("/tmp/merge_input.txt"), + ) + + wrapper = LunarAPIWrapper( + session + ) + + result = wrapper.lunar_workflow( + session.inputs, + [], + ) + + assert result == ( + "final-files" + ) + + +# ============================================================================= +# Execution order +# ============================================================================= + + +def test_lunar_workflow_executes_stages_in_order( + tmp_path, + monkeypatch, +): + session = make_session( + tmp_path + ) + + events = [] + + monkeypatch.setattr( + lunar_api, + "get_LUNAR_loc", + lambda use_gui=False: + "/tmp/LUNAR", + ) + + monkeypatch.setattr( + lunar_api, + "loading_screen", + lambda name: + events.append( + "loading" + ), + ) + + monkeypatch.setattr( + lunar_api, + "get_force_field_file", + lambda **kwargs: + ( + events.append( + "force-field" + ) + or Path( + "/tmp/pcff.frc" + ) + ), + ) + + class OrderedExecutor: + def __init__( + self, + lunar_location, + cache_dir, + ): + self.cache_all2lmp = ( + Path(cache_dir) + / "all2lmp" + ) + + self.cache_bond_react_merge = ( + Path(cache_dir) + / "bond_react_merge" + ) + + def run_atom_typing( + self, + **kwargs, + ): + events.append( + "atom-typing" + ) + + return [ + "typed" + ] + + def run_all2lmp( + self, + **kwargs, + ): + events.append( + "all2lmp" + ) + + return [ + "converted" + ] + + def run_bond_react_merge( + self, + **kwargs, + ): + events.append( + "bond-react-merge" + ) + + return "final" + + monkeypatch.setattr( + lunar_api, + "LunarExecutor", + OrderedExecutor, + ) + + monkeypatch.setattr( + lunar_api, + "write_bond_react_merge_input", + lambda **kwargs: + ( + events.append( + "merge-builder" + ) + or Path( + "/tmp/merge_input.txt" + ) + ), + ) + + wrapper = LunarAPIWrapper( + session + ) + + result = wrapper.lunar_workflow( + session.inputs, + [], + ) + + assert result == "final" + + assert events == [ + "loading", + "force-field", + "atom-typing", + "all2lmp", + "merge-builder", + "bond-react-merge", + ] + + +# ============================================================================= +# Failure propagation +# ============================================================================= + + +def test_force_field_lookup_error_propagates( + tmp_path, + monkeypatch, +): + session = make_session( + tmp_path + ) + + monkeypatch.setattr( + lunar_api, + "get_LUNAR_loc", + lambda use_gui=False: + "/tmp/LUNAR", + ) + + monkeypatch.setattr( + lunar_api, + "LunarExecutor", + FakeExecutor, + ) + + monkeypatch.setattr( + lunar_api, + "loading_screen", + lambda name: None, + ) + + def fail(**kwargs): + raise FileNotFoundError( + "force field missing" + ) + + monkeypatch.setattr( + lunar_api, + "get_force_field_file", + fail, + ) + + wrapper = LunarAPIWrapper( + session + ) + + with pytest.raises( + FileNotFoundError, + match="force field missing", + ): + wrapper.lunar_workflow( + session.inputs, + [], + ) + + +def test_atom_typing_error_stops_later_stages( + tmp_path, + monkeypatch, +): + session = make_session( + tmp_path + ) + + monkeypatch.setattr( + lunar_api, + "get_LUNAR_loc", + lambda use_gui=False: + "/tmp/LUNAR", + ) + + monkeypatch.setattr( + lunar_api, + "loading_screen", + lambda name: None, + ) + + monkeypatch.setattr( + lunar_api, + "get_force_field_file", + lambda **kwargs: + Path("/tmp/pcff.frc"), + ) + + class FailingExecutor: + def __init__( + self, + lunar_location, + cache_dir, + ): + self.cache_all2lmp = ( + Path(cache_dir) + / "all2lmp" + ) + + self.cache_bond_react_merge = ( + Path(cache_dir) + / "bond_react_merge" + ) + + def run_atom_typing( + self, + **kwargs, + ): + raise RuntimeError( + "atom typing failed" + ) + + def run_all2lmp( + self, + **kwargs, + ): + pytest.fail( + "all2lmp must not run" + ) + + def run_bond_react_merge( + self, + **kwargs, + ): + pytest.fail( + "merge must not run" + ) + + monkeypatch.setattr( + lunar_api, + "LunarExecutor", + FailingExecutor, + ) + + monkeypatch.setattr( + lunar_api, + "write_bond_react_merge_input", + lambda **kwargs: + pytest.fail( + "merge builder must not run" + ), + ) + + wrapper = LunarAPIWrapper( + session + ) + + with pytest.raises( + RuntimeError, + match="atom typing failed", + ): + wrapper.lunar_workflow( + session.inputs, + [], + ) + + +def test_all2lmp_error_stops_merge_stages( + tmp_path, + monkeypatch, +): + session = make_session( + tmp_path + ) + + monkeypatch.setattr( + lunar_api, + "get_LUNAR_loc", + lambda use_gui=False: + "/tmp/LUNAR", + ) + + monkeypatch.setattr( + lunar_api, + "loading_screen", + lambda name: None, + ) + + monkeypatch.setattr( + lunar_api, + "get_force_field_file", + lambda **kwargs: + Path("/tmp/pcff.frc"), + ) + + class FailingExecutor: + def __init__( + self, + lunar_location, + cache_dir, + ): + self.cache_all2lmp = ( + Path(cache_dir) + / "all2lmp" + ) + + self.cache_bond_react_merge = ( + Path(cache_dir) + / "bond_react_merge" + ) + + def run_atom_typing( + self, + **kwargs, + ): + return [ + "typed" + ] + + def run_all2lmp( + self, + **kwargs, + ): + raise RuntimeError( + "all2lmp failed" + ) + + def run_bond_react_merge( + self, + **kwargs, + ): + pytest.fail( + "bond merge must not run" + ) + + monkeypatch.setattr( + lunar_api, + "LunarExecutor", + FailingExecutor, + ) + + monkeypatch.setattr( + lunar_api, + "write_bond_react_merge_input", + lambda **kwargs: + pytest.fail( + "merge builder must not run" + ), + ) + + wrapper = LunarAPIWrapper( + session + ) + + with pytest.raises( + RuntimeError, + match="all2lmp failed", + ): + wrapper.lunar_workflow( + session.inputs, + [], + ) + + +def test_merge_builder_error_stops_final_merge( + tmp_path, + monkeypatch, +): + session = make_session( + tmp_path + ) + + monkeypatch.setattr( + lunar_api, + "get_LUNAR_loc", + lambda use_gui=False: + "/tmp/LUNAR", + ) + + monkeypatch.setattr( + lunar_api, + "loading_screen", + lambda name: None, + ) + + monkeypatch.setattr( + lunar_api, + "get_force_field_file", + lambda **kwargs: + Path("/tmp/pcff.frc"), + ) + + class Executor: + def __init__( + self, + lunar_location, + cache_dir, + ): + self.cache_all2lmp = ( + Path(cache_dir) + / "all2lmp" + ) + + self.cache_bond_react_merge = ( + Path(cache_dir) + / "bond_react_merge" + ) + + def run_atom_typing( + self, + **kwargs, + ): + return [ + "typed" + ] + + def run_all2lmp( + self, + **kwargs, + ): + return [ + "converted" + ] + + def run_bond_react_merge( + self, + **kwargs, + ): + pytest.fail( + "final merge must not run" + ) + + monkeypatch.setattr( + lunar_api, + "LunarExecutor", + Executor, + ) + + def fail_merge_builder( + **kwargs, + ): + raise ValueError( + "bad merge input" + ) + + monkeypatch.setattr( + lunar_api, + "write_bond_react_merge_input", + fail_merge_builder, + ) + + wrapper = LunarAPIWrapper( + session + ) + + with pytest.raises( + ValueError, + match="bad merge input", + ): + wrapper.lunar_workflow( + session.inputs, + [], + ) \ No newline at end of file diff --git a/tests/unit/reaction_preparation/ff_wrapper/lunar_client/test_lunar_executor.py b/tests/unit/reaction_preparation/ff_wrapper/lunar_client/test_lunar_executor.py new file mode 100644 index 00000000..b9345c45 --- /dev/null +++ b/tests/unit/reaction_preparation/ff_wrapper/lunar_client/test_lunar_executor.py @@ -0,0 +1,2257 @@ +from dataclasses import dataclass +from pathlib import Path +from types import SimpleNamespace +import subprocess +import sys + +import pytest + +import AutoREACTER.reaction_preparation.ff_wrapper.lunar_client.lunar_executor as lunar_executor +from AutoREACTER.reaction_preparation.ff_wrapper.lunar_client.lunar_executor import ( + All2LMPResult, + AtomTypingResult, + LunarExecutor, +) + + +# ============================================================================= +# Helpers +# ============================================================================= + + +def make_monomer( + *, + name="mma", + molecule_3Dmol_path=None, + status=True, +): + return SimpleNamespace( + name=name, + molecule_3Dmol_path=molecule_3Dmol_path, + status=status, + ) + + +def make_inputs( + monomers=None, +): + return SimpleNamespace( + monomers=list( + monomers or [] + ) + ) + + +def make_reaction( + *, + reaction_id=1, + pre_path=None, + post_path=None, +): + return SimpleNamespace( + reaction_id=reaction_id, + reactant_combined_3Dmol_path=pre_path, + product_combined_3Dmol_path=post_path, + ) + + +def make_executor( + tmp_path, +): + lunar_root = ( + tmp_path / "LUNAR" + ) + + lunar_root.mkdir( + parents=True, + exist_ok=True, + ) + + return LunarExecutor( + lunar_location=lunar_root, + cache_dir=tmp_path / "cache", + ) + + +def install_fake_atom_typing_subprocess( + monkeypatch, +): + """ + Fake atom_typing.py execution and create exactly the files that + LunarExecutor expects. + """ + calls = [] + + def fake_run( + command, + check=False, + **kwargs, + ): + calls.append( + { + "command": list(command), + "check": check, + **kwargs, + } + ) + + topo = Path( + command[ + command.index("-topo") + 1 + ] + ) + + output_dir = Path( + command[ + command.index("-dir") + 1 + ] + ) + + output_dir.mkdir( + parents=True, + exist_ok=True, + ) + + # Monomer output naming is based on the input stem. + stem = topo.stem + + ( + output_dir + / f"{stem}_typed.data" + ).write_text( + "typed data", + encoding="utf-8", + ) + + ( + output_dir + / f"{stem}_typed.nta" + ).write_text( + "nta", + encoding="utf-8", + ) + + return SimpleNamespace( + returncode=0 + ) + + monkeypatch.setattr( + lunar_executor.subprocess, + "run", + fake_run, + ) + + return calls + + +# ============================================================================= +# Fake FF-wrapper objects for bond_react_merge tests +# ============================================================================= + + +@dataclass +class FakeDataFiles: + data_file: Path + lmp_molecule_file: Path + + +@dataclass +class FakeMoleculeFile: + id: str + molecule_files: FakeDataFiles + + +@dataclass +class FakeTemplateFile: + reaction_id: int | None + pre_reaction_file: FakeDataFiles + post_reaction_file: FakeDataFiles + + +@dataclass +class FakeFFFiles: + force_field_data: Path + in_file: Path + molecule_files: list + template_files: list + + +def install_fake_ff_structures( + monkeypatch, +): + monkeypatch.setattr( + lunar_executor, + "DataFiles", + FakeDataFiles, + ) + + monkeypatch.setattr( + lunar_executor, + "MoleculeFile", + FakeMoleculeFile, + ) + + monkeypatch.setattr( + lunar_executor, + "TemplateFile", + FakeTemplateFile, + ) + + monkeypatch.setattr( + lunar_executor, + "FFFiles", + FakeFFFiles, + ) + + +# ============================================================================= +# Dataclasses +# ============================================================================= + + +def test_atom_typing_result_fields( + tmp_path, +): + result = AtomTypingResult( + id="data1", + molecule=True, + typed_data_file=( + tmp_path / "typed.data" + ), + nta_file=( + tmp_path / "typed.nta" + ), + ) + + assert result.id == "data1" + assert result.molecule is True + + assert result.typed_data_file == ( + tmp_path / "typed.data" + ) + + assert result.nta_file == ( + tmp_path / "typed.nta" + ) + + +def test_atom_typing_result_uses_slots( + tmp_path, +): + result = AtomTypingResult( + id="data1", + molecule=True, + typed_data_file=( + tmp_path / "typed.data" + ), + nta_file=( + tmp_path / "typed.nta" + ), + ) + + with pytest.raises( + AttributeError + ): + result.extra = 1 + + +def test_all2lmp_result_fields(): + result = All2LMPResult( + id="pre1", + molecule=False, + all2lmp_data_file=Path( + "pre1_typed_IFF.data" + ), + ) + + assert result.id == "pre1" + assert result.molecule is False + + assert ( + result.all2lmp_data_file + == Path( + "pre1_typed_IFF.data" + ) + ) + + +def test_all2lmp_result_uses_slots(): + result = All2LMPResult( + id="data1", + molecule=True, + all2lmp_data_file=Path( + "data1_typed_IFF.data" + ), + ) + + with pytest.raises( + AttributeError + ): + result.extra = 1 + + +# ============================================================================= +# Constructor +# ============================================================================= + + +def test_constructor_sets_lunar_script_paths( + tmp_path, +): + lunar_root = ( + tmp_path / "LUNAR" + ) + + executor = LunarExecutor( + lunar_root, + tmp_path / "cache", + ) + + assert executor.lunar_location == ( + lunar_root + ) + + assert executor.atom_typing_py == ( + lunar_root + / "atom_typing.py" + ) + + assert executor.all2lmp_py == ( + lunar_root + / "all2lmp.py" + ) + + assert executor.bond_react_merge_py == ( + lunar_root + / "bond_react_merge.py" + ) + + +def test_constructor_creates_stage_caches( + tmp_path, +): + cache = ( + tmp_path / "cache" + ) + + executor = LunarExecutor( + tmp_path / "LUNAR", + cache, + ) + + assert executor.cache_atom_typing == ( + cache / "atom_typing" + ) + + assert executor.cache_all2lmp == ( + cache / "all2lmp" + ) + + assert ( + executor.cache_bond_react_merge + == cache / "bond_react_merge" + ) + + assert ( + executor.cache_atom_typing.is_dir() + ) + + assert ( + executor.cache_all2lmp.is_dir() + ) + + assert ( + executor + .cache_bond_react_merge + .is_dir() + ) + + +def test_constructor_accepts_string_paths( + tmp_path, +): + executor = LunarExecutor( + str(tmp_path / "LUNAR"), + str(tmp_path / "cache"), + ) + + assert isinstance( + executor.lunar_location, + Path, + ) + + assert isinstance( + executor.cache_atom_typing, + Path, + ) + + +# ============================================================================= +# run_atom_typing - monomers +# ============================================================================= + + +def test_run_atom_typing_active_monomer( + tmp_path, + monkeypatch, +): + executor = make_executor( + tmp_path + ) + + molecule_file = ( + tmp_path / "mma.mol" + ) + + molecule_file.write_text( + "molecule", + encoding="utf-8", + ) + + inputs = make_inputs( + [ + make_monomer( + name="mma", + molecule_3Dmol_path=molecule_file, + ) + ] + ) + + calls = ( + install_fake_atom_typing_subprocess( + monkeypatch + ) + ) + + monkeypatch.setattr( + lunar_executor.time, + "sleep", + lambda seconds: None, + ) + + results = executor.run_atom_typing( + inputs, + prepared_reactions=[], + force_field="PCFF", + ) + + assert len(results) == 1 + + result = results[0] + + assert result.id == "mma" + assert result.molecule is True + + assert result.typed_data_file == ( + executor.cache_atom_typing + / "mma_typed.data" + ) + + assert result.nta_file == ( + executor.cache_atom_typing + / "mma_typed.nta" + ) + + assert len(calls) == 1 + + +def test_run_atom_typing_command_for_monomer( + tmp_path, + monkeypatch, +): + executor = make_executor( + tmp_path + ) + + molecule_file = ( + tmp_path / "styrene.mol" + ) + + molecule_file.write_text( + "", + encoding="utf-8", + ) + + inputs = make_inputs( + [ + make_monomer( + name="styrene", + molecule_3Dmol_path=molecule_file, + ) + ] + ) + + calls = ( + install_fake_atom_typing_subprocess( + monkeypatch + ) + ) + + monkeypatch.setattr( + lunar_executor.time, + "sleep", + lambda seconds: None, + ) + + executor.run_atom_typing( + inputs, + [], + "PCFF", + ) + + assert calls[0][ + "command" + ] == [ + sys.executable, + str( + executor.atom_typing_py + ), + "-topo", + str(molecule_file), + "-dir", + str( + executor.cache_atom_typing + ), + "-ff", + "PCFF", + "-del-method", + "mass", + "-del-crit", + "0", + ] + + assert ( + calls[0]["check"] + is True + ) + + +def test_run_atom_typing_skips_inactive_monomer( + tmp_path, + monkeypatch, +): + executor = make_executor( + tmp_path + ) + + calls = [] + + monkeypatch.setattr( + lunar_executor.subprocess, + "run", + lambda *args, **kwargs: + calls.append( + args + ), + ) + + monkeypatch.setattr( + lunar_executor.time, + "sleep", + lambda seconds: None, + ) + + inputs = make_inputs( + [ + make_monomer( + name="inactive", + molecule_3Dmol_path=None, + status=False, + ) + ] + ) + + results = executor.run_atom_typing( + inputs, + [], + "PCFF", + ) + + assert results == [] + assert calls == [] + + +def test_run_atom_typing_missing_active_monomer_3d_path_raises( + tmp_path, + monkeypatch, +): + executor = make_executor( + tmp_path + ) + + monkeypatch.setattr( + lunar_executor.subprocess, + "run", + lambda *args, **kwargs: + pytest.fail( + "subprocess must not run" + ), + ) + + inputs = make_inputs( + [ + make_monomer( + name="mma", + molecule_3Dmol_path=None, + status=True, + ) + ] + ) + + with pytest.raises( + ValueError, + match="missing molecule_3Dmol_path", + ): + executor.run_atom_typing( + inputs, + [], + "PCFF", + ) + + +def test_run_atom_typing_status_missing_defaults_active( + tmp_path, + monkeypatch, +): + executor = make_executor( + tmp_path + ) + + molecule_file = ( + tmp_path / "mma.mol" + ) + + molecule_file.write_text( + "", + encoding="utf-8", + ) + + monomer = SimpleNamespace( + name="mma", + molecule_3Dmol_path=molecule_file, + ) + + inputs = make_inputs( + [ + monomer + ] + ) + + install_fake_atom_typing_subprocess( + monkeypatch + ) + + monkeypatch.setattr( + lunar_executor.time, + "sleep", + lambda seconds: None, + ) + + results = executor.run_atom_typing( + inputs, + [], + "PCFF", + ) + + assert len(results) == 1 + assert results[0].id == "mma" + + +# ============================================================================= +# run_atom_typing - reaction templates +# ============================================================================= + + +def test_run_atom_typing_processes_pre_and_post_templates( + tmp_path, + monkeypatch, +): + executor = make_executor( + tmp_path + ) + + pre = ( + tmp_path / "reaction_pre.mol" + ) + + post = ( + tmp_path / "reaction_post.mol" + ) + + pre.write_text( + "", + encoding="utf-8", + ) + + post.write_text( + "", + encoding="utf-8", + ) + + reaction = make_reaction( + reaction_id=7, + pre_path=pre, + post_path=post, + ) + + install_fake_atom_typing_subprocess( + monkeypatch + ) + + monkeypatch.setattr( + lunar_executor.time, + "sleep", + lambda seconds: None, + ) + + results = executor.run_atom_typing( + make_inputs(), + [ + reaction + ], + "PCFF", + ) + + assert [ + result.id + for result in results + ] == [ + "pre7", + "post7", + ] + + assert all( + result.molecule is False + for result in results + ) + + +def test_run_atom_typing_template_outputs_use_separate_directories( + tmp_path, + monkeypatch, +): + executor = make_executor( + tmp_path + ) + + pre = ( + tmp_path / "foo_pre.mol" + ) + + post = ( + tmp_path / "foo_post.mol" + ) + + pre.write_text("") + post.write_text("") + + reaction = make_reaction( + reaction_id=3, + pre_path=pre, + post_path=post, + ) + + install_fake_atom_typing_subprocess( + monkeypatch + ) + + monkeypatch.setattr( + lunar_executor.time, + "sleep", + lambda seconds: None, + ) + + results = executor.run_atom_typing( + make_inputs(), + [ + reaction + ], + "PCFF", + ) + + assert ( + results[0] + .typed_data_file + == executor.cache_atom_typing + / "pre3" + / "foo_pre_typed.data" + ) + + assert ( + results[1] + .typed_data_file + == executor.cache_atom_typing + / "post3" + / "foo_post_typed.data" + ) + + +def test_run_atom_typing_result_order_is_monomers_then_templates( + tmp_path, + monkeypatch, +): + executor = make_executor( + tmp_path + ) + + monomer_file = ( + tmp_path / "mma.mol" + ) + + pre = ( + tmp_path / "pre.mol" + ) + + post = ( + tmp_path / "post.mol" + ) + + for path in ( + monomer_file, + pre, + post, + ): + path.write_text("") + + inputs = make_inputs( + [ + make_monomer( + name="mma", + molecule_3Dmol_path=monomer_file, + ) + ] + ) + + reaction = make_reaction( + reaction_id=1, + pre_path=pre, + post_path=post, + ) + + install_fake_atom_typing_subprocess( + monkeypatch + ) + + monkeypatch.setattr( + lunar_executor.time, + "sleep", + lambda seconds: None, + ) + + results = executor.run_atom_typing( + inputs, + [ + reaction + ], + "PCFF", + ) + + assert [ + result.id + for result in results + ] == [ + "mma", + "pre1", + "post1", + ] + + +def test_run_atom_typing_sleeps_after_every_executed_item( + tmp_path, + monkeypatch, +): + executor = make_executor( + tmp_path + ) + + monomer_file = ( + tmp_path / "mma.mol" + ) + + pre = ( + tmp_path / "pre.mol" + ) + + post = ( + tmp_path / "post.mol" + ) + + for path in ( + monomer_file, + pre, + post, + ): + path.write_text("") + + inputs = make_inputs( + [ + make_monomer( + name="mma", + molecule_3Dmol_path=monomer_file, + ) + ] + ) + + reaction = make_reaction( + reaction_id=1, + pre_path=pre, + post_path=post, + ) + + install_fake_atom_typing_subprocess( + monkeypatch + ) + + sleeps = [] + + monkeypatch.setattr( + lunar_executor.time, + "sleep", + lambda seconds: + sleeps.append(seconds), + ) + + executor.run_atom_typing( + inputs, + [ + reaction + ], + "PCFF", + ) + + assert sleeps == [ + 0.1, + 0.1, + 0.1, + ] + + +# ============================================================================= +# run_atom_typing - output verification +# ============================================================================= + + +def test_run_atom_typing_missing_expected_output_raises( + tmp_path, + monkeypatch, +): + executor = make_executor( + tmp_path + ) + + molecule_file = ( + tmp_path / "mma.mol" + ) + + molecule_file.write_text("") + + monkeypatch.setattr( + lunar_executor.subprocess, + "run", + lambda *args, **kwargs: + SimpleNamespace( + returncode=0 + ), + ) + + monkeypatch.setattr( + lunar_executor.time, + "sleep", + lambda seconds: None, + ) + + inputs = make_inputs( + [ + make_monomer( + name="mma", + molecule_3Dmol_path=molecule_file, + ) + ] + ) + + with pytest.raises( + FileNotFoundError, + match="Expected LUNAR output not found for mma", + ): + executor.run_atom_typing( + inputs, + [], + "PCFF", + ) + + +def test_run_atom_typing_prints_generated_message( + tmp_path, + monkeypatch, + capsys, +): + executor = make_executor( + tmp_path + ) + + molecule_file = ( + tmp_path / "mma.mol" + ) + + molecule_file.write_text("") + + install_fake_atom_typing_subprocess( + monkeypatch + ) + + monkeypatch.setattr( + lunar_executor.time, + "sleep", + lambda seconds: None, + ) + + executor.run_atom_typing( + make_inputs( + [ + make_monomer( + name="mma", + molecule_3Dmol_path=molecule_file, + ) + ] + ), + [], + "PCFF", + ) + + assert ( + "[LUNAR atom_typing] Generated files for mma" + in capsys.readouterr().out + ) + + +def test_run_atom_typing_subprocess_failure_propagates( + tmp_path, + monkeypatch, +): + executor = make_executor( + tmp_path + ) + + molecule_file = ( + tmp_path / "mma.mol" + ) + + molecule_file.write_text("") + + def fail(*args, **kwargs): + raise subprocess.CalledProcessError( + 1, + "atom_typing", + ) + + monkeypatch.setattr( + lunar_executor.subprocess, + "run", + fail, + ) + + with pytest.raises( + subprocess.CalledProcessError + ): + executor.run_atom_typing( + make_inputs( + [ + make_monomer( + name="mma", + molecule_3Dmol_path=molecule_file, + ) + ] + ), + [], + "PCFF", + ) + + +# ============================================================================= +# run_all2lmp +# ============================================================================= + + +def install_fake_all2lmp_subprocess( + executor, + monkeypatch, +): + calls = [] + + def fake_run( + command, + check=False, + **kwargs, + ): + calls.append( + { + "command": list(command), + "check": check, + **kwargs, + } + ) + + topo = Path( + command[ + command.index("-topo") + 1 + ] + ) + + # Executor output naming is derived from the identifier. + # The typed input file stem is e.g. "pre1_typed". + stem = topo.stem + + if stem.endswith( + "_typed" + ): + result_id = stem[ + :-len("_typed") + ] + else: + result_id = stem + + ( + executor.cache_all2lmp + / f"{result_id}_typed_IFF.data" + ).write_text( + "IFF", + encoding="utf-8", + ) + + return SimpleNamespace( + returncode=0 + ) + + monkeypatch.setattr( + lunar_executor.subprocess, + "run", + fake_run, + ) + + return calls + + +def test_run_all2lmp_converts_single_result( + tmp_path, + monkeypatch, +): + executor = make_executor( + tmp_path + ) + + typed = ( + tmp_path / "data1_typed.data" + ) + + nta = ( + tmp_path / "data1_typed.nta" + ) + + typed.write_text("") + nta.write_text("") + + input_result = AtomTypingResult( + id="data1", + molecule=True, + typed_data_file=typed, + nta_file=nta, + ) + + install_fake_all2lmp_subprocess( + executor, + monkeypatch, + ) + + monkeypatch.setattr( + lunar_executor.time, + "sleep", + lambda seconds: None, + ) + + results = executor.run_all2lmp( + [ + input_result + ], + tmp_path / "pcff.frc", + ) + + assert len(results) == 1 + + assert results[0] == ( + All2LMPResult( + id="data1", + molecule=True, + all2lmp_data_file=Path( + "data1_typed_IFF.data" + ), + ) + ) + + +def test_run_all2lmp_exact_command( + tmp_path, + monkeypatch, +): + executor = make_executor( + tmp_path + ) + + typed = ( + tmp_path / "pre2_typed.data" + ) + + nta = ( + tmp_path / "pre2_typed.nta" + ) + + typed.write_text("") + nta.write_text("") + + frc = ( + tmp_path / "pcff.frc" + ) + + calls = ( + install_fake_all2lmp_subprocess( + executor, + monkeypatch, + ) + ) + + monkeypatch.setattr( + lunar_executor.time, + "sleep", + lambda seconds: None, + ) + + executor.run_all2lmp( + [ + AtomTypingResult( + id="pre2", + molecule=False, + typed_data_file=typed, + nta_file=nta, + ) + ], + frc, + ) + + assert calls[0][ + "command" + ] == [ + sys.executable, + str( + executor.all2lmp_py + ), + "-topo", + str(typed), + "-nta", + str(nta), + "-frc", + str(frc), + "-asm", + "T", + "-dir", + str( + executor.cache_all2lmp + ), + ] + + assert ( + calls[0]["check"] + is True + ) + + +def test_run_all2lmp_preserves_id_and_molecule_flag( + tmp_path, + monkeypatch, +): + executor = make_executor( + tmp_path + ) + + typed = ( + tmp_path / "post9_typed.data" + ) + + nta = ( + tmp_path / "post9_typed.nta" + ) + + typed.write_text("") + nta.write_text("") + + install_fake_all2lmp_subprocess( + executor, + monkeypatch, + ) + + monkeypatch.setattr( + lunar_executor.time, + "sleep", + lambda seconds: None, + ) + + result = executor.run_all2lmp( + [ + AtomTypingResult( + id="post9", + molecule=False, + typed_data_file=typed, + nta_file=nta, + ) + ], + tmp_path / "pcff.frc", + )[0] + + assert result.id == "post9" + assert result.molecule is False + + +def test_run_all2lmp_processes_results_in_input_order( + tmp_path, + monkeypatch, +): + executor = make_executor( + tmp_path + ) + + entries = [] + + for identifier, molecule in [ + ("data1", True), + ("pre1", False), + ("post1", False), + ]: + typed = ( + tmp_path + / f"{identifier}_typed.data" + ) + + nta = ( + tmp_path + / f"{identifier}_typed.nta" + ) + + typed.write_text("") + nta.write_text("") + + entries.append( + AtomTypingResult( + id=identifier, + molecule=molecule, + typed_data_file=typed, + nta_file=nta, + ) + ) + + install_fake_all2lmp_subprocess( + executor, + monkeypatch, + ) + + monkeypatch.setattr( + lunar_executor.time, + "sleep", + lambda seconds: None, + ) + + results = executor.run_all2lmp( + entries, + tmp_path / "pcff.frc", + ) + + assert [ + result.id + for result in results + ] == [ + "data1", + "pre1", + "post1", + ] + + +def test_run_all2lmp_sleeps_once_after_all_commands( + tmp_path, + monkeypatch, +): + executor = make_executor( + tmp_path + ) + + typed = ( + tmp_path / "data1_typed.data" + ) + + nta = ( + tmp_path / "data1_typed.nta" + ) + + typed.write_text("") + nta.write_text("") + + install_fake_all2lmp_subprocess( + executor, + monkeypatch, + ) + + sleeps = [] + + monkeypatch.setattr( + lunar_executor.time, + "sleep", + lambda seconds: + sleeps.append(seconds), + ) + + executor.run_all2lmp( + [ + AtomTypingResult( + id="data1", + molecule=True, + typed_data_file=typed, + nta_file=nta, + ) + ], + tmp_path / "pcff.frc", + ) + + assert sleeps == [ + 0.1 + ] + + +def test_run_all2lmp_empty_input_still_sleeps_once( + tmp_path, + monkeypatch, +): + executor = make_executor( + tmp_path + ) + + sleeps = [] + + monkeypatch.setattr( + lunar_executor.time, + "sleep", + lambda seconds: + sleeps.append(seconds), + ) + + results = executor.run_all2lmp( + [], + tmp_path / "pcff.frc", + ) + + assert results == [] + + assert sleeps == [ + 0.1 + ] + + +def test_run_all2lmp_missing_output_raises( + tmp_path, + monkeypatch, +): + executor = make_executor( + tmp_path + ) + + typed = ( + tmp_path / "data1_typed.data" + ) + + nta = ( + tmp_path / "data1_typed.nta" + ) + + typed.write_text("") + nta.write_text("") + + monkeypatch.setattr( + lunar_executor.subprocess, + "run", + lambda *args, **kwargs: + SimpleNamespace( + returncode=0 + ), + ) + + monkeypatch.setattr( + lunar_executor.time, + "sleep", + lambda seconds: None, + ) + + with pytest.raises( + FileNotFoundError, + match="Expected output file not found", + ): + executor.run_all2lmp( + [ + AtomTypingResult( + id="data1", + molecule=True, + typed_data_file=typed, + nta_file=nta, + ) + ], + tmp_path / "pcff.frc", + ) + + +def test_run_all2lmp_prints_generated_message( + tmp_path, + monkeypatch, + capsys, +): + executor = make_executor( + tmp_path + ) + + typed = ( + tmp_path / "data1_typed.data" + ) + + nta = ( + tmp_path / "data1_typed.nta" + ) + + typed.write_text("") + nta.write_text("") + + install_fake_all2lmp_subprocess( + executor, + monkeypatch, + ) + + monkeypatch.setattr( + lunar_executor.time, + "sleep", + lambda seconds: None, + ) + + executor.run_all2lmp( + [ + AtomTypingResult( + id="data1", + molecule=True, + typed_data_file=typed, + nta_file=nta, + ) + ], + tmp_path / "pcff.frc", + ) + + assert ( + "[LUNAR all2lmp] Generated file for data1" + in capsys.readouterr().out + ) + + +# ============================================================================= +# run_bond_react_merge - subprocess +# ============================================================================= + + +def test_run_bond_react_merge_exact_command( + tmp_path, + monkeypatch, +): + executor = make_executor( + tmp_path + ) + + merge_input = ( + executor.cache_bond_react_merge + / "merge_input.txt" + ) + + merge_input.write_text("") + + calls = [] + + def fake_run( + command, + **kwargs, + ): + calls.append( + ( + list(command), + kwargs, + ) + ) + + return SimpleNamespace( + returncode=0 + ) + + monkeypatch.setattr( + lunar_executor.subprocess, + "run", + fake_run, + ) + + monkeypatch.setattr( + lunar_executor, + "move_merge_outputs", + lambda src, dst: None, + ) + + install_fake_ff_structures( + monkeypatch + ) + + monkeypatch.setattr( + lunar_executor, + "FFValidator", + lambda files: None, + ) + + executor.run_bond_react_merge( + merge_input, + [], + ) + + command, kwargs = calls[0] + + assert command == [ + sys.executable, + str( + executor.bond_react_merge_py + ), + "-files", + "infile:merge_input.txt", + "-atomstyle", + "full", + "-tl", + "T", + "-wrd", + "F", + "-map", + "F", + ] + + assert kwargs[ + "cwd" + ] == str( + executor.cache_bond_react_merge + ) + + assert ( + kwargs["check"] + is True + ) + + +def test_run_bond_react_merge_sets_qt_offscreen_environment( + tmp_path, + monkeypatch, +): + executor = make_executor( + tmp_path + ) + + merge_input = ( + executor.cache_bond_react_merge + / "merge_input.txt" + ) + + merge_input.write_text("") + + captured_env = [] + + def fake_run( + command, + **kwargs, + ): + captured_env.append( + kwargs["env"] + ) + + return SimpleNamespace( + returncode=0 + ) + + monkeypatch.setenv( + "AUTOREACTER_TEST_ENV", + "preserved", + ) + + monkeypatch.setattr( + lunar_executor.subprocess, + "run", + fake_run, + ) + + monkeypatch.setattr( + lunar_executor, + "move_merge_outputs", + lambda src, dst: None, + ) + + install_fake_ff_structures( + monkeypatch + ) + + monkeypatch.setattr( + lunar_executor, + "FFValidator", + lambda files: None, + ) + + executor.run_bond_react_merge( + merge_input, + [], + ) + + env = captured_env[0] + + assert ( + env["QT_QPA_PLATFORM"] + == "offscreen" + ) + + assert ( + env["AUTOREACTER_TEST_ENV"] + == "preserved" + ) + + +def test_run_bond_react_merge_subprocess_failure_propagates( + tmp_path, + monkeypatch, +): + executor = make_executor( + tmp_path + ) + + merge_input = ( + tmp_path / "merge_input.txt" + ) + + merge_input.write_text("") + + def fail(*args, **kwargs): + raise subprocess.CalledProcessError( + 1, + "bond_react_merge", + ) + + monkeypatch.setattr( + lunar_executor.subprocess, + "run", + fail, + ) + + monkeypatch.setattr( + lunar_executor, + "move_merge_outputs", + lambda *args: + pytest.fail( + "outputs must not move after subprocess failure" + ), + ) + + with pytest.raises( + subprocess.CalledProcessError + ): + executor.run_bond_react_merge( + merge_input, + [], + ) + + +# ============================================================================= +# run_bond_react_merge - moving outputs +# ============================================================================= + + +def test_run_bond_react_merge_moves_all2lmp_outputs_after_execution( + tmp_path, + monkeypatch, +): + executor = make_executor( + tmp_path + ) + + merge_input = ( + tmp_path / "merge_input.txt" + ) + + merge_input.write_text("") + + order = [] + + monkeypatch.setattr( + lunar_executor.subprocess, + "run", + lambda *args, **kwargs: + ( + order.append( + "subprocess" + ) + or SimpleNamespace( + returncode=0 + ) + ), + ) + + def fake_move( + src, + dst, + ): + order.append( + "move" + ) + + assert src == ( + executor.cache_all2lmp + ) + + assert dst == ( + executor + .cache_bond_react_merge + ) + + monkeypatch.setattr( + lunar_executor, + "move_merge_outputs", + fake_move, + ) + + install_fake_ff_structures( + monkeypatch + ) + + monkeypatch.setattr( + lunar_executor, + "FFValidator", + lambda files: + order.append( + "validate" + ), + ) + + executor.run_bond_react_merge( + merge_input, + [], + ) + + assert order == [ + "subprocess", + "move", + "validate", + ] + + +# ============================================================================= +# run_bond_react_merge - resulting FFFiles +# ============================================================================= + + +def test_run_bond_react_merge_builds_molecule_files( + tmp_path, + monkeypatch, +): + executor = make_executor( + tmp_path + ) + + merge_input = ( + tmp_path / "merge_input.txt" + ) + + merge_input.write_text("") + + monkeypatch.setattr( + lunar_executor.subprocess, + "run", + lambda *args, **kwargs: + SimpleNamespace( + returncode=0 + ), + ) + + monkeypatch.setattr( + lunar_executor, + "move_merge_outputs", + lambda src, dst: None, + ) + + install_fake_ff_structures( + monkeypatch + ) + + monkeypatch.setattr( + lunar_executor, + "FFValidator", + lambda files: None, + ) + + result = ( + executor.run_bond_react_merge( + merge_input, + [ + All2LMPResult( + id="mma", + molecule=True, + all2lmp_data_file=Path( + "mma_typed_IFF.data" + ), + ) + ], + ) + ) + + assert len( + result.molecule_files + ) == 1 + + molecule_file = ( + result.molecule_files[0] + ) + + assert molecule_file.id == "mma" + + assert ( + molecule_file + .molecule_files + .data_file + == executor.cache_bond_react_merge + / "mma_typed_IFF_merged.data" + ) + + assert ( + molecule_file + .molecule_files + .lmp_molecule_file + == executor.cache_bond_react_merge + / "mma_typed_IFF_merged.lmpmol" + ) + + +def test_run_bond_react_merge_builds_template_from_pre_result( + tmp_path, + monkeypatch, +): + executor = make_executor( + tmp_path + ) + + merge_input = ( + tmp_path / "merge_input.txt" + ) + + merge_input.write_text("") + + monkeypatch.setattr( + lunar_executor.subprocess, + "run", + lambda *args, **kwargs: + SimpleNamespace( + returncode=0 + ), + ) + + monkeypatch.setattr( + lunar_executor, + "move_merge_outputs", + lambda src, dst: None, + ) + + install_fake_ff_structures( + monkeypatch + ) + + monkeypatch.setattr( + lunar_executor, + "FFValidator", + lambda files: None, + ) + + results = [ + All2LMPResult( + id="pre12", + molecule=False, + all2lmp_data_file=Path( + "pre12_typed_IFF.data" + ), + ), + All2LMPResult( + id="post12", + molecule=False, + all2lmp_data_file=Path( + "post12_typed_IFF.data" + ), + ), + ] + + result = ( + executor.run_bond_react_merge( + merge_input, + results, + ) + ) + + assert len( + result.template_files + ) == 1 + + template = ( + result.template_files[0] + ) + + assert ( + template.reaction_id + == 12 + ) + + assert ( + template + .pre_reaction_file + .data_file + == executor.cache_bond_react_merge + / "pre12_typed_IFF_merged.data" + ) + + assert ( + template + .post_reaction_file + .data_file + == executor.cache_bond_react_merge + / "post12_typed_IFF_merged.data" + ) + + +def test_run_bond_react_merge_post_result_does_not_create_second_template( + tmp_path, + monkeypatch, +): + executor = make_executor( + tmp_path + ) + + merge_input = ( + tmp_path / "merge_input.txt" + ) + + merge_input.write_text("") + + monkeypatch.setattr( + lunar_executor.subprocess, + "run", + lambda *args, **kwargs: + SimpleNamespace( + returncode=0 + ), + ) + + monkeypatch.setattr( + lunar_executor, + "move_merge_outputs", + lambda src, dst: None, + ) + + install_fake_ff_structures( + monkeypatch + ) + + monkeypatch.setattr( + lunar_executor, + "FFValidator", + lambda files: None, + ) + + result = executor.run_bond_react_merge( + merge_input, + [ + All2LMPResult( + id="pre1", + molecule=False, + all2lmp_data_file=Path( + "pre1_typed_IFF.data" + ), + ), + All2LMPResult( + id="post1", + molecule=False, + all2lmp_data_file=Path( + "post1_typed_IFF.data" + ), + ), + ], + ) + + assert len( + result.template_files + ) == 1 + + +def test_run_bond_react_merge_sets_force_field_and_input_files( + tmp_path, + monkeypatch, +): + executor = make_executor( + tmp_path + ) + + merge_input = ( + tmp_path / "merge_input.txt" + ) + + merge_input.write_text("") + + monkeypatch.setattr( + lunar_executor.subprocess, + "run", + lambda *args, **kwargs: + SimpleNamespace( + returncode=0 + ), + ) + + monkeypatch.setattr( + lunar_executor, + "move_merge_outputs", + lambda src, dst: None, + ) + + install_fake_ff_structures( + monkeypatch + ) + + monkeypatch.setattr( + lunar_executor, + "FFValidator", + lambda files: None, + ) + + result = ( + executor.run_bond_react_merge( + merge_input, + [], + ) + ) + + assert ( + result.force_field_data + == executor.cache_bond_react_merge + / "force_field.data" + ) + + # Characterizes current implementation: + # in.create_atoms.script remains referenced in all2lmp cache. + assert ( + result.in_file + == executor.cache_all2lmp + / "in.create_atoms.script" + ) + + +def test_run_bond_react_merge_validates_final_files( + tmp_path, + monkeypatch, +): + executor = make_executor( + tmp_path + ) + + merge_input = ( + tmp_path / "merge_input.txt" + ) + + merge_input.write_text("") + + monkeypatch.setattr( + lunar_executor.subprocess, + "run", + lambda *args, **kwargs: + SimpleNamespace( + returncode=0 + ), + ) + + monkeypatch.setattr( + lunar_executor, + "move_merge_outputs", + lambda src, dst: None, + ) + + install_fake_ff_structures( + monkeypatch + ) + + validated = [] + + monkeypatch.setattr( + lunar_executor, + "FFValidator", + lambda files: + validated.append(files), + ) + + result = ( + executor.run_bond_react_merge( + merge_input, + [], + ) + ) + + assert validated == [ + result + ] + + +def test_run_bond_react_merge_returns_fffiles( + tmp_path, + monkeypatch, +): + executor = make_executor( + tmp_path + ) + + merge_input = ( + tmp_path / "merge_input.txt" + ) + + merge_input.write_text("") + + monkeypatch.setattr( + lunar_executor.subprocess, + "run", + lambda *args, **kwargs: + SimpleNamespace( + returncode=0 + ), + ) + + monkeypatch.setattr( + lunar_executor, + "move_merge_outputs", + lambda src, dst: None, + ) + + install_fake_ff_structures( + monkeypatch + ) + + monkeypatch.setattr( + lunar_executor, + "FFValidator", + lambda files: None, + ) + + result = ( + executor.run_bond_react_merge( + merge_input, + [], + ) + ) + + assert isinstance( + result, + FakeFFFiles, + ) \ No newline at end of file diff --git a/tests/unit/reaction_preparation/ff_wrapper/lunar_client/test_lunar_utils.py b/tests/unit/reaction_preparation/ff_wrapper/lunar_client/test_lunar_utils.py new file mode 100644 index 00000000..a39bd856 --- /dev/null +++ b/tests/unit/reaction_preparation/ff_wrapper/lunar_client/test_lunar_utils.py @@ -0,0 +1,691 @@ +from pathlib import Path + +import pytest + +import AutoREACTER.reaction_preparation.ff_wrapper.lunar_client.lunar_utils as lunar_utils +from AutoREACTER.reaction_preparation.ff_wrapper.lunar_client.lunar_utils import ( + get_ending_integer, + is_wsl, + loading_screen, + move_merge_outputs, + normalize_path, +) + + +# ============================================================================= +# is_wsl +# ============================================================================= + + +def test_is_wsl_true_when_platform_release_contains_microsoft( + monkeypatch, +): + monkeypatch.setattr( + lunar_utils.platform, + "release", + lambda: ( + "5.15.153.1-microsoft-standard-WSL2" + ), + ) + + monkeypatch.delenv( + "WSL_INTEROP", + raising=False, + ) + + assert is_wsl() is True + + +def test_is_wsl_release_check_is_case_insensitive( + monkeypatch, +): + monkeypatch.setattr( + lunar_utils.platform, + "release", + lambda: ( + "5.15-MICROSOFT-STANDARD" + ), + ) + + monkeypatch.delenv( + "WSL_INTEROP", + raising=False, + ) + + assert is_wsl() is True + + +def test_is_wsl_true_when_wsl_interop_environment_exists( + monkeypatch, +): + monkeypatch.setattr( + lunar_utils.platform, + "release", + lambda: "Linux", + ) + + monkeypatch.setenv( + "WSL_INTEROP", + "/run/WSL/123_interop", + ) + + assert is_wsl() is True + + +def test_is_wsl_false_on_normal_linux( + monkeypatch, +): + monkeypatch.setattr( + lunar_utils.platform, + "release", + lambda: ( + "6.8.0-generic" + ), + ) + + monkeypatch.delenv( + "WSL_INTEROP", + raising=False, + ) + + assert is_wsl() is False + + +# ============================================================================= +# normalize_path +# ============================================================================= + + +def test_normalize_path_accepts_path_object( + monkeypatch, +): + monkeypatch.setattr( + lunar_utils, + "is_wsl", + lambda: False, + ) + + monkeypatch.setattr( + lunar_utils.platform, + "system", + lambda: "Linux", + ) + + result = normalize_path( + Path("/tmp/test/path") + ) + + assert result == ( + "/tmp/test/path" + ) + + +def test_normalize_path_strips_whitespace_and_double_quotes( + monkeypatch, +): + monkeypatch.setattr( + lunar_utils, + "is_wsl", + lambda: False, + ) + + monkeypatch.setattr( + lunar_utils.platform, + "system", + lambda: "Linux", + ) + + result = normalize_path( + ' "/tmp/test/path" ' + ) + + assert result == ( + "/tmp/test/path" + ) + + +def test_normalize_path_strips_single_quotes( + monkeypatch, +): + monkeypatch.setattr( + lunar_utils, + "is_wsl", + lambda: False, + ) + + monkeypatch.setattr( + lunar_utils.platform, + "system", + lambda: "Linux", + ) + + result = normalize_path( + "'/tmp/test/path'" + ) + + assert result == ( + "/tmp/test/path" + ) + + +def test_normalize_path_converts_backslashes_on_linux( + monkeypatch, +): + monkeypatch.setattr( + lunar_utils, + "is_wsl", + lambda: False, + ) + + monkeypatch.setattr( + lunar_utils.platform, + "system", + lambda: "Linux", + ) + + result = normalize_path( + r"/tmp/test\folder\file" + ) + + assert result == ( + "/tmp/test/folder/file" + ) + + +def test_normalize_path_windows_drive_to_wsl( + monkeypatch, +): + monkeypatch.setattr( + lunar_utils, + "is_wsl", + lambda: True, + ) + + result = normalize_path( + "C:/Users/Janitha/LUNAR" + ) + + assert result == ( + "/mnt/c/Users/Janitha/LUNAR" + ) + + +def test_normalize_path_windows_backslashes_to_wsl( + monkeypatch, +): + monkeypatch.setattr( + lunar_utils, + "is_wsl", + lambda: True, + ) + + result = normalize_path( + r"D:\Research\LUNAR\data" + ) + + assert result == ( + "/mnt/d/Research/LUNAR/data" + ) + + +def test_normalize_path_wsl_drive_letter_is_lowercase( + monkeypatch, +): + monkeypatch.setattr( + lunar_utils, + "is_wsl", + lambda: True, + ) + + result = normalize_path( + "Z:/Some/Folder" + ) + + assert result == ( + "/mnt/z/Some/Folder" + ) + + +def test_normalize_path_existing_wsl_path_is_preserved( + monkeypatch, +): + monkeypatch.setattr( + lunar_utils, + "is_wsl", + lambda: True, + ) + + result = normalize_path( + "/mnt/c/Users/Janitha/LUNAR" + ) + + assert result == ( + "/mnt/c/Users/Janitha/LUNAR" + ) + + +def test_normalize_path_wsl_path_to_windows( + monkeypatch, +): + monkeypatch.setattr( + lunar_utils, + "is_wsl", + lambda: False, + ) + + monkeypatch.setattr( + lunar_utils.platform, + "system", + lambda: "Windows", + ) + + result = normalize_path( + "/mnt/c/Users/Janitha/LUNAR" + ) + + assert result == ( + r"C:\Users\Janitha\LUNAR" + ) + + +def test_normalize_path_wsl_path_without_leading_slash_to_windows( + monkeypatch, +): + monkeypatch.setattr( + lunar_utils, + "is_wsl", + lambda: False, + ) + + monkeypatch.setattr( + lunar_utils.platform, + "system", + lambda: "Windows", + ) + + result = normalize_path( + "mnt/d/Research/LUNAR" + ) + + assert result == ( + r"D:\Research\LUNAR" + ) + + +def test_normalize_path_linux_collapses_parent_segments( + monkeypatch, +): + monkeypatch.setattr( + lunar_utils, + "is_wsl", + lambda: False, + ) + + monkeypatch.setattr( + lunar_utils.platform, + "system", + lambda: "Linux", + ) + + result = normalize_path( + "/tmp/a/../b" + ) + + assert result == ( + "/tmp/b" + ) + + +# ============================================================================= +# get_ending_integer +# ============================================================================= + + +@pytest.mark.parametrize( + "value, expected", + [ + ( + "pre12", + 12, + ), + ( + "reaction_123", + 123, + ), + ( + "RXN_1", + 1, + ), + ( + "abc0007", + 7, + ), + ( + "42", + 42, + ), + ( + "test0", + 0, + ), + ], +) +def test_get_ending_integer( + value, + expected, +): + assert ( + get_ending_integer( + value + ) + == expected + ) + + +@pytest.mark.parametrize( + "value, expected", + [ + ("pre12", 12), + ("reaction_123", 123), + ("RXN_1", 1), + ("abc0007", 7), + ("42", 42), + ("test0", 0), + ("1.5", 5), + ], +) +def test_get_ending_integer( + value, + expected, +): + assert ( + get_ending_integer(value) + == expected + ) + + +@pytest.mark.parametrize( + "value", + [ + "", + "abc", + "123abc", + "rxn_1_test", + ], +) +def test_get_ending_integer_none_when_no_trailing_integer( + value, +): + assert ( + get_ending_integer(value) + is None + ) + +# ============================================================================= +# move_merge_outputs +# ============================================================================= + + +def test_move_merge_outputs_creates_destination( + tmp_path, +): + src = tmp_path / "src" + dst = tmp_path / "dst" + + src.mkdir() + + move_merge_outputs( + src, + dst, + ) + + assert dst.is_dir() + + +def test_move_merge_outputs_moves_expected_files( + tmp_path, +): + src = tmp_path / "src" + dst = tmp_path / "dst" + + src.mkdir() + + filenames = [ + "system_merged.data", + "system_merged.lmpmol", + "force_field.data", + "log.lammps", + "all2lmp.log", + "other.log", + ] + + for filename in filenames: + ( + src / filename + ).write_text( + filename, + encoding="utf-8", + ) + + move_merge_outputs( + src, + dst, + ) + + for filename in filenames: + assert ( + dst / filename + ).is_file() + + assert not ( + src / filename + ).exists() + + +def test_move_merge_outputs_leaves_irrelevant_files_in_source( + tmp_path, +): + src = tmp_path / "src" + dst = tmp_path / "dst" + + src.mkdir() + + irrelevant = [ + "input.data", + "notes.txt", + "template.molecule", + "random.json", + ] + + for filename in irrelevant: + ( + src / filename + ).write_text( + "keep", + encoding="utf-8", + ) + + move_merge_outputs( + src, + dst, + ) + + for filename in irrelevant: + assert ( + src / filename + ).is_file() + + assert not ( + dst / filename + ).exists() + + +def test_move_merge_outputs_preserves_file_contents( + tmp_path, +): + src = tmp_path / "src" + dst = tmp_path / "dst" + + src.mkdir() + + source_file = ( + src / "sample_merged.data" + ) + + source_file.write_text( + "LAMMPS DATA CONTENT", + encoding="utf-8", + ) + + move_merge_outputs( + src, + dst, + ) + + assert ( + dst + / "sample_merged.data" + ).read_text( + encoding="utf-8" + ) == "LAMMPS DATA CONTENT" + + +def test_move_merge_outputs_accepts_string_paths( + tmp_path, +): + src = tmp_path / "src" + dst = tmp_path / "dst" + + src.mkdir() + + ( + src / "force_field.data" + ).write_text( + "force field", + encoding="utf-8", + ) + + move_merge_outputs( + str(src), + str(dst), + ) + + assert ( + dst / "force_field.data" + ).is_file() + + +def test_move_merge_outputs_empty_source_is_noop_except_destination_creation( + tmp_path, +): + src = tmp_path / "src" + dst = tmp_path / "dst" + + src.mkdir() + + move_merge_outputs( + src, + dst, + ) + + assert dst.is_dir() + + assert list( + dst.iterdir() + ) == [] + + +# ============================================================================= +# loading_screen +# ============================================================================= + + +def test_loading_screen_prints_banner_and_ready( + monkeypatch, + capsys, +): + monkeypatch.setattr( + lunar_utils.time, + "sleep", + lambda seconds: None, + ) + + loading_screen() + + output = capsys.readouterr().out + + assert "Loading LUNAR" in output + assert "Ready!" in output + + # Stable portion of the ASCII banner. + assert "█████" in output + + +def test_loading_screen_uses_custom_name( + monkeypatch, + capsys, +): + monkeypatch.setattr( + lunar_utils.time, + "sleep", + lambda seconds: None, + ) + + loading_screen( + "all2lmp" + ) + + output = capsys.readouterr().out + + assert ( + "Loading all2lmp" + in output + ) + + +def test_loading_screen_runs_ten_spinner_steps( + monkeypatch, +): + sleep_calls = [] + + monkeypatch.setattr( + lunar_utils.time, + "sleep", + lambda seconds: + sleep_calls.append( + seconds + ), + ) + + loading_screen( + "test" + ) + + assert sleep_calls == [ + 0.1, + 0.1, + 0.1, + 0.1, + 0.1, + 0.1, + 0.1, + 0.1, + 0.1, + 0.1, + ] + + +def test_loading_screen_returns_none( + monkeypatch, +): + monkeypatch.setattr( + lunar_utils.time, + "sleep", + lambda seconds: None, + ) + + result = loading_screen( + "test" + ) + + assert result is None \ No newline at end of file diff --git a/tests/unit/reaction_preparation/ff_wrapper/lunar_client/test_merge_builder.py b/tests/unit/reaction_preparation/ff_wrapper/lunar_client/test_merge_builder.py new file mode 100644 index 00000000..4d8bdc42 --- /dev/null +++ b/tests/unit/reaction_preparation/ff_wrapper/lunar_client/test_merge_builder.py @@ -0,0 +1,1140 @@ +from pathlib import Path +from types import SimpleNamespace + +import pytest + +import AutoREACTER.reaction_preparation.ff_wrapper.lunar_client.merge_builder as merge_builder +from AutoREACTER.reaction_preparation.ff_wrapper.lunar_client.merge_builder import ( + write_bond_react_merge_input, +) + + +# ============================================================================= +# Helpers +# ============================================================================= + + +def make_result( + *, + result_id, + molecule, + filename, +): + return SimpleNamespace( + id=result_id, + molecule=molecule, + all2lmp_data_file=filename, + ) + + +# ============================================================================= +# Basic file creation +# ============================================================================= + + +def test_write_merge_input_creates_output_directory( + tmp_path, +): + output_dir = ( + tmp_path + / "nested" + / "bond_react_merge" + ) + + all2lmp_dir = ( + tmp_path / "all2lmp" + ) + + result = ( + write_bond_react_merge_input( + output_dir, + all2lmp_dir, + [], + ) + ) + + assert output_dir.is_dir() + + assert result == ( + output_dir + / "merge_input.txt" + ) + + assert result.is_file() + + +def test_write_merge_input_returns_path_object( + tmp_path, +): + result = ( + write_bond_react_merge_input( + tmp_path / "merge", + tmp_path / "all2lmp", + [], + ) + ) + + assert isinstance( + result, + Path, + ) + + +def test_write_merge_input_empty_results_still_writes_header( + tmp_path, +): + merge_file = ( + write_bond_react_merge_input( + tmp_path / "merge", + tmp_path / "all2lmp", + [], + ) + ) + + text = merge_file.read_text() + + assert ( + '# anything following the "#" character will be ignored' + in text + ) + + assert "# file-tag" in text + + assert "filename" in text + + assert ( + "comment (required)" + in text + ) + + +def test_write_merge_input_writes_parent_directory_comment( + tmp_path, +): + merge_file = ( + write_bond_react_merge_input( + tmp_path / "merge", + tmp_path / "all2lmp", + [], + ) + ) + + text = merge_file.read_text() + + assert ( + "Specify the parent_directory" + in text + ) + + +# ============================================================================= +# Molecule/data entries +# ============================================================================= + + +def test_single_molecule_is_tagged_data1( + tmp_path, + monkeypatch, +): + monkeypatch.setattr( + merge_builder, + "normalize_path", + lambda path: str(path), + ) + + result = make_result( + result_id="monomer1", + molecule=True, + filename="monomer.data", + ) + + merge_file = ( + write_bond_react_merge_input( + tmp_path / "merge", + tmp_path / "all2lmp", + [ + result, + ], + ) + ) + + text = merge_file.read_text() + + assert "data1" in text + assert "monomer.data" in text + + assert ( + "# This datafile will have all coeffs in it" + in text + ) + + +def test_multiple_molecules_are_numbered_sequentially( + tmp_path, + monkeypatch, +): + monkeypatch.setattr( + merge_builder, + "normalize_path", + lambda path: str(path), + ) + + results = [ + make_result( + result_id="monomer_a", + molecule=True, + filename="a.data", + ), + make_result( + result_id="monomer_b", + molecule=True, + filename="b.data", + ), + make_result( + result_id="monomer_c", + molecule=True, + filename="c.data", + ), + ] + + merge_file = ( + write_bond_react_merge_input( + tmp_path / "merge", + tmp_path / "all2lmp", + results, + ) + ) + + text = merge_file.read_text() + + assert "data1" in text + assert "data2" in text + assert "data3" in text + + assert ( + text.index("data1") + < text.index("data2") + < text.index("data3") + ) + + +def test_molecule_path_uses_all2lmp_cache_directory( + tmp_path, + monkeypatch, +): + captured = [] + + def fake_normalize(path): + captured.append( + Path(path) + ) + + return str(path) + + monkeypatch.setattr( + merge_builder, + "normalize_path", + fake_normalize, + ) + + all2lmp_dir = ( + tmp_path / "all2lmp" + ) + + result = make_result( + result_id="molecule1", + molecule=True, + filename="sample.data", + ) + + write_bond_react_merge_input( + tmp_path / "merge", + all2lmp_dir, + [ + result, + ], + ) + + assert captured == [ + all2lmp_dir + / "sample.data" + ] + + +def test_only_molecule_true_results_become_data_entries( + tmp_path, + monkeypatch, +): + monkeypatch.setattr( + merge_builder, + "normalize_path", + lambda path: str(path), + ) + + results = [ + make_result( + result_id="monomer1", + molecule=True, + filename="monomer.data", + ), + make_result( + result_id="reaction_without_valid_suffix", + molecule=False, + filename="reaction.data", + ), + ] + + merge_file = ( + write_bond_react_merge_input( + tmp_path / "merge", + tmp_path / "all2lmp", + results, + ) + ) + + text = merge_file.read_text() + + assert "data1" in text + assert "monomer.data" in text + + assert "data2" not in text + + +# ============================================================================= +# Reaction pair handling +# ============================================================================= + + +def test_single_pre_post_pair_is_written( + tmp_path, + monkeypatch, +): + monkeypatch.setattr( + merge_builder, + "normalize_path", + lambda path: str(path), + ) + + results = [ + make_result( + result_id="pre1", + molecule=False, + filename="pre1.data", + ), + make_result( + result_id="post1", + molecule=False, + filename="post1.data", + ), + ] + + merge_file = ( + write_bond_react_merge_input( + tmp_path / "merge", + tmp_path / "all2lmp", + results, + ) + ) + + text = merge_file.read_text() + + assert "pre1" in text + assert "post1" in text + + assert "pre1.data" in text + assert "post1.data" in text + + assert "# for rxn1" in text + + +def test_reaction_pairs_are_sorted_by_reaction_id( + tmp_path, + monkeypatch, +): + monkeypatch.setattr( + merge_builder, + "normalize_path", + lambda path: str(path), + ) + + results = [ + make_result( + result_id="pre10", + molecule=False, + filename="pre10.data", + ), + make_result( + result_id="post10", + molecule=False, + filename="post10.data", + ), + make_result( + result_id="pre2", + molecule=False, + filename="pre2.data", + ), + make_result( + result_id="post2", + molecule=False, + filename="post2.data", + ), + ] + + merge_file = ( + write_bond_react_merge_input( + tmp_path / "merge", + tmp_path / "all2lmp", + results, + ) + ) + + text = merge_file.read_text() + + # Reaction IDs are sorted numerically: + # original ID 2 becomes output rxn1, + # original ID 10 becomes output rxn2. + assert ( + text.index("pre2.data") + < text.index("pre10.data") + ) + + lines = text.splitlines() + + pre2_line = next( + line + for line in lines + if "pre2.data" in line + ) + + pre10_line = next( + line + for line in lines + if "pre10.data" in line + ) + + assert pre2_line.startswith( + "pre1" + ) + + assert pre10_line.startswith( + "pre2" + ) + + +def test_reaction_output_numbering_is_contiguous( + tmp_path, + monkeypatch, +): + monkeypatch.setattr( + merge_builder, + "normalize_path", + lambda path: str(path), + ) + + results = [ + make_result( + result_id="pre5", + molecule=False, + filename="pre5.data", + ), + make_result( + result_id="post5", + molecule=False, + filename="post5.data", + ), + make_result( + result_id="pre99", + molecule=False, + filename="pre99.data", + ), + make_result( + result_id="post99", + molecule=False, + filename="post99.data", + ), + ] + + merge_file = ( + write_bond_react_merge_input( + tmp_path / "merge", + tmp_path / "all2lmp", + results, + ) + ) + + lines = merge_file.read_text().splitlines() + + pre_lines = [ + line + for line in lines + if line.startswith("pre") + ] + + post_lines = [ + line + for line in lines + if line.startswith("post") + ] + + assert pre_lines[0].startswith( + "pre1" + ) + + assert pre_lines[1].startswith( + "pre2" + ) + + assert post_lines[0].startswith( + "post1" + ) + + assert post_lines[1].startswith( + "post2" + ) + + +def test_pre_post_input_order_does_not_matter( + tmp_path, + monkeypatch, +): + monkeypatch.setattr( + merge_builder, + "normalize_path", + lambda path: str(path), + ) + + results = [ + make_result( + result_id="post7", + molecule=False, + filename="post7.data", + ), + make_result( + result_id="pre7", + molecule=False, + filename="pre7.data", + ), + ] + + merge_file = ( + write_bond_react_merge_input( + tmp_path / "merge", + tmp_path / "all2lmp", + results, + ) + ) + + text = merge_file.read_text() + + assert "pre7.data" in text + assert "post7.data" in text + + +def test_missing_post_reaction_raises( + tmp_path, + monkeypatch, +): + monkeypatch.setattr( + merge_builder, + "normalize_path", + lambda path: str(path), + ) + + results = [ + make_result( + result_id="pre4", + molecule=False, + filename="pre4.data", + ) + ] + + with pytest.raises( + ValueError, + match="Incomplete reaction pair for reaction ID 4", + ): + write_bond_react_merge_input( + tmp_path / "merge", + tmp_path / "all2lmp", + results, + ) + + +def test_missing_pre_reaction_raises( + tmp_path, + monkeypatch, +): + monkeypatch.setattr( + merge_builder, + "normalize_path", + lambda path: str(path), + ) + + results = [ + make_result( + result_id="post4", + molecule=False, + filename="post4.data", + ) + ] + + with pytest.raises( + ValueError, + match="Incomplete reaction pair for reaction ID 4", + ): + write_bond_react_merge_input( + tmp_path / "merge", + tmp_path / "all2lmp", + results, + ) + + +def test_nonmolecule_without_trailing_integer_is_ignored( + tmp_path, + monkeypatch, +): + monkeypatch.setattr( + merge_builder, + "normalize_path", + lambda path: str(path), + ) + + results = [ + make_result( + result_id="reaction", + molecule=False, + filename="ignored.data", + ) + ] + + merge_file = ( + write_bond_react_merge_input( + tmp_path / "merge", + tmp_path / "all2lmp", + results, + ) + ) + + text = merge_file.read_text() + + assert "ignored.data" not in text + assert "pre1" not in text + assert "post1" not in text + + +def test_nonmolecule_with_integer_but_unknown_prefix_is_grouped_then_fails( + tmp_path, + monkeypatch, +): + monkeypatch.setattr( + merge_builder, + "normalize_path", + lambda path: str(path), + ) + + results = [ + make_result( + result_id="reaction5", + molecule=False, + filename="reaction5.data", + ) + ] + + # Current behavior: + # reaction5 creates reaction_pairs[5], but does not populate + # either pre or post, so the pair is incomplete. + with pytest.raises( + ValueError, + match="Incomplete reaction pair for reaction ID 5", + ): + write_bond_react_merge_input( + tmp_path / "merge", + tmp_path / "all2lmp", + results, + ) + + +# ============================================================================= +# Duplicate IDs / current overwrite semantics +# ============================================================================= + + +def test_later_pre_entry_with_same_reaction_id_replaces_earlier_one( + tmp_path, + monkeypatch, +): + monkeypatch.setattr( + merge_builder, + "normalize_path", + lambda path: str(path), + ) + + results = [ + make_result( + result_id="pre1", + molecule=False, + filename="old_pre.data", + ), + make_result( + result_id="pre1", + molecule=False, + filename="new_pre.data", + ), + make_result( + result_id="post1", + molecule=False, + filename="post.data", + ), + ] + + merge_file = ( + write_bond_react_merge_input( + tmp_path / "merge", + tmp_path / "all2lmp", + results, + ) + ) + + text = merge_file.read_text() + + assert "new_pre.data" in text + assert "old_pre.data" not in text + + +def test_later_post_entry_with_same_reaction_id_replaces_earlier_one( + tmp_path, + monkeypatch, +): + monkeypatch.setattr( + merge_builder, + "normalize_path", + lambda path: str(path), + ) + + results = [ + make_result( + result_id="pre1", + molecule=False, + filename="pre.data", + ), + make_result( + result_id="post1", + molecule=False, + filename="old_post.data", + ), + make_result( + result_id="post1", + molecule=False, + filename="new_post.data", + ), + ] + + merge_file = ( + write_bond_react_merge_input( + tmp_path / "merge", + tmp_path / "all2lmp", + results, + ) + ) + + text = merge_file.read_text() + + assert "new_post.data" in text + assert "old_post.data" not in text + + +# ============================================================================= +# Mixed molecule / reaction output +# ============================================================================= + + +def test_molecules_are_written_before_reactions( + tmp_path, + monkeypatch, +): + monkeypatch.setattr( + merge_builder, + "normalize_path", + lambda path: str(path), + ) + + results = [ + make_result( + result_id="pre1", + molecule=False, + filename="pre.data", + ), + make_result( + result_id="monomer", + molecule=True, + filename="monomer.data", + ), + make_result( + result_id="post1", + molecule=False, + filename="post.data", + ), + ] + + merge_file = ( + write_bond_react_merge_input( + tmp_path / "merge", + tmp_path / "all2lmp", + results, + ) + ) + + text = merge_file.read_text() + + assert ( + text.index("monomer.data") + < text.index("pre.data") + ) + + assert ( + text.index("pre.data") + < text.index("post.data") + ) + + +def test_data_and_reaction_numbering_are_independent( + tmp_path, + monkeypatch, +): + monkeypatch.setattr( + merge_builder, + "normalize_path", + lambda path: str(path), + ) + + results = [ + make_result( + result_id="monomer1", + molecule=True, + filename="m1.data", + ), + make_result( + result_id="monomer2", + molecule=True, + filename="m2.data", + ), + make_result( + result_id="pre50", + molecule=False, + filename="pre50.data", + ), + make_result( + result_id="post50", + molecule=False, + filename="post50.data", + ), + ] + + merge_file = ( + write_bond_react_merge_input( + tmp_path / "merge", + tmp_path / "all2lmp", + results, + ) + ) + + lines = merge_file.read_text().splitlines() + + assert any( + line.startswith("data1") + and "m1.data" in line + for line in lines + ) + + assert any( + line.startswith("data2") + and "m2.data" in line + for line in lines + ) + + assert any( + line.startswith("pre1") + and "pre50.data" in line + for line in lines + ) + + assert any( + line.startswith("post1") + and "post50.data" in line + for line in lines + ) + + +# ============================================================================= +# normalize_path integration boundary +# ============================================================================= + + +def test_normalize_path_called_for_every_written_input_file( + tmp_path, + monkeypatch, +): + calls = [] + + def fake_normalize(path): + calls.append( + Path(path) + ) + + return f"NORMALIZED:{path}" + + monkeypatch.setattr( + merge_builder, + "normalize_path", + fake_normalize, + ) + + all2lmp_dir = ( + tmp_path / "all2lmp" + ) + + results = [ + make_result( + result_id="monomer", + molecule=True, + filename="molecule.data", + ), + make_result( + result_id="pre3", + molecule=False, + filename="pre.data", + ), + make_result( + result_id="post3", + molecule=False, + filename="post.data", + ), + ] + + write_bond_react_merge_input( + tmp_path / "merge", + all2lmp_dir, + results, + ) + + assert calls == [ + all2lmp_dir + / "molecule.data", + all2lmp_dir + / "pre.data", + all2lmp_dir + / "post.data", + ] + + +def test_normalized_paths_are_used_in_output( + tmp_path, + monkeypatch, +): + monkeypatch.setattr( + merge_builder, + "normalize_path", + lambda path: ( + f"/normalized/{Path(path).name}" + ), + ) + + results = [ + make_result( + result_id="monomer", + molecule=True, + filename="molecule.data", + ), + make_result( + result_id="pre1", + molecule=False, + filename="pre.data", + ), + make_result( + result_id="post1", + molecule=False, + filename="post.data", + ), + ] + + merge_file = ( + write_bond_react_merge_input( + tmp_path / "merge", + tmp_path / "all2lmp", + results, + ) + ) + + text = merge_file.read_text() + + assert ( + "/normalized/molecule.data" + in text + ) + + assert ( + "/normalized/pre.data" + in text + ) + + assert ( + "/normalized/post.data" + in text + ) + + +# ============================================================================= +# get_ending_integer integration boundary +# ============================================================================= + + +def test_get_ending_integer_called_for_nonmolecule_results_only( + tmp_path, + monkeypatch, +): + monkeypatch.setattr( + merge_builder, + "normalize_path", + lambda path: str(path), + ) + + calls = [] + + def fake_get_ending_integer(value): + calls.append(value) + + if value == "preX": + return 1 + + if value == "postX": + return 1 + + return None + + monkeypatch.setattr( + merge_builder, + "get_ending_integer", + fake_get_ending_integer, + ) + + results = [ + make_result( + result_id="moleculeX", + molecule=True, + filename="molecule.data", + ), + make_result( + result_id="preX", + molecule=False, + filename="pre.data", + ), + make_result( + result_id="postX", + molecule=False, + filename="post.data", + ), + ] + + write_bond_react_merge_input( + tmp_path / "merge", + tmp_path / "all2lmp", + results, + ) + + assert calls == [ + "preX", + "postX", + ] + + +# ============================================================================= +# Input immutability +# ============================================================================= + + +def test_function_does_not_mutate_results_list( + tmp_path, + monkeypatch, +): + monkeypatch.setattr( + merge_builder, + "normalize_path", + lambda path: str(path), + ) + + first = make_result( + result_id="pre1", + molecule=False, + filename="pre.data", + ) + + second = make_result( + result_id="post1", + molecule=False, + filename="post.data", + ) + + results = [ + first, + second, + ] + + original = list( + results + ) + + write_bond_react_merge_input( + tmp_path / "merge", + tmp_path / "all2lmp", + results, + ) + + assert results == original + + assert results[0] is first + assert results[1] is second + + +def test_function_does_not_modify_result_objects( + tmp_path, + monkeypatch, +): + monkeypatch.setattr( + merge_builder, + "normalize_path", + lambda path: str(path), + ) + + pre = make_result( + result_id="pre1", + molecule=False, + filename="pre.data", + ) + + post = make_result( + result_id="post1", + molecule=False, + filename="post.data", + ) + + write_bond_react_merge_input( + tmp_path / "merge", + tmp_path / "all2lmp", + [ + pre, + post, + ], + ) + + assert pre.id == "pre1" + assert pre.molecule is False + assert ( + pre.all2lmp_data_file + == "pre.data" + ) + + assert post.id == "post1" + assert post.molecule is False + assert ( + post.all2lmp_data_file + == "post.data" + ) \ No newline at end of file diff --git a/tests/unit/reaction_preparation/ff_wrapper/test_REACTER_files_builder.py b/tests/unit/reaction_preparation/ff_wrapper/test_REACTER_files_builder.py new file mode 100644 index 00000000..6cc19332 --- /dev/null +++ b/tests/unit/reaction_preparation/ff_wrapper/test_REACTER_files_builder.py @@ -0,0 +1,3765 @@ +from pathlib import Path +from types import SimpleNamespace + +import pandas as pd +import pytest + +import AutoREACTER.reaction_preparation.ff_wrapper.REACTER_files_builder as builder_module +from AutoREACTER.reaction_preparation.ff_wrapper.REACTER_files_builder import ( + REACTERFiles, + REACTERFilesBuilder, +) + + +# ============================================================================= +# Helpers +# ============================================================================= + + +def make_monomer( + *, + monomer_id=1, + data_id="data1", + name="mma", + lmp_molecule_file=None, +): + return SimpleNamespace( + id=monomer_id, + data_id=data_id, + name=name, + lmp_molecule_file=lmp_molecule_file, + ) + + +def make_reaction_metadata( + *, + reaction_id=1, + activity_stats=True, + dataframe=None, + delete_atom=False, +): + if dataframe is None: + dataframe = pd.DataFrame() + + return SimpleNamespace( + reaction_id=reaction_id, + activity_stats=activity_stats, + reaction_dataframe=dataframe, + delete_atom=delete_atom, + map_file=None, + map_file_with_delete_ids=None, + pre_reaction_file=None, + post_reaction_file=None, + ) + + +def make_session( + tmp_path, + *, + force_field="PCFF", + wildcards=False, + deduplicate=True, + monomers=None, + reactions=None, +): + inputs = SimpleNamespace( + force_field=force_field, + wildcards=wildcards, + deduplicate_reaction_templates=deduplicate, + monomers=list( + monomers or [] + ), + ) + + return SimpleNamespace( + inputs=inputs, + reaction_metadata=list( + reactions or [] + ), + staging_dir=tmp_path / "staging", + output_dir=tmp_path / "output", + ff_files=None, + reacter_files=None, + ) + + +def make_builder( + tmp_path, + **kwargs, +): + session = make_session( + tmp_path, + **kwargs, + ) + + return ( + REACTERFilesBuilder( + session + ), + session, + ) + + +def make_data_files( + *, + data_file, + molecule_file, +): + return SimpleNamespace( + data_file=Path( + data_file + ), + lmp_molecule_file=Path( + molecule_file + ), + ) + + +def make_template_ff_entry( + *, + reaction_id, + pre_file, + post_file, +): + return SimpleNamespace( + reaction_id=reaction_id, + pre_reaction_file=make_data_files( + data_file=Path( + str(pre_file) + ".data" + ), + molecule_file=pre_file, + ), + post_reaction_file=make_data_files( + data_file=Path( + str(post_file) + ".data" + ), + molecule_file=post_file, + ), + ) + + +# ============================================================================= +# REACTERFiles +# ============================================================================= + + +def test_reacter_files_stores_values( + tmp_path, +): + result = REACTERFiles( + force_field_data=( + tmp_path / "force_field.data" + ), + in_file=( + tmp_path / "in.script" + ), + molecule_files=[ + "molecule" + ], + template_files=[ + "template" + ], + ) + + assert result.force_field_data == ( + tmp_path / "force_field.data" + ) + + assert result.in_file == ( + tmp_path / "in.script" + ) + + assert result.molecule_files == [ + "molecule" + ] + + assert result.template_files == [ + "template" + ] + + +def test_reacter_files_uses_slots( + tmp_path, +): + result = REACTERFiles( + force_field_data=( + tmp_path / "ff.data" + ), + in_file=( + tmp_path / "in.script" + ), + molecule_files=[], + template_files=[], + ) + + with pytest.raises( + AttributeError + ): + result.extra = 1 + + +# ============================================================================= +# Constructor +# ============================================================================= + + +def test_constructor_stores_session( + tmp_path, +): + builder, session = ( + make_builder( + tmp_path + ) + ) + + assert ( + builder.session + is session + ) + + +def test_constructor_creates_reacter_cache( + tmp_path, +): + builder, _ = ( + make_builder( + tmp_path + ) + ) + + assert builder.cache_dir == ( + tmp_path + / "staging" + / "lunar" + / "REACTER_files" + ) + + assert ( + builder.cache_dir.is_dir() + ) + + +def test_constructor_reads_force_field_options( + tmp_path, +): + builder, _ = ( + make_builder( + tmp_path, + force_field="CVFF", + wildcards=True, + deduplicate=False, + ) + ) + + assert ( + builder.force_field + == "CVFF" + ) + + assert ( + builder.wildcards + is True + ) + + assert ( + builder + .deduplicate_reaction_templates + is False + ) + + +# ============================================================================= +# _get_ending_integer +# ============================================================================= + + +@pytest.mark.parametrize( + "value, expected", + [ + ( + "pre1", + 1, + ), + ( + "post27", + 27, + ), + ( + "data003", + 3, + ), + ( + "RXN_99", + 99, + ), + ( + "1.5", + 5, + ), + ], +) +def test_get_ending_integer( + tmp_path, + value, + expected, +): + builder, _ = ( + make_builder( + tmp_path + ) + ) + + assert ( + builder._get_ending_integer( + value + ) + == expected + ) + + +@pytest.mark.parametrize( + "value", + [ + "", + "pre", + "post_", + "123abc", + "reaction_test", + ], +) +def test_get_ending_integer_none( + tmp_path, + value, +): + builder, _ = ( + make_builder( + tmp_path + ) + ) + + assert ( + builder._get_ending_integer( + value + ) + is None + ) + + +# ============================================================================= +# _ensure_dir +# ============================================================================= + + +def test_ensure_dir_creates_directory( + tmp_path, +): + builder, _ = ( + make_builder( + tmp_path + ) + ) + + target = ( + tmp_path + / "nested" + / "directory" + ) + + result = builder._ensure_dir( + str(target) + ) + + assert result == str( + target + ) + + assert target.is_dir() + + +def test_ensure_dir_existing_directory_is_ok( + tmp_path, +): + builder, _ = ( + make_builder( + tmp_path + ) + ) + + target = ( + tmp_path / "existing" + ) + + target.mkdir() + + result = builder._ensure_dir( + str(target) + ) + + assert result == str( + target + ) + + +def test_ensure_dir_blocking_file_raises( + tmp_path, +): + builder, _ = ( + make_builder( + tmp_path + ) + ) + + target = ( + tmp_path / "blocked" + ) + + target.write_text( + "file", + encoding="utf-8", + ) + + with pytest.raises( + FileExistsError, + match="expected dir", + ): + builder._ensure_dir( + str(target) + ) + + +def test_ensure_dir_can_remove_blocking_file( + tmp_path, +): + builder, _ = ( + make_builder( + tmp_path + ) + ) + + target = ( + tmp_path / "blocked" + ) + + target.write_text( + "file", + encoding="utf-8", + ) + + builder._ensure_dir( + str(target), + remove_blocking_file=True, + ) + + assert target.is_dir() + + +# ============================================================================= +# _col_int_list +# ============================================================================= + + +def test_col_int_list_missing_column_returns_empty( + tmp_path, +): + builder, _ = ( + make_builder( + tmp_path + ) + ) + + df = pd.DataFrame( + { + "other": [ + 1, + 2, + ] + } + ) + + assert ( + builder._col_int_list( + "initiators", + df, + ) + == [] + ) + + +def test_col_int_list_empty_column_returns_empty( + tmp_path, +): + builder, _ = ( + make_builder( + tmp_path + ) + ) + + df = pd.DataFrame( + { + "initiators": [ + None, + None, + ] + } + ) + + assert ( + builder._col_int_list( + "initiators", + df, + ) + == [] + ) + + +def test_col_int_list_converts_integer_like_values( + tmp_path, +): + builder, _ = ( + make_builder( + tmp_path + ) + ) + + df = pd.DataFrame( + { + "initiators": [ + 1, + 2.0, + "3", + "4.0", + ] + } + ) + + assert ( + builder._col_int_list( + "initiators", + df, + ) + == [ + 1, + 2, + 3, + 4, + ] + ) + + +def test_col_int_list_removes_duplicates_preserving_order( + tmp_path, +): + builder, _ = ( + make_builder( + tmp_path + ) + ) + + df = pd.DataFrame( + { + "edge_atoms": [ + 5, + 2, + 5, + 3, + 2, + ] + } + ) + + assert ( + builder._col_int_list( + "edge_atoms", + df, + ) + == [ + 5, + 2, + 3, + ] + ) + + +def test_col_int_list_ignores_nan( + tmp_path, +): + builder, _ = ( + make_builder( + tmp_path + ) + ) + + df = pd.DataFrame( + { + "byproduct_idx": [ + 1, + None, + 2, + ] + } + ) + + assert ( + builder._col_int_list( + "byproduct_idx", + df, + ) + == [ + 1, + 2, + ] + ) + + +def test_col_int_list_non_numeric_raises( + tmp_path, +): + builder, _ = ( + make_builder( + tmp_path + ) + ) + + df = pd.DataFrame( + { + "initiators": [ + 1, + "bad", + ] + } + ) + + with pytest.raises( + ValueError, + match="contains non-numeric value", + ): + builder._col_int_list( + "initiators", + df, + ) + + +def test_col_int_list_non_integer_numeric_raises( + tmp_path, +): + builder, _ = ( + make_builder( + tmp_path + ) + ) + + df = pd.DataFrame( + { + "initiators": [ + 1, + 2.5, + ] + } + ) + + with pytest.raises( + ValueError, + match="contains non-integer value", + ): + builder._col_int_list( + "initiators", + df, + ) + + +# ============================================================================= +# _load_molecule_file +# ============================================================================= + + +def test_load_molecule_file_finds_all_sections( + tmp_path, +): + builder, _ = ( + make_builder( + tmp_path + ) + ) + + path = ( + tmp_path / "template.molecule" + ) + + path.write_text( + """Types + +1 1 # c + +Charges + +1 0.0 # c + +Coords + +1 0.0 0.0 0.0 # c + +Bonds + +1 1 1 1 # c c + +Angles + +1 1 1 1 1 # c c c + +Dihedrals + +1 1 1 1 1 1 # c c c c + +Impropers + +1 1 1 1 1 1 # c c c c + +""", + encoding="utf-8", + ) + + result = ( + builder._load_molecule_file( + path + ) + ) + + ( + lines, + type_start, + charge_start, + coord_start, + bond_start, + angle_start, + dihedral_start, + improper_start, + ) = result + + assert type_start == 2 + assert charge_start == 6 + assert coord_start == 10 + assert bond_start == 14 + assert angle_start == 18 + assert dihedral_start == 22 + assert improper_start == 26 + + assert lines[0] == "Types" + + +def test_load_molecule_file_strips_line_whitespace( + tmp_path, +): + builder, _ = ( + make_builder( + tmp_path + ) + ) + + path = ( + tmp_path / "template.molecule" + ) + + path.write_text( + """ Types + + 1 10 # c + +Charges + +1 0.0 # c + +Coords + +1 0 0 0 # c + +""", + encoding="utf-8", + ) + + lines, *_ = ( + builder._load_molecule_file( + path + ) + ) + + assert lines[0] == "Types" + assert lines[2] == "1 10 # c" + + +def test_load_molecule_file_requires_types( + tmp_path, +): + builder, _ = ( + make_builder( + tmp_path + ) + ) + + path = ( + tmp_path / "bad.molecule" + ) + + path.write_text( + """Charges + +1 0 # c + +Coords + +1 0 0 0 # c +""", + encoding="utf-8", + ) + + with pytest.raises( + ValueError, + match="Essential sections", + ): + builder._load_molecule_file( + path + ) + + +def test_load_molecule_file_requires_charges( + tmp_path, +): + builder, _ = ( + make_builder( + tmp_path + ) + ) + + path = ( + tmp_path / "bad.molecule" + ) + + path.write_text( + """Types + +1 1 # c + +Coords + +1 0 0 0 # c +""", + encoding="utf-8", + ) + + with pytest.raises( + ValueError, + match="Essential sections", + ): + builder._load_molecule_file( + path + ) + + +def test_load_molecule_file_requires_coords( + tmp_path, +): + builder, _ = ( + make_builder( + tmp_path + ) + ) + + path = ( + tmp_path / "bad.molecule" + ) + + path.write_text( + """Types + +1 1 # c + +Charges + +1 0 # c +""", + encoding="utf-8", + ) + + with pytest.raises( + ValueError, + match="Essential sections", + ): + builder._load_molecule_file( + path + ) + + +def test_load_molecule_file_missing_optional_topology_sections_returns_none( + tmp_path, +): + builder, _ = ( + make_builder( + tmp_path + ) + ) + + path = ( + tmp_path / "minimal.molecule" + ) + + path.write_text( + """Types + +1 1 # c + +Charges + +1 0 # c + +Coords + +1 0 0 0 # c + +""", + encoding="utf-8", + ) + + result = ( + builder._load_molecule_file( + path + ) + ) + + assert result[4] is None + assert result[5] is None + assert result[6] is None + assert result[7] is None + + +# ============================================================================= +# _molecule_file_format +# ============================================================================= + + +def test_molecule_file_format_contains_counts( + tmp_path, +): + builder, _ = ( + make_builder( + tmp_path, + force_field="PCFF", + ) + ) + + text = builder._molecule_file_format( + 5, + 4, + 3, + 2, + 1, + "TYPES\n", + "CHARGES\n", + "COORDS\n", + "BONDS\n", + "ANGLES\n", + "DIHEDRALS\n", + "IMPROPERS\n", + "template_pre_1.molecule", + ) + + assert "5 atoms" in text + assert "4 bonds" in text + assert "3 angles" in text + assert "2 dihedrals" in text + assert "1 impropers" in text + + +def test_molecule_file_format_contains_force_field_and_filename( + tmp_path, +): + builder, _ = ( + make_builder( + tmp_path, + force_field="PCFF", + ) + ) + + text = builder._molecule_file_format( + 1, + 0, + 0, + 0, + 0, + "", + "", + "", + "", + "", + "", + "", + "template_pre_7.molecule", + ) + + assert ( + "template_pre_7.molecule" + in text + ) + + assert ( + "PCFF forcefield" + in text + ) + + +def test_molecule_file_format_section_order( + tmp_path, +): + builder, _ = ( + make_builder( + tmp_path + ) + ) + + text = builder._molecule_file_format( + 1, + 1, + 1, + 1, + 1, + "TYPE_DATA\n", + "CHARGE_DATA\n", + "COORD_DATA\n", + "BOND_DATA\n", + "ANGLE_DATA\n", + "DIHEDRAL_DATA\n", + "IMPROPER_DATA\n", + "test.molecule", + ) + + assert ( + text.index("Types") + < text.index("Charges") + < text.index("Coords") + < text.index("Bonds") + < text.index("Angles") + < text.index("Dihedrals") + < text.index("Impropers") + ) + + +# ============================================================================= +# _molecule_file_preparation +# ============================================================================= + + +def test_molecule_file_preparation_calls_modifier_pipeline( + tmp_path, + monkeypatch, +): + builder, _ = ( + make_builder( + tmp_path + ) + ) + + fake_df = pd.DataFrame( + { + "atom_index": [ + 1, + 2, + ], + "new_atom_index": [ + 1, + 2, + ], + } + ) + + monkeypatch.setattr( + builder, + "_load_molecule_file", + lambda path: ( + ["fake-lines"], + 1, + 2, + 3, + 4, + 5, + 6, + 7, + ), + ) + + events = [] + + def fake_types( + lines, + indexes, + start, + ): + events.append( + ( + "types", + indexes, + start, + ) + ) + + return ( + fake_df, + "TYPES\n", + 2, + { + 10: 1, + 20: 2, + }, + False, + ) + + monkeypatch.setattr( + builder_module, + "modify_types", + fake_types, + ) + + monkeypatch.setattr( + builder_module, + "modify_charges", + lambda lines, df, start: + ( + events.append( + ( + "charges", + start, + ) + ) + or "CHARGES\n" + ), + ) + + monkeypatch.setattr( + builder_module, + "modify_coords", + lambda lines, df, start: + ( + events.append( + ( + "coords", + start, + ) + ) + or "COORDS\n" + ), + ) + + monkeypatch.setattr( + builder_module, + "modify_bonds", + lambda lines, df, start, legacy_mode: + ( + events.append( + ( + "bonds", + start, + legacy_mode, + ) + ) + or ( + "BONDS\n", + 1, + ) + ), + ) + + monkeypatch.setattr( + builder_module, + "modify_angles", + lambda lines, df, start, legacy_mode: + ( + events.append( + ( + "angles", + start, + legacy_mode, + ) + ) + or ( + "ANGLES\n", + 2, + ) + ), + ) + + monkeypatch.setattr( + builder_module, + "modify_dihedrals", + lambda lines, df, start, legacy_mode: + ( + events.append( + ( + "dihedrals", + start, + legacy_mode, + ) + ) + or ( + "DIHEDRALS\n", + 3, + ) + ), + ) + + monkeypatch.setattr( + builder_module, + "modify_impropers", + lambda lines, df, start, legacy_mode: + ( + events.append( + ( + "impropers", + start, + legacy_mode, + ) + ) + or ( + "IMPROPERS\n", + 4, + ) + ), + ) + + monkeypatch.setattr( + builder, + "_molecule_file_format", + lambda *args: + "FINAL-MOLECULE", + ) + + result, mapping = ( + builder + ._molecule_file_preparation( + tmp_path / "input.molecule", + [ + 10, + 20, + ], + "template_pre_1.molecule", + ) + ) + + assert result == ( + "FINAL-MOLECULE" + ) + + assert mapping == { + 10: 1, + 20: 2, + } + + assert events == [ + ( + "types", + [ + 10, + 20, + ], + 1, + ), + ( + "charges", + 2, + ), + ( + "coords", + 3, + ), + ( + "bonds", + 4, + False, + ), + ( + "angles", + 5, + False, + ), + ( + "dihedrals", + 6, + False, + ), + ( + "impropers", + 7, + False, + ), + ] + + +# ============================================================================= +# _map_file_write +# ============================================================================= + + +def test_map_file_requires_exactly_two_initiators( + tmp_path, +): + builder, _ = ( + make_builder( + tmp_path + ) + ) + + with pytest.raises( + ValueError, + match="Expected exactly 2 initiator atoms", + ): + builder._map_file_write( + reactant_to_product={ + 0: 0, + }, + initiator_atoms=[ + 0, + ], + edge_atoms=[], + delete_ids=[], + file_name="RXN_1.map", + ) + + +def test_standard_map_has_no_delete_ids_section( + tmp_path, +): + """ + Important AutoREACTER contract: + + The normal RXN_N.map is the standard map and does NOT contain DeleteIDs. + Delete IDs belong only in the optional supplementary map. + """ + builder, _ = ( + make_builder( + tmp_path + ) + ) + + text = builder._map_file_write( + reactant_to_product={ + 0: 1, + 1: 0, + }, + initiator_atoms=[ + 0, + 1, + ], + edge_atoms=[ + 2, + ], + delete_ids=[], + file_name="RXN_1.map", + ) + + assert "deleteIDs" not in text + assert "\nDeleteIDs\n" not in text + + +def test_map_file_with_delete_ids_contains_optional_section( + tmp_path, +): + builder, _ = ( + make_builder( + tmp_path + ) + ) + + text = builder._map_file_write( + reactant_to_product={ + 0: 0, + 1: 1, + }, + initiator_atoms=[ + 0, + 1, + ], + edge_atoms=[], + delete_ids=[ + 3, + 2, + ], + file_name=( + "RXN_1_with_delete_ids.map" + ), + ) + + assert "2 deleteIDs" in text + + assert ( + "\nDeleteIDs\n\n" + in text + ) + + # Written as 1-based and sorted. + delete_block = ( + text.split( + "DeleteIDs\n\n", + 1, + )[1] + ) + + assert delete_block.splitlines() == [ + "3", + "4", + ] + + +def test_map_file_uses_one_based_indices( + tmp_path, +): + builder, _ = ( + make_builder( + tmp_path + ) + ) + + text = builder._map_file_write( + reactant_to_product={ + 0: 2, + 4: 1, + }, + initiator_atoms=[ + 0, + 4, + ], + edge_atoms=[ + 3, + ], + delete_ids=[], + file_name="RXN_5.map", + ) + + assert ( + "\nInitiatorIDs\n\n1\n5\n" + in text + ) + + assert ( + "\nEdgeIDs\n\n4\n" + in text + ) + + assert "1 3" in text + assert "5 2" in text + + +def test_map_file_sorts_initiators_edges_and_equivalences( + tmp_path, +): + builder, _ = ( + make_builder( + tmp_path + ) + ) + + text = builder._map_file_write( + reactant_to_product={ + 5: 8, + 1: 2, + 3: 4, + }, + initiator_atoms=[ + 5, + 1, + ], + edge_atoms=[ + 8, + 2, + 4, + ], + delete_ids=[], + file_name="RXN_1.map", + ) + + initiator_block = ( + text.split( + "InitiatorIDs\n\n", + 1, + )[1] + .split( + "\nEdgeIDs", + 1, + )[0] + .strip() + .splitlines() + ) + + assert initiator_block == [ + "2", + "6", + ] + + edge_block = ( + text.split( + "EdgeIDs\n\n", + 1, + )[1] + .split( + "\nEquivalences", + 1, + )[0] + .strip() + .splitlines() + ) + + assert edge_block == [ + "3", + "5", + "9", + ] + + +# ============================================================================= +# _build_bond_react_templates +# ============================================================================= + + +def test_build_templates_missing_post_entry_raises( + tmp_path, +): + builder, _ = ( + make_builder( + tmp_path + ) + ) + + pre = ( + tmp_path / "pre.molecule" + ) + + pre.write_text( + "pre", + encoding="utf-8", + ) + + with pytest.raises( + ValueError, + match="Corresponding post-reaction file", + ): + builder._build_bond_react_templates( + file_dict={ + "pre_1": pre, + }, + reactant_to_product={ + 0: 0, + }, + initiator_atoms=[ + 0, + 1, + ], + edge_atoms=[], + delete_ids=[], + ) + + +def test_build_templates_missing_pre_file_raises( + tmp_path, +): + builder, _ = ( + make_builder( + tmp_path + ) + ) + + post = ( + tmp_path / "post.molecule" + ) + + post.write_text( + "post", + encoding="utf-8", + ) + + with pytest.raises( + FileNotFoundError, + match="Missing pre file", + ): + builder._build_bond_react_templates( + file_dict={ + "pre_1": ( + tmp_path + / "missing.molecule" + ), + "post_1": post, + }, + reactant_to_product={ + 0: 0, + }, + initiator_atoms=[ + 0, + 1, + ], + edge_atoms=[], + delete_ids=[], + ) + + +def test_build_templates_missing_post_file_raises( + tmp_path, +): + builder, _ = ( + make_builder( + tmp_path + ) + ) + + pre = ( + tmp_path / "pre.molecule" + ) + + pre.write_text( + "pre", + encoding="utf-8", + ) + + with pytest.raises( + FileNotFoundError, + match="Missing post file", + ): + builder._build_bond_react_templates( + file_dict={ + "pre_1": pre, + "post_1": ( + tmp_path + / "missing.molecule" + ), + }, + reactant_to_product={ + 0: 0, + }, + initiator_atoms=[ + 0, + 1, + ], + edge_atoms=[], + delete_ids=[], + ) + + +def test_build_templates_writes_standard_pre_post_and_map( + tmp_path, + monkeypatch, +): + builder, _ = ( + make_builder( + tmp_path + ) + ) + + pre = ( + tmp_path / "pre_source.molecule" + ) + + post = ( + tmp_path / "post_source.molecule" + ) + + pre.write_text( + "pre", + encoding="utf-8", + ) + + post.write_text( + "post", + encoding="utf-8", + ) + + def fake_prepare( + path, + indexes, + file_name, + ): + if Path(path) == pre: + return ( + "PRE-CONTENT", + { + 1: 1, + 2: 2, + }, + ) + + return ( + "POST-CONTENT", + { + 1: 1, + 2: 2, + }, + ) + + monkeypatch.setattr( + builder, + "_molecule_file_preparation", + fake_prepare, + ) + + monkeypatch.setattr( + builder, + "_map_file_write", + lambda *args, **kwargs: + "STANDARD-MAP", + ) + + pre_out, post_out, map_path = ( + builder + ._build_bond_react_templates( + file_dict={ + "pre_1": pre, + "post_1": post, + }, + reactant_to_product={ + 0: 0, + 1: 1, + }, + initiator_atoms=[ + 0, + 1, + ], + edge_atoms=[], + delete_ids=[], + ) + ) + + assert Path( + pre_out + ).read_text( + encoding="utf-8" + ) == "PRE-CONTENT" + + assert Path( + post_out + ).read_text( + encoding="utf-8" + ) == "POST-CONTENT" + + assert Path( + map_path + ).read_text( + encoding="utf-8" + ) == "STANDARD-MAP" + + assert Path( + pre_out + ).name == ( + "template_pre_1.molecule" + ) + + assert Path( + post_out + ).name == ( + "template_post_1.molecule" + ) + + assert Path( + map_path + ).name == "RXN_1.map" + + +def test_build_templates_standard_map_suppresses_delete_ids( + tmp_path, + monkeypatch, +): + """ + Even when delete atoms exist, the PRIMARY map must be generated + with delete_ids=[]. + """ + builder, _ = ( + make_builder( + tmp_path + ) + ) + + pre = tmp_path / "pre.molecule" + post = tmp_path / "post.molecule" + + pre.write_text("pre") + post.write_text("post") + + monkeypatch.setattr( + builder, + "_molecule_file_preparation", + lambda path, indexes, file_name: + ( + "CONTENT", + { + 1: 1, + 2: 2, + 3: 3, + }, + ), + ) + + calls = [] + + def fake_map( + mapping, + initiators, + edges, + deletes, + file_name, + ): + calls.append( + ( + mapping, + initiators, + edges, + deletes, + file_name, + ) + ) + + return file_name + + monkeypatch.setattr( + builder, + "_map_file_write", + fake_map, + ) + + builder._build_bond_react_templates( + file_dict={ + "pre_1": pre, + "post_1": post, + }, + reactant_to_product={ + 0: 0, + 1: 1, + 2: 2, + }, + initiator_atoms=[ + 0, + 1, + ], + edge_atoms=[ + 2, + ], + delete_ids=[ + 2, + ], + ) + + # First map is always standard. + assert calls[0][3] == [] + + assert calls[0][4] == ( + "RXN_1.map" + ) + + +def test_build_templates_optional_delete_map_written_only_when_needed( + tmp_path, + monkeypatch, +): + builder, _ = ( + make_builder( + tmp_path + ) + ) + + pre = tmp_path / "pre.molecule" + post = tmp_path / "post.molecule" + + pre.write_text("pre") + post.write_text("post") + + monkeypatch.setattr( + builder, + "_molecule_file_preparation", + lambda path, indexes, file_name: + ( + "CONTENT", + { + 1: 1, + 2: 2, + 3: 3, + }, + ), + ) + + calls = [] + + def fake_map( + mapping, + initiators, + edges, + deletes, + file_name, + ): + calls.append( + ( + list(deletes), + file_name, + ) + ) + + return file_name + + monkeypatch.setattr( + builder, + "_map_file_write", + fake_map, + ) + + builder._build_bond_react_templates( + file_dict={ + "pre_4": pre, + "post_4": post, + }, + reactant_to_product={ + 0: 0, + 1: 1, + 2: 2, + }, + initiator_atoms=[ + 0, + 1, + ], + edge_atoms=[], + delete_ids=[ + 2, + ], + ) + + assert calls == [ + ( + [], + "RXN_4.map", + ), + ( + [ + 2, + ], + "RXN_4_with_delete_ids.map", + ), + ] + + assert ( + builder.cache_dir + / "RXN_4_with_delete_ids.map" + ).is_file() + + +def test_build_templates_does_not_write_optional_delete_map_when_no_delete_ids( + tmp_path, + monkeypatch, +): + builder, _ = ( + make_builder( + tmp_path + ) + ) + + pre = tmp_path / "pre.molecule" + post = tmp_path / "post.molecule" + + pre.write_text("pre") + post.write_text("post") + + monkeypatch.setattr( + builder, + "_molecule_file_preparation", + lambda path, indexes, file_name: + ( + "CONTENT", + { + 1: 1, + 2: 2, + }, + ), + ) + + monkeypatch.setattr( + builder, + "_map_file_write", + lambda *args, **kwargs: + "MAP", + ) + + builder._build_bond_react_templates( + file_dict={ + "pre_2": pre, + "post_2": post, + }, + reactant_to_product={ + 0: 0, + 1: 1, + }, + initiator_atoms=[ + 0, + 1, + ], + edge_atoms=[], + delete_ids=[], + ) + + assert not ( + builder.cache_dir + / "RXN_2_with_delete_ids.map" + ).exists() + + +def test_build_templates_reindexes_mapping_into_trimmed_template_space( + tmp_path, + monkeypatch, +): + builder, _ = ( + make_builder( + tmp_path + ) + ) + + pre = tmp_path / "pre.molecule" + post = tmp_path / "post.molecule" + + pre.write_text("pre") + post.write_text("post") + + def fake_prepare( + path, + indexes, + file_name, + ): + if Path(path) == pre: + return ( + "PRE", + { + 1: 2, + 2: 1, + }, + ) + + return ( + "POST", + { + 1: 2, + 3: 1, + }, + ) + + monkeypatch.setattr( + builder, + "_molecule_file_preparation", + fake_prepare, + ) + + mappings = [] + + def fake_map( + mapping, + initiators, + edges, + deletes, + file_name, + ): + mappings.append( + dict(mapping) + ) + + return "MAP" + + monkeypatch.setattr( + builder, + "_map_file_write", + fake_map, + ) + + builder._build_bond_react_templates( + file_dict={ + "pre_1": pre, + "post_1": post, + }, + reactant_to_product={ + 0: 2, + 1: 0, + }, + initiator_atoms=[ + 0, + 1, + ], + edge_atoms=[], + delete_ids=[], + ) + + # full 0 -> full 2 + # reactant old1 -> template2 -> zero-based1 + # product old3 -> template1 -> zero-based0 + # + # full 1 -> full 0 + # reactant old2 -> template1 -> zero-based0 + # product old1 -> template2 -> zero-based1 + assert mappings[0] == { + 1: 0, + 0: 1, + } + + +def test_build_templates_missing_filtered_mapping_raises( + tmp_path, + monkeypatch, +): + builder, _ = ( + make_builder( + tmp_path + ) + ) + + pre = tmp_path / "pre.molecule" + post = tmp_path / "post.molecule" + + pre.write_text("pre") + post.write_text("post") + + monkeypatch.setattr( + builder, + "_molecule_file_preparation", + lambda path, indexes, file_name: + ( + "CONTENT", + { + 1: 1, + }, + ), + ) + + with pytest.raises( + ValueError, + match=( + "Template mapping indices " + "missing after filtering" + ), + ): + builder._build_bond_react_templates( + file_dict={ + "pre_1": pre, + "post_1": post, + }, + reactant_to_product={ + 0: 0, + 1: 1, + }, + initiator_atoms=[ + 0, + 1, + ], + edge_atoms=[], + delete_ids=[], + ) + + +# ============================================================================= +# _copy_lunar_files_to_cache +# ============================================================================= + + +def test_copy_lunar_files_copies_force_field_and_input( + tmp_path, +): + monomer = make_monomer() + + builder, session = ( + make_builder( + tmp_path, + monomers=[ + monomer + ], + ) + ) + + ff_src = ( + tmp_path / "force_field.data" + ) + + in_src = ( + tmp_path + / "in.create_atoms.script" + ) + + ff_src.write_text( + "FF", + encoding="utf-8", + ) + + in_src.write_text( + "INPUT", + encoding="utf-8", + ) + + ff_files = SimpleNamespace( + force_field_data=ff_src, + in_file=in_src, + molecule_files=[], + ) + + ff_dest, in_dest = ( + builder + ._copy_lunar_files_to_cache( + ff_files + ) + ) + + assert ff_dest == ( + builder.cache_dir + / "force_field.data" + ) + + assert in_dest == ( + builder.cache_dir + / "in.create_atoms.script" + ) + + assert ff_dest.read_text() == "FF" + assert in_dest.read_text() == "INPUT" + + +def test_copy_lunar_files_attaches_molecule_by_name( + tmp_path, +): + monomer = make_monomer( + monomer_id=7, + data_id="data7", + name="mma", + ) + + builder, _ = ( + make_builder( + tmp_path, + monomers=[ + monomer + ], + ) + ) + + ff_src = tmp_path / "ff.data" + in_src = tmp_path / "in.script" + mol_src = tmp_path / "mma.lmpmol" + + ff_src.write_text("ff") + in_src.write_text("in") + mol_src.write_text("mol") + + ff_files = SimpleNamespace( + force_field_data=ff_src, + in_file=in_src, + molecule_files=[ + SimpleNamespace( + id="mma", + molecule_files=SimpleNamespace( + lmp_molecule_file=mol_src, + ), + ) + ], + ) + + builder._copy_lunar_files_to_cache( + ff_files + ) + + assert ( + monomer.lmp_molecule_file + == builder.cache_dir + / "mma.molecule" + ) + + assert ( + monomer + .lmp_molecule_file + .read_text() + == "mol" + ) + + +@pytest.mark.parametrize( + "lookup_id", + [ + "7", + "data7", + "mma", + ], +) +def test_copy_lunar_files_matches_monomer_by_id_data_id_or_name( + tmp_path, + lookup_id, +): + monomer = make_monomer( + monomer_id=7, + data_id="data7", + name="mma", + ) + + builder, _ = ( + make_builder( + tmp_path, + monomers=[ + monomer + ], + ) + ) + + ff_src = tmp_path / "ff.data" + in_src = tmp_path / "in.script" + mol_src = tmp_path / "mol.lmpmol" + + ff_src.write_text("ff") + in_src.write_text("in") + mol_src.write_text("mol") + + ff_files = SimpleNamespace( + force_field_data=ff_src, + in_file=in_src, + molecule_files=[ + SimpleNamespace( + id=lookup_id, + molecule_files=SimpleNamespace( + lmp_molecule_file=mol_src, + ), + ) + ], + ) + + builder._copy_lunar_files_to_cache( + ff_files + ) + + assert ( + monomer.lmp_molecule_file + is not None + ) + + +def test_copy_lunar_force_field_failure_wrapped( + tmp_path, +): + builder, _ = ( + make_builder( + tmp_path + ) + ) + + ff_files = SimpleNamespace( + force_field_data=( + tmp_path / "missing.data" + ), + in_file=( + tmp_path / "missing.script" + ), + molecule_files=[], + ) + + with pytest.raises( + FileNotFoundError, + match=( + "Failed to copy force field data" + ), + ): + builder._copy_lunar_files_to_cache( + ff_files + ) + + +# ============================================================================= +# _copy_path_to_final +# ============================================================================= + + +def test_copy_path_to_final_none_returns_none( + tmp_path, +): + builder, _ = ( + make_builder( + tmp_path + ) + ) + + assert ( + builder._copy_path_to_final( + None, + tmp_path / "final", + ) + is None + ) + + +def test_copy_path_to_final_missing_file_returns_none( + tmp_path, +): + builder, _ = ( + make_builder( + tmp_path + ) + ) + + result = ( + builder._copy_path_to_final( + tmp_path / "missing.map", + tmp_path / "final", + ) + ) + + assert result is None + + +def test_copy_path_to_final_copies_existing_file( + tmp_path, +): + builder, _ = ( + make_builder( + tmp_path + ) + ) + + src = tmp_path / "RXN_1.map" + + src.write_text( + "MAP", + encoding="utf-8", + ) + + final_dir = ( + tmp_path / "final" + ) + + result = ( + builder._copy_path_to_final( + src, + final_dir, + ) + ) + + assert result == ( + final_dir / "RXN_1.map" + ) + + assert result.read_text() == "MAP" + + +def test_copy_path_to_final_same_source_and_destination( + tmp_path, +): + builder, _ = ( + make_builder( + tmp_path + ) + ) + + final_dir = ( + tmp_path / "final" + ) + + final_dir.mkdir() + + src = ( + final_dir / "RXN_1.map" + ) + + src.write_text( + "MAP", + encoding="utf-8", + ) + + result = ( + builder._copy_path_to_final( + src, + final_dir, + ) + ) + + assert result == src + assert result.read_text() == "MAP" + + +# ============================================================================= +# molecule_template_preparation - core orchestration +# ============================================================================= + + +def test_molecule_template_preparation_builds_template_mapping_from_dataframe( + tmp_path, + monkeypatch, +): + df = pd.DataFrame( + { + "template_reactant_idx": [ + 0, + 1, + ], + "template_product_idx": [ + 2, + 0, + ], + "initiators": [ + 0, + 1, + ], + "edge_atoms": [ + 3, + None, + ], + "byproduct_idx": [ + None, + None, + ], + } + ) + + metadata = ( + make_reaction_metadata( + reaction_id=5, + dataframe=df, + ) + ) + + monomer = make_monomer() + + builder, session = ( + make_builder( + tmp_path, + monomers=[ + monomer + ], + reactions=[ + metadata + ], + ) + ) + + pre_source = ( + tmp_path / "pre5.lmpmol" + ) + + post_source = ( + tmp_path / "post5.lmpmol" + ) + + pre_source.write_text("pre") + post_source.write_text("post") + + session.ff_files = ( + SimpleNamespace( + template_files=[ + make_template_ff_entry( + reaction_id=5, + pre_file=pre_source, + post_file=post_source, + ) + ], + ) + ) + + ff_cache = ( + builder.cache_dir + / "force_field.data" + ) + + in_cache = ( + builder.cache_dir + / "in.script" + ) + + ff_cache.write_text("ff") + in_cache.write_text("in") + + monkeypatch.setattr( + builder, + "_copy_lunar_files_to_cache", + lambda ff_files: + ( + ff_cache, + in_cache, + ), + ) + + pre_out = ( + builder.cache_dir + / "template_pre_5.molecule" + ) + + post_out = ( + builder.cache_dir + / "template_post_5.molecule" + ) + + map_out = ( + builder.cache_dir + / "RXN_5.map" + ) + + for path in ( + pre_out, + post_out, + map_out, + ): + path.write_text( + path.name, + encoding="utf-8", + ) + + calls = [] + + def fake_build( + *, + file_dict, + reactant_to_product, + initiator_atoms, + edge_atoms, + delete_ids, + ): + calls.append( + { + "file_dict": file_dict, + "mapping": reactant_to_product, + "initiators": initiator_atoms, + "edges": edge_atoms, + "deletes": delete_ids, + } + ) + + return ( + pre_out, + post_out, + map_out, + ) + + monkeypatch.setattr( + builder, + "_build_bond_react_templates", + fake_build, + ) + + class FakeDetector: + def compare_lammps_templates( + self, + template_files, + wildcards, + ): + return template_files + + monkeypatch.setattr( + builder_module, + "DeduplicationDetector", + FakeDetector, + ) + + builder.molecule_template_preparation( + session + ) + + assert calls[0][ + "mapping" + ] == { + 0: 2, + 1: 0, + } + + assert calls[0][ + "initiators" + ] == [ + 0, + 1, + ] + + assert calls[0][ + "edges" + ] == [ + 3, + ] + + assert calls[0][ + "deletes" + ] == [] + + +def test_delete_atom_false_does_not_pass_byproducts_as_delete_ids( + tmp_path, + monkeypatch, +): + df = pd.DataFrame( + { + "template_reactant_idx": [ + 0, + 1, + ], + "template_product_idx": [ + 0, + 1, + ], + "initiators": [ + 0, + 1, + ], + "byproduct_idx": [ + 5, + 6, + ], + } + ) + + metadata = make_reaction_metadata( + reaction_id=1, + dataframe=df, + delete_atom=False, + ) + + builder, session = ( + make_builder( + tmp_path, + reactions=[ + metadata + ], + ) + ) + + pre = tmp_path / "pre.lmpmol" + post = tmp_path / "post.lmpmol" + + pre.write_text("pre") + post.write_text("post") + + session.ff_files = ( + SimpleNamespace( + template_files=[ + make_template_ff_entry( + reaction_id=1, + pre_file=pre, + post_file=post, + ) + ], + ) + ) + + ff_cache = builder.cache_dir / "ff.data" + in_cache = builder.cache_dir / "in.script" + + ff_cache.write_text("ff") + in_cache.write_text("in") + + monkeypatch.setattr( + builder, + "_copy_lunar_files_to_cache", + lambda files: + ( + ff_cache, + in_cache, + ), + ) + + captured = [] + + def fake_build(**kwargs): + captured.append( + kwargs["delete_ids"] + ) + + pre_out = ( + builder.cache_dir + / "template_pre_1.molecule" + ) + + post_out = ( + builder.cache_dir + / "template_post_1.molecule" + ) + + map_out = ( + builder.cache_dir + / "RXN_1.map" + ) + + for path in ( + pre_out, + post_out, + map_out, + ): + path.write_text("x") + + return ( + pre_out, + post_out, + map_out, + ) + + monkeypatch.setattr( + builder, + "_build_bond_react_templates", + fake_build, + ) + + monkeypatch.setattr( + builder_module, + "DeduplicationDetector", + lambda: + SimpleNamespace( + compare_lammps_templates=( + lambda template_files, wildcards: + template_files + ) + ), + ) + + builder.molecule_template_preparation( + session + ) + + assert captured == [ + [] + ] + + +def test_delete_atom_true_passes_byproducts_as_optional_delete_ids( + tmp_path, + monkeypatch, +): + df = pd.DataFrame( + { + "template_reactant_idx": [ + 0, + 1, + ], + "template_product_idx": [ + 0, + 1, + ], + "initiators": [ + 0, + 1, + ], + "byproduct_idx": [ + 5, + 6, + ], + } + ) + + metadata = make_reaction_metadata( + reaction_id=1, + dataframe=df, + delete_atom=True, + ) + + builder, session = ( + make_builder( + tmp_path, + reactions=[ + metadata + ], + ) + ) + + pre = tmp_path / "pre.lmpmol" + post = tmp_path / "post.lmpmol" + + pre.write_text("pre") + post.write_text("post") + + session.ff_files = ( + SimpleNamespace( + template_files=[ + make_template_ff_entry( + reaction_id=1, + pre_file=pre, + post_file=post, + ) + ], + ) + ) + + ff_cache = builder.cache_dir / "ff.data" + in_cache = builder.cache_dir / "in.script" + + ff_cache.write_text("ff") + in_cache.write_text("in") + + monkeypatch.setattr( + builder, + "_copy_lunar_files_to_cache", + lambda files: + ( + ff_cache, + in_cache, + ), + ) + + captured = [] + + def fake_build(**kwargs): + captured.append( + kwargs["delete_ids"] + ) + + pre_out = ( + builder.cache_dir + / "template_pre_1.molecule" + ) + + post_out = ( + builder.cache_dir + / "template_post_1.molecule" + ) + + map_out = ( + builder.cache_dir + / "RXN_1.map" + ) + + for path in ( + pre_out, + post_out, + map_out, + ): + path.write_text("x") + + return ( + pre_out, + post_out, + map_out, + ) + + monkeypatch.setattr( + builder, + "_build_bond_react_templates", + fake_build, + ) + + monkeypatch.setattr( + builder_module, + "DeduplicationDetector", + lambda: + SimpleNamespace( + compare_lammps_templates=( + lambda template_files, wildcards: + template_files + ) + ), + ) + + builder.molecule_template_preparation( + session + ) + + assert captured == [ + [ + 5, + 6, + ] + ] + + +def test_optional_delete_map_is_not_required( + tmp_path, + monkeypatch, +): + """ + Important contract: + + A reaction is valid with only RXN_N.map. + RXN_N_with_delete_ids.map is supplementary and may remain None. + """ + df = pd.DataFrame( + { + "template_reactant_idx": [ + 0, + 1, + ], + "template_product_idx": [ + 0, + 1, + ], + "initiators": [ + 0, + 1, + ], + } + ) + + metadata = make_reaction_metadata( + reaction_id=1, + dataframe=df, + delete_atom=False, + ) + + builder, session = ( + make_builder( + tmp_path, + reactions=[ + metadata + ], + ) + ) + + pre_source = tmp_path / "pre.lmpmol" + post_source = tmp_path / "post.lmpmol" + + pre_source.write_text("pre") + post_source.write_text("post") + + session.ff_files = ( + SimpleNamespace( + template_files=[ + make_template_ff_entry( + reaction_id=1, + pre_file=pre_source, + post_file=post_source, + ) + ], + ) + ) + + ff_cache = builder.cache_dir / "ff.data" + in_cache = builder.cache_dir / "in.script" + pre_out = builder.cache_dir / "template_pre_1.molecule" + post_out = builder.cache_dir / "template_post_1.molecule" + map_out = builder.cache_dir / "RXN_1.map" + + for path in ( + ff_cache, + in_cache, + pre_out, + post_out, + map_out, + ): + path.write_text("x") + + monkeypatch.setattr( + builder, + "_copy_lunar_files_to_cache", + lambda files: + ( + ff_cache, + in_cache, + ), + ) + + monkeypatch.setattr( + builder, + "_build_bond_react_templates", + lambda **kwargs: + ( + pre_out, + post_out, + map_out, + ), + ) + + monkeypatch.setattr( + builder_module, + "DeduplicationDetector", + lambda: + SimpleNamespace( + compare_lammps_templates=( + lambda template_files, wildcards: + template_files + ) + ), + ) + + builder.molecule_template_preparation( + session + ) + + assert ( + metadata.map_file + is not None + ) + + assert ( + metadata.pre_reaction_file + is not None + ) + + assert ( + metadata.post_reaction_file + is not None + ) + + assert ( + metadata.map_file_with_delete_ids + is None + ) + + assert len( + session + .reacter_files + .template_files + ) == 1 + + +# ============================================================================= +# Deduplication routing +# ============================================================================= + + +def test_template_deduplication_enabled_calls_compare_lammps_templates( + tmp_path, + monkeypatch, +): + builder, session = ( + make_builder( + tmp_path, + deduplicate=True, + wildcards=True, + ) + ) + + builder_reaction = ( + make_reaction_metadata( + reaction_id=1, + activity_stats=True, + ) + ) + + builder_reaction.map_file = ( + tmp_path / "RXN_1.map" + ) + + builder_reaction.pre_reaction_file = ( + tmp_path / "pre.molecule" + ) + + builder_reaction.post_reaction_file = ( + tmp_path / "post.molecule" + ) + + session.reaction_metadata = [ + builder_reaction + ] + + session.ff_files = SimpleNamespace( + template_files=[], + ) + + ff_cache = builder.cache_dir / "ff.data" + in_cache = builder.cache_dir / "in.script" + + ff_cache.write_text("ff") + in_cache.write_text("in") + + monkeypatch.setattr( + builder, + "_copy_lunar_files_to_cache", + lambda files: + ( + ff_cache, + in_cache, + ), + ) + + calls = [] + + class FakeDetector: + def compare_lammps_templates( + self, + template_files, + wildcards, + ): + calls.append( + ( + list(template_files), + wildcards, + ) + ) + + return [ + "DEDUPED" + ] + + monkeypatch.setattr( + builder_module, + "DeduplicationDetector", + FakeDetector, + ) + + # Keep already-set reaction paths intact. + monkeypatch.setattr( + builder, + "_copy_path_to_final", + lambda src, final: + Path(src) + if src is not None + else None, + ) + + builder.molecule_template_preparation( + session + ) + + assert calls == [ + ( + [ + builder_reaction + ], + True, + ) + ] + + assert ( + session + .reacter_files + .template_files + == [ + "DEDUPED" + ] + ) + + +def test_deduplication_disabled_skips_compare( + tmp_path, + monkeypatch, + capsys, +): + builder, session = ( + make_builder( + tmp_path, + deduplicate=False, + wildcards=False, + ) + ) + + reaction = ( + make_reaction_metadata( + reaction_id=1, + activity_stats=True, + ) + ) + + reaction.map_file = ( + tmp_path / "RXN_1.map" + ) + + reaction.pre_reaction_file = ( + tmp_path / "pre.molecule" + ) + + reaction.post_reaction_file = ( + tmp_path / "post.molecule" + ) + + session.reaction_metadata = [ + reaction + ] + + session.ff_files = ( + SimpleNamespace( + template_files=[], + ) + ) + + ff_cache = builder.cache_dir / "ff.data" + in_cache = builder.cache_dir / "in.script" + + ff_cache.write_text("ff") + in_cache.write_text("in") + + monkeypatch.setattr( + builder, + "_copy_lunar_files_to_cache", + lambda files: + ( + ff_cache, + in_cache, + ), + ) + + class FakeDetector: + def compare_lammps_templates( + self, + **kwargs, + ): + pytest.fail( + "deduplication must not run" + ) + + def write_wildcard_maps( + self, + **kwargs, + ): + pytest.fail( + "wildcard rewrite must not run" + ) + + monkeypatch.setattr( + builder_module, + "DeduplicationDetector", + FakeDetector, + ) + + monkeypatch.setattr( + builder, + "_copy_path_to_final", + lambda src, final: + Path(src) + if src is not None + else None, + ) + + builder.molecule_template_preparation( + session + ) + + assert ( + "Skipping LAMMPS reaction-template deduplication" + in capsys.readouterr().out + ) + + assert ( + session + .reacter_files + .template_files + == [ + reaction + ] + ) + + +def test_wildcards_without_deduplication_calls_write_wildcard_maps( + tmp_path, + monkeypatch, +): + builder, session = ( + make_builder( + tmp_path, + deduplicate=False, + wildcards=True, + ) + ) + + reaction = ( + make_reaction_metadata( + reaction_id=1, + activity_stats=True, + ) + ) + + reaction.map_file = ( + tmp_path / "RXN_1.map" + ) + + reaction.pre_reaction_file = ( + tmp_path / "pre.molecule" + ) + + reaction.post_reaction_file = ( + tmp_path / "post.molecule" + ) + + session.reaction_metadata = [ + reaction + ] + + session.ff_files = ( + SimpleNamespace( + template_files=[], + ) + ) + + ff_cache = builder.cache_dir / "ff.data" + in_cache = builder.cache_dir / "in.script" + + ff_cache.write_text("ff") + in_cache.write_text("in") + + monkeypatch.setattr( + builder, + "_copy_lunar_files_to_cache", + lambda files: + ( + ff_cache, + in_cache, + ), + ) + + calls = [] + + class FakeDetector: + def write_wildcard_maps( + self, + template_files, + ): + calls.append( + list( + template_files + ) + ) + + return [ + "WILDCARD" + ] + + monkeypatch.setattr( + builder_module, + "DeduplicationDetector", + FakeDetector, + ) + + monkeypatch.setattr( + builder, + "_copy_path_to_final", + lambda src, final: + Path(src) + if src is not None + else None, + ) + + builder.molecule_template_preparation( + session + ) + + assert calls == [ + [ + reaction + ] + ] + + assert ( + session + .reacter_files + .template_files + == [ + "WILDCARD" + ] + ) + + +# ============================================================================= +# Active-template filtering +# ============================================================================= + + +def test_only_active_complete_reactions_enter_final_template_list( + tmp_path, + monkeypatch, +): + complete = ( + make_reaction_metadata( + reaction_id=1, + activity_stats=True, + ) + ) + + inactive = ( + make_reaction_metadata( + reaction_id=2, + activity_stats=False, + ) + ) + + incomplete = ( + make_reaction_metadata( + reaction_id=3, + activity_stats=True, + ) + ) + + for reaction in ( + complete, + inactive, + ): + reaction.map_file = ( + tmp_path + / f"RXN_{reaction.reaction_id}.map" + ) + + reaction.pre_reaction_file = ( + tmp_path + / f"pre{reaction.reaction_id}.molecule" + ) + + reaction.post_reaction_file = ( + tmp_path + / f"post{reaction.reaction_id}.molecule" + ) + + incomplete.map_file = ( + tmp_path / "RXN_3.map" + ) + + incomplete.pre_reaction_file = ( + tmp_path / "pre3.molecule" + ) + + incomplete.post_reaction_file = None + + builder, session = ( + make_builder( + tmp_path, + deduplicate=False, + reactions=[ + complete, + inactive, + incomplete, + ], + ) + ) + + session.ff_files = ( + SimpleNamespace( + template_files=[], + ) + ) + + ff_cache = builder.cache_dir / "ff.data" + in_cache = builder.cache_dir / "in.script" + + ff_cache.write_text("ff") + in_cache.write_text("in") + + monkeypatch.setattr( + builder, + "_copy_lunar_files_to_cache", + lambda files: + ( + ff_cache, + in_cache, + ), + ) + + monkeypatch.setattr( + builder, + "_copy_path_to_final", + lambda src, final: + Path(src) + if src is not None + else None, + ) + + monkeypatch.setattr( + builder_module, + "DeduplicationDetector", + lambda: + SimpleNamespace(), + ) + + builder.molecule_template_preparation( + session + ) + + assert ( + session + .reacter_files + .template_files + == [ + complete + ] + ) + + +# ============================================================================= +# Final output validation +# ============================================================================= + + +def test_missing_final_force_field_copy_raises( + tmp_path, + monkeypatch, +): + builder, session = ( + make_builder( + tmp_path + ) + ) + + session.ff_files = ( + SimpleNamespace( + template_files=[], + ) + ) + + ff_cache = ( + builder.cache_dir + / "force_field.data" + ) + + in_cache = ( + builder.cache_dir + / "in.script" + ) + + ff_cache.write_text("ff") + in_cache.write_text("in") + + monkeypatch.setattr( + builder, + "_copy_lunar_files_to_cache", + lambda files: + ( + ff_cache, + in_cache, + ), + ) + + def fake_copy( + src, + final, + ): + if Path(src) == ff_cache: + return None + + return Path(src) + + monkeypatch.setattr( + builder, + "_copy_path_to_final", + fake_copy, + ) + + with pytest.raises( + FileNotFoundError, + match="force_field.data was not copied", + ): + builder.molecule_template_preparation( + session + ) + + +def test_missing_final_input_copy_raises( + tmp_path, + monkeypatch, +): + builder, session = ( + make_builder( + tmp_path + ) + ) + + session.ff_files = ( + SimpleNamespace( + template_files=[], + ) + ) + + ff_cache = ( + builder.cache_dir + / "force_field.data" + ) + + in_cache = ( + builder.cache_dir + / "in.script" + ) + + ff_cache.write_text("ff") + in_cache.write_text("in") + + monkeypatch.setattr( + builder, + "_copy_lunar_files_to_cache", + lambda files: + ( + ff_cache, + in_cache, + ), + ) + + def fake_copy( + src, + final, + ): + if Path(src) == in_cache: + return None + + return Path(src) + + monkeypatch.setattr( + builder, + "_copy_path_to_final", + fake_copy, + ) + + with pytest.raises( + FileNotFoundError, + match="LAMMPS input file was not copied", + ): + builder.molecule_template_preparation( + session + ) + + +def test_molecule_template_preparation_returns_none_and_sets_session_result( + tmp_path, + monkeypatch, +): + monomer = make_monomer( + lmp_molecule_file=( + tmp_path + / "mma.molecule" + ) + ) + + monomer.lmp_molecule_file.write_text( + "mol" + ) + + builder, session = ( + make_builder( + tmp_path, + monomers=[ + monomer + ], + deduplicate=False, + ) + ) + + session.ff_files = ( + SimpleNamespace( + template_files=[], + ) + ) + + ff_cache = ( + builder.cache_dir + / "force_field.data" + ) + + in_cache = ( + builder.cache_dir + / "in.script" + ) + + ff_cache.write_text("ff") + in_cache.write_text("in") + + monkeypatch.setattr( + builder, + "_copy_lunar_files_to_cache", + lambda files: + ( + ff_cache, + in_cache, + ), + ) + + monkeypatch.setattr( + builder_module, + "DeduplicationDetector", + lambda: + SimpleNamespace(), + ) + + result = ( + builder + .molecule_template_preparation( + session + ) + ) + + assert result is None + + assert isinstance( + session.reacter_files, + REACTERFiles, + ) + + assert ( + session + .reacter_files + .force_field_data + == Path(session.output_dir) + / "force_field.data" + ) + + assert ( + session + .reacter_files + .molecule_files + == [ + monomer + ] + ) \ No newline at end of file diff --git a/tests/unit/reaction_preparation/ff_wrapper/test_ff_locator.py b/tests/unit/reaction_preparation/ff_wrapper/test_ff_locator.py new file mode 100644 index 00000000..17fe2bb1 --- /dev/null +++ b/tests/unit/reaction_preparation/ff_wrapper/test_ff_locator.py @@ -0,0 +1,829 @@ +from pathlib import Path + +import pytest + +import AutoREACTER.reaction_preparation.ff_wrapper.ff_locator as ff_locator +from AutoREACTER.reaction_preparation.ff_wrapper.ff_locator import ( + get_force_field_file, +) + + +# ============================================================================= +# Helpers +# ============================================================================= + + +def make_fake_internal_ff_tree( + tmp_path: Path, + *, + include_pcff: bool = True, +) -> Path: + """ + Build the directory that pkg_resources.files("AutoREACTER") + is expected to expose. + """ + package_root = ( + tmp_path / "AutoREACTER" + ) + + ff_dir = ( + package_root + / "reaction_preparation" + / "ff_wrapper" + / "FF_files" + ) + + ff_dir.mkdir( + parents=True, + exist_ok=True, + ) + + if include_pcff: + ( + ff_dir / "pcff.frc" + ).write_text( + "fake pcff", + encoding="utf-8", + ) + + return package_root + + +def make_fake_lunar( + tmp_path: Path, + *, + files=(), +) -> Path: + lunar_root = ( + tmp_path / "LUNAR" + ) + + frc_dir = ( + lunar_root / "frc_files" + ) + + frc_dir.mkdir( + parents=True, + exist_ok=True, + ) + + for filename in files: + ( + frc_dir / filename + ).write_text( + "fake frc", + encoding="utf-8", + ) + + return lunar_root + + +def patch_internal_package_root( + monkeypatch, + package_root: Path, +): + monkeypatch.setattr( + ff_locator.pkg_resources, + "files", + lambda package_name: package_root, + ) + + +# ============================================================================= +# Bundled PCFF +# ============================================================================= + + +@pytest.mark.parametrize( + "force_field", + [ + "PCFF", + "PCFF-IFF", + ], +) +def test_internal_pcff_variants_resolve_to_same_file( + tmp_path, + monkeypatch, + force_field, +): + package_root = ( + make_fake_internal_ff_tree( + tmp_path + ) + ) + + patch_internal_package_root( + monkeypatch, + package_root, + ) + + result = get_force_field_file( + force_field + ) + + expected = ( + package_root + / "reaction_preparation" + / "ff_wrapper" + / "FF_files" + / "pcff.frc" + ) + + assert result == expected + + +@pytest.mark.parametrize( + "force_field", + [ + "PCFF", + "PCFF-IFF", + ], +) +def test_internal_pcff_does_not_require_lunar_location( + tmp_path, + monkeypatch, + force_field, +): + package_root = ( + make_fake_internal_ff_tree( + tmp_path + ) + ) + + patch_internal_package_root( + monkeypatch, + package_root, + ) + + result = get_force_field_file( + force_field=force_field, + lunar_location=None, + ) + + assert result.is_file() + + +def test_internal_pcff_returns_path_object( + tmp_path, + monkeypatch, +): + package_root = ( + make_fake_internal_ff_tree( + tmp_path + ) + ) + + patch_internal_package_root( + monkeypatch, + package_root, + ) + + result = get_force_field_file( + "PCFF" + ) + + assert isinstance( + result, + Path, + ) + + +def test_internal_pcff_missing_file_raises_value_error( + tmp_path, + monkeypatch, +): + package_root = ( + make_fake_internal_ff_tree( + tmp_path, + include_pcff=False, + ) + ) + + patch_internal_package_root( + monkeypatch, + package_root, + ) + + expected = ( + package_root + / "reaction_preparation" + / "ff_wrapper" + / "FF_files" + / "pcff.frc" + ) + + with pytest.raises( + ValueError, + match="Force field file not found", + ) as exc_info: + get_force_field_file( + "PCFF" + ) + + assert str( + expected + ) in str( + exc_info.value + ) + + +def test_real_packaged_pcff_file_is_available(): + """ + Small packaging regression check. + + This intentionally uses the real package resource rather than + monkeypatching pkg_resources. If pcff.frc disappears from the + installation/package, AutoREACTER's supported PCFF backend is broken. + """ + result = get_force_field_file( + "PCFF" + ) + + assert isinstance( + result, + Path, + ) + + assert result.is_file() + + assert result.name == ( + "pcff.frc" + ) + + +def test_pcff_prefers_internal_file_even_when_lunar_location_given( + tmp_path, + monkeypatch, +): + package_root = ( + make_fake_internal_ff_tree( + tmp_path + ) + ) + + patch_internal_package_root( + monkeypatch, + package_root, + ) + + lunar_root = ( + make_fake_lunar( + tmp_path, + files=[ + "compass_published.frc", + ], + ) + ) + + result = get_force_field_file( + force_field="PCFF", + lunar_location=lunar_root, + ) + + assert result == ( + package_root + / "reaction_preparation" + / "ff_wrapper" + / "FF_files" + / "pcff.frc" + ) + + +# ============================================================================= +# pkg_resources failure +# ============================================================================= + + +def test_pkg_resources_failure_is_wrapped_as_runtime_error( + monkeypatch, +): + def fail(package_name): + raise RuntimeError( + "resource lookup exploded" + ) + + monkeypatch.setattr( + ff_locator.pkg_resources, + "files", + fail, + ) + + with pytest.raises( + RuntimeError, + match="Error locating FF_files directory using pkg_resources", + ) as exc_info: + get_force_field_file( + "PCFF" + ) + + assert ( + "resource lookup exploded" + in str( + exc_info.value + ) + ) + + +@pytest.mark.parametrize( + "force_field", + [ + "PCFF", + "PCFF-IFF", + "compass", + "CVFF", + "CVFF-IFF", + "DREIDING", + ], +) +def test_lunar_force_field_branch_attempts_internal_resource_lookup_first( + monkeypatch, + force_field, +): + calls = [] + + def fail(package_name): + calls.append( + package_name + ) + + raise RuntimeError( + "boom" + ) + + monkeypatch.setattr( + ff_locator.pkg_resources, + "files", + fail, + ) + + with pytest.raises( + RuntimeError, + match="Error locating FF_files", + ): + get_force_field_file( + force_field, + lunar_location="/tmp/LUNAR", + ) + + assert calls == [ + "AutoREACTER" + ] + + +# ============================================================================= +# External LUNAR force fields +# ============================================================================= + + +@pytest.mark.parametrize( + "force_field, expected_filename", + [ + ( + "compass", + "compass_published.frc", + ), + ( + "CVFF-IFF", + "cvff_aug.frc", + ), + ( + "CVFF", + "cvff.frc", + ), + ( + "DREIDING", + "all2lmp_dreiding.frc", + ), + ], +) +def test_external_lunar_force_field_mapping( + tmp_path, + monkeypatch, + force_field, + expected_filename, +): + package_root = ( + make_fake_internal_ff_tree( + tmp_path + ) + ) + + patch_internal_package_root( + monkeypatch, + package_root, + ) + + lunar_root = ( + make_fake_lunar( + tmp_path, + files=[ + expected_filename + ], + ) + ) + + result = get_force_field_file( + force_field=force_field, + lunar_location=lunar_root, + ) + + assert result == ( + lunar_root + / "frc_files" + / expected_filename + ) + + assert result.is_file() + + +@pytest.mark.parametrize( + "force_field", + [ + "compass", + "CVFF-IFF", + "CVFF", + "DREIDING", + ], +) +def test_external_lunar_force_field_without_lunar_location_raises( + tmp_path, + monkeypatch, + force_field, +): + package_root = ( + make_fake_internal_ff_tree( + tmp_path + ) + ) + + patch_internal_package_root( + monkeypatch, + package_root, + ) + + with pytest.raises( + ValueError, + match="Required LUNAR installation not found to resolve", + ) as exc_info: + get_force_field_file( + force_field=force_field, + lunar_location=None, + ) + + assert force_field in str( + exc_info.value + ) + + +@pytest.mark.parametrize( + "force_field, expected_filename", + [ + ( + "compass", + "compass_published.frc", + ), + ( + "CVFF-IFF", + "cvff_aug.frc", + ), + ( + "CVFF", + "cvff.frc", + ), + ( + "DREIDING", + "all2lmp_dreiding.frc", + ), + ], +) +def test_external_lunar_force_field_missing_file_raises( + tmp_path, + monkeypatch, + force_field, + expected_filename, +): + package_root = ( + make_fake_internal_ff_tree( + tmp_path + ) + ) + + patch_internal_package_root( + monkeypatch, + package_root, + ) + + lunar_root = ( + make_fake_lunar( + tmp_path + ) + ) + + expected = ( + lunar_root + / "frc_files" + / expected_filename + ) + + with pytest.raises( + ValueError, + match="Force field file not found", + ) as exc_info: + get_force_field_file( + force_field=force_field, + lunar_location=lunar_root, + ) + + assert str( + expected + ) in str( + exc_info.value + ) + + +def test_lunar_location_accepts_string_path( + tmp_path, + monkeypatch, +): + package_root = ( + make_fake_internal_ff_tree( + tmp_path + ) + ) + + patch_internal_package_root( + monkeypatch, + package_root, + ) + + lunar_root = ( + make_fake_lunar( + tmp_path, + files=[ + "cvff.frc" + ], + ) + ) + + result = get_force_field_file( + force_field="CVFF", + lunar_location=str( + lunar_root + ), + ) + + assert result == ( + lunar_root + / "frc_files" + / "cvff.frc" + ) + + +# ============================================================================= +# Exact current force-field naming contract +# ============================================================================= + + +def test_compass_lowercase_is_supported( + tmp_path, + monkeypatch, +): + package_root = ( + make_fake_internal_ff_tree( + tmp_path + ) + ) + + patch_internal_package_root( + monkeypatch, + package_root, + ) + + lunar_root = ( + make_fake_lunar( + tmp_path, + files=[ + "compass_published.frc" + ], + ) + ) + + result = get_force_field_file( + "compass", + lunar_location=lunar_root, + ) + + assert result.name == ( + "compass_published.frc" + ) + + +@pytest.mark.parametrize( + "force_field", + [ + "COMPASS", + "Compass", + "pcff", + "Pcff", + "cvff", + "dreiding", + ], +) +def test_force_field_names_are_currently_case_sensitive( + force_field, +): + """ + Characterizes the current locator contract. + + Do not silently change this test into case-insensitive behavior unless + force-field normalization is deliberately moved into this function. + """ + with pytest.raises( + ValueError, + match="Unsupported force field requested", + ): + get_force_field_file( + force_field + ) + + +def test_leading_or_trailing_whitespace_is_not_normalized(): + with pytest.raises( + ValueError, + match="Unsupported force field requested", + ): + get_force_field_file( + " PCFF " + ) + + +# ============================================================================= +# Foyer placeholders +# ============================================================================= + + +@pytest.mark.parametrize( + "force_field", + [ + "OPLSAA", + "GAFF", + ], +) +def test_foyer_force_fields_are_explicitly_not_implemented( + force_field, +): + with pytest.raises( + NotImplementedError, + match="support is currently in development", + ) as exc_info: + get_force_field_file( + force_field + ) + + assert force_field in str( + exc_info.value + ) + + +def test_foyer_placeholder_does_not_need_lunar_location(): + with pytest.raises( + NotImplementedError + ): + get_force_field_file( + force_field="OPLSAA", + lunar_location=None, + ) + + +# ============================================================================= +# Unsupported force fields +# ============================================================================= + + +@pytest.mark.parametrize( + "force_field", + [ + "AMBER", + "CHARMM", + "UFF", + "MMFF94", + "", + "unknown", + ], +) +def test_unsupported_force_field_raises_value_error( + force_field, +): + with pytest.raises( + ValueError, + match="Unsupported force field requested", + ) as exc_info: + get_force_field_file( + force_field + ) + + assert repr( + force_field + ).strip("'") in str( + exc_info.value + ) or force_field == "" + + +def test_unsupported_error_includes_requested_force_field(): + with pytest.raises( + ValueError + ) as exc_info: + get_force_field_file( + "NOT_A_FORCE_FIELD" + ) + + assert ( + "NOT_A_FORCE_FIELD" + in str( + exc_info.value + ) + ) + + +# ============================================================================= +# Path/layout contract +# ============================================================================= + + +def test_external_force_fields_are_expected_under_frc_files( + tmp_path, + monkeypatch, +): + package_root = ( + make_fake_internal_ff_tree( + tmp_path + ) + ) + + patch_internal_package_root( + monkeypatch, + package_root, + ) + + lunar_root = ( + tmp_path / "LUNAR" + ) + + lunar_root.mkdir() + + # Correct filename, deliberately placed in the WRONG directory. + ( + lunar_root + / "cvff.frc" + ).write_text( + "wrong location", + encoding="utf-8", + ) + + with pytest.raises( + ValueError, + match="Force field file not found", + ): + get_force_field_file( + force_field="CVFF", + lunar_location=lunar_root, + ) + + +def test_directory_named_like_force_field_file_is_rejected( + tmp_path, + monkeypatch, +): + package_root = ( + make_fake_internal_ff_tree( + tmp_path + ) + ) + + patch_internal_package_root( + monkeypatch, + package_root, + ) + + lunar_root = ( + make_fake_lunar( + tmp_path + ) + ) + + fake_file_as_directory = ( + lunar_root + / "frc_files" + / "cvff.frc" + ) + + fake_file_as_directory.mkdir() + + with pytest.raises( + ValueError, + match="Force field file not found", + ): + get_force_field_file( + force_field="CVFF", + lunar_location=lunar_root, + ) \ No newline at end of file diff --git a/tests/unit/reaction_preparation/ff_wrapper/test_ff_validator.py b/tests/unit/reaction_preparation/ff_wrapper/test_ff_validator.py new file mode 100644 index 00000000..7bf41cb8 --- /dev/null +++ b/tests/unit/reaction_preparation/ff_wrapper/test_ff_validator.py @@ -0,0 +1,1273 @@ +from pathlib import Path +from types import SimpleNamespace + +import pytest + +import AutoREACTER.reaction_preparation.ff_wrapper.ff_validator as ff_validator +from AutoREACTER.reaction_preparation.ff_wrapper.ff_validator import ( + FFValidator, + ForceFieldValidationError, +) + + +# ============================================================================= +# Helpers +# ============================================================================= + + +def make_ff_files( + ff_file: Path, +): + return SimpleNamespace( + force_field_data=ff_file, + ) + + +def write_force_field( + path: Path, + text: str, +) -> Path: + path.write_text( + text, + encoding="utf-8", + ) + + return path + + +def make_uninitialized_validator( + ff_file=None, +): + """ + Construct FFValidator without calling __init__/validate. + + Useful for testing the helper methods independently. + """ + validator = object.__new__( + FFValidator + ) + + validator.ff_file = ff_file + + if ff_file is not None: + validator.ff_files = ( + make_ff_files( + ff_file + ) + ) + + return validator + + +# ============================================================================= +# Exception type +# ============================================================================= + + +def test_force_field_validation_error_is_exception(): + assert issubclass( + ForceFieldValidationError, + Exception, + ) + + +# ============================================================================= +# Constructor +# ============================================================================= + + +def test_constructor_stores_ff_files_and_force_field_path( + tmp_path, + monkeypatch, +): + ff_file = ( + tmp_path / "force_field.data" + ) + + ff_files = make_ff_files( + ff_file + ) + + calls = [] + + monkeypatch.setattr( + FFValidator, + "validate", + lambda self: + ( + calls.append(self) + or True + ), + ) + + validator = FFValidator( + ff_files + ) + + assert ( + validator.ff_files + is ff_files + ) + + assert ( + validator.ff_file + == ff_file + ) + + assert calls == [ + validator + ] + + +def test_constructor_validates_immediately( + tmp_path, +): + missing = ( + tmp_path + / "missing.data" + ) + + with pytest.raises( + ForceFieldValidationError, + match="File not found", + ): + FFValidator( + make_ff_files( + missing + ) + ) + + +# ============================================================================= +# validate - file existence +# ============================================================================= + + +def test_validate_missing_file_raises( + tmp_path, +): + missing = ( + tmp_path + / "missing.data" + ) + + validator = ( + make_uninitialized_validator( + missing + ) + ) + + with pytest.raises( + ForceFieldValidationError, + match="File not found", + ) as exc_info: + validator.validate() + + assert str( + missing + ) in str( + exc_info.value + ) + + +def test_validate_existing_valid_file_returns_true( + tmp_path, +): + ff_file = write_force_field( + tmp_path / "force_field.data", + """ +Pair Coeffs + +1 0.100 3.500 +2 0.200 4.000 + +""", + ) + + validator = FFValidator( + make_ff_files( + ff_file + ) + ) + + assert ( + validator.validate() + is True + ) + + +# ============================================================================= +# validate - current section-presence behavior +# ============================================================================= + + +def test_validate_empty_file_currently_returns_true( + tmp_path, +): + """ + Characterization test. + + Although the class documentation says required section labels + are checked, the current implementation skips any section that + is absent. + """ + ff_file = write_force_field( + tmp_path / "empty.data", + "", + ) + + validator = FFValidator( + make_ff_files( + ff_file + ) + ) + + assert ( + validator.validate() + is True + ) + + +def test_validate_file_with_no_recognized_sections_currently_returns_true( + tmp_path, +): + """ + Characterizes the current implementation: + unrecognized/missing coefficient sections are ignored. + """ + ff_file = write_force_field( + tmp_path / "other.data", + """ +Masses + +1 12.011 +2 1.008 + +Atoms + +1 1 1 0.0 0.0 0.0 0.0 +""", + ) + + validator = FFValidator( + make_ff_files( + ff_file + ) + ) + + assert ( + validator.validate() + is True + ) + + +def test_validate_missing_some_coeff_sections_are_skipped( + tmp_path, +): + ff_file = write_force_field( + tmp_path / "partial.data", + """ +Bond Coeffs + +1 100.0 1.5 +2 200.0 1.4 + +""", + ) + + validator = FFValidator( + make_ff_files( + ff_file + ) + ) + + assert ( + validator.validate() + is True + ) + + +@pytest.mark.parametrize( + "section_name", + [ + "Pair Coeffs", + "Bond Coeffs", + "Angle Coeffs", + "Dihedral Coeffs", + ], +) +def test_validate_recognizes_each_supported_section( + tmp_path, + section_name, +): + ff_file = write_force_field( + tmp_path / "force_field.data", + f""" +{section_name} + +1 1.0 2.0 +2 3.0 4.0 + +""", + ) + + validator = FFValidator( + make_ff_files( + ff_file + ) + ) + + assert ( + validator.validate() + is True + ) + + +# ============================================================================= +# find_section_start +# ============================================================================= + + +def test_find_section_start_returns_correct_index(): + validator = ( + make_uninitialized_validator() + ) + + lines = [ + "LAMMPS data file\n", + "\n", + "Masses\n", + "\n", + "Pair Coeffs\n", + "\n", + ] + + assert ( + validator.find_section_start( + lines, + "Pair Coeffs", + ) + == 4 + ) + + +def test_find_section_start_returns_none_when_absent(): + validator = ( + make_uninitialized_validator() + ) + + lines = [ + "Masses\n", + "Atoms\n", + "Bonds\n", + ] + + assert ( + validator.find_section_start( + lines, + "Pair Coeffs", + ) + is None + ) + + +def test_find_section_start_uses_substring_matching(): + validator = ( + make_uninitialized_validator() + ) + + lines = [ + "Pair Coeffs # class2\n", + ] + + assert ( + validator.find_section_start( + lines, + "Pair Coeffs", + ) + == 0 + ) + + +def test_find_section_start_returns_first_match(): + validator = ( + make_uninitialized_validator() + ) + + lines = [ + "Pair Coeffs\n", + "\n", + "Pair Coeffs\n", + ] + + assert ( + validator.find_section_start( + lines, + "Pair Coeffs", + ) + == 0 + ) + + +def test_find_section_start_is_case_sensitive(): + validator = ( + make_uninitialized_validator() + ) + + lines = [ + "pair coeffs\n", + ] + + assert ( + validator.find_section_start( + lines, + "Pair Coeffs", + ) + is None + ) + + +# ============================================================================= +# find_data_block +# ============================================================================= + + +def test_find_data_block_skips_blank_lines_after_header(): + validator = ( + make_uninitialized_validator() + ) + + lines = [ + "Pair Coeffs\n", + "\n", + "\n", + "1 0.1 3.5\n", + "2 0.2 4.0\n", + "\n", + "Bond Coeffs\n", + ] + + result = ( + validator.find_data_block( + lines, + 0, + ) + ) + + assert result == ( + 3, + 5, + ) + + +def test_find_data_block_stops_at_first_blank_line(): + validator = ( + make_uninitialized_validator() + ) + + lines = [ + "Pair Coeffs\n", + "\n", + "1 0.1 3.5\n", + "2 0.2 4.0\n", + "\n", + "3 0.3 5.0\n", + ] + + result = ( + validator.find_data_block( + lines, + 0, + ) + ) + + assert result == ( + 2, + 4, + ) + + +def test_find_data_block_can_end_at_eof(): + validator = ( + make_uninitialized_validator() + ) + + lines = [ + "Bond Coeffs\n", + "\n", + "1 100.0 1.5\n", + "2 200.0 1.4\n", + ] + + result = ( + validator.find_data_block( + lines, + 0, + ) + ) + + assert result == ( + 2, + 4, + ) + + +def test_find_data_block_empty_after_header_returns_eof_indexes(): + validator = ( + make_uninitialized_validator() + ) + + lines = [ + "Angle Coeffs\n", + "\n", + "\n", + ] + + result = ( + validator.find_data_block( + lines, + 0, + ) + ) + + assert result == ( + 3, + 3, + ) + + +# ============================================================================= +# check_coefficients - valid input +# ============================================================================= + + +def test_check_coefficients_single_valid_row(): + validator = ( + make_uninitialized_validator() + ) + + lines = [ + "1 0.100 3.500\n", + ] + + assert ( + validator.check_coefficients( + start_line=0, + end_line=1, + lines=lines, + section_name="Pair Coeffs", + ) + is True + ) + + +def test_check_coefficients_multiple_rows_same_count(): + validator = ( + make_uninitialized_validator() + ) + + lines = [ + "1 0.100 3.500\n", + "2 0.200 4.000\n", + "3 0.300 4.500\n", + ] + + assert ( + validator.check_coefficients( + start_line=0, + end_line=3, + lines=lines, + section_name="Pair Coeffs", + ) + is True + ) + + +def test_check_coefficients_accepts_negative_values(): + validator = ( + make_uninitialized_validator() + ) + + lines = [ + "1 -1.25 2.50 -0.75\n", + "2 1.25 -2.50 0.75\n", + ] + + assert ( + validator.check_coefficients( + 0, + 2, + lines, + "Dihedral Coeffs", + ) + is True + ) + + +def test_check_coefficients_accepts_scientific_notation(): + validator = ( + make_uninitialized_validator() + ) + + lines = [ + "1 1.0e-4 2.5E+2\n", + "2 -3.0e-5 4.0E-1\n", + ] + + assert ( + validator.check_coefficients( + 0, + 2, + lines, + "Pair Coeffs", + ) + is True + ) + + +def test_check_coefficients_accepts_zero_values_when_row_not_all_zero(): + validator = ( + make_uninitialized_validator() + ) + + lines = [ + "1 0.0 0.0 1.5 0.0\n", + ] + + assert ( + validator.check_coefficients( + 0, + 1, + lines, + "Dihedral Coeffs", + ) + is True + ) + + +def test_check_coefficients_ignores_inline_comments(): + validator = ( + make_uninitialized_validator() + ) + + lines = [ + "1 0.100 3.500 # carbon\n", + "2 0.200 4.000 # hydrogen\n", + ] + + assert ( + validator.check_coefficients( + 0, + 2, + lines, + "Pair Coeffs", + ) + is True + ) + + +def test_check_coefficients_ignores_full_comment_lines_inside_block(): + validator = ( + make_uninitialized_validator() + ) + + lines = [ + "# comment only\n", + "1 10.0 1.5\n", + "# another comment\n", + "2 20.0 1.4\n", + ] + + assert ( + validator.check_coefficients( + 0, + 4, + lines, + "Bond Coeffs", + ) + is True + ) + + +def test_check_coefficients_currently_does_not_validate_type_identifier(): + """ + Characterization test. + + values[0] is treated as the type identifier, but current production + code only converts values[1:] to floats. + """ + validator = ( + make_uninitialized_validator() + ) + + lines = [ + "not_an_integer 1.0 2.0\n", + "another_id 3.0 4.0\n", + ] + + assert ( + validator.check_coefficients( + 0, + 2, + lines, + "Pair Coeffs", + ) + is True + ) + + +# ============================================================================= +# check_coefficients - dynamic coefficient count +# ============================================================================= + + +def test_first_data_row_sets_expected_coefficient_count(): + validator = ( + make_uninitialized_validator() + ) + + lines = [ + "1 1.0 2.0 3.0 4.0\n", + "2 5.0 6.0 7.0 8.0\n", + ] + + assert ( + validator.check_coefficients( + 0, + 2, + lines, + "Dihedral Coeffs", + ) + is True + ) + + +def test_inconsistent_coefficient_count_raises(): + validator = ( + make_uninitialized_validator() + ) + + lines = [ + "1 1.0 2.0 3.0\n", + "2 4.0 5.0\n", + ] + + with pytest.raises( + ForceFieldValidationError, + match="Inconsistent data", + ) as exc_info: + validator.check_coefficients( + 0, + 2, + lines, + "Angle Coeffs", + ) + + message = str( + exc_info.value + ) + + assert ( + "Expected 3 coefficients" + in message + ) + + assert ( + "found 2" + in message + ) + + +def test_inconsistent_coefficient_error_includes_section_name(): + validator = ( + make_uninitialized_validator() + ) + + lines = [ + "1 1.0 2.0\n", + "2 3.0\n", + ] + + with pytest.raises( + ForceFieldValidationError + ) as exc_info: + validator.check_coefficients( + 0, + 2, + lines, + "Bond Coeffs", + ) + + assert ( + "Bond Coeffs" + in str( + exc_info.value + ) + ) + + +def test_comment_does_not_affect_dynamic_coefficient_count(): + validator = ( + make_uninitialized_validator() + ) + + lines = [ + "# ignored comment\n", + "1 1.0 2.0 3.0\n", + "2 4.0 5.0 6.0\n", + ] + + assert ( + validator.check_coefficients( + 0, + 3, + lines, + "Angle Coeffs", + ) + is True + ) + + +# ============================================================================= +# check_coefficients - non-numeric values +# ============================================================================= + + +def test_non_numeric_coefficient_raises(): + validator = ( + make_uninitialized_validator() + ) + + lines = [ + "1 1.0 not-a-number 3.0\n", + ] + + with pytest.raises( + ForceFieldValidationError, + match="Non-numeric data", + ): + validator.check_coefficients( + 0, + 1, + lines, + "Angle Coeffs", + ) + + +def test_non_numeric_error_includes_section_name(): + validator = ( + make_uninitialized_validator() + ) + + lines = [ + "1 1.0 BAD\n", + ] + + with pytest.raises( + ForceFieldValidationError + ) as exc_info: + validator.check_coefficients( + 0, + 1, + lines, + "Pair Coeffs", + ) + + assert ( + "Pair Coeffs" + in str( + exc_info.value + ) + ) + + +def test_non_numeric_error_reports_one_based_line_number(): + validator = ( + make_uninitialized_validator() + ) + + lines = [ + "header\n", + "ignored\n", + "1 1.0 2.0\n", + "2 BAD 4.0\n", + ] + + with pytest.raises( + ForceFieldValidationError + ) as exc_info: + validator.check_coefficients( + start_line=2, + end_line=4, + lines=lines, + section_name="Pair Coeffs", + ) + + assert ( + "line 4" + in str( + exc_info.value + ) + ) + + +# ============================================================================= +# check_coefficients - all-zero rows +# ============================================================================= + + +@pytest.mark.parametrize( + "line", + [ + "1 0.0\n", + "1 0 0\n", + "1 0.000000 0.000000 0.000000\n", + "1 -0.0 0.0\n", + ], +) +def test_all_zero_coefficients_raise( + line, +): + validator = ( + make_uninitialized_validator() + ) + + with pytest.raises( + ForceFieldValidationError, + match="All zeros", + ): + validator.check_coefficients( + 0, + 1, + [ + line + ], + "Pair Coeffs", + ) + + +def test_all_zero_error_includes_section_name(): + validator = ( + make_uninitialized_validator() + ) + + with pytest.raises( + ForceFieldValidationError + ) as exc_info: + validator.check_coefficients( + 0, + 1, + [ + "1 0.0 0.0\n" + ], + "Dihedral Coeffs", + ) + + assert ( + "Dihedral Coeffs" + in str( + exc_info.value + ) + ) + + +def test_all_zero_error_includes_original_row(): + validator = ( + make_uninitialized_validator() + ) + + line = ( + "7 0.000 0.000 0.000 # test type\n" + ) + + with pytest.raises( + ForceFieldValidationError + ) as exc_info: + validator.check_coefficients( + 0, + 1, + [ + line + ], + "Angle Coeffs", + ) + + assert ( + "7 0.000 0.000 0.000" + in str( + exc_info.value + ) + ) + + +# ============================================================================= +# check_coefficients - no usable data +# ============================================================================= + + +def test_empty_data_block_raises_no_data(): + validator = ( + make_uninitialized_validator() + ) + + with pytest.raises( + ForceFieldValidationError, + match="No data found in section Pair Coeffs", + ): + validator.check_coefficients( + 0, + 0, + [], + "Pair Coeffs", + ) + + +def test_comment_only_block_raises_no_data(): + validator = ( + make_uninitialized_validator() + ) + + lines = [ + "# comment one\n", + "# comment two\n", + ] + + with pytest.raises( + ForceFieldValidationError, + match="No data found in section Bond Coeffs", + ): + validator.check_coefficients( + 0, + 2, + lines, + "Bond Coeffs", + ) + + +def test_whitespace_and_comments_only_raise_no_data(): + validator = ( + make_uninitialized_validator() + ) + + lines = [ + " # comment\n", + " \n", + "# another\n", + ] + + with pytest.raises( + ForceFieldValidationError, + match="No data found", + ): + validator.check_coefficients( + 0, + 3, + lines, + "Angle Coeffs", + ) + + +# ============================================================================= +# validate - full section integration +# ============================================================================= + + +def test_validate_checks_multiple_coefficient_sections( + tmp_path, +): + ff_file = write_force_field( + tmp_path / "force_field.data", + """ +Pair Coeffs + +1 0.10 3.50 +2 0.20 4.00 + +Bond Coeffs + +1 300.0 1.50 +2 250.0 1.40 + +Angle Coeffs + +1 50.0 109.5 +2 60.0 120.0 + +Dihedral Coeffs + +1 1.0 2.0 3.0 +2 4.0 5.0 6.0 + +""", + ) + + validator = FFValidator( + make_ff_files( + ff_file + ) + ) + + assert ( + validator.validate() + is True + ) + + +def test_validate_invalid_later_section_still_raises( + tmp_path, +): + ff_file = write_force_field( + tmp_path / "force_field.data", + """ +Pair Coeffs + +1 0.10 3.50 +2 0.20 4.00 + +Bond Coeffs + +1 300.0 1.50 +2 0.0 0.0 + +""", + ) + + with pytest.raises( + ForceFieldValidationError, + match="All zeros", + ): + FFValidator( + make_ff_files( + ff_file + ) + ) + + +def test_validate_section_with_no_data_raises( + tmp_path, +): + ff_file = write_force_field( + tmp_path / "force_field.data", + """ +Pair Coeffs + + +""", + ) + + with pytest.raises( + ForceFieldValidationError, + match="No data found in section Pair Coeffs", + ): + FFValidator( + make_ff_files( + ff_file + ) + ) + + +def test_validate_inconsistent_rows_in_file_raises( + tmp_path, +): + ff_file = write_force_field( + tmp_path / "force_field.data", + """ +Angle Coeffs + +1 50.0 109.5 2.0 +2 60.0 120.0 + +""", + ) + + with pytest.raises( + ForceFieldValidationError, + match="Inconsistent data", + ): + FFValidator( + make_ff_files( + ff_file + ) + ) + + +def test_validate_non_numeric_file_data_raises( + tmp_path, +): + ff_file = write_force_field( + tmp_path / "force_field.data", + """ +Bond Coeffs + +1 300.0 1.50 +2 WRONG 1.40 + +""", + ) + + with pytest.raises( + ForceFieldValidationError, + match="Non-numeric data", + ): + FFValidator( + make_ff_files( + ff_file + ) + ) + + +# ============================================================================= +# Comments / LAMMPS-style headers +# ============================================================================= + + +def test_validate_class2_section_header_comment( + tmp_path, +): + ff_file = write_force_field( + tmp_path / "force_field.data", + """ +Dihedral Coeffs # class2 + +1 0.0 0.0 0.0514 0.0 -0.143 0.0 +2 0.0 0.0 0.0316 0.0 -0.1681 0.0 + +""", + ) + + validator = FFValidator( + make_ff_files( + ff_file + ) + ) + + assert ( + validator.validate() + is True + ) + + +def test_validate_inline_atom_type_comments_do_not_count_as_coefficients( + tmp_path, +): + ff_file = write_force_field( + tmp_path / "force_field.data", + """ +Pair Coeffs + +1 0.0540 4.0100 # c1 +2 0.0640 3.8540 # c2 + +""", + ) + + validator = FFValidator( + make_ff_files( + ff_file + ) + ) + + assert ( + validator.validate() + is True + ) \ No newline at end of file diff --git a/tests/unit/reaction_preparation/ff_wrapper/test_ff_wrapper.py b/tests/unit/reaction_preparation/ff_wrapper/test_ff_wrapper.py new file mode 100644 index 00000000..9de21777 --- /dev/null +++ b/tests/unit/reaction_preparation/ff_wrapper/test_ff_wrapper.py @@ -0,0 +1,1156 @@ +import sys +from pathlib import Path +from types import ModuleType, SimpleNamespace + +import pytest + +from AutoREACTER.reaction_preparation.ff_wrapper.ff_wrapper import ( + DataFiles, + FFFiles, + FFWrapper, + MoleculeFile, + TemplateFile, +) + + +# ============================================================================= +# Helpers +# ============================================================================= + + +def make_session( + *, + force_field="PCFF", + reaction_metadata=None, +): + inputs = SimpleNamespace( + force_field=force_field, + ) + + return SimpleNamespace( + inputs=inputs, + reaction_metadata=( + [] + if reaction_metadata is None + else reaction_metadata + ), + ff_files=None, + ) + + +def install_fake_lunar_wrapper( + monkeypatch, + *, + final_files="lunar-final-files", + events=None, +): + """ + Install a fake module for the lazy import performed inside + FFWrapper.generate_force_field_files(). + """ + module_name = ( + "AutoREACTER.reaction_preparation." + "ff_wrapper.lunar_client.lunar_api_wrapper" + ) + + fake_module = ModuleType( + module_name + ) + + class FakeLunarAPIWrapper: + def __init__( + self, + ARX, + ): + self.ARX = ARX + + if events is not None: + events.append( + ( + "lunar-init", + ARX, + ) + ) + + def lunar_workflow( + self, + updated_inputs, + prepared_reactions, + ): + if events is not None: + events.append( + ( + "lunar-workflow", + updated_inputs, + prepared_reactions, + ) + ) + + return final_files + + fake_module.LunarAPIWrapper = ( + FakeLunarAPIWrapper + ) + + monkeypatch.setitem( + sys.modules, + module_name, + fake_module, + ) + + return FakeLunarAPIWrapper + + +def install_fake_foyer_wrapper( + monkeypatch, + *, + final_files="foyer-final-files", + events=None, +): + """ + Install a fake module for the current lazy Foyer routing branch. + + This does NOT claim Foyer support is functional. It only + characterizes the current FFWrapper routing behavior. + """ + module_name = ( + "AutoREACTER.reaction_preparation." + "ff_wrapper.foyer_client.foyer_api_wrapper" + ) + + fake_module = ModuleType( + module_name + ) + + class FakeFoyerAPIWrapper: + def __init__( + self, + ARX, + prepared_reactions_with_3d_mols, + ): + self.ARX = ARX + self.prepared_reactions = ( + prepared_reactions_with_3d_mols + ) + + self.final_foyer_files = ( + final_files + ) + + if events is not None: + events.append( + ( + "foyer-init", + ARX, + prepared_reactions_with_3d_mols, + ) + ) + + fake_module.FoyerAPIWrapper = ( + FakeFoyerAPIWrapper + ) + + monkeypatch.setitem( + sys.modules, + module_name, + fake_module, + ) + + return FakeFoyerAPIWrapper + + +# ============================================================================= +# DataFiles +# ============================================================================= + + +def test_data_files_stores_paths( + tmp_path, +): + data_file = ( + tmp_path / "system.data" + ) + + molecule_file = ( + tmp_path / "system.lmpmol" + ) + + result = DataFiles( + data_file=data_file, + lmp_molecule_file=molecule_file, + ) + + assert result.data_file == ( + data_file + ) + + assert ( + result.lmp_molecule_file + == molecule_file + ) + + +def test_data_files_uses_slots( + tmp_path, +): + result = DataFiles( + data_file=( + tmp_path / "a.data" + ), + lmp_molecule_file=( + tmp_path / "a.lmpmol" + ), + ) + + with pytest.raises( + AttributeError + ): + result.extra = 1 + + +# ============================================================================= +# MoleculeFile +# ============================================================================= + + +def test_molecule_file_stores_data_files( + tmp_path, +): + files = DataFiles( + data_file=( + tmp_path / "mma.data" + ), + lmp_molecule_file=( + tmp_path / "mma.lmpmol" + ), + ) + + result = MoleculeFile( + id="mma", + molecule_files=files, + ) + + assert result.id == "mma" + + assert ( + result.molecule_files + is files + ) + + +def test_molecule_file_accepts_none(): + result = MoleculeFile( + id="mma", + molecule_files=None, + ) + + assert ( + result.molecule_files + is None + ) + + +def test_molecule_file_uses_slots(): + result = MoleculeFile( + id="mma", + molecule_files=None, + ) + + with pytest.raises( + AttributeError + ): + result.extra = 1 + + +# ============================================================================= +# TemplateFile +# ============================================================================= + + +def test_template_file_stores_pre_and_post( + tmp_path, +): + pre = DataFiles( + data_file=( + tmp_path / "pre.data" + ), + lmp_molecule_file=( + tmp_path / "pre.lmpmol" + ), + ) + + post = DataFiles( + data_file=( + tmp_path / "post.data" + ), + lmp_molecule_file=( + tmp_path / "post.lmpmol" + ), + ) + + result = TemplateFile( + reaction_id=7, + pre_reaction_file=pre, + post_reaction_file=post, + ) + + assert result.reaction_id == 7 + + assert ( + result.pre_reaction_file + is pre + ) + + assert ( + result.post_reaction_file + is post + ) + + +def test_template_file_accepts_optional_values(): + result = TemplateFile( + reaction_id=None, + pre_reaction_file=None, + post_reaction_file=None, + ) + + assert result.reaction_id is None + assert result.pre_reaction_file is None + assert result.post_reaction_file is None + + +def test_template_file_uses_slots(): + result = TemplateFile( + reaction_id=None, + pre_reaction_file=None, + post_reaction_file=None, + ) + + with pytest.raises( + AttributeError + ): + result.extra = 1 + + +# ============================================================================= +# FFFiles +# ============================================================================= + + +def test_ff_files_stores_complete_output( + tmp_path, +): + molecule = MoleculeFile( + id="mma", + molecule_files=None, + ) + + template = TemplateFile( + reaction_id=1, + pre_reaction_file=None, + post_reaction_file=None, + ) + + result = FFFiles( + molecule_files=[ + molecule + ], + template_files=[ + template + ], + force_field_data=( + tmp_path + / "force_field.data" + ), + in_file=( + tmp_path + / "in.create_atoms.script" + ), + ) + + assert result.molecule_files == [ + molecule + ] + + assert result.template_files == [ + template + ] + + assert result.force_field_data == ( + tmp_path + / "force_field.data" + ) + + assert result.in_file == ( + tmp_path + / "in.create_atoms.script" + ) + + +def test_ff_files_in_file_defaults_none( + tmp_path, +): + result = FFFiles( + molecule_files=[], + template_files=[], + force_field_data=( + tmp_path / "ff.data" + ), + ) + + assert result.in_file is None + + +def test_ff_files_uses_slots( + tmp_path, +): + result = FFFiles( + molecule_files=[], + template_files=[], + force_field_data=( + tmp_path / "ff.data" + ), + ) + + with pytest.raises( + AttributeError + ): + result.extra = 1 + + +# ============================================================================= +# FFWrapper constructor +# ============================================================================= + + +def test_ff_wrapper_constructor_stores_session(): + session = make_session() + + wrapper = FFWrapper( + session + ) + + assert wrapper.session is session + + +def test_ff_wrapper_constructor_stores_inputs(): + session = make_session( + force_field="CVFF" + ) + + wrapper = FFWrapper( + session + ) + + assert ( + wrapper.inputs + is session.inputs + ) + + +# ============================================================================= +# LUNAR routing +# ============================================================================= + + +@pytest.mark.parametrize( + "force_field", + [ + "PCFF", + "PCFF-IFF", + "compass", + "CVFF", + "CVFF-IFF", + "DREIDING", + "Clay-FF", + ], +) +def test_supported_lunar_names_route_to_lunar( + monkeypatch, + capsys, + force_field, +): + session = make_session( + force_field=force_field, + ) + + events = [] + + install_fake_lunar_wrapper( + monkeypatch, + final_files="LUNAR_RESULT", + events=events, + ) + + wrapper = FFWrapper( + session + ) + + result = ( + wrapper + .generate_force_field_files( + session + ) + ) + + assert result is None + + assert ( + session.ff_files + == "LUNAR_RESULT" + ) + + assert events[0] == ( + "lunar-init", + session, + ) + + assert events[1] == ( + "lunar-workflow", + session.inputs, + session.reaction_metadata, + ) + + assert ( + f"Routing to LUNAR for force field: {force_field}" + in capsys.readouterr().out + ) + + +def test_lunar_wrapper_receives_original_wrapper_session( + monkeypatch, +): + original_session = ( + make_session( + force_field="PCFF" + ) + ) + + passed_session = ( + make_session( + force_field="CVFF" + ) + ) + + events = [] + + install_fake_lunar_wrapper( + monkeypatch, + events=events, + ) + + wrapper = FFWrapper( + original_session + ) + + wrapper.generate_force_field_files( + passed_session + ) + + # Characterizes current implementation: + # + # routing decisions and workflow arguments come from + # the method's session, but LunarAPIWrapper is + # constructed using self.session from FFWrapper.__init__. + assert events[0] == ( + "lunar-init", + original_session, + ) + + assert events[1] == ( + "lunar-workflow", + passed_session.inputs, + passed_session.reaction_metadata, + ) + + +def test_lunar_result_is_written_to_passed_session( + monkeypatch, +): + original_session = ( + make_session( + force_field="PCFF" + ) + ) + + passed_session = ( + make_session( + force_field="CVFF" + ) + ) + + original_session.ff_files = ( + "ORIGINAL" + ) + + install_fake_lunar_wrapper( + monkeypatch, + final_files="NEW_FILES", + ) + + wrapper = FFWrapper( + original_session + ) + + wrapper.generate_force_field_files( + passed_session + ) + + assert ( + passed_session.ff_files + == "NEW_FILES" + ) + + # Current behavior: result is assigned to the + # session passed to generate_force_field_files(). + assert ( + original_session.ff_files + == "ORIGINAL" + ) + + +# ============================================================================= +# Force-field fallback +# ============================================================================= + + +def test_none_force_field_falls_back_to_pcff( + monkeypatch, + capsys, +): + session = make_session( + force_field=None, + ) + + install_fake_lunar_wrapper( + monkeypatch, + final_files="PCFF_RESULT", + ) + + wrapper = FFWrapper( + session + ) + + wrapper.generate_force_field_files( + session + ) + + assert ( + session.ff_files + == "PCFF_RESULT" + ) + + assert ( + "Routing to LUNAR for force field: PCFF" + in capsys.readouterr().out + ) + + +def test_empty_force_field_falls_back_to_pcff( + monkeypatch, +): + session = make_session( + force_field="", + ) + + install_fake_lunar_wrapper( + monkeypatch, + final_files="PCFF_RESULT", + ) + + wrapper = FFWrapper( + session + ) + + wrapper.generate_force_field_files( + session + ) + + assert ( + session.ff_files + == "PCFF_RESULT" + ) + + +# ============================================================================= +# session.inputs fallback +# ============================================================================= + + +def test_missing_passed_session_inputs_falls_back_to_wrapper_inputs( + monkeypatch, +): + original_session = ( + make_session( + force_field="PCFF" + ) + ) + + passed_session = SimpleNamespace( + inputs=None, + reaction_metadata=[], + ff_files=None, + ) + + events = [] + + install_fake_lunar_wrapper( + monkeypatch, + final_files="RESULT", + events=events, + ) + + wrapper = FFWrapper( + original_session + ) + + wrapper.generate_force_field_files( + passed_session + ) + + assert ( + passed_session.ff_files + == "RESULT" + ) + + assert events[1] == ( + "lunar-workflow", + original_session.inputs, + passed_session.reaction_metadata, + ) + + +def test_both_input_sources_none_raises(): + original_session = SimpleNamespace( + inputs=None, + ) + + passed_session = SimpleNamespace( + inputs=None, + reaction_metadata=[], + ff_files=None, + ) + + wrapper = FFWrapper( + original_session + ) + + with pytest.raises( + ValueError, + match=( + "No inputs provided to FFWrapper " + "and session inputs are None" + ), + ): + wrapper.generate_force_field_files( + passed_session + ) + + +# ============================================================================= +# Reaction metadata forwarding +# ============================================================================= + + +def test_reaction_metadata_is_forwarded_to_lunar( + monkeypatch, +): + reactions = [ + object(), + object(), + object(), + ] + + session = make_session( + force_field="PCFF", + reaction_metadata=reactions, + ) + + events = [] + + install_fake_lunar_wrapper( + monkeypatch, + events=events, + ) + + wrapper = FFWrapper( + session + ) + + wrapper.generate_force_field_files( + session + ) + + assert events[1][2] is reactions + + +# ============================================================================= +# Foyer routing - current behavior only +# ============================================================================= + + +@pytest.mark.parametrize( + "force_field", + [ + "OPLSAA", + "GAFF", + ], +) +def test_current_foyer_names_route_to_foyer( + monkeypatch, + capsys, + force_field, +): + """ + Characterization only. + + AutoREACTER currently marks Foyer force-field lookup as + not implemented, but FFWrapper still contains a routing + branch for OPLSAA and GAFF. + + This test does NOT claim Foyer works. + """ + reactions = [ + object(), + ] + + session = make_session( + force_field=force_field, + reaction_metadata=reactions, + ) + + events = [] + + install_fake_foyer_wrapper( + monkeypatch, + final_files="FOYER_RESULT", + events=events, + ) + + wrapper = FFWrapper( + session + ) + + result = ( + wrapper + .generate_force_field_files( + session + ) + ) + + assert result is None + + assert ( + session.ff_files + == "FOYER_RESULT" + ) + + assert events == [ + ( + "foyer-init", + session, + reactions, + ) + ] + + assert ( + f"Routing to Foyer for force field: {force_field}" + in capsys.readouterr().out + ) + + +# ============================================================================= +# Unsupported force fields +# ============================================================================= + + +@pytest.mark.parametrize( + "force_field", + [ + "AMBER", + "CHARMM", + "UFF", + "MMFF94", + "UNKNOWN", + "pcff", + "cvff", + "COMPASS", + ], +) +def test_unrecognized_force_field_raises( + force_field, +): + session = make_session( + force_field=force_field, + ) + + wrapper = FFWrapper( + session + ) + + with pytest.raises( + ValueError, + match=( + "Unsupported or unrecognized " + "force field requested" + ), + ) as exc_info: + wrapper.generate_force_field_files( + session + ) + + assert force_field in str( + exc_info.value + ) + + +def test_unsupported_force_field_does_not_change_ff_files(): + session = make_session( + force_field="UNKNOWN", + ) + + session.ff_files = ( + "existing-files" + ) + + wrapper = FFWrapper( + session + ) + + with pytest.raises( + ValueError + ): + wrapper.generate_force_field_files( + session + ) + + assert ( + session.ff_files + == "existing-files" + ) + + +# ============================================================================= +# Backend failure propagation +# ============================================================================= + + +def test_lunar_constructor_failure_propagates( + monkeypatch, +): + session = make_session( + force_field="PCFF" + ) + + module_name = ( + "AutoREACTER.reaction_preparation." + "ff_wrapper.lunar_client.lunar_api_wrapper" + ) + + fake_module = ModuleType( + module_name + ) + + class FailingLunarAPIWrapper: + def __init__( + self, + ARX, + ): + raise RuntimeError( + "LUNAR init failed" + ) + + fake_module.LunarAPIWrapper = ( + FailingLunarAPIWrapper + ) + + monkeypatch.setitem( + sys.modules, + module_name, + fake_module, + ) + + wrapper = FFWrapper( + session + ) + + with pytest.raises( + RuntimeError, + match="LUNAR init failed", + ): + wrapper.generate_force_field_files( + session + ) + + +def test_lunar_workflow_failure_propagates( + monkeypatch, +): + session = make_session( + force_field="PCFF" + ) + + module_name = ( + "AutoREACTER.reaction_preparation." + "ff_wrapper.lunar_client.lunar_api_wrapper" + ) + + fake_module = ModuleType( + module_name + ) + + class FailingLunarAPIWrapper: + def __init__( + self, + ARX, + ): + pass + + def lunar_workflow( + self, + updated_inputs, + prepared_reactions, + ): + raise RuntimeError( + "LUNAR workflow failed" + ) + + fake_module.LunarAPIWrapper = ( + FailingLunarAPIWrapper + ) + + monkeypatch.setitem( + sys.modules, + module_name, + fake_module, + ) + + wrapper = FFWrapper( + session + ) + + with pytest.raises( + RuntimeError, + match="LUNAR workflow failed", + ): + wrapper.generate_force_field_files( + session + ) + + +def test_backend_failure_does_not_replace_existing_ff_files( + monkeypatch, +): + session = make_session( + force_field="PCFF" + ) + + session.ff_files = ( + "old-files" + ) + + module_name = ( + "AutoREACTER.reaction_preparation." + "ff_wrapper.lunar_client.lunar_api_wrapper" + ) + + fake_module = ModuleType( + module_name + ) + + class FailingLunarAPIWrapper: + def __init__( + self, + ARX, + ): + pass + + def lunar_workflow( + self, + updated_inputs, + prepared_reactions, + ): + raise RuntimeError( + "failure" + ) + + fake_module.LunarAPIWrapper = ( + FailingLunarAPIWrapper + ) + + monkeypatch.setitem( + sys.modules, + module_name, + fake_module, + ) + + wrapper = FFWrapper( + session + ) + + with pytest.raises( + RuntimeError + ): + wrapper.generate_force_field_files( + session + ) + + assert ( + session.ff_files + == "old-files" + ) + + +# ============================================================================= +# Return contract +# ============================================================================= + + +def test_generate_force_field_files_returns_none_on_lunar_success( + monkeypatch, +): + session = make_session( + force_field="PCFF" + ) + + install_fake_lunar_wrapper( + monkeypatch + ) + + wrapper = FFWrapper( + session + ) + + result = ( + wrapper + .generate_force_field_files( + session + ) + ) + + assert result is None + + +def test_generate_force_field_files_returns_none_on_current_foyer_branch( + monkeypatch, +): + session = make_session( + force_field="GAFF" + ) + + install_fake_foyer_wrapper( + monkeypatch + ) + + wrapper = FFWrapper( + session + ) + + result = ( + wrapper + .generate_force_field_files( + session + ) + ) + + assert result is None \ No newline at end of file diff --git a/tests/unit/reaction_preparation/ff_wrapper/test_modifiers_molecule_files.py b/tests/unit/reaction_preparation/ff_wrapper/test_modifiers_molecule_files.py new file mode 100644 index 00000000..82be5e8c --- /dev/null +++ b/tests/unit/reaction_preparation/ff_wrapper/test_modifiers_molecule_files.py @@ -0,0 +1,1717 @@ +import pandas as pd +import pytest + +from AutoREACTER.reaction_preparation.ff_wrapper.modifiers_molecule_files import ( + modify_angles, + modify_bonds, + modify_charges, + modify_coords, + modify_dihedrals, + modify_impropers, + modify_types, +) + + +# ============================================================================= +# Helpers +# ============================================================================= + + +def make_type_df( + mapping, +): + """ + Build the minimal type DataFrame required by topology modifiers. + + mapping: + { + old_atom_index: new_atom_index, + ... + } + """ + return pd.DataFrame( + { + "atom_index": list( + mapping.keys() + ), + "new_atom_index": list( + mapping.values() + ), + } + ) + + +# ============================================================================= +# modify_types +# ============================================================================= + + +def test_modify_types_filters_atoms_and_reindexes(): + lines = [ + "1 10 # C\n", + "2 20 # H\n", + "3 30 # O\n", + "4 40 # N\n", + "\n", + ] + + template_indexes = [ + 1, + 3, + ] + + ( + df, + section, + count, + mapping, + legacy_mode, + ) = modify_types( + lines, + template_indexes, + 0, + ) + + assert list( + df["atom_index"] + ) == [ + 1, + 3, + ] + + assert list( + df["new_atom_index"] + ) == [ + 1, + 2, + ] + + assert count == 2 + + assert mapping == { + 1: 1, + 3: 2, + } + + assert legacy_mode is True + + assert "10" in section + assert "30" in section + + assert "20" not in section + assert "40" not in section + + +def test_modify_types_numeric_atom_types_are_ints(): + lines = [ + "1 12 # c1\n", + "2 7 # hc\n", + "\n", + ] + + ( + df, + _, + _, + _, + legacy_mode, + ) = modify_types( + lines, + [ + 1, + 2, + ], + 0, + ) + + assert df.loc[ + 0, + "atom_type", + ] == 12 + + assert df.loc[ + 1, + "atom_type", + ] == 7 + + assert legacy_mode is True + + +def test_modify_types_string_atom_type_disables_legacy_mode(): + lines = [ + "1 c1 # c1\n", + "2 hc # hc\n", + "\n", + ] + + ( + df, + _, + _, + _, + legacy_mode, + ) = modify_types( + lines, + [ + 1, + 2, + ], + 0, + ) + + assert ( + df.loc[ + 0, + "atom_type", + ] + == "c1" + ) + + assert ( + df.loc[ + 1, + "atom_type", + ] + == "hc" + ) + + assert legacy_mode is False + + +def test_modify_types_one_string_type_switches_global_legacy_mode_false(): + lines = [ + "1 10 # C\n", + "2 hc # H\n", + "\n", + ] + + ( + df, + _, + _, + _, + legacy_mode, + ) = modify_types( + lines, + [ + 1, + 2, + ], + 0, + ) + + # Characterizes current mixed parsing behavior: + # first row remains integer, second row is string, + # while legacy_mode becomes False globally. + assert df.loc[ + 0, + "atom_type", + ] == 10 + + assert ( + df.loc[ + 1, + "atom_type", + ] + == "hc" + ) + + assert legacy_mode is False + + +def test_modify_types_stops_at_first_blank_line(): + lines = [ + "1 10 # C\n", + "2 20 # H\n", + "\n", + "3 30 # O\n", + ] + + ( + df, + _, + count, + _, + _, + ) = modify_types( + lines, + [ + 1, + 2, + 3, + ], + 0, + ) + + assert count == 2 + + assert list( + df["atom_index"] + ) == [ + 1, + 2, + ] + + +def test_modify_types_respects_start_index(): + lines = [ + "Types\n", + "1 10 # C\n", + "2 20 # H\n", + "\n", + ] + + ( + df, + _, + count, + _, + _, + ) = modify_types( + lines, + [ + 1, + 2, + ], + 1, + ) + + assert count == 2 + + assert list( + df["atom_index"] + ) == [ + 1, + 2, + ] + + +def test_modify_types_ignores_lines_with_fewer_than_four_fields(): + lines = [ + "this is short\n", + "1 10 # C\n", + "\n", + ] + + ( + df, + _, + count, + _, + _, + ) = modify_types( + lines, + [ + 1, + ], + 0, + ) + + assert count == 1 + + assert list( + df["atom_index"] + ) == [ + 1, + ] + + +def test_modify_types_template_indexes_are_sorted_in_place(): + lines = [ + "1 10 # C\n", + "2 20 # H\n", + "3 30 # O\n", + "\n", + ] + + template_indexes = [ + 3, + 1, + 2, + ] + + modify_types( + lines, + template_indexes, + 0, + ) + + # Characterization: + # production currently mutates the supplied list. + assert template_indexes == [ + 1, + 2, + 3, + ] + + +def test_modify_types_output_order_follows_input_rows_not_template_list(): + lines = [ + "3 30 # O\n", + "1 10 # C\n", + "2 20 # H\n", + "\n", + ] + + template_indexes = [ + 2, + 1, + 3, + ] + + ( + df, + _, + _, + mapping, + _, + ) = modify_types( + lines, + template_indexes, + 0, + ) + + # Even though template_indexes gets sorted, + # surviving rows retain their order from the source section. + assert list( + df["atom_index"] + ) == [ + 3, + 1, + 2, + ] + + assert mapping == { + 3: 1, + 1: 2, + 2: 3, + } + + +def test_modify_types_all_selected(): + lines = [ + "10 4 # ca\n", + "20 8 # hc\n", + "\n", + ] + + ( + df, + _, + count, + mapping, + _, + ) = modify_types( + lines, + [ + 10, + 20, + ], + 0, + ) + + assert count == 2 + + assert mapping == { + 10: 1, + 20: 2, + } + + assert list( + df["new_atom_index"] + ) == [ + 1, + 2, + ] + + +def test_modify_types_none_selected_returns_empty_filtered_dataframe(): + lines = [ + "1 10 # C\n", + "2 20 # H\n", + "\n", + ] + + ( + df, + section, + count, + mapping, + legacy_mode, + ) = modify_types( + lines, + [ + 100, + ], + 0, + ) + + assert df.empty + + assert section == "" + + assert count == 0 + + assert mapping == {} + + assert legacy_mode is True + + +def test_modify_types_preserves_hash_and_real_type(): + lines = [ + "5 12 HASHVALUE c2\n", + "\n", + ] + + ( + df, + section, + _, + _, + _, + ) = modify_types( + lines, + [ + 5, + ], + 0, + ) + + assert ( + df.loc[ + 0, + "hash", + ] + == "HASHVALUE" + ) + + assert ( + df.loc[ + 0, + "atom_type_real", + ] + == "c2" + ) + + assert "HASHVALUE" in section + assert "c2" in section + + +# ============================================================================= +# modify_charges +# ============================================================================= + + +def test_modify_charges_filters_and_reindexes_atoms(): + type_df = make_type_df( + { + 2: 1, + 4: 2, + } + ) + + lines = [ + "1 -0.1 # c1\n", + "2 0.2 # c2\n", + "3 -0.3 # h\n", + "4 0.4 # o\n", + "\n", + ] + + section = modify_charges( + lines, + type_df, + 0, + ) + + assert "0.200000" in section + assert "0.400000" in section + + assert "-0.100000" not in section + assert "-0.300000" not in section + + output_lines = ( + section.strip().splitlines() + ) + + assert len( + output_lines + ) == 2 + + assert ( + output_lines[0] + .split()[0] + == "1" + ) + + assert ( + output_lines[1] + .split()[0] + == "2" + ) + + +def test_modify_charges_formats_six_decimal_places(): + type_df = make_type_df( + { + 1: 1, + } + ) + + section = modify_charges( + [ + "1 0.123456789 # c1\n", + "\n", + ], + type_df, + 0, + ) + + assert ( + "0.123457" + in section + ) + + +def test_modify_charges_stops_at_blank_line(): + type_df = make_type_df( + { + 1: 1, + 2: 2, + } + ) + + section = modify_charges( + [ + "1 0.1 # c1\n", + "\n", + "2 0.2 # c2\n", + ], + type_df, + 0, + ) + + assert "0.100000" in section + assert "0.200000" not in section + + +def test_modify_charges_respects_start_index(): + type_df = make_type_df( + { + 1: 1, + } + ) + + lines = [ + "Charges\n", + "1 -0.25 # c1\n", + "\n", + ] + + section = modify_charges( + lines, + type_df, + 1, + ) + + assert "-0.250000" in section + + +def test_modify_charges_no_matching_atoms_returns_empty_string(): + type_df = make_type_df( + { + 10: 1, + } + ) + + section = modify_charges( + [ + "1 0.1 # c1\n", + "2 0.2 # c2\n", + "\n", + ], + type_df, + 0, + ) + + assert section == "" + + +def test_modify_charges_preserves_hash_and_real_type(): + type_df = make_type_df( + { + 1: 1, + } + ) + + section = modify_charges( + [ + "1 -0.5 HASH c2\n", + "\n", + ], + type_df, + 0, + ) + + assert "HASH" in section + assert "c2" in section + + +# ============================================================================= +# modify_coords +# ============================================================================= + + +def test_modify_coords_filters_and_reindexes(): + type_df = make_type_df( + { + 2: 1, + 3: 2, + } + ) + + lines = [ + "1 1.0 2.0 3.0 # c1\n", + "2 4.0 5.0 6.0 # c2\n", + "3 7.0 8.0 9.0 # o\n", + "\n", + ] + + section = modify_coords( + lines, + type_df, + 0, + ) + + assert "4.000000" in section + assert "5.000000" in section + assert "6.000000" in section + + assert "7.000000" in section + assert "8.000000" in section + assert "9.000000" in section + + assert "1.000000" not in section + + +def test_modify_coords_formats_six_decimal_places(): + type_df = make_type_df( + { + 1: 1, + } + ) + + section = modify_coords( + [ + "1 1.1234567 -2.3456789 3.1 # c1\n", + "\n", + ], + type_df, + 0, + ) + + assert "1.123457" in section + assert "-2.345679" in section + assert "3.100000" in section + + +def test_modify_coords_no_matching_atoms_returns_empty_string(): + type_df = make_type_df( + { + 99: 1, + } + ) + + section = modify_coords( + [ + "1 1.0 2.0 3.0 # c\n", + "\n", + ], + type_df, + 0, + ) + + assert section == "" + + +def test_modify_coords_stops_at_blank_line(): + type_df = make_type_df( + { + 1: 1, + 2: 2, + } + ) + + section = modify_coords( + [ + "1 1.0 2.0 3.0 # c\n", + "\n", + "2 4.0 5.0 6.0 # o\n", + ], + type_df, + 0, + ) + + assert "1.000000" in section + assert "4.000000" not in section + + +# ============================================================================= +# modify_bonds +# ============================================================================= + + +def test_modify_bonds_keeps_bond_when_both_atoms_survive(): + type_df = make_type_df( + { + 5: 1, + 9: 2, + } + ) + + section, count = modify_bonds( + [ + "7 12 5 9 # c1 c2\n", + "\n", + ], + type_df, + 0, + True, + ) + + assert count == 1 + + parts = section.split() + + assert parts[:4] == [ + "1", + "12", + "1", + "2", + ] + + +def test_modify_bonds_drops_bond_if_first_atom_missing(): + type_df = make_type_df( + { + 2: 1, + } + ) + + section, count = modify_bonds( + [ + "1 5 1 2 # c1 c2\n", + "\n", + ], + type_df, + 0, + True, + ) + + assert section == "" + assert count == 0 + + +def test_modify_bonds_drops_bond_if_second_atom_missing(): + type_df = make_type_df( + { + 1: 1, + } + ) + + section, count = modify_bonds( + [ + "1 5 1 2 # c1 c2\n", + "\n", + ], + type_df, + 0, + True, + ) + + assert section == "" + assert count == 0 + + +def test_modify_bonds_reindexes_bond_ids_sequentially(): + type_df = make_type_df( + { + 1: 1, + 2: 2, + 3: 3, + } + ) + + section, count = modify_bonds( + [ + "50 5 1 2 # c1 c2\n", + "99 6 2 3 # c2 c3\n", + "\n", + ], + type_df, + 0, + True, + ) + + rows = section.strip().splitlines() + + assert count == 2 + + assert rows[0].split()[0] == "1" + assert rows[1].split()[0] == "2" + + +def test_modify_bonds_legacy_mode_uses_integer_type(): + type_df = make_type_df( + { + 1: 1, + 2: 2, + } + ) + + section, count = modify_bonds( + [ + "1 25 1 2 # c1 c2\n", + "\n", + ], + type_df, + 0, + True, + ) + + assert count == 1 + assert section.split()[1] == "25" + + +def test_modify_bonds_nonlegacy_mode_accepts_string_type(): + type_df = make_type_df( + { + 1: 1, + 2: 2, + } + ) + + section, count = modify_bonds( + [ + "1 c1-c2 1 2 # c1 c2\n", + "\n", + ], + type_df, + 0, + False, + ) + + assert count == 1 + + assert ( + section.split()[1] + == "c1-c2" + ) + + +def test_modify_bonds_preserves_atom_mapping_from_type_df(): + type_df = make_type_df( + { + 10: 2, + 20: 1, + } + ) + + section, _ = modify_bonds( + [ + "1 5 10 20 # a b\n", + "\n", + ], + type_df, + 0, + True, + ) + + parts = section.split() + + assert parts[2:4] == [ + "2", + "1", + ] + + +# ============================================================================= +# modify_angles +# ============================================================================= + + +def test_modify_angles_keeps_complete_angle(): + type_df = make_type_df( + { + 10: 1, + 20: 2, + 30: 3, + } + ) + + section, count = modify_angles( + [ + "9 7 10 20 30 # c1 c2 c3\n", + "\n", + ], + type_df, + 0, + True, + ) + + assert count == 1 + + parts = section.split() + + assert parts[:5] == [ + "1", + "7", + "1", + "2", + "3", + ] + + +def test_modify_angles_drops_angle_when_any_atom_missing(): + type_df = make_type_df( + { + 10: 1, + 20: 2, + } + ) + + section, count = modify_angles( + [ + "1 7 10 20 30 # c1 c2 c3\n", + "\n", + ], + type_df, + 0, + True, + ) + + assert section == "" + assert count == 0 + + +def test_modify_angles_reindexes_ids_sequentially(): + type_df = make_type_df( + { + 1: 1, + 2: 2, + 3: 3, + 4: 4, + } + ) + + section, count = modify_angles( + [ + "50 4 1 2 3 # a b c\n", + "80 5 2 3 4 # b c d\n", + "\n", + ], + type_df, + 0, + True, + ) + + rows = section.strip().splitlines() + + assert count == 2 + + assert rows[0].split()[0] == "1" + assert rows[1].split()[0] == "2" + + +def test_modify_angles_nonlegacy_accepts_string_type(): + type_df = make_type_df( + { + 1: 1, + 2: 2, + 3: 3, + } + ) + + section, count = modify_angles( + [ + "1 c1-c2-c3 1 2 3 # c1 c2 c3\n", + "\n", + ], + type_df, + 0, + False, + ) + + assert count == 1 + + assert ( + section.split()[1] + == "c1-c2-c3" + ) + + +# ============================================================================= +# modify_dihedrals +# ============================================================================= + + +def test_modify_dihedrals_keeps_complete_dihedral(): + type_df = make_type_df( + { + 10: 1, + 20: 2, + 30: 3, + 40: 4, + } + ) + + section, count = modify_dihedrals( + [ + "15 8 10 20 30 40 # a b c d\n", + "\n", + ], + type_df, + 0, + True, + ) + + assert count == 1 + + parts = section.split() + + assert parts[:6] == [ + "1", + "8", + "1", + "2", + "3", + "4", + ] + + +def test_modify_dihedrals_drops_when_any_atom_missing(): + type_df = make_type_df( + { + 10: 1, + 20: 2, + 30: 3, + } + ) + + section, count = modify_dihedrals( + [ + "1 8 10 20 30 40 # a b c d\n", + "\n", + ], + type_df, + 0, + True, + ) + + assert section == "" + assert count == 0 + + +def test_modify_dihedrals_reindexes_ids(): + type_df = make_type_df( + { + 1: 1, + 2: 2, + 3: 3, + 4: 4, + 5: 5, + } + ) + + section, count = modify_dihedrals( + [ + "90 8 1 2 3 4 # a b c d\n", + "92 9 2 3 4 5 # b c d e\n", + "\n", + ], + type_df, + 0, + True, + ) + + rows = section.strip().splitlines() + + assert count == 2 + + assert rows[0].split()[0] == "1" + assert rows[1].split()[0] == "2" + + +def test_modify_dihedrals_nonlegacy_accepts_string_type(): + type_df = make_type_df( + { + 1: 1, + 2: 2, + 3: 3, + 4: 4, + } + ) + + section, count = modify_dihedrals( + [ + "1 torsion_name 1 2 3 4 # a b c d\n", + "\n", + ], + type_df, + 0, + False, + ) + + assert count == 1 + + assert ( + section.split()[1] + == "torsion_name" + ) + + +# ============================================================================= +# modify_impropers +# ============================================================================= + + +def test_modify_impropers_keeps_complete_improper(): + type_df = make_type_df( + { + 10: 1, + 20: 2, + 30: 3, + 40: 4, + } + ) + + section, count = modify_impropers( + [ + "25 11 10 20 30 40 # a b c d\n", + "\n", + ], + type_df, + 0, + True, + ) + + assert count == 1 + + parts = section.split() + + assert parts[:6] == [ + "1", + "11", + "1", + "2", + "3", + "4", + ] + + +def test_modify_impropers_drops_when_any_atom_missing(): + type_df = make_type_df( + { + 10: 1, + 20: 2, + 30: 3, + } + ) + + section, count = modify_impropers( + [ + "1 11 10 20 30 40 # a b c d\n", + "\n", + ], + type_df, + 0, + True, + ) + + assert section == "" + assert count == 0 + + +def test_modify_impropers_reindexes_ids_sequentially(): + type_df = make_type_df( + { + 1: 1, + 2: 2, + 3: 3, + 4: 4, + 5: 5, + } + ) + + section, count = modify_impropers( + [ + "40 11 1 2 3 4 # a b c d\n", + "70 12 2 3 4 5 # b c d e\n", + "\n", + ], + type_df, + 0, + True, + ) + + rows = section.strip().splitlines() + + assert count == 2 + + assert rows[0].split()[0] == "1" + assert rows[1].split()[0] == "2" + + +def test_modify_impropers_nonlegacy_accepts_string_type(): + type_df = make_type_df( + { + 1: 1, + 2: 2, + 3: 3, + 4: 4, + } + ) + + section, count = modify_impropers( + [ + "1 improper_name 1 2 3 4 # a b c d\n", + "\n", + ], + type_df, + 0, + False, + ) + + assert count == 1 + + assert ( + section.split()[1] + == "improper_name" + ) + + +# ============================================================================= +# Filtering invariants across topology types +# ============================================================================= + + +@pytest.mark.parametrize( + "function,line", + [ + ( + modify_bonds, + "1 1 1 99 # a z\n", + ), + ( + modify_angles, + "1 1 1 2 99 # a b z\n", + ), + ( + modify_dihedrals, + "1 1 1 2 3 99 # a b c z\n", + ), + ( + modify_impropers, + "1 1 1 2 3 99 # a b c z\n", + ), + ], +) +def test_topology_entry_removed_when_it_references_atom_outside_template( + function, + line, +): + type_df = make_type_df( + { + 1: 1, + 2: 2, + 3: 3, + } + ) + + section, count = function( + [ + line, + "\n", + ], + type_df, + 0, + True, + ) + + assert section == "" + assert count == 0 + + +@pytest.mark.parametrize( + "function,line", + [ + ( + modify_bonds, + "1 10 5 10 # a b\n", + ), + ( + modify_angles, + "1 10 5 10 15 # a b c\n", + ), + ( + modify_dihedrals, + "1 10 5 10 15 20 # a b c d\n", + ), + ( + modify_impropers, + "1 10 5 10 15 20 # a b c d\n", + ), + ], +) +def test_topology_functions_preserve_nontrivial_atom_remapping( + function, + line, +): + type_df = make_type_df( + { + 5: 4, + 10: 3, + 15: 2, + 20: 1, + } + ) + + section, count = function( + [ + line, + "\n", + ], + type_df, + 0, + True, + ) + + assert count == 1 + + parts = section.split() + + if function is modify_bonds: + assert parts[2:4] == [ + "4", + "3", + ] + + elif function is modify_angles: + assert parts[2:5] == [ + "4", + "3", + "2", + ] + + else: + assert parts[2:6] == [ + "4", + "3", + "2", + "1", + ] + + +# ============================================================================= +# Malformed / short rows +# ============================================================================= + + +def test_modify_bonds_ignores_short_rows(): + type_df = make_type_df( + { + 1: 1, + 2: 2, + } + ) + + section, count = modify_bonds( + [ + "1 2 1\n", + "2 3 1 2 # a b\n", + "\n", + ], + type_df, + 0, + True, + ) + + assert count == 1 + assert section.split()[0] == "1" + + +def test_modify_angles_ignores_short_rows(): + type_df = make_type_df( + { + 1: 1, + 2: 2, + 3: 3, + } + ) + + section, count = modify_angles( + [ + "1 2 1 2\n", + "2 3 1 2 3 # a b c\n", + "\n", + ], + type_df, + 0, + True, + ) + + assert count == 1 + + +def test_modify_dihedrals_ignores_short_rows(): + type_df = make_type_df( + { + 1: 1, + 2: 2, + 3: 3, + 4: 4, + } + ) + + section, count = modify_dihedrals( + [ + "1 2 1 2 3\n", + "2 3 1 2 3 4 # a b c d\n", + "\n", + ], + type_df, + 0, + True, + ) + + assert count == 1 + + +def test_modify_impropers_ignores_short_rows(): + type_df = make_type_df( + { + 1: 1, + 2: 2, + 3: 3, + 4: 4, + } + ) + + section, count = modify_impropers( + [ + "1 2 1 2 3\n", + "2 3 1 2 3 4 # a b c d\n", + "\n", + ], + type_df, + 0, + True, + ) + + assert count == 1 + + +# ============================================================================= +# Cross-function integration +# ============================================================================= + + +def test_modify_types_mapping_can_drive_all_topology_modifiers(): + type_lines = [ + "10 5 # c1\n", + "20 6 # c2\n", + "30 7 # c3\n", + "40 8 # c4\n", + "\n", + ] + + ( + type_df, + _, + number_of_types, + index_mapping, + legacy_mode, + ) = modify_types( + type_lines, + [ + 10, + 20, + 30, + 40, + ], + 0, + ) + + assert number_of_types == 4 + + assert index_mapping == { + 10: 1, + 20: 2, + 30: 3, + 40: 4, + } + + assert legacy_mode is True + + bond_section, bonds = modify_bonds( + [ + "1 1 10 20 # c1 c2\n", + "\n", + ], + type_df, + 0, + legacy_mode, + ) + + angle_section, angles = modify_angles( + [ + "1 1 10 20 30 # c1 c2 c3\n", + "\n", + ], + type_df, + 0, + legacy_mode, + ) + + dihedral_section, dihedrals = ( + modify_dihedrals( + [ + "1 1 10 20 30 40 # c1 c2 c3 c4\n", + "\n", + ], + type_df, + 0, + legacy_mode, + ) + ) + + improper_section, impropers = ( + modify_impropers( + [ + "1 1 10 20 30 40 # c1 c2 c3 c4\n", + "\n", + ], + type_df, + 0, + legacy_mode, + ) + ) + + assert bonds == 1 + assert angles == 1 + assert dihedrals == 1 + assert impropers == 1 + + assert bond_section.split()[2:4] == [ + "1", + "2", + ] + + assert angle_section.split()[2:5] == [ + "1", + "2", + "3", + ] + + assert dihedral_section.split()[2:6] == [ + "1", + "2", + "3", + "4", + ] + + assert improper_section.split()[2:6] == [ + "1", + "2", + "3", + "4", + ] + + +def test_string_type_mode_propagates_to_topology_modifiers(): + type_lines = [ + "1 c1 # c1\n", + "2 c2 # c2\n", + "3 c3 # c3\n", + "4 c4 # c4\n", + "\n", + ] + + ( + type_df, + _, + _, + _, + legacy_mode, + ) = modify_types( + type_lines, + [ + 1, + 2, + 3, + 4, + ], + 0, + ) + + assert legacy_mode is False + + bond_section, _ = modify_bonds( + [ + "1 c1-c2 1 2 # c1 c2\n", + "\n", + ], + type_df, + 0, + legacy_mode, + ) + + angle_section, _ = modify_angles( + [ + "1 c1-c2-c3 1 2 3 # c1 c2 c3\n", + "\n", + ], + type_df, + 0, + legacy_mode, + ) + + dihedral_section, _ = modify_dihedrals( + [ + "1 c1-c2-c3-c4 1 2 3 4 # c1 c2 c3 c4\n", + "\n", + ], + type_df, + 0, + legacy_mode, + ) + + improper_section, _ = modify_impropers( + [ + "1 improper-c1 1 2 3 4 # c1 c2 c3 c4\n", + "\n", + ], + type_df, + 0, + legacy_mode, + ) + + assert ( + bond_section.split()[1] + == "c1-c2" + ) + + assert ( + angle_section.split()[1] + == "c1-c2-c3" + ) + + assert ( + dihedral_section.split()[1] + == "c1-c2-c3-c4" + ) + + assert ( + improper_section.split()[1] + == "improper-c1" + ) diff --git a/tests/unit/reaction_preparation/ff_wrapper/test_molecule_3d_preparation.py b/tests/unit/reaction_preparation/ff_wrapper/test_molecule_3d_preparation.py new file mode 100644 index 00000000..b41a814c --- /dev/null +++ b/tests/unit/reaction_preparation/ff_wrapper/test_molecule_3d_preparation.py @@ -0,0 +1,2288 @@ +from pathlib import Path +from types import SimpleNamespace + +import numpy as np +import pytest +from rdkit import Chem + +import AutoREACTER.reaction_preparation.ff_wrapper.molecule_3d_preparation as molecule_3d +from AutoREACTER.reaction_preparation.ff_wrapper.molecule_3d_preparation import ( + FragmentSeparationError, + Molecule3DPreparation, + Molecule3DPreparationError, + OptimizationError, +) + + +# ============================================================================= +# Helpers +# ============================================================================= + + +def make_session( + tmp_path, + *, + monomers=None, + reactions=None, +): + inputs = SimpleNamespace( + monomers=list( + monomers or [] + ) + ) + + return SimpleNamespace( + inputs=inputs, + reaction_metadata=list( + reactions or [] + ), + staging_dir=tmp_path / "staging", + ) + + +def make_monomer( + *, + name="mma", + status=True, + rdkit_mol=None, +): + return SimpleNamespace( + name=name, + status=status, + rdkit_mol=rdkit_mol, + molecule_3Dmol_path=None, + ) + + +def make_reaction( + *, + reaction_id=1, + activity_stats=True, + reactant=None, + product=None, +): + return SimpleNamespace( + reaction_id=reaction_id, + activity_stats=activity_stats, + reactant_combined_RDmol=reactant, + product_combined_RDmol=product, + reactant_combined_3Dmol_path=None, + product_combined_3Dmol_path=None, + ) + + +def make_two_fragment_mol(): + """ + Create two disconnected carbon atoms with a manually assigned conformer. + No embedding is required for the separation tests. + """ + mol = Chem.MolFromSmiles( + "[C].[C]" + ) + + conf = Chem.Conformer( + mol.GetNumAtoms() + ) + + conf.SetAtomPosition( + 0, + ( + 1.0, + 2.0, + 3.0, + ), + ) + + conf.SetAtomPosition( + 1, + ( + 10.0, + 20.0, + 30.0, + ), + ) + + mol.AddConformer( + conf + ) + + return mol + + +def make_three_fragment_mol(): + mol = Chem.MolFromSmiles( + "[C].[C].[C]" + ) + + conf = Chem.Conformer( + mol.GetNumAtoms() + ) + + for i in range( + mol.GetNumAtoms() + ): + conf.SetAtomPosition( + i, + ( + float(i), + 0.0, + 0.0, + ), + ) + + mol.AddConformer( + conf + ) + + return mol + + +def make_explicit_ch3_with_duplicate_h_property(): + """ + Build a carbon with three real H neighbors plus an erroneous + NumExplicitHs property of 1. + + This reproduces the kind of duplicate hydrogen bookkeeping that + _repair_reaction_molecule_for_3d() is designed to repair. + """ + editable = Chem.RWMol() + + carbon = Chem.Atom(6) + carbon.SetNumExplicitHs(1) + + carbon_idx = editable.AddAtom( + carbon + ) + + for _ in range(3): + hydrogen_idx = ( + editable.AddAtom( + Chem.Atom(1) + ) + ) + + editable.AddBond( + carbon_idx, + hydrogen_idx, + Chem.BondType.SINGLE, + ) + + mol = editable.GetMol() + + mol.UpdatePropertyCache( + strict=False + ) + + return mol + + +def make_explicit_ch4_with_radical(): + """ + Build carbon with four explicit H neighbors but incorrectly mark it + as a radical. Repair should clear that radical because bond valence + is already four. + """ + editable = Chem.RWMol() + + carbon = Chem.Atom(6) + carbon.SetNumRadicalElectrons( + 1 + ) + + carbon_idx = editable.AddAtom( + carbon + ) + + for _ in range(4): + hydrogen_idx = ( + editable.AddAtom( + Chem.Atom(1) + ) + ) + + editable.AddBond( + carbon_idx, + hydrogen_idx, + Chem.BondType.SINGLE, + ) + + mol = editable.GetMol() + + mol.UpdatePropertyCache( + strict=False + ) + + return mol + + +def patch_basic_optimization_stack( + preparer, + monkeypatch, + *, + embed_result=0, + mmff_available=True, + mmff_result=0, + uff_available=False, + uff_result=0, +): + """ + Patch the expensive/variable RDKit 3D operations while preserving + _optimization() control flow. + """ + events = [] + + params = SimpleNamespace( + randomSeed=None, + useRandomCoords=False, + ignoreSmoothingFailures=False, + ) + + monkeypatch.setattr( + preparer, + "_repair_reaction_molecule_for_3d", + lambda mol: mol, + ) + + monkeypatch.setattr( + molecule_3d.AllChem, + "ETKDGv3", + lambda: params, + ) + + def fake_embed( + mol, + passed_params, + ): + events.append( + ( + "embed", + mol, + passed_params, + ) + ) + + return embed_result + + monkeypatch.setattr( + molecule_3d.AllChem, + "EmbedMolecule", + fake_embed, + ) + + monkeypatch.setattr( + molecule_3d.AllChem, + "MMFFHasAllMoleculeParams", + lambda mol: + mmff_available, + ) + + def fake_mmff( + mol, + maxIters, + ): + events.append( + ( + "mmff", + maxIters, + ) + ) + + return mmff_result + + monkeypatch.setattr( + molecule_3d.AllChem, + "MMFFOptimizeMolecule", + fake_mmff, + ) + + monkeypatch.setattr( + molecule_3d.AllChem, + "UFFHasAllMoleculeParams", + lambda mol: + uff_available, + ) + + def fake_uff( + mol, + maxIters, + ): + events.append( + ( + "uff", + maxIters, + ) + ) + + return uff_result + + monkeypatch.setattr( + molecule_3d.AllChem, + "UFFOptimizeMolecule", + fake_uff, + ) + + def fake_write( + mol, + filename, + **kwargs, + ): + events.append( + ( + "write", + Path(filename), + kwargs, + ) + ) + + Path( + filename + ).write_text( + "fake mol file", + encoding="utf-8", + ) + + monkeypatch.setattr( + molecule_3d.Chem, + "MolToMolFile", + fake_write, + ) + + return events, params + + +# ============================================================================= +# Exception hierarchy +# ============================================================================= + + +def test_molecule_3d_preparation_error_is_exception(): + assert issubclass( + Molecule3DPreparationError, + Exception, + ) + + +def test_fragment_separation_error_inherits_base_error(): + assert issubclass( + FragmentSeparationError, + Molecule3DPreparationError, + ) + + +def test_optimization_error_inherits_base_error(): + assert issubclass( + OptimizationError, + Molecule3DPreparationError, + ) + + +# ============================================================================= +# Constructor / cache +# ============================================================================= + + +def test_constructor_stores_session( + tmp_path, +): + session = make_session( + tmp_path + ) + + preparer = ( + Molecule3DPreparation( + session + ) + ) + + assert ( + preparer.session + is session + ) + + assert ( + preparer.inputs + is session.inputs + ) + + +def test_constructor_creates_cache_directories( + tmp_path, +): + session = make_session( + tmp_path + ) + + preparer = ( + Molecule3DPreparation( + session + ) + ) + + assert ( + preparer.cache_dir + == tmp_path + / "staging" + / "3D_molecules" + ) + + assert ( + preparer.molecule_3d_path + == preparer.cache_dir + / "molecules_3Dmol" + ) + + assert ( + preparer.full_templates_path + == preparer.cache_dir + / "full_templates_3Dmol" + ) + + assert ( + preparer.cache_dir.is_dir() + ) + + assert ( + preparer + .molecule_3d_path + .is_dir() + ) + + assert ( + preparer + .full_templates_path + .is_dir() + ) + + +def test_cache_property_returns_main_cache( + tmp_path, +): + session = make_session( + tmp_path + ) + + preparer = ( + Molecule3DPreparation( + session + ) + ) + + assert ( + preparer.cache + == preparer.cache_dir + ) + + +# ============================================================================= +# prepare_molecule_3d_geometry - monomers +# ============================================================================= + + +def test_prepare_active_monomer_calls_add_hs_and_optimization( + tmp_path, + monkeypatch, +): + original_mol = ( + Chem.MolFromSmiles( + "CC" + ) + ) + + monomer = make_monomer( + name="ethane", + status=True, + rdkit_mol=original_mol, + ) + + session = make_session( + tmp_path, + monomers=[ + monomer + ], + ) + + preparer = ( + Molecule3DPreparation( + session + ) + ) + + added_hs = object() + + add_h_calls = [] + + monkeypatch.setattr( + molecule_3d.Chem, + "AddHs", + lambda mol: + ( + add_h_calls.append( + mol + ) + or added_hs + ), + ) + + optimization_calls = [] + + output_path = ( + preparer.molecule_3d_path + / "ethane.mol" + ) + + def fake_optimization( + *, + molecule_name, + mol, + cache_dir, + separate_fragments=False, + ): + optimization_calls.append( + ( + molecule_name, + mol, + cache_dir, + separate_fragments, + ) + ) + + return output_path + + monkeypatch.setattr( + preparer, + "_optimization", + fake_optimization, + ) + + result = ( + preparer + .prepare_molecule_3d_geometry( + session + ) + ) + + assert result is None + + assert add_h_calls == [ + original_mol + ] + + assert optimization_calls == [ + ( + "ethane", + added_hs, + preparer.molecule_3d_path, + False, + ) + ] + + assert ( + monomer.molecule_3Dmol_path + == output_path + ) + + +def test_prepare_skips_inactive_monomer( + tmp_path, + monkeypatch, +): + monomer = make_monomer( + name="inactive", + status=False, + rdkit_mol=None, + ) + + session = make_session( + tmp_path, + monomers=[ + monomer + ], + ) + + preparer = ( + Molecule3DPreparation( + session + ) + ) + + monkeypatch.setattr( + preparer, + "_optimization", + lambda **kwargs: + pytest.fail( + "inactive monomer must not be optimized" + ), + ) + + preparer.prepare_molecule_3d_geometry( + session + ) + + assert ( + monomer.molecule_3Dmol_path + is None + ) + + +def test_prepare_active_monomer_without_rdkit_mol_raises( + tmp_path, +): + monomer = make_monomer( + name="mma", + status=True, + rdkit_mol=None, + ) + + session = make_session( + tmp_path, + monomers=[ + monomer + ], + ) + + preparer = ( + Molecule3DPreparation( + session + ) + ) + + with pytest.raises( + Molecule3DPreparationError, + match="RDKit Mol object is missing for molecule mma", + ): + preparer.prepare_molecule_3d_geometry( + session + ) + + +def test_prepare_monomer_optimization_error_is_wrapped( + tmp_path, + monkeypatch, +): + monomer = make_monomer( + name="styrene", + status=True, + rdkit_mol=( + Chem.MolFromSmiles( + "C=C" + ) + ), + ) + + session = make_session( + tmp_path, + monomers=[ + monomer + ], + ) + + preparer = ( + Molecule3DPreparation( + session + ) + ) + + monkeypatch.setattr( + preparer, + "_optimization", + lambda **kwargs: + (_ for _ in ()).throw( + RuntimeError( + "boom" + ) + ), + ) + + with pytest.raises( + OptimizationError, + match="Error optimizing molecule styrene: boom", + ) as exc_info: + preparer.prepare_molecule_3d_geometry( + session + ) + + assert isinstance( + exc_info.value.__cause__, + RuntimeError, + ) + + +# ============================================================================= +# prepare_molecule_3d_geometry - reactions +# ============================================================================= + + +def test_prepare_skips_inactive_reaction( + tmp_path, + monkeypatch, +): + reaction = make_reaction( + reaction_id=5, + activity_stats=False, + reactant=object(), + product=object(), + ) + + session = make_session( + tmp_path, + reactions=[ + reaction + ], + ) + + preparer = ( + Molecule3DPreparation( + session + ) + ) + + monkeypatch.setattr( + preparer, + "_optimization", + lambda **kwargs: + pytest.fail( + "inactive reaction must not be optimized" + ), + ) + + preparer.prepare_molecule_3d_geometry( + session + ) + + assert ( + reaction + .reactant_combined_3Dmol_path + is None + ) + + assert ( + reaction + .product_combined_3Dmol_path + is None + ) + + +def test_prepare_reaction_pre_and_post( + tmp_path, + monkeypatch, +): + pre_mol = object() + post_mol = object() + + reaction = make_reaction( + reaction_id=12, + activity_stats=True, + reactant=pre_mol, + product=post_mol, + ) + + session = make_session( + tmp_path, + reactions=[ + reaction + ], + ) + + preparer = ( + Molecule3DPreparation( + session + ) + ) + + calls = [] + + def fake_optimization( + *, + molecule_name, + mol, + cache_dir, + separate_fragments=False, + ): + calls.append( + ( + molecule_name, + mol, + cache_dir, + separate_fragments, + ) + ) + + return ( + Path(cache_dir) + / f"{molecule_name}.mol" + ) + + monkeypatch.setattr( + preparer, + "_optimization", + fake_optimization, + ) + + preparer.prepare_molecule_3d_geometry( + session + ) + + assert calls == [ + ( + "pre12", + pre_mol, + preparer.full_templates_path, + True, + ), + ( + "post12", + post_mol, + preparer.full_templates_path, + True, + ), + ] + + assert ( + reaction + .reactant_combined_3Dmol_path + == preparer.full_templates_path + / "pre12.mol" + ) + + assert ( + reaction + .product_combined_3Dmol_path + == preparer.full_templates_path + / "post12.mol" + ) + + +def test_prepare_reaction_missing_pre_molecule_is_skipped( + tmp_path, + monkeypatch, +): + product = object() + + reaction = make_reaction( + reaction_id=2, + reactant=None, + product=product, + ) + + session = make_session( + tmp_path, + reactions=[ + reaction + ], + ) + + preparer = ( + Molecule3DPreparation( + session + ) + ) + + calls = [] + + monkeypatch.setattr( + preparer, + "_optimization", + lambda **kwargs: + ( + calls.append( + kwargs["molecule_name"] + ) + or Path( + f"/tmp/{kwargs['molecule_name']}.mol" + ) + ), + ) + + preparer.prepare_molecule_3d_geometry( + session + ) + + assert calls == [ + "post2" + ] + + +def test_prepare_reaction_missing_post_molecule_is_skipped( + tmp_path, + monkeypatch, +): + reactant = object() + + reaction = make_reaction( + reaction_id=2, + reactant=reactant, + product=None, + ) + + session = make_session( + tmp_path, + reactions=[ + reaction + ], + ) + + preparer = ( + Molecule3DPreparation( + session + ) + ) + + calls = [] + + monkeypatch.setattr( + preparer, + "_optimization", + lambda **kwargs: + ( + calls.append( + kwargs["molecule_name"] + ) + or Path( + f"/tmp/{kwargs['molecule_name']}.mol" + ) + ), + ) + + preparer.prepare_molecule_3d_geometry( + session + ) + + assert calls == [ + "pre2" + ] + + +def test_prepare_reactant_optimization_error_is_wrapped( + tmp_path, + monkeypatch, +): + reaction = make_reaction( + reaction_id=8, + reactant=object(), + product=None, + ) + + session = make_session( + tmp_path, + reactions=[ + reaction + ], + ) + + preparer = ( + Molecule3DPreparation( + session + ) + ) + + monkeypatch.setattr( + preparer, + "_optimization", + lambda **kwargs: + (_ for _ in ()).throw( + RuntimeError( + "pre failure" + ) + ), + ) + + with pytest.raises( + OptimizationError, + match=( + "Error optimizing reactant complex " + "for reaction 8: pre failure" + ), + ): + preparer.prepare_molecule_3d_geometry( + session + ) + + +def test_prepare_product_optimization_error_is_wrapped( + tmp_path, + monkeypatch, +): + reaction = make_reaction( + reaction_id=9, + reactant=None, + product=object(), + ) + + session = make_session( + tmp_path, + reactions=[ + reaction + ], + ) + + preparer = ( + Molecule3DPreparation( + session + ) + ) + + monkeypatch.setattr( + preparer, + "_optimization", + lambda **kwargs: + (_ for _ in ()).throw( + RuntimeError( + "post failure" + ) + ), + ) + + with pytest.raises( + OptimizationError, + match=( + "Error optimizing product complex " + "for reaction 9: post failure" + ), + ): + preparer.prepare_molecule_3d_geometry( + session + ) + + +# ============================================================================= +# _separate_fragments_3d +# ============================================================================= + + +def test_separate_fragments_single_fragment_returns_same_object( + tmp_path, +): + session = make_session( + tmp_path + ) + + preparer = ( + Molecule3DPreparation( + session + ) + ) + + mol = Chem.MolFromSmiles( + "CC" + ) + + result = ( + preparer + ._separate_fragments_3d( + mol + ) + ) + + assert result is mol + + +def test_separate_fragments_more_than_two_raises( + tmp_path, +): + session = make_session( + tmp_path + ) + + preparer = ( + Molecule3DPreparation( + session + ) + ) + + mol = ( + make_three_fragment_mol() + ) + + with pytest.raises( + FragmentSeparationError, + match="Expected 2 fragments", + ): + preparer._separate_fragments_3d( + mol + ) + + +def test_separate_fragments_moves_only_second_fragment( + tmp_path, +): + session = make_session( + tmp_path + ) + + preparer = ( + Molecule3DPreparation( + session + ) + ) + + mol = make_two_fragment_mol() + + conf = mol.GetConformer() + + first_before = np.array( + conf.GetAtomPosition(0) + ) + + second_before = np.array( + conf.GetAtomPosition(1) + ) + + result = ( + preparer + ._separate_fragments_3d( + mol + ) + ) + + conf_after = ( + result.GetConformer() + ) + + first_after = np.array( + conf_after.GetAtomPosition(0) + ) + + second_after = np.array( + conf_after.GetAtomPosition(1) + ) + + np.testing.assert_allclose( + first_after, + first_before, + ) + + # [C].[C] has molecular weight ~24, + # round(24/100) == 0, therefore shift = 4 Å. + np.testing.assert_allclose( + second_after, + second_before + + np.array( + [ + 4.0, + 0.0, + 0.0, + ] + ), + ) + + +def test_separate_fragments_returns_same_molecule_instance( + tmp_path, +): + session = make_session( + tmp_path + ) + + preparer = ( + Molecule3DPreparation( + session + ) + ) + + mol = make_two_fragment_mol() + + result = ( + preparer + ._separate_fragments_3d( + mol + ) + ) + + assert result is mol + + +# ============================================================================= +# _repair_reaction_molecule_for_3d +# ============================================================================= + + +def test_repair_does_not_change_atom_count( + tmp_path, +): + session = make_session( + tmp_path + ) + + preparer = ( + Molecule3DPreparation( + session + ) + ) + + mol = ( + make_explicit_ch3_with_duplicate_h_property() + ) + + count_before = ( + mol.GetNumAtoms() + ) + + repaired = ( + preparer + ._repair_reaction_molecule_for_3d( + mol + ) + ) + + assert ( + repaired.GetNumAtoms() + == count_before + ) + + +def test_repair_clears_duplicate_explicit_h_property( + tmp_path, +): + session = make_session( + tmp_path + ) + + preparer = ( + Molecule3DPreparation( + session + ) + ) + + mol = ( + make_explicit_ch3_with_duplicate_h_property() + ) + + assert ( + mol.GetAtomWithIdx(0) + .GetNumExplicitHs() + == 1 + ) + + repaired = ( + preparer + ._repair_reaction_molecule_for_3d( + mol + ) + ) + + carbon = ( + repaired.GetAtomWithIdx( + 0 + ) + ) + + assert ( + carbon.GetNumExplicitHs() + == 0 + ) + + +def test_repair_sets_no_implicit_when_real_h_neighbors_exist( + tmp_path, +): + session = make_session( + tmp_path + ) + + preparer = ( + Molecule3DPreparation( + session + ) + ) + + repaired = ( + preparer + ._repair_reaction_molecule_for_3d( + make_explicit_ch3_with_duplicate_h_property() + ) + ) + + carbon = ( + repaired.GetAtomWithIdx( + 0 + ) + ) + + assert ( + carbon.GetNoImplicit() + is True + ) + + +def test_repair_valence_three_neutral_carbon_becomes_radical( + tmp_path, +): + session = make_session( + tmp_path + ) + + preparer = ( + Molecule3DPreparation( + session + ) + ) + + repaired = ( + preparer + ._repair_reaction_molecule_for_3d( + make_explicit_ch3_with_duplicate_h_property() + ) + ) + + carbon = ( + repaired.GetAtomWithIdx( + 0 + ) + ) + + assert ( + carbon.GetNumRadicalElectrons() + == 1 + ) + + +def test_repair_valence_four_carbon_clears_radical( + tmp_path, +): + session = make_session( + tmp_path + ) + + preparer = ( + Molecule3DPreparation( + session + ) + ) + + mol = ( + make_explicit_ch4_with_radical() + ) + + assert ( + mol.GetAtomWithIdx(0) + .GetNumRadicalElectrons() + == 1 + ) + + repaired = ( + preparer + ._repair_reaction_molecule_for_3d( + mol + ) + ) + + carbon = ( + repaired.GetAtomWithIdx( + 0 + ) + ) + + assert ( + carbon.GetNumRadicalElectrons() + == 0 + ) + + +def test_repair_does_not_mutate_original_molecule( + tmp_path, +): + session = make_session( + tmp_path + ) + + preparer = ( + Molecule3DPreparation( + session + ) + ) + + original = ( + make_explicit_ch3_with_duplicate_h_property() + ) + + original_explicit_h = ( + original.GetAtomWithIdx(0) + .GetNumExplicitHs() + ) + + repaired = ( + preparer + ._repair_reaction_molecule_for_3d( + original + ) + ) + + assert repaired is not original + + assert ( + original.GetAtomWithIdx(0) + .GetNumExplicitHs() + == original_explicit_h + ) + + assert ( + repaired.GetAtomWithIdx(0) + .GetNumExplicitHs() + == 0 + ) + + +# ============================================================================= +# _optimization - setup / embedding +# ============================================================================= + + +def test_optimization_returns_expected_output_path( + tmp_path, + monkeypatch, +): + session = make_session( + tmp_path + ) + + preparer = ( + Molecule3DPreparation( + session + ) + ) + + events, _ = ( + patch_basic_optimization_stack( + preparer, + monkeypatch, + ) + ) + + output_dir = ( + tmp_path / "output" + ) + + result = preparer._optimization( + molecule_name="styrene", + mol=Chem.MolFromSmiles( + "CC" + ), + cache_dir=output_dir, + ) + + assert result == ( + output_dir + / "styrene.mol" + ) + + assert result.is_file() + + +def test_optimization_creates_output_directory( + tmp_path, + monkeypatch, +): + session = make_session( + tmp_path + ) + + preparer = ( + Molecule3DPreparation( + session + ) + ) + + patch_basic_optimization_stack( + preparer, + monkeypatch, + ) + + output_dir = ( + tmp_path + / "deep" + / "nested" + / "output" + ) + + assert not output_dir.exists() + + preparer._optimization( + "test", + Chem.MolFromSmiles( + "CC" + ), + output_dir, + ) + + assert output_dir.is_dir() + + +def test_optimization_sets_deterministic_etkdg_parameters( + tmp_path, + monkeypatch, +): + session = make_session( + tmp_path + ) + + preparer = ( + Molecule3DPreparation( + session + ) + ) + + _, params = ( + patch_basic_optimization_stack( + preparer, + monkeypatch, + ) + ) + + preparer._optimization( + "test", + Chem.MolFromSmiles( + "CC" + ), + tmp_path, + ) + + assert ( + params.randomSeed + == 0xF00D + ) + + assert ( + params.useRandomCoords + is True + ) + + assert ( + params.ignoreSmoothingFailures + is True + ) + + +def test_optimization_embedding_failure_raises( + tmp_path, + monkeypatch, +): + session = make_session( + tmp_path + ) + + preparer = ( + Molecule3DPreparation( + session + ) + ) + + patch_basic_optimization_stack( + preparer, + monkeypatch, + embed_result=-1, + ) + + with pytest.raises( + OptimizationError, + match=( + "Failed to embed molecule test " + "in 3D" + ), + ): + preparer._optimization( + "test", + Chem.MolFromSmiles( + "CC" + ), + tmp_path, + ) + + +def test_optimization_repair_failure_is_wrapped( + tmp_path, + monkeypatch, +): + session = make_session( + tmp_path + ) + + preparer = ( + Molecule3DPreparation( + session + ) + ) + + def fail_repair( + mol, + ): + raise ValueError( + "bad valence" + ) + + monkeypatch.setattr( + preparer, + "_repair_reaction_molecule_for_3d", + fail_repair, + ) + + with pytest.raises( + OptimizationError, + match=( + "Failed to repair molecule test " + "before 3D embedding" + ), + ) as exc_info: + preparer._optimization( + "test", + Chem.MolFromSmiles( + "CC" + ), + tmp_path, + ) + + assert isinstance( + exc_info.value.__cause__, + ValueError, + ) + + +# ============================================================================= +# _optimization - fragment separation +# ============================================================================= + + +def test_optimization_does_not_separate_by_default( + tmp_path, + monkeypatch, +): + session = make_session( + tmp_path + ) + + preparer = ( + Molecule3DPreparation( + session + ) + ) + + patch_basic_optimization_stack( + preparer, + monkeypatch, + ) + + monkeypatch.setattr( + preparer, + "_separate_fragments_3d", + lambda mol: + pytest.fail( + "fragment separation should not run" + ), + ) + + preparer._optimization( + "test", + Chem.MolFromSmiles( + "CC" + ), + tmp_path, + separate_fragments=False, + ) + + +def test_optimization_separates_when_requested( + tmp_path, + monkeypatch, +): + session = make_session( + tmp_path + ) + + preparer = ( + Molecule3DPreparation( + session + ) + ) + + patch_basic_optimization_stack( + preparer, + monkeypatch, + ) + + calls = [] + + monkeypatch.setattr( + preparer, + "_separate_fragments_3d", + lambda mol: + ( + calls.append(mol) + or mol + ), + ) + + preparer._optimization( + "test", + Chem.MolFromSmiles( + "C.C" + ), + tmp_path, + separate_fragments=True, + ) + + assert len(calls) == 1 + + +# ============================================================================= +# _optimization - force-field selection +# ============================================================================= + + +def test_optimization_prefers_mmff_when_available( + tmp_path, + monkeypatch, +): + session = make_session( + tmp_path + ) + + preparer = ( + Molecule3DPreparation( + session + ) + ) + + events, _ = ( + patch_basic_optimization_stack( + preparer, + monkeypatch, + mmff_available=True, + uff_available=True, + ) + ) + + preparer._optimization( + "test", + Chem.MolFromSmiles( + "CC" + ), + tmp_path, + ) + + assert ( + "mmff", + 1000, + ) in events + + assert not any( + event[0] == "uff" + for event in events + ) + + +def test_optimization_uses_uff_when_mmff_unavailable( + tmp_path, + monkeypatch, +): + session = make_session( + tmp_path + ) + + preparer = ( + Molecule3DPreparation( + session + ) + ) + + events, _ = ( + patch_basic_optimization_stack( + preparer, + monkeypatch, + mmff_available=False, + uff_available=True, + ) + ) + + preparer._optimization( + "test", + Chem.MolFromSmiles( + "CC" + ), + tmp_path, + ) + + assert ( + "uff", + 1000, + ) in events + + assert not any( + event[0] == "mmff" + for event in events + ) + + +def test_optimization_without_mmff_or_uff_still_saves( + tmp_path, + monkeypatch, + capsys, +): + session = make_session( + tmp_path + ) + + preparer = ( + Molecule3DPreparation( + session + ) + ) + + events, _ = ( + patch_basic_optimization_stack( + preparer, + monkeypatch, + mmff_available=False, + uff_available=False, + ) + ) + + result = preparer._optimization( + "unsupported", + Chem.MolFromSmiles( + "CC" + ), + tmp_path, + ) + + assert result.is_file() + + assert ( + "no MMFF or UFF parameters are available" + in capsys.readouterr().out + ) + + assert not any( + event[0] in { + "mmff", + "uff", + } + for event in events + ) + + +def test_mmff_failure_minus_one_raises( + tmp_path, + monkeypatch, +): + session = make_session( + tmp_path + ) + + preparer = ( + Molecule3DPreparation( + session + ) + ) + + patch_basic_optimization_stack( + preparer, + monkeypatch, + mmff_available=True, + mmff_result=-1, + ) + + with pytest.raises( + OptimizationError, + match=( + "MMFF optimization failed " + "for test" + ), + ): + preparer._optimization( + "test", + Chem.MolFromSmiles( + "CC" + ), + tmp_path, + ) + + +def test_uff_failure_minus_one_raises( + tmp_path, + monkeypatch, +): + session = make_session( + tmp_path + ) + + preparer = ( + Molecule3DPreparation( + session + ) + ) + + patch_basic_optimization_stack( + preparer, + monkeypatch, + mmff_available=False, + uff_available=True, + uff_result=-1, + ) + + with pytest.raises( + OptimizationError, + match=( + "UFF optimization failed " + "for test" + ), + ): + preparer._optimization( + "test", + Chem.MolFromSmiles( + "CC" + ), + tmp_path, + ) + + +@pytest.mark.parametrize( + "use_mmff", + [ + True, + False, + ], +) +def test_nonconverged_optimization_warns_but_saves( + tmp_path, + monkeypatch, + capsys, + use_mmff, +): + session = make_session( + tmp_path + ) + + preparer = ( + Molecule3DPreparation( + session + ) + ) + + patch_basic_optimization_stack( + preparer, + monkeypatch, + mmff_available=use_mmff, + mmff_result=1, + uff_available=not use_mmff, + uff_result=1, + ) + + result = preparer._optimization( + "test", + Chem.MolFromSmiles( + "CC" + ), + tmp_path, + ) + + assert result.is_file() + + output = ( + capsys.readouterr().out + ) + + if use_mmff: + assert ( + "MMFF optimization did not converge" + in output + ) + else: + assert ( + "UFF optimization did not converge" + in output + ) + + +# ============================================================================= +# _optimization - atom-count invariant +# ============================================================================= + + +def test_optimization_rejects_atom_count_change_during_repair( + tmp_path, + monkeypatch, +): + session = make_session( + tmp_path + ) + + preparer = ( + Molecule3DPreparation( + session + ) + ) + + original = ( + Chem.MolFromSmiles( + "C" + ) + ) + + monkeypatch.setattr( + preparer, + "_repair_reaction_molecule_for_3d", + lambda mol: + Chem.AddHs( + mol + ), + ) + + params = SimpleNamespace( + randomSeed=None, + useRandomCoords=False, + ignoreSmoothingFailures=False, + ) + + monkeypatch.setattr( + molecule_3d.AllChem, + "ETKDGv3", + lambda: params, + ) + + monkeypatch.setattr( + molecule_3d.AllChem, + "EmbedMolecule", + lambda mol, params: 0, + ) + + monkeypatch.setattr( + molecule_3d.AllChem, + "MMFFHasAllMoleculeParams", + lambda mol: False, + ) + + monkeypatch.setattr( + molecule_3d.AllChem, + "UFFHasAllMoleculeParams", + lambda mol: False, + ) + + monkeypatch.setattr( + molecule_3d.Chem, + "MolToMolFile", + lambda *args, **kwargs: + pytest.fail( + "file must not be written " + "after atom-count mismatch" + ), + ) + + with pytest.raises( + OptimizationError, + match="Atom count mismatch for test", + ): + preparer._optimization( + "test", + original, + tmp_path, + ) + + +# ============================================================================= +# _optimization - input molecule isolation +# ============================================================================= + + +def test_optimization_works_on_copy_of_input_molecule( + tmp_path, + monkeypatch, +): + session = make_session( + tmp_path + ) + + preparer = ( + Molecule3DPreparation( + session + ) + ) + + original = ( + Chem.MolFromSmiles( + "CC" + ) + ) + + original.SetProp( + "ORIGINAL_MARKER", + "yes", + ) + + seen = [] + + def fake_repair( + mol, + ): + seen.append( + mol + ) + + mol.SetProp( + "MODIFIED_COPY", + "yes", + ) + + return mol + + monkeypatch.setattr( + preparer, + "_repair_reaction_molecule_for_3d", + fake_repair, + ) + + params = SimpleNamespace( + randomSeed=None, + useRandomCoords=False, + ignoreSmoothingFailures=False, + ) + + monkeypatch.setattr( + molecule_3d.AllChem, + "ETKDGv3", + lambda: params, + ) + + monkeypatch.setattr( + molecule_3d.AllChem, + "EmbedMolecule", + lambda mol, params: 0, + ) + + monkeypatch.setattr( + molecule_3d.AllChem, + "MMFFHasAllMoleculeParams", + lambda mol: False, + ) + + monkeypatch.setattr( + molecule_3d.AllChem, + "UFFHasAllMoleculeParams", + lambda mol: False, + ) + + monkeypatch.setattr( + molecule_3d.Chem, + "MolToMolFile", + lambda mol, filename, **kwargs: + Path(filename).write_text( + "fake", + encoding="utf-8", + ), + ) + + preparer._optimization( + "test", + original, + tmp_path, + ) + + assert len(seen) == 1 + + assert seen[0] is not original + + assert not original.HasProp( + "MODIFIED_COPY" + ) + + assert ( + original.GetProp( + "ORIGINAL_MARKER" + ) + == "yes" + ) + + +# ============================================================================= +# _optimization - output writer contract +# ============================================================================= + + +def test_optimization_writes_with_stereo_and_without_kekulization( + tmp_path, + monkeypatch, +): + session = make_session( + tmp_path + ) + + preparer = ( + Molecule3DPreparation( + session + ) + ) + + events, _ = ( + patch_basic_optimization_stack( + preparer, + monkeypatch, + ) + ) + + preparer._optimization( + "test", + Chem.MolFromSmiles( + "CC" + ), + tmp_path, + ) + + write_event = next( + event + for event in events + if event[0] == "write" + ) + + _, path, kwargs = ( + write_event + ) + + assert path == ( + tmp_path / "test.mol" + ) + + assert ( + kwargs["includeStereo"] + is True + ) + + assert ( + kwargs["kekulize"] + is False + ) + + +def test_optimization_prints_save_location( + tmp_path, + monkeypatch, + capsys, +): + session = make_session( + tmp_path + ) + + preparer = ( + Molecule3DPreparation( + session + ) + ) + + patch_basic_optimization_stack( + preparer, + monkeypatch, + ) + + expected = ( + tmp_path / "styrene.mol" + ) + + preparer._optimization( + "styrene", + Chem.MolFromSmiles( + "CC" + ), + tmp_path, + ) + + assert ( + f"Saving optimized styrene to {expected}" + in capsys.readouterr().out + ) diff --git a/tests/unit/reaction_preparation/reaction_processor/test_prepare_reactions.py b/tests/unit/reaction_preparation/reaction_processor/test_prepare_reactions.py new file mode 100644 index 00000000..fdfe1484 --- /dev/null +++ b/tests/unit/reaction_preparation/reaction_processor/test_prepare_reactions.py @@ -0,0 +1,3559 @@ +from types import SimpleNamespace + +import pandas as pd +import pytest +from rdkit import Chem + +import AutoREACTER.reaction_preparation.reaction_processor.prepare_reactions as prepare_module +from AutoREACTER.reaction_preparation.reaction_processor.prepare_reactions import ( + MappingError, + PrepareReactions, + ReactionMetadata, + SMARTSParsingError, + ZeroActiveReactionsError, +) + + +# ============================================================================= +# Helpers +# ============================================================================= + + +def mol(smiles: str) -> Chem.Mol: + molecule = Chem.MolFromSmiles(smiles) + assert molecule is not None + return molecule + + +def make_session( + tmp_path, + *, + loop=False, + deep_search=True, + reaction_metadata=None, + reaction_instances=None, + reaction_id_counter=0, +): + return SimpleNamespace( + inputs=SimpleNamespace( + loop=loop, + deep_search=deep_search, + ), + staging_dir=tmp_path / "staging", + reaction_metadata=list( + reaction_metadata or [] + ), + reaction_instances=list( + reaction_instances or [] + ), + reaction_id_counter=reaction_id_counter, + ) + + +def make_metadata( + *, + reaction_id=1, + reactant_smiles="CC", + product_smiles="CC", + mapping=None, + activity_stats=True, + tmp_path=None, +): + reactant = mol(reactant_smiles) + product = mol(product_smiles) + + if mapping is None: + mapping = { + idx: idx + for idx in range( + min( + reactant.GetNumAtoms(), + product.GetNumAtoms(), + ) + ) + } + + reverse_mapping = { + product_idx: reactant_idx + for reactant_idx, product_idx + in mapping.items() + } + + dataframe = pd.DataFrame( + { + "reactant_idx": list( + mapping.keys() + ), + "product_idx": list( + mapping.values() + ), + } + ) + + if len(dataframe): + dataframe["first_shell"] = pd.Series( + list(mapping.keys()), + dtype="Int64", + ) + + csv_path = ( + tmp_path / f"reaction_{reaction_id}.csv" + if tmp_path is not None + else None + ) + + return ReactionMetadata( + reaction_id=reaction_id, + reactant_combined_RDmol=reactant, + product_combined_RDmol=product, + reactant_to_product_mapping=mapping, + product_to_reactant_mapping=reverse_mapping, + first_shell=list( + mapping.keys() + ), + initiators=( + list(mapping.keys())[:2] + ), + csv_path=csv_path, + reaction_dataframe=dataframe, + activity_stats=activity_stats, + ) + + +def make_fg( + fg_1_indexes=None, + fg_2_indexes=None, +): + return SimpleNamespace( + fg_1_indexes=fg_1_indexes, + fg_2_indexes=fg_2_indexes, + ) + + +def make_monomer_role( + *, + smiles="C", + rdkit_mol=None, + is_monomer=True, +): + return SimpleNamespace( + smiles=smiles, + rdkit_mol=rdkit_mol, + is_monomer=is_monomer, + ) + + +def make_reaction_instance( + *, + monomer_1=None, + monomer_2=None, + same_reactants=False, + reaction_smarts="[C:1].[O:2]>>[C:1][O:2]", + delete_atom=False, + functional_group_1=None, + functional_group_2=None, + reaction_name="test_reaction", +): + if monomer_1 is None: + monomer_1 = make_monomer_role( + smiles="C", + rdkit_mol=mol("C"), + ) + + if ( + monomer_2 is None + and not same_reactants + ): + monomer_2 = make_monomer_role( + smiles="O", + rdkit_mol=mol("O"), + ) + + return SimpleNamespace( + monomer_1=monomer_1, + monomer_2=monomer_2, + same_reactants=same_reactants, + reaction_smarts=reaction_smarts, + delete_atom=delete_atom, + functional_group_1=functional_group_1, + functional_group_2=functional_group_2, + reaction_name=reaction_name, + ) + + +# ============================================================================= +# Exceptions +# ============================================================================= + + +@pytest.mark.parametrize( + "exception_type", + [ + MappingError, + SMARTSParsingError, + ZeroActiveReactionsError, + ], +) +def test_custom_errors_are_exceptions( + exception_type, +): + assert issubclass( + exception_type, + Exception, + ) + + +# ============================================================================= +# ReactionMetadata +# ============================================================================= + + +def test_reaction_metadata_required_fields(): + reactant = mol("C") + product = mol("C") + + metadata = ReactionMetadata( + reaction_id=7, + reactant_combined_RDmol=reactant, + product_combined_RDmol=product, + reactant_to_product_mapping={ + 0: 0, + }, + product_to_reactant_mapping={ + 0: 0, + }, + ) + + assert metadata.reaction_id == 7 + assert ( + metadata.reactant_combined_RDmol + is reactant + ) + assert ( + metadata.product_combined_RDmol + is product + ) + + +def test_reaction_metadata_defaults(): + metadata = ReactionMetadata( + reaction_id=1, + reactant_combined_RDmol=mol("C"), + product_combined_RDmol=mol("C"), + reactant_to_product_mapping={ + 0: 0, + }, + product_to_reactant_mapping={ + 0: 0, + }, + ) + + assert ( + metadata + .template_reactant_to_product_mapping + is None + ) + assert metadata.edge_atoms is None + assert metadata.first_shell is None + assert metadata.initiators is None + assert metadata.byproduct_indices is None + + assert metadata.delete_atom is True + assert metadata.delete_atom_idx is None + + assert metadata.is_radical is False + assert metadata.radical_atom_idxs == () + assert metadata.activity_stats is True + + +def test_reaction_metadata_uses_slots(): + metadata = ReactionMetadata( + reaction_id=1, + reactant_combined_RDmol=mol("C"), + product_combined_RDmol=mol("C"), + reactant_to_product_mapping={ + 0: 0, + }, + product_to_reactant_mapping={ + 0: 0, + }, + ) + + with pytest.raises( + AttributeError + ): + metadata.unexpected = True + + +# ============================================================================= +# Constructor +# ============================================================================= + + +def test_constructor_sets_paths( + tmp_path, +): + session = make_session( + tmp_path + ) + + preparer = PrepareReactions( + session + ) + + assert preparer.session is session + assert ( + preparer.inputs + is session.inputs + ) + + assert preparer.staging_dir == ( + tmp_path / "staging" + ) + + assert preparer.cache == ( + tmp_path / "staging" + ) + + assert preparer.csv_cache == ( + tmp_path + / "staging" + / "csv_cache" + ) + + assert preparer.csv_cache.is_dir() + + +def test_constructor_preserves_existing_reaction_counter( + tmp_path, +): + session = make_session( + tmp_path, + reaction_id_counter=12, + ) + + PrepareReactions( + session + ) + + assert ( + session.reaction_id_counter + == 12 + ) + + +def test_constructor_adds_missing_counter( + tmp_path, +): + session = SimpleNamespace( + inputs=SimpleNamespace(), + staging_dir=tmp_path, + ) + + PrepareReactions( + session + ) + + assert ( + session.reaction_id_counter + == 0 + ) + + +# ============================================================================= +# Zero-active-reaction guard +# ============================================================================= + + +def test_zero_active_reactions_accepts_active_reaction( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + reaction = SimpleNamespace( + activity_stats=True + ) + + preparer._zero_active_reactions_error( + [ + reaction, + ] + ) + + +def test_zero_active_reactions_accepts_mixed_list( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + preparer._zero_active_reactions_error( + [ + SimpleNamespace( + activity_stats=False + ), + SimpleNamespace( + activity_stats=True + ), + ] + ) + + +def test_zero_active_reactions_rejects_all_inactive( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + with pytest.raises( + ZeroActiveReactionsError, + match="No active reactions", + ): + preparer._zero_active_reactions_error( + [ + SimpleNamespace( + activity_stats=False + ) + ] + ) + + +def test_zero_active_reactions_rejects_empty_list( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + with pytest.raises( + ZeroActiveReactionsError + ): + preparer._zero_active_reactions_error( + [] + ) + + +# ============================================================================= +# Functional-group index flattening +# ============================================================================= + + +def test_flatten_fg_indexes_none( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + assert ( + preparer._flatten_fg_indexes( + None + ) + is None + ) + + +def test_flatten_fg_indexes_fg1( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + fg = make_fg( + fg_1_indexes=[ + ( + 1, + 2, + ), + ( + 3, + 4, + ), + ] + ) + + assert ( + preparer._flatten_fg_indexes( + fg + ) + == { + 1, + 2, + 3, + 4, + } + ) + + +def test_flatten_fg_indexes_both_groups( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + fg = make_fg( + fg_1_indexes=[ + ( + 1, + 2, + ) + ], + fg_2_indexes=[ + ( + 2, + 5, + ) + ], + ) + + assert ( + preparer._flatten_fg_indexes( + fg + ) + == { + 1, + 2, + 5, + } + ) + + +def test_flatten_fg_indexes_empty_returns_none( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + fg = make_fg( + fg_1_indexes=[], + fg_2_indexes=[], + ) + + assert ( + preparer._flatten_fg_indexes( + fg + ) + is None + ) + + +# ============================================================================= +# Forced initiator filtering +# ============================================================================= + + +def test_initiators_no_restrictions( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + assert ( + preparer + ._initiators_within_forced_indexes( + [ + 0, + 3, + ], + r1_atom_count=2, + forced_indexes_1=None, + forced_indexes_2=None, + ) + is True + ) + + +def test_initiator_reactant1_allowed( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + assert ( + preparer + ._initiators_within_forced_indexes( + [ + 1, + ], + 2, + { + 1, + }, + None, + ) + ) + + +def test_initiator_reactant1_rejected( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + assert not ( + preparer + ._initiators_within_forced_indexes( + [ + 1, + ], + 2, + { + 0, + }, + None, + ) + ) + + +def test_initiator_reactant2_uses_local_index( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + # Combined index 4 with r1 size 3 -> local r2 index 1. + assert ( + preparer + ._initiators_within_forced_indexes( + [ + 4, + ], + 3, + None, + { + 1, + }, + ) + ) + + +def test_initiator_reactant2_rejected( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + assert not ( + preparer + ._initiators_within_forced_indexes( + [ + 4, + ], + 3, + None, + { + 0, + }, + ) + ) + + +def test_initiator_at_r1_boundary_belongs_to_reactant2( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + assert ( + preparer + ._initiators_within_forced_indexes( + [ + 3, + ], + 3, + None, + { + 0, + }, + ) + ) + + +def test_empty_initiator_list_is_allowed( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + assert ( + preparer + ._initiators_within_forced_indexes( + [], + 2, + set(), + set(), + ) + ) + + +# ============================================================================= +# Loop reactant copying +# ============================================================================= + + +def test_copy_loop_reactant_missing_role_raises( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + with pytest.raises( + SMARTSParsingError, + match="missing its RDKit molecule", + ): + preparer._copy_loop_reactant_mol( + None + ) + + +def test_copy_loop_reactant_missing_molecule_raises( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + role = make_monomer_role( + rdkit_mol=None, + ) + + with pytest.raises( + SMARTSParsingError + ): + preparer._copy_loop_reactant_mol( + role + ) + + +def test_copy_loop_input_monomer_adds_hydrogens( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + original = mol("C") + + role = make_monomer_role( + rdkit_mol=original, + is_monomer=True, + ) + + result = ( + preparer + ._copy_loop_reactant_mol( + role + ) + ) + + assert ( + result.GetNumAtoms() + == 5 + ) + + assert ( + original.GetNumAtoms() + == 1 + ) + + +def test_copy_loop_generated_product_does_not_add_hydrogens( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + original = mol("C") + + role = make_monomer_role( + rdkit_mol=original, + is_monomer=False, + ) + + result = ( + preparer + ._copy_loop_reactant_mol( + role + ) + ) + + assert ( + result.GetNumAtoms() + == 1 + ) + + +def test_copy_loop_reactant_returns_copy( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + original = mol("CC") + + role = make_monomer_role( + rdkit_mol=original, + is_monomer=False, + ) + + result = ( + preparer + ._copy_loop_reactant_mol( + role + ) + ) + + assert result is not original + + +# ============================================================================= +# Atom-map / isotope tracking +# ============================================================================= + + +def test_assign_atom_maps_and_isotopes( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + r1 = mol("CC") + r2 = mol("CO") + + preparer._assign_atom_map_numbers_and_set_isotopes( + r1, + r2, + ) + + assert [ + atom.GetAtomMapNum() + for atom in r1.GetAtoms() + ] == [ + 1001, + 1002, + ] + + assert [ + atom.GetIsotope() + for atom in r1.GetAtoms() + ] == [ + 1001, + 1002, + ] + + assert [ + atom.GetAtomMapNum() + for atom in r2.GetAtoms() + ] == [ + 2001, + 2002, + ] + + +def test_reassign_atom_map_numbers_from_isotope( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + molecule = mol("CC") + + molecule.GetAtomWithIdx( + 0 + ).SetIsotope(1234) + + molecule.GetAtomWithIdx( + 1 + ).SetIsotope(5678) + + preparer._reassign_atom_map_numbers_by_isotope( + molecule + ) + + assert [ + atom.GetAtomMapNum() + for atom in molecule.GetAtoms() + ] == [ + 1234, + 5678, + ] + + assert [ + atom.GetIsotope() + for atom in molecule.GetAtoms() + ] == [ + 0, + 0, + ] + + +def test_reassign_zero_isotope_does_not_overwrite_map( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + molecule = mol("C") + + atom = molecule.GetAtomWithIdx( + 0 + ) + + atom.SetAtomMapNum(77) + atom.SetIsotope(0) + + preparer._reassign_atom_map_numbers_by_isotope( + molecule + ) + + assert atom.GetAtomMapNum() == 77 + + +def test_reveal_template_map_numbers( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + molecule = mol("CC") + + atom = molecule.GetAtomWithIdx( + 0 + ) + + atom.SetIntProp( + "old_mapno", + 2, + ) + + preparer._reveal_template_map_numbers( + molecule + ) + + assert atom.GetAtomMapNum() == 2 + + +def test_reveal_template_map_numbers_ignores_atom_without_property( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + molecule = mol("C") + + atom = molecule.GetAtomWithIdx( + 0 + ) + + atom.SetAtomMapNum(88) + + preparer._reveal_template_map_numbers( + molecule + ) + + assert atom.GetAtomMapNum() == 88 + + +def test_clear_isotopes( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + first = mol("CC") + second = mol("CO") + + for atom in first.GetAtoms(): + atom.SetIsotope(13) + + for atom in second.GetAtoms(): + atom.SetIsotope(18) + + preparer._clear_isotopes( + first, + second, + ) + + assert all( + atom.GetIsotope() == 0 + for atom in first.GetAtoms() + ) + + assert all( + atom.GetIsotope() == 0 + for atom in second.GetAtoms() + ) + + +# ============================================================================= +# Reaction / reactant construction +# ============================================================================= + + +def test_build_reaction_returns_rdkit_reaction( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + reaction = preparer._build_reaction( + "[C:1].[O:2]>>[C:1][O:2]" + ) + + assert reaction is not None + + assert reaction.GetNumReactantTemplates() == 2 + assert reaction.GetNumProductTemplates() == 1 + + +def test_build_reactants_valid_smiles_adds_explicit_hydrogens( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + first, second = ( + preparer._build_reactants( + "C", + "O", + ) + ) + + # CH4 + assert first.GetNumAtoms() == 5 + + # H2O + assert second.GetNumAtoms() == 3 + + +def test_build_reactants_invalid_first_smiles( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + with pytest.raises( + SMARTSParsingError, + match="first reactant", + ): + preparer._build_reactants( + "not-a-smiles", + "O", + ) + + +def test_build_reactants_invalid_second_smiles( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + with pytest.raises( + SMARTSParsingError, + match="second reactant", + ): + preparer._build_reactants( + "C", + "not-a-smiles", + ) + + +def test_build_reaction_tuple_same_reactant( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + first = mol("C") + second = mol("O") + + result = preparer._build_reaction_tuple( + True, + first, + second, + ) + + assert len(result) == 1 + + assert result[0][0] is first + assert result[0][1] is first + + +def test_build_reaction_tuple_different_reactants( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + first = mol("C") + second = mol("O") + + result = preparer._build_reaction_tuple( + False, + first, + second, + ) + + assert result == [ + [ + first, + second, + ], + [ + second, + first, + ], + ] + + +# ============================================================================= +# Consecutive-number helper +# ============================================================================= + + +@pytest.mark.parametrize( + "values", + [ + [1], + [1, 2], + [3, 2, 1], + [-2, -1, 0, 1], + ], +) +def test_is_consecutive_true( + tmp_path, + values, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + assert preparer._is_consecutive( + values + ) + + +@pytest.mark.parametrize( + "values", + [ + [], + [1, 1], + [1, 3], + [0, 2, 3], + ], +) +def test_is_consecutive_false( + tmp_path, + values, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + assert not preparer._is_consecutive( + values + ) + + +# ============================================================================= +# Atom-index mapping construction +# ============================================================================= + + +def test_build_atom_index_mapping( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + reactant = mol("CC") + product = mol("CC") + + reactant.GetAtomWithIdx( + 0 + ).SetAtomMapNum(1001) + + reactant.GetAtomWithIdx( + 1 + ).SetAtomMapNum(1002) + + product.GetAtomWithIdx( + 0 + ).SetAtomMapNum(1002) + + product.GetAtomWithIdx( + 1 + ).SetAtomMapNum(1001) + + mapping, dataframe = ( + preparer._build_atom_index_mapping( + reactant, + product, + ) + ) + + assert mapping == { + 0: 1, + 1: 0, + } + + assert dataframe.to_dict( + orient="records" + ) == [ + { + "reactant_idx": 0, + "product_idx": 1, + }, + { + "reactant_idx": 1, + "product_idx": 0, + }, + ] + + +def test_build_atom_index_mapping_ignores_zero_map_number( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + reactant = mol("CC") + product = mol("CC") + + reactant.GetAtomWithIdx( + 0 + ).SetAtomMapNum(0) + + reactant.GetAtomWithIdx( + 1 + ).SetAtomMapNum(10) + + product.GetAtomWithIdx( + 0 + ).SetAtomMapNum(10) + + mapping, _ = ( + preparer._build_atom_index_mapping( + reactant, + product, + ) + ) + + assert mapping == { + 1: 0, + } + + +def test_build_atom_index_mapping_ignores_unmatched_reactant_tag( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + reactant = mol("C") + product = mol("C") + + reactant.GetAtomWithIdx( + 0 + ).SetAtomMapNum(123) + + product.GetAtomWithIdx( + 0 + ).SetAtomMapNum(456) + + mapping, dataframe = ( + preparer._build_atom_index_mapping( + reactant, + product, + ) + ) + + assert mapping == {} + assert dataframe.empty + + +# ============================================================================= +# Mapping validation +# ============================================================================= + + +def test_validate_mapping_accepts_complete_bijection( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + dataframe = pd.DataFrame( + { + "reactant_idx": [ + 0, + 1, + ], + "product_idx": [ + 1, + 0, + ], + } + ) + + preparer._validate_mapping( + dataframe, + mol("CC"), + mol("CC"), + ) + + +def test_validate_mapping_rejects_none_dataframe( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + with pytest.raises( + MappingError, + match="empty dataframe", + ): + preparer._validate_mapping( + None, + mol("C"), + mol("C"), + ) + + +def test_validate_mapping_rejects_empty_dataframe( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + with pytest.raises( + MappingError, + match="empty dataframe", + ): + preparer._validate_mapping( + pd.DataFrame(), + mol("C"), + mol("C"), + ) + + +def test_validate_mapping_requires_columns( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + with pytest.raises( + MappingError, + match="required columns", + ): + preparer._validate_mapping( + pd.DataFrame( + { + "wrong": [ + 0 + ] + } + ), + mol("C"), + mol("C"), + ) + + +def test_validate_mapping_rejects_count_mismatch( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + dataframe = pd.DataFrame( + { + "reactant_idx": pd.Series( + [ + 0, + 1, + ], + dtype="Int64", + ), + "product_idx": pd.Series( + [ + 0, + pd.NA, + ], + dtype="Int64", + ), + } + ) + + with pytest.raises( + MappingError, + match="mismatch in atom counts", + ): + preparer._validate_mapping( + dataframe, + mol("CC"), + mol("CC"), + ) + + +def test_validate_mapping_rejects_duplicate_reactant_idx( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + dataframe = pd.DataFrame( + { + "reactant_idx": [ + 0, + 0, + ], + "product_idx": [ + 0, + 1, + ], + } + ) + + with pytest.raises( + MappingError, + match="duplicate idxs.*reactant", + ): + preparer._validate_mapping( + dataframe, + mol("CC"), + mol("CC"), + ) + + +def test_validate_mapping_rejects_duplicate_product_idx( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + dataframe = pd.DataFrame( + { + "reactant_idx": [ + 0, + 1, + ], + "product_idx": [ + 0, + 0, + ], + } + ) + + with pytest.raises( + MappingError, + match="duplicate idxs.*product", + ): + preparer._validate_mapping( + dataframe, + mol("CC"), + mol("CC"), + ) + + +def test_validate_mapping_rejects_reactant_idx_above_bounds( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + dataframe = pd.DataFrame( + { + "reactant_idx": [ + 0, + 2, + ], + "product_idx": [ + 0, + 1, + ], + } + ) + + with pytest.raises( + MappingError, + match="reactant idx out of bounds", + ): + preparer._validate_mapping( + dataframe, + mol("CC"), + mol("CC"), + ) + + +def test_validate_mapping_rejects_product_idx_above_bounds( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + dataframe = pd.DataFrame( + { + "reactant_idx": [ + 0, + 1, + ], + "product_idx": [ + 0, + 2, + ], + } + ) + + with pytest.raises( + MappingError, + match="product idx out of bounds", + ): + preparer._validate_mapping( + dataframe, + mol("CC"), + mol("CC"), + ) + + +def test_validate_mapping_rejects_negative_reactant_idx( + tmp_path, +): + """ + A valid RDKit atom mapping cannot contain negative dataframe indices. + + This is an invariant test, not a change to reaction chemistry. + """ + preparer = PrepareReactions( + make_session(tmp_path) + ) + + dataframe = pd.DataFrame( + { + "reactant_idx": [ + -1, + 1, + ], + "product_idx": [ + 0, + 1, + ], + } + ) + + with pytest.raises( + MappingError, + match="reactant idx out of bounds", + ): + preparer._validate_mapping( + dataframe, + mol("CC"), + mol("CC"), + ) + + +def test_validate_mapping_rejects_negative_product_idx( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + dataframe = pd.DataFrame( + { + "reactant_idx": [ + 0, + 1, + ], + "product_idx": [ + -1, + 1, + ], + } + ) + + with pytest.raises( + MappingError, + match="product idx out of bounds", + ): + preparer._validate_mapping( + dataframe, + mol("CC"), + mol("CC"), + ) + + +def test_validate_mapping_rejects_incomplete_reactant( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + dataframe = pd.DataFrame( + { + "reactant_idx": [ + 0, + 1, + ], + "product_idx": [ + 0, + 1, + ], + } + ) + + with pytest.raises( + MappingError, + match="incomplete mapping for reactant", + ): + preparer._validate_mapping( + dataframe, + mol("CCC"), + mol("CC"), + ) + + +def test_validate_mapping_rejects_incomplete_product( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + dataframe = pd.DataFrame( + { + "reactant_idx": [ + 0, + 1, + ], + "product_idx": [ + 0, + 1, + ], + } + ) + + with pytest.raises( + MappingError, + match="incomplete mapping for product", + ): + preparer._validate_mapping( + dataframe, + mol("CC"), + mol("CCC"), + ) + + +# ============================================================================= +# First shell / initiators +# ============================================================================= + + +def test_assign_first_shell_and_initiators( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + reactant = mol("CCC") + product = mol("CCC") + + product.GetAtomWithIdx( + 0 + ).SetAtomMapNum(1) + + product.GetAtomWithIdx( + 1 + ).SetAtomMapNum(2) + + product.GetAtomWithIdx( + 2 + ).SetAtomMapNum(999) + + first_shell, initiators = ( + preparer + ._assign_first_shell_and_initiators( + reactant, + product, + { + 0: 0, + 1: 1, + 2: 2, + }, + ) + ) + + assert first_shell == [ + 0, + 1, + ] + + assert initiators == [ + 0, + 1, + ] + + assert ( + reactant + .GetAtomWithIdx(0) + .GetAtomMapNum() + == 1 + ) + + assert ( + reactant + .GetAtomWithIdx(1) + .GetAtomMapNum() + == 2 + ) + + +def test_assign_first_shell_missing_reverse_mapping_raises( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + reactant = mol("CC") + product = mol("CC") + + product.GetAtomWithIdx( + 0 + ).SetAtomMapNum(1) + + product.GetAtomWithIdx( + 1 + ).SetAtomMapNum(2) + + with pytest.raises( + ValueError, + match="not found in mapping", + ): + ( + preparer + ._assign_first_shell_and_initiators( + reactant, + product, + { + 0: 0, + }, + ) + ) + + +def test_assign_first_shell_requires_exactly_two_initiators( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + reactant = mol("CC") + product = mol("CC") + + product.GetAtomWithIdx( + 0 + ).SetAtomMapNum(1) + + product.GetAtomWithIdx( + 1 + ).SetAtomMapNum(3) + + with pytest.raises( + ValueError, + match="Expected 2 initiators", + ): + ( + preparer + ._assign_first_shell_and_initiators( + reactant, + product, + { + 0: 0, + 1: 1, + }, + ) + ) + + +# ============================================================================= +# Byproduct detection +# ============================================================================= + + +def test_detect_byproducts_disabled_returns_empty( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + assert ( + preparer._detect_byproducts( + mol("CC.O"), + { + 0: 0, + 1: 1, + 2: 2, + }, + False, + ) + == [] + ) + + +def test_detect_byproducts_uses_smallest_fragment( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + product = mol( + "CC.O" + ) + + result = preparer._detect_byproducts( + product, + { + 0: 10, + 1: 11, + 2: 12, + }, + True, + ) + + assert result == [ + 12, + ] + + +def test_detect_byproducts_skips_unmapped_product_atoms( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + product = mol( + "CC.O" + ) + + result = preparer._detect_byproducts( + product, + { + 0: 10, + 1: 11, + }, + True, + ) + + assert result == [] + + +# ============================================================================= +# Duplicate handling +# ============================================================================= + + +def test_detect_duplicates_keeps_first_unique( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + first = make_metadata( + reaction_id=1 + ) + + result = preparer._detect_duplicates( + [ + first, + ] + ) + + assert result == [ + first, + ] + + assert first.activity_stats is True + + +def test_detect_duplicates_marks_later_duplicate_inactive_and_excludes_it( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + first = make_metadata( + reaction_id=1, + reactant_smiles="CC", + product_smiles="CO", + ) + + second = make_metadata( + reaction_id=2, + reactant_smiles="CC", + product_smiles="CO", + ) + + result = preparer._detect_duplicates( + [ + first, + second, + ] + ) + + # Characterizes current runtime behavior: + # duplicate is disabled AND omitted from the returned unique list. + assert result == [ + first, + ] + + assert first.activity_stats is True + assert second.activity_stats is False + + +def test_detect_duplicates_same_reactant_different_product_is_unique( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + first = make_metadata( + reaction_id=1, + reactant_smiles="CC", + product_smiles="CO", + ) + + second = make_metadata( + reaction_id=2, + reactant_smiles="CC", + product_smiles="CN", + ) + + result = preparer._detect_duplicates( + [ + first, + second, + ] + ) + + assert result == [ + first, + second, + ] + + +# ============================================================================= +# Reaction instance processing - initial stage +# ============================================================================= + + +def test_process_reaction_instances_initial_stage_uses_smiles( + tmp_path, + monkeypatch, +): + session = make_session( + tmp_path + ) + + preparer = PrepareReactions( + session + ) + + reaction = make_reaction_instance( + monomer_1=make_monomer_role( + smiles="C" + ), + monomer_2=make_monomer_role( + smiles="O" + ), + ) + + first = mol("C") + second = mol("O") + + calls = [] + + monkeypatch.setattr( + preparer, + "_build_reactants", + lambda s1, s2: ( + calls.append( + ( + "reactants", + s1, + s2, + ) + ) + or ( + first, + second, + ) + ), + ) + + reaction_tuple = [ + [ + first, + second, + ] + ] + + monkeypatch.setattr( + preparer, + "_build_reaction_tuple", + lambda same, r1, r2: ( + calls.append( + ( + "tuple", + same, + ) + ) + or reaction_tuple + ), + ) + + fake_rxn = object() + + monkeypatch.setattr( + preparer, + "_build_reaction", + lambda smarts: ( + calls.append( + ( + "reaction", + smarts, + ) + ) + or fake_rxn + ), + ) + + monkeypatch.setattr( + preparer, + "_process_reaction_products", + lambda **kwargs: ( + calls.append( + ( + "products", + kwargs, + ) + ) + or [ + "metadata" + ] + ), + ) + + result = ( + preparer + ._process_reaction_instances( + [ + reaction, + ], + loop=False, + ) + ) + + assert result == [ + "metadata" + ] + + assert ( + "reactants", + "C", + "O", + ) in calls + + +def test_process_reaction_instances_same_reactants_uses_first_smiles_twice( + tmp_path, + monkeypatch, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + reaction = make_reaction_instance( + monomer_1=make_monomer_role( + smiles="CC" + ), + monomer_2=None, + same_reactants=True, + ) + + captured = [] + + monkeypatch.setattr( + preparer, + "_build_reactants", + lambda first, second: ( + captured.append( + ( + first, + second, + ) + ) + or ( + mol("CC"), + mol("CC"), + ) + ), + ) + + monkeypatch.setattr( + preparer, + "_build_reaction_tuple", + lambda *args: [], + ) + + monkeypatch.setattr( + preparer, + "_build_reaction", + lambda smarts: object(), + ) + + monkeypatch.setattr( + preparer, + "_process_reaction_products", + lambda **kwargs: + kwargs["reaction_metadata"], + ) + + preparer._process_reaction_instances( + [ + reaction, + ] + ) + + assert captured == [ + ( + "CC", + "CC", + ) + ] + + +# ============================================================================= +# Reaction instance processing - loop mode +# ============================================================================= + + +def test_process_reaction_instances_loop_skips_missing_monomer1_molecule( + tmp_path, + monkeypatch, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + reaction = make_reaction_instance( + monomer_1=make_monomer_role( + rdkit_mol=None, + ), + monomer_2=make_monomer_role( + rdkit_mol=mol("O"), + ), + ) + + monkeypatch.setattr( + preparer, + "_process_reaction_products", + lambda **kwargs: + pytest.fail( + "reaction should have been skipped" + ), + ) + + result = ( + preparer + ._process_reaction_instances( + [ + reaction, + ], + loop=True, + ) + ) + + assert result == [] + + +def test_process_reaction_instances_loop_skips_missing_monomer2_molecule( + tmp_path, + monkeypatch, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + reaction = make_reaction_instance( + monomer_1=make_monomer_role( + rdkit_mol=mol("C"), + ), + monomer_2=make_monomer_role( + rdkit_mol=None, + ), + ) + + monkeypatch.setattr( + preparer, + "_process_reaction_products", + lambda **kwargs: + pytest.fail( + "reaction should have been skipped" + ), + ) + + result = ( + preparer + ._process_reaction_instances( + [ + reaction, + ], + loop=True, + ) + ) + + assert result == [] + + +def test_process_reaction_instances_loop_passes_forced_fg_indices( + tmp_path, + monkeypatch, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + reaction = make_reaction_instance( + monomer_1=make_monomer_role( + rdkit_mol=mol("CC"), + is_monomer=False, + ), + monomer_2=make_monomer_role( + rdkit_mol=mol("CO"), + is_monomer=False, + ), + functional_group_1=make_fg( + fg_1_indexes=[ + ( + 1, + 2, + ) + ] + ), + functional_group_2=make_fg( + fg_1_indexes=[ + ( + 3, + 4, + ) + ] + ), + ) + + captured = [] + + monkeypatch.setattr( + preparer, + "_build_reaction", + lambda smarts: object(), + ) + + monkeypatch.setattr( + preparer, + "_process_reaction_products", + lambda **kwargs: ( + captured.append( + kwargs + ) + or [] + ), + ) + + preparer._process_reaction_instances( + [ + reaction, + ], + loop=True, + ) + + assert len(captured) == 1 + + assert ( + captured[0][ + "forced_indexes_1" + ] + == { + 1, + 2, + } + ) + + assert ( + captured[0][ + "forced_indexes_2" + ] + == { + 3, + 4, + } + ) + + assert len( + captured[0][ + "reaction_tuple" + ] + ) == 1 + + +# ============================================================================= +# Reaction product processing orchestration +# ============================================================================= + + +class FakeReaction: + def __init__( + self, + responses, + ): + self.responses = list( + responses + ) + self.calls = [] + + def RunReactants( + self, + reactants, + ): + self.calls.append( + reactants + ) + + if self.responses: + return self.responses.pop(0) + + return () + + +def configure_product_processing_helpers( + preparer, + monkeypatch, + *, + first_shell=None, + initiators=None, + byproducts=None, +): + if first_shell is None: + first_shell = [ + 0, + 1, + ] + + if initiators is None: + initiators = [ + 0, + 1, + ] + + if byproducts is None: + byproducts = [] + + monkeypatch.setattr( + preparer, + "_reassign_atom_map_numbers_by_isotope", + lambda molecule: None, + ) + + monkeypatch.setattr( + preparer, + "_build_atom_index_mapping", + lambda reactant, product: ( + { + 0: 0, + 1: 1, + }, + pd.DataFrame( + { + "reactant_idx": [ + 0, + 1, + ], + "product_idx": [ + 0, + 1, + ], + } + ), + ), + ) + + monkeypatch.setattr( + preparer, + "_reveal_template_map_numbers", + lambda molecule: None, + ) + + monkeypatch.setattr( + preparer, + "_validate_mapping", + lambda *args: None, + ) + + monkeypatch.setattr( + preparer, + "_assign_first_shell_and_initiators", + lambda *args: ( + first_shell, + initiators, + ), + ) + + monkeypatch.setattr( + preparer, + "_detect_byproducts", + lambda *args: + byproducts, + ) + + +def test_process_reaction_products_creates_metadata_and_csv( + tmp_path, + monkeypatch, +): + session = make_session( + tmp_path + ) + + preparer = PrepareReactions( + session + ) + + product = Chem.CombineMols( + mol("C"), + mol("O"), + ) + + reaction = FakeReaction( + [ + ( + ( + product, + ), + ) + ] + ) + + configure_product_processing_helpers( + preparer, + monkeypatch, + ) + + result = ( + preparer + ._process_reaction_products( + rxn=reaction, + csv_cache=preparer.csv_cache, + reaction_tuple=[ + [ + mol("C"), + mol("O"), + ] + ], + delete_atoms=False, + ) + ) + + assert len(result) == 1 + + metadata = result[0] + + assert metadata.reaction_id == 1 + + assert ( + session.reaction_id_counter + == 1 + ) + + assert metadata.csv_path.is_file() + + assert metadata.first_shell == [ + 0, + 1, + ] + + assert metadata.initiators == [ + 0, + 1, + ] + + assert ( + metadata.activity_stats + is True + ) + + +def test_process_reaction_products_appends_to_existing_list( + tmp_path, + monkeypatch, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + existing = [ + "existing" + ] + + product = Chem.CombineMols( + mol("C"), + mol("O"), + ) + + reaction = FakeReaction( + [ + ( + ( + product, + ), + ) + ] + ) + + configure_product_processing_helpers( + preparer, + monkeypatch, + ) + + result = ( + preparer + ._process_reaction_products( + reaction, + preparer.csv_cache, + [ + [ + mol("C"), + mol("O"), + ] + ], + reaction_metadata=existing, + ) + ) + + assert result is existing + assert result[0] == "existing" + assert len(result) == 2 + + +def test_process_reaction_products_tries_reverse_order_after_failure( + tmp_path, + monkeypatch, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + product = Chem.CombineMols( + mol("C"), + mol("O"), + ) + + reaction = FakeReaction( + [ + (), + ( + ( + product, + ), + ), + ] + ) + + configure_product_processing_helpers( + preparer, + monkeypatch, + ) + + result = ( + preparer + ._process_reaction_products( + reaction, + preparer.csv_cache, + [ + [ + mol("C"), + mol("O"), + ] + ], + ) + ) + + assert len(reaction.calls) == 2 + assert len(result) == 1 + + +def test_process_reaction_products_both_orders_fail_returns_no_metadata( + tmp_path, + capsys, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + # Use a real RDKit ChemicalReaction here because the failure-reporting + # path calls ReactionToSmarts(rxn). + # + # This reaction requires two nitrogen reactants, while we provide + # carbon and oxygen, so both reactant orders legitimately produce + # no products. + reaction = preparer._build_reaction( + "[N:1].[N:2]>>[N:1][N:2]" + ) + + result = ( + preparer + ._process_reaction_products( + reaction, + preparer.csv_cache, + [ + [ + mol("C"), + mol("O"), + ] + ], + ) + ) + + assert result == [] + + assert ( + preparer.session + .reaction_id_counter + == 0 + ) + + output = capsys.readouterr().out + + assert ( + "Reaction failed in both orders" + in output + ) + + assert ( + "RDKit failed to react" + in output + ) + + assert ( + "Reaction SMARTS:" + in output + ) + + +def test_process_reaction_products_forced_indexes_can_reject_product( + tmp_path, + monkeypatch, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + product = Chem.CombineMols( + mol("C"), + mol("O"), + ) + + reaction = FakeReaction( + [ + ( + ( + product, + ), + ) + ] + ) + + configure_product_processing_helpers( + preparer, + monkeypatch, + initiators=[ + 0, + 1, + ], + ) + + result = ( + preparer + ._process_reaction_products( + reaction, + preparer.csv_cache, + [ + [ + mol("C"), + mol("O"), + ] + ], + forced_indexes_1={ + 999, + }, + forced_indexes_2={ + 999, + }, + ) + ) + + assert result == [] + + assert ( + preparer.session + .reaction_id_counter + == 0 + ) + + +def test_process_reaction_products_sets_delete_atom_idx( + tmp_path, + monkeypatch, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + product = Chem.CombineMols( + mol("C"), + mol("O"), + ) + + reaction = FakeReaction( + [ + ( + ( + product, + ), + ) + ] + ) + + configure_product_processing_helpers( + preparer, + monkeypatch, + byproducts=[ + 1, + ], + ) + + result = ( + preparer + ._process_reaction_products( + reaction, + preparer.csv_cache, + [ + [ + mol("C"), + mol("O"), + ] + ], + delete_atoms=True, + ) + ) + + metadata = result[0] + + assert metadata.byproduct_indices == [ + 1, + ] + + assert metadata.delete_atom_idx == 1 + + +# ============================================================================= +# _prepare_reactions_stage +# ============================================================================= + + +def test_prepare_reactions_stage_adds_template_mapping_and_edges( + tmp_path, + monkeypatch, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + reaction = make_metadata( + reaction_id=1, + reactant_smiles="CCC", + product_smiles="CCC", + mapping={ + 0: 0, + 1: 1, + 2: 2, + }, + tmp_path=tmp_path, + ) + + reaction.reaction_dataframe = ( + pd.DataFrame( + { + "reactant_idx": [ + 0, + 1, + 2, + ], + "product_idx": [ + 0, + 1, + 2, + ], + "first_shell": pd.Series( + [ + 1, + pd.NA, + pd.NA, + ], + dtype="Int64", + ), + } + ) + ) + + monkeypatch.setattr( + preparer, + "_process_reaction_instances", + lambda detected, loop=False: [ + reaction + ], + ) + + monkeypatch.setattr( + preparer, + "_detect_duplicates", + lambda reactions: + reactions, + ) + + monkeypatch.setattr( + prepare_module, + "reaction_atom_walker", + lambda molecule, first_shell, mapping: + ( + { + 0: 0, + 1: 1, + }, + [ + 2, + ], + ), + ) + + session = make_session( + tmp_path, + reaction_instances=[ + object() + ], + ) + + result = ( + preparer + ._prepare_reactions_stage( + session + ) + ) + + assert result == [ + reaction + ] + + assert ( + reaction + .template_reactant_to_product_mapping + == { + 0: 0, + 1: 1, + } + ) + + assert reaction.edge_atoms == [ + 2, + ] + + assert ( + "template_reactant_idx" + in reaction + .reaction_dataframe + .columns + ) + + assert ( + "edge_atoms" + in reaction + .reaction_dataframe + .columns + ) + + assert reaction.csv_path.is_file() + + +def test_prepare_reactions_stage_skips_inactive_reactions( + tmp_path, + monkeypatch, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + reaction = make_metadata( + activity_stats=False, + tmp_path=tmp_path, + ) + + monkeypatch.setattr( + preparer, + "_process_reaction_instances", + lambda detected, loop=False: [ + reaction + ], + ) + + monkeypatch.setattr( + preparer, + "_detect_duplicates", + lambda reactions: + reactions, + ) + + monkeypatch.setattr( + prepare_module, + "reaction_atom_walker", + lambda *args: + pytest.fail( + "inactive reaction should not be walked" + ), + ) + + result = ( + preparer + ._prepare_reactions_stage( + [] + ) + ) + + assert result == [ + reaction + ] + + +def test_prepare_reactions_stage_accepts_direct_reaction_instance_list( + tmp_path, + monkeypatch, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + instances = [ + object(), + object(), + ] + + captured = [] + + monkeypatch.setattr( + preparer, + "_process_reaction_instances", + lambda detected, loop=False: ( + captured.append( + ( + detected, + loop, + ) + ) + or [] + ), + ) + + monkeypatch.setattr( + preparer, + "_detect_duplicates", + lambda reactions: + reactions, + ) + + result = ( + preparer + ._prepare_reactions_stage( + instances, + loop=True, + ) + ) + + assert result == [] + + assert captured == [ + ( + instances, + True, + ) + ] + + +# ============================================================================= +# _index_based_reaction_preparation +# ============================================================================= + + +def test_index_based_reaction_preparation_creates_fresh_preparer_and_uses_loop_mode( + tmp_path, + monkeypatch, +): + session = make_session( + tmp_path + ) + + preparer = PrepareReactions( + session + ) + + calls = [] + + class FakePreparer: + def __init__( + self, + received_session, + ): + calls.append( + ( + "init", + received_session, + ) + ) + + def _prepare_reactions_stage( + self, + reaction_instances, + loop=False, + ): + calls.append( + ( + "stage", + reaction_instances, + loop, + ) + ) + + return [ + "prepared" + ] + + monkeypatch.setattr( + prepare_module, + "PrepareReactions", + FakePreparer, + ) + + instances = [ + object() + ] + + result = ( + preparer + ._index_based_reaction_preparation( + instances + ) + ) + + assert result == [ + "prepared" + ] + + assert calls == [ + ( + "init", + session, + ), + ( + "stage", + instances, + True, + ), + ] + + +# ============================================================================= +# prepare_reactions() orchestration +# ============================================================================= + + +def test_prepare_reactions_nonloop_deduplicates( + tmp_path, + monkeypatch, +): + session = make_session( + tmp_path, + loop=False, + deep_search=False, + ) + + preparer = PrepareReactions( + session + ) + + prepared = [ + SimpleNamespace( + activity_stats=True + ) + ] + + final = [ + SimpleNamespace( + activity_stats=True + ) + ] + + monkeypatch.setattr( + preparer, + "_prepare_reactions_stage", + lambda received_session: + prepared, + ) + + calls = [] + + class FakeDeduplicator: + def compare_graphs_mol( + self, + reactions, + deep_check=True, + ): + calls.append( + ( + reactions, + deep_check, + ) + ) + + return final + + monkeypatch.setattr( + prepare_module, + "DeduplicationDetector", + FakeDeduplicator, + ) + + result = preparer.prepare_reactions( + session + ) + + assert result is session + + assert ( + session.reaction_metadata + is final + ) + + assert calls == [ + ( + prepared, + False, + ) + ] + + +def test_prepare_reactions_nonloop_rejects_zero_active_before_dedup( + tmp_path, + monkeypatch, +): + session = make_session( + tmp_path, + loop=False, + ) + + preparer = PrepareReactions( + session + ) + + monkeypatch.setattr( + preparer, + "_prepare_reactions_stage", + lambda received_session: [ + SimpleNamespace( + activity_stats=False + ) + ], + ) + + with pytest.raises( + ZeroActiveReactionsError + ): + preparer.prepare_reactions( + session + ) + + +def test_prepare_reactions_loop_uses_progression( + tmp_path, + monkeypatch, +): + session = make_session( + tmp_path, + loop=True, + ) + + preparer = PrepareReactions( + session + ) + + initial = [ + SimpleNamespace( + activity_stats=True + ) + ] + + final = [ + SimpleNamespace( + activity_stats=True + ) + ] + + monkeypatch.setattr( + preparer, + "_prepare_reactions_stage", + lambda received_session: + initial, + ) + + calls = [] + + class FakeProgression: + def __init__( + self, + received_session, + preparer=None, + ): + calls.append( + ( + "init", + received_session, + preparer, + ) + ) + + def reaction_progression( + self, + ): + calls.append( + ( + "run", + ) + ) + + return final + + monkeypatch.setattr( + prepare_module, + "ReactionProgression", + FakeProgression, + ) + + result = preparer.prepare_reactions( + session + ) + + assert result is session + + assert ( + session.reaction_metadata + is final + ) + + assert calls == [ + ( + "init", + session, + preparer, + ), + ( + "run", + ), + ] + + +def test_prepare_reactions_loop_rejects_zero_active_final_pool( + tmp_path, + monkeypatch, +): + session = make_session( + tmp_path, + loop=True, + ) + + preparer = PrepareReactions( + session + ) + + monkeypatch.setattr( + preparer, + "_prepare_reactions_stage", + lambda received_session: [ + SimpleNamespace( + activity_stats=True + ) + ], + ) + + class FakeProgression: + def __init__( + self, + session, + preparer=None, + ): + pass + + def reaction_progression( + self, + ): + return [ + SimpleNamespace( + activity_stats=False + ) + ] + + monkeypatch.setattr( + prepare_module, + "ReactionProgression", + FakeProgression, + ) + + with pytest.raises( + ZeroActiveReactionsError + ): + preparer.prepare_reactions( + session + ) + + +# ============================================================================= +# Highlighted reaction-template images +# ============================================================================= + + +def make_image_metadata(): + metadata = make_metadata( + reaction_id=1, + reactant_smiles="CCO", + product_smiles="CCO", + mapping={ + 0: 0, + 1: 1, + 2: 2, + }, + ) + + metadata.template_reactant_to_product_mapping = { + 0: 0, + 1: 1, + } + + metadata.edge_atoms = [ + 2, + ] + + metadata.byproduct_indices = [ + 2, + ] + + metadata.delete_atom = True + + metadata.reaction_dataframe = ( + pd.DataFrame( + { + "initiators": pd.Series( + [ + 0, + 1, + pd.NA, + ], + dtype="Int64", + ) + } + ) + ) + + return metadata + + +def test_reaction_templates_image_empty_metadata_returns_none( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + session = SimpleNamespace( + reaction_metadata=[] + ) + + assert ( + preparer + .reaction_templates_highlighted_image_grid( + session + ) + is None + ) + + +def test_reaction_templates_image_all_inactive_returns_none( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + metadata = make_image_metadata() + metadata.activity_stats = False + + session = SimpleNamespace( + reaction_metadata=[ + metadata + ] + ) + + assert ( + preparer + .reaction_templates_highlighted_image_grid( + session + ) + is None + ) + + +@pytest.mark.parametrize( + "highlight_type", + [ + "template", + "edge", + "initiators", + "delete", + ], +) +def test_reaction_templates_highlight_types_generate_image( + tmp_path, + highlight_type, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + metadata = make_image_metadata() + + session = SimpleNamespace( + reaction_metadata=[ + metadata + ] + ) + + image = ( + preparer + .reaction_templates_highlighted_image_grid( + session, + highlight_type=highlight_type, + ) + ) + + assert image is not None + assert image.size[0] > 0 + assert image.size[1] > 0 + + +def test_reaction_templates_image_does_not_clear_original_atom_maps( + tmp_path, +): + preparer = PrepareReactions( + make_session(tmp_path) + ) + + metadata = make_image_metadata() + + metadata.reactant_combined_RDmol.GetAtomWithIdx( + 0 + ).SetAtomMapNum(123) + + metadata.product_combined_RDmol.GetAtomWithIdx( + 0 + ).SetAtomMapNum(456) + + session = SimpleNamespace( + reaction_metadata=[ + metadata + ] + ) + + preparer.reaction_templates_highlighted_image_grid( + session, + highlight_type="template", + ) + + assert ( + metadata + .reactant_combined_RDmol + .GetAtomWithIdx(0) + .GetAtomMapNum() + == 123 + ) + + assert ( + metadata + .product_combined_RDmol + .GetAtomWithIdx(0) + .GetAtomMapNum() + == 456 + ) diff --git a/tests/unit/reaction_preparation/reaction_processor/test_reaction_progression.py b/tests/unit/reaction_preparation/reaction_processor/test_reaction_progression.py new file mode 100644 index 00000000..2232bd4a --- /dev/null +++ b/tests/unit/reaction_preparation/reaction_processor/test_reaction_progression.py @@ -0,0 +1,2221 @@ +from types import SimpleNamespace + +import pytest +from rdkit import Chem + +import AutoREACTER.reaction_preparation.reaction_processor.reaction_progression as progression_module +from AutoREACTER.reaction_preparation.reaction_processor.reaction_progression import ( + MAX_LOOP, + MonomerRoleforIndexBasedFGDetection, + ReactionProgression, + ReactionProgressionSession, +) + + +# ============================================================================= +# Helpers +# ============================================================================= + + +def mol(smiles: str) -> Chem.Mol: + molecule = Chem.MolFromSmiles(smiles) + + assert molecule is not None + + return molecule + + +def make_role( + smiles="CC", + name="monomer", + *, + is_monomer=True, + is_looped=False, + rdkit_mol=None, +): + return SimpleNamespace( + smiles=smiles, + name=name, + is_monomer=is_monomer, + is_looped=is_looped, + rdkit_mol=rdkit_mol, + ) + + +def make_reaction( + reaction_id=1, + *, + reactant_smiles="CC", + product_smiles="CC", + activity_stats=True, + delete_atom=False, + template_mapping=None, + product_to_reactant_mapping=None, + is_radical=False, +): + reactant = mol(reactant_smiles) + product = mol(product_smiles) + + if template_mapping is None: + template_mapping = { + idx: idx + for idx in range( + min( + reactant.GetNumAtoms(), + product.GetNumAtoms(), + ) + ) + } + + if product_to_reactant_mapping is None: + product_to_reactant_mapping = { + product_idx: reactant_idx + for reactant_idx, product_idx + in template_mapping.items() + } + + return SimpleNamespace( + reaction_id=reaction_id, + reactant_combined_RDmol=reactant, + product_combined_RDmol=product, + template_reactant_to_product_mapping=template_mapping, + product_to_reactant_mapping=product_to_reactant_mapping, + activity_stats=activity_stats, + delete_atom=delete_atom, + is_radical=is_radical, + radical_atom_idxs=(), + ) + + +def make_session( + *, + monomer_roles=None, + reaction_metadata=None, + deep_search=True, + reaction_iteration_depth=5, +): + return SimpleNamespace( + monomer_roles=list( + monomer_roles or [] + ), + reaction_metadata=list( + reaction_metadata or [] + ), + reaction_progression_session=None, + inputs=SimpleNamespace( + deep_search=deep_search, + reaction_iteration_depth=reaction_iteration_depth, + ), + ) + + +def make_progression( + session, + preparer=None, +): + """ + Build a ReactionProgression without invoking its constructor. + + Most method-level tests do not need real detector construction or the + user-facing beta warning. + """ + progression = ReactionProgression.__new__( + ReactionProgression + ) + + progression.session = session + progression.preparer = preparer + + if ( + session.reaction_progression_session + is None + ): + session.reaction_progression_session = ( + ReactionProgressionSession() + ) + + progression.fg_detector = ( + SimpleNamespace() + ) + + progression.rxn_detector = ( + SimpleNamespace() + ) + + progression.deduplication_detector = ( + SimpleNamespace() + ) + + return progression + + +# ============================================================================= +# Dataclasses +# ============================================================================= + + +def test_index_based_role_required_fields(): + role = ( + MonomerRoleforIndexBasedFGDetection( + smiles="CC", + name="new_1", + indexes_in_template=[0, 1], + ) + ) + + assert role.smiles == "CC" + assert role.name == "new_1" + + assert role.indexes_in_template == [ + 0, + 1, + ] + + +def test_index_based_role_defaults(): + role = ( + MonomerRoleforIndexBasedFGDetection( + smiles="C", + name="new_1", + indexes_in_template=[], + ) + ) + + assert role.is_monomer is False + assert role.is_looped is False + assert role.rdkit_mol is None + + +def test_index_based_role_uses_slots(): + role = ( + MonomerRoleforIndexBasedFGDetection( + smiles="C", + name="new_1", + indexes_in_template=[], + ) + ) + + with pytest.raises( + AttributeError + ): + role.unexpected = True + + +def test_progression_session_defaults(): + session = ReactionProgressionSession() + + assert session.monomer_roles == [] + assert session.iteration == 0 + + +def test_progression_session_lists_are_independent(): + first = ReactionProgressionSession() + second = ReactionProgressionSession() + + first.monomer_roles.append( + object() + ) + + assert second.monomer_roles == [] + + +def test_progression_session_uses_slots(): + session = ReactionProgressionSession() + + with pytest.raises( + AttributeError + ): + session.unexpected = True + + +def test_max_loop_constant(): + assert MAX_LOOP == 5 + + +# ============================================================================= +# Constructor +# ============================================================================= + + +def test_constructor_attaches_progression_session( + monkeypatch, +): + session = make_session() + + monkeypatch.setattr( + progression_module, + "FunctionalGroupsDetector", + lambda: "fg-detector", + ) + + monkeypatch.setattr( + progression_module, + "ReactionDetector", + lambda: "rxn-detector", + ) + + monkeypatch.setattr( + progression_module, + "DeduplicationDetector", + lambda: "dedup-detector", + ) + + warnings = [] + + monkeypatch.setattr( + progression_module, + "print_warning", + lambda: warnings.append(True), + ) + + preparer = object() + + progression = ReactionProgression( + session, + preparer=preparer, + ) + + assert progression.session is session + assert progression.preparer is preparer + + assert isinstance( + session.reaction_progression_session, + ReactionProgressionSession, + ) + + assert progression.fg_detector == ( + "fg-detector" + ) + + assert progression.rxn_detector == ( + "rxn-detector" + ) + + assert ( + progression.deduplication_detector + == "dedup-detector" + ) + + assert warnings == [ + True + ] + + +def test_constructor_replaces_existing_progression_session( + monkeypatch, +): + old = ReactionProgressionSession( + iteration=99 + ) + + session = make_session() + session.reaction_progression_session = old + + monkeypatch.setattr( + progression_module, + "FunctionalGroupsDetector", + lambda: object(), + ) + + monkeypatch.setattr( + progression_module, + "ReactionDetector", + lambda: object(), + ) + + monkeypatch.setattr( + progression_module, + "DeduplicationDetector", + lambda: object(), + ) + + monkeypatch.setattr( + progression_module, + "print_warning", + lambda: None, + ) + + ReactionProgression(session) + + assert ( + session.reaction_progression_session + is not old + ) + + assert ( + session + .reaction_progression_session + .iteration + == 0 + ) + + +# ============================================================================= +# Thin preparation wrapper +# ============================================================================= + + +def test_index_based_reaction_preparation_delegates(): + session = make_session() + + calls = [] + + class FakePreparer: + def _prepare_reactions_stage( + self, + reaction_instances, + loop=False, + ): + calls.append( + ( + reaction_instances, + loop, + ) + ) + + return [ + "prepared" + ] + + progression = make_progression( + session, + preparer=FakePreparer(), + ) + + instances = [ + object(), + object(), + ] + + result = ( + progression + ._index_based_reaction_preparation( + instances + ) + ) + + assert result == [ + "prepared" + ] + + assert calls == [ + ( + instances, + True, + ) + ] + + +# ============================================================================= +# Basic bookkeeping helpers +# ============================================================================= + + +def test_store_reactions_updates_session(): + session = make_session() + + progression = make_progression( + session + ) + + reactions = [ + object(), + object(), + ] + + result = progression._store_reactions( + reactions + ) + + assert result is reactions + + assert ( + session.reaction_metadata + is reactions + ) + + +def test_count_active_reactions(): + progression = make_progression( + make_session() + ) + + reactions = [ + SimpleNamespace( + activity_stats=True + ), + SimpleNamespace( + activity_stats=False + ), + SimpleNamespace( + activity_stats=1 + ), + SimpleNamespace( + activity_stats=None + ), + ] + + assert ( + progression + ._count_active_reactions( + reactions + ) + == 2 + ) + + +def test_count_active_reactions_empty_list(): + progression = make_progression( + make_session() + ) + + assert ( + progression + ._count_active_reactions([]) + == 0 + ) + + +def test_set_is_looped_flag_marks_all_roles(): + roles = [ + make_role( + is_looped=False + ), + make_role( + is_looped=False + ), + ] + + progression = make_progression( + make_session() + ) + + progression._set_is_looped_flag( + roles + ) + + assert all( + role.is_looped + for role in roles + ) + + +def test_set_is_looped_flag_empty_list(): + progression = make_progression( + make_session() + ) + + progression._set_is_looped_flag( + [] + ) + + +# ============================================================================= +# Loop break logic +# ============================================================================= + + +@pytest.mark.parametrize( + "before, after", + [ + (1, 1), + (2, 1), + (5, 0), + ], +) +def test_loop_break_condition_true_when_pool_does_not_grow( + before, + after, +): + progression = make_progression( + make_session() + ) + + assert ( + progression + ._loop_break_condition( + before, + after, + ) + is True + ) + + +@pytest.mark.parametrize( + "before, after", + [ + (0, 1), + (1, 2), + (10, 11), + ], +) +def test_loop_break_condition_false_when_pool_grows( + before, + after, +): + progression = make_progression( + make_session() + ) + + assert ( + progression + ._loop_break_condition( + before, + after, + ) + is False + ) + + +def test_loop_break_condition_prints_reason( + capsys, +): + progression = make_progression( + make_session() + ) + + progression._loop_break_condition( + 3, + 3, + ) + + output = capsys.readouterr().out + + assert ( + "pool did not grow" + in output + ) + + assert "before=3" in output + assert "after=3" in output + + +# ============================================================================= +# Monomer population +# ============================================================================= + + +def test_smiles_to_rdkit_mol_valid(): + progression = make_progression( + make_session() + ) + + result = ( + progression + ._smiles_to_rdkit_mol( + "CCO" + ) + ) + + assert isinstance( + result, + Chem.Mol, + ) + + +def test_smiles_to_rdkit_mol_invalid_returns_none(): + progression = make_progression( + make_session() + ) + + result = ( + progression + ._smiles_to_rdkit_mol( + "not-a-smiles" + ) + ) + + assert result is None + + +def test_populate_monomer_roles_only_populates_monomers(): + monomer = make_role( + smiles="CC", + is_monomer=True, + ) + + generated = make_role( + smiles="CO", + is_monomer=False, + ) + + session = make_session( + monomer_roles=[ + monomer, + generated, + ] + ) + + progression = make_progression( + session + ) + + progression._populate_monomer_roles() + + assert isinstance( + monomer.rdkit_mol, + Chem.Mol, + ) + + assert generated.rdkit_mol is None + + +def test_populate_monomer_roles_invalid_smiles_sets_none(): + role = make_role( + smiles="not-a-smiles", + is_monomer=True, + rdkit_mol=object(), + ) + + session = make_session( + monomer_roles=[ + role + ] + ) + + progression = make_progression( + session + ) + + progression._populate_monomer_roles() + + assert role.rdkit_mol is None + + +# ============================================================================= +# Product cleanup +# ============================================================================= + + +def test_clean_product_returns_copy(): + progression = make_progression( + make_session() + ) + + molecule = mol("CC") + + cleaned = ( + progression + ._clean_product( + molecule + ) + ) + + assert cleaned is not molecule + + assert ( + Chem.MolToSmiles(cleaned) + == Chem.MolToSmiles(molecule) + ) + + +def test_clean_product_removes_atom_map_numbers(): + progression = make_progression( + make_session() + ) + + molecule = mol("CC") + + molecule.GetAtomWithIdx( + 0 + ).SetAtomMapNum(123) + + cleaned = ( + progression + ._clean_product( + molecule + ) + ) + + assert ( + cleaned + .GetAtomWithIdx(0) + .GetAtomMapNum() + == 0 + ) + + # Original must remain unchanged. + assert ( + molecule + .GetAtomWithIdx(0) + .GetAtomMapNum() + == 123 + ) + + +def test_clean_product_removes_isotopes(): + progression = make_progression( + make_session() + ) + + molecule = mol("CC") + + molecule.GetAtomWithIdx( + 0 + ).SetIsotope(13) + + cleaned = ( + progression + ._clean_product( + molecule + ) + ) + + assert ( + cleaned + .GetAtomWithIdx(0) + .GetIsotope() + == 0 + ) + + assert ( + molecule + .GetAtomWithIdx(0) + .GetIsotope() + == 13 + ) + + +def test_clean_product_removes_tracking_properties(): + progression = make_progression( + make_session() + ) + + molecule = mol("C") + + atom = molecule.GetAtomWithIdx( + 0 + ) + + atom.SetIntProp( + "old_mapno", + 1, + ) + + atom.SetIntProp( + "react_atom_idx", + 2, + ) + + cleaned = ( + progression + ._clean_product( + molecule + ) + ) + + cleaned_atom = ( + cleaned.GetAtomWithIdx(0) + ) + + assert not cleaned_atom.HasProp( + "old_mapno" + ) + + assert not cleaned_atom.HasProp( + "react_atom_idx" + ) + + +# ============================================================================= +# Product SMILES +# ============================================================================= + + +def test_get_product_smiles_returns_canonical_smiles(): + progression = make_progression( + make_session() + ) + + result = ( + progression + ._get_product_smiles( + mol("OCC") + ) + ) + + assert result == Chem.MolToSmiles( + mol("CCO") + ) + + +def test_get_product_smiles_removes_mapping_artifacts(): + progression = make_progression( + make_session() + ) + + molecule = mol("CC") + + molecule.GetAtomWithIdx( + 0 + ).SetAtomMapNum(999) + + result = ( + progression + ._get_product_smiles( + molecule + ) + ) + + assert ":" not in result + + +# ============================================================================= +# Product index handling +# ============================================================================= + + +def test_get_product_idxs_single_fragment_preserves_indexes(): + progression = make_progression( + make_session() + ) + + molecule = mol("CCC") + + mapping = { + 10: 0, + 11: 2, + } + + idxs, result_mol = ( + progression + ._get_product_idxs( + mapping, + molecule, + ) + ) + + assert idxs == [ + 0, + 2, + ] + + assert ( + Chem.MolToSmiles( + result_mol + ) + == Chem.MolToSmiles( + molecule + ) + ) + + +def test_get_product_idxs_returns_molecule_copy(): + progression = make_progression( + make_session() + ) + + molecule = mol("CC") + + _, result = ( + progression + ._get_product_idxs( + { + 0: 0, + }, + molecule, + ) + ) + + assert result is not molecule + + +def test_keep_largest_fragment_uses_heavy_atom_count(): + progression = make_progression( + make_session() + ) + + molecule = mol( + "CCCO.CC" + ) + + largest, _ = ( + progression + ._keep_largest_fragment( + molecule, + [], + ) + ) + + assert ( + largest.GetNumHeavyAtoms() + == 4 + ) + + +def test_keep_largest_fragment_remaps_product_indices(): + progression = make_progression( + make_session() + ) + + # First fragment atoms: 0,1 + # Second/larger fragment atoms: 2,3,4 + molecule = mol( + "CC.CCC" + ) + + largest, remapped = ( + progression + ._keep_largest_fragment( + molecule, + [ + 2, + 4, + ], + ) + ) + + assert ( + largest.GetNumHeavyAtoms() + == 3 + ) + + assert remapped == [ + 0, + 2, + ] + + +def test_keep_largest_fragment_drops_indices_from_removed_fragment(): + progression = make_progression( + make_session() + ) + + molecule = mol( + "CC.CCC" + ) + + _, remapped = ( + progression + ._keep_largest_fragment( + molecule, + [ + 0, + 2, + 3, + ], + ) + ) + + assert remapped == [ + 0, + 1, + ] + + +def test_get_product_idxs_multifragment_keeps_largest_fragment(): + progression = make_progression( + make_session() + ) + + molecule = mol( + "CC.CCC" + ) + + idxs, result = ( + progression + ._get_product_idxs( + { + 10: 2, + 11: 4, + }, + molecule, + ) + ) + + assert ( + result.GetNumHeavyAtoms() + == 3 + ) + + assert idxs == [ + 0, + 2, + ] + + +# ============================================================================= +# Radical metadata +# ============================================================================= + + +def test_set_reaction_radical_metadata_no_radicals(): + progression = make_progression( + make_session() + ) + + reaction = make_reaction( + product_smiles="CC", + ) + + progression._set_reaction_radical_metadata( + reaction, + mol("CC"), + ) + + assert reaction.is_radical is False + + assert ( + reaction.radical_atom_idxs + == () + ) + + +def test_set_reaction_radical_metadata_maps_product_idx_to_reactant_idx(): + progression = make_progression( + make_session() + ) + + reaction = make_reaction( + product_smiles="[CH3]", + template_mapping={ + 5: 0, + }, + product_to_reactant_mapping={ + 0: 5, + }, + ) + + radical_product = mol( + "[CH3]" + ) + + progression._set_reaction_radical_metadata( + reaction, + radical_product, + ) + + assert reaction.is_radical is True + + assert ( + reaction.radical_atom_idxs + == (5,) + ) + + +def test_set_reaction_radical_metadata_ignores_unmapped_product_radical(): + progression = make_progression( + make_session() + ) + + reaction = make_reaction( + product_smiles="[CH3]", + template_mapping={ + 0: 0, + }, + product_to_reactant_mapping={}, + ) + + progression._set_reaction_radical_metadata( + reaction, + mol("[CH3]"), + ) + + assert reaction.is_radical is False + assert reaction.radical_atom_idxs == () + + +# ============================================================================= +# Radical annotation before deduplication +# ============================================================================= + + +def test_annotate_radicals_skips_inactive_reactions( + monkeypatch, +): + progression = make_progression( + make_session() + ) + + reaction = make_reaction( + activity_stats=False + ) + + calls = [] + + monkeypatch.setattr( + progression, + "_sanitize_molecule", + lambda mol: ( + calls.append(True) + or (mol, True) + ), + ) + + progression._annotate_radicals_before_deduplication( + [ + reaction, + ] + ) + + assert calls == [] + + +def test_annotate_radicals_handles_missing_product(): + progression = make_progression( + make_session() + ) + + reaction = make_reaction() + + reaction.product_combined_RDmol = None + reaction.is_radical = True + reaction.radical_atom_idxs = ( + 10, + ) + + progression._annotate_radicals_before_deduplication( + [ + reaction, + ] + ) + + assert reaction.is_radical is False + assert reaction.radical_atom_idxs == () + + +def test_annotate_radicals_handles_failed_sanitization( + monkeypatch, +): + progression = make_progression( + make_session() + ) + + reaction = make_reaction() + + reaction.is_radical = True + reaction.radical_atom_idxs = ( + 1, + ) + + monkeypatch.setattr( + progression, + "_sanitize_molecule", + lambda mol: ( + mol, + False, + ), + ) + + progression._annotate_radicals_before_deduplication( + [ + reaction, + ] + ) + + assert reaction.is_radical is False + assert reaction.radical_atom_idxs == () + + +def test_annotate_radicals_delegates_metadata_on_success( + monkeypatch, +): + progression = make_progression( + make_session() + ) + + reaction = make_reaction() + + sanitized = mol("CC") + + calls = [] + + monkeypatch.setattr( + progression, + "_sanitize_molecule", + lambda mol: ( + sanitized, + True, + ), + ) + + monkeypatch.setattr( + progression, + "_set_reaction_radical_metadata", + lambda rxn, molecule: + calls.append( + ( + rxn, + molecule, + ) + ), + ) + + progression._annotate_radicals_before_deduplication( + [ + reaction, + ] + ) + + assert calls == [ + ( + reaction, + sanitized, + ) + ] + + +# ============================================================================= +# Sanitization +# ============================================================================= + + +def test_sanitize_simple_molecule_succeeds(): + progression = make_progression( + make_session() + ) + + result, success = ( + progression + ._sanitize_molecule( + mol("CC") + ) + ) + + assert success is True + + assert isinstance( + result, + Chem.Mol, + ) + + +def test_sanitize_returns_new_molecule(): + progression = make_progression( + make_session() + ) + + original = mol("CC") + + result, success = ( + progression + ._sanitize_molecule( + original + ) + ) + + assert success is True + assert result is not original + + +def test_fix_radical_and_sanitize_returns_molecule(): + progression = make_progression( + make_session() + ) + + result = ( + progression + ._fix_radical_and_sanitize( + mol("[CH3]") + ) + ) + + assert isinstance( + result, + Chem.Mol, + ) + + +# ============================================================================= +# Product preparation for FG detection +# ============================================================================= + + +def test_prepare_products_skips_inactive_reactions(): + inactive = make_reaction( + activity_stats=False + ) + + session = make_session( + reaction_metadata=[ + inactive + ] + ) + + progression = make_progression( + session + ) + + result = ( + progression + ._prepare_products_for_idx_based_fg_detection() + ) + + assert result == [] + + +def test_prepare_products_creates_role_for_active_reaction( + monkeypatch, +): + reaction = make_reaction( + reaction_id=7, + product_smiles="CC", + delete_atom=False, + ) + + session = make_session( + reaction_metadata=[ + reaction + ] + ) + + progression = make_progression( + session + ) + + sanitized = mol("CC") + + monkeypatch.setattr( + progression, + "_sanitize_molecule", + lambda molecule: ( + sanitized, + True, + ), + ) + + result = ( + progression + ._prepare_products_for_idx_based_fg_detection() + ) + + assert len(result) == 1 + + role = result[0] + + assert ( + role.name + == "new_7" + ) + + assert role.smiles == ( + Chem.MolToSmiles( + sanitized + ) + ) + + assert ( + role.rdkit_mol + is sanitized + ) + + +def test_prepare_products_uses_template_product_indices( + monkeypatch, +): + reaction = make_reaction( + product_smiles="CCC", + template_mapping={ + 10: 0, + 11: 2, + }, + ) + + session = make_session( + reaction_metadata=[ + reaction + ] + ) + + progression = make_progression( + session + ) + + monkeypatch.setattr( + progression, + "_sanitize_molecule", + lambda molecule: ( + molecule, + True, + ), + ) + + role = ( + progression + ._prepare_products_for_idx_based_fg_detection()[0] + ) + + assert ( + role.indexes_in_template + == [ + 0, + 2, + ] + ) + + +def test_prepare_products_replaces_single_fragment_nondelete_product_with_sanitized_copy( + monkeypatch, +): + reaction = make_reaction( + product_smiles="CC", + delete_atom=False, + ) + + original = ( + reaction.product_combined_RDmol + ) + + sanitized = mol("CO") + + session = make_session( + reaction_metadata=[ + reaction + ] + ) + + progression = make_progression( + session + ) + + monkeypatch.setattr( + progression, + "_sanitize_molecule", + lambda molecule: ( + sanitized, + True, + ), + ) + + progression._prepare_products_for_idx_based_fg_detection() + + assert ( + reaction.product_combined_RDmol + is not original + ) + + assert ( + Chem.MolToSmiles( + reaction.product_combined_RDmol + ) + == Chem.MolToSmiles( + sanitized + ) + ) + + +def test_prepare_products_does_not_replace_delete_product( + monkeypatch, +): + reaction = make_reaction( + product_smiles="CC", + delete_atom=True, + ) + + original = ( + reaction.product_combined_RDmol + ) + + session = make_session( + reaction_metadata=[ + reaction + ] + ) + + progression = make_progression( + session + ) + + monkeypatch.setattr( + progression, + "_sanitize_molecule", + lambda molecule: ( + mol("CO"), + True, + ), + ) + + progression._prepare_products_for_idx_based_fg_detection() + + assert ( + reaction.product_combined_RDmol + is original + ) + + +def test_prepare_products_failed_sanitization_clears_radical_metadata( + monkeypatch, + capsys, +): + reaction = make_reaction() + + reaction.is_radical = True + reaction.radical_atom_idxs = ( + 1, + ) + + session = make_session( + reaction_metadata=[ + reaction + ] + ) + + progression = make_progression( + session + ) + + best_effort = mol("CC") + + monkeypatch.setattr( + progression, + "_sanitize_molecule", + lambda molecule: ( + best_effort, + False, + ), + ) + + result = ( + progression + ._prepare_products_for_idx_based_fg_detection() + ) + + assert len(result) == 1 + + assert reaction.is_radical is False + assert reaction.radical_atom_idxs == () + + assert ( + "sanitization failed" + in capsys.readouterr().out + ) + + +# ============================================================================= +# reaction_progression() control flow +# ============================================================================= + + +def test_reaction_progression_zero_loop_returns_copy_of_existing_metadata(): + reaction = make_reaction() + + session = make_session( + reaction_metadata=[ + reaction + ] + ) + + progression = make_progression( + session + ) + + result = progression.reaction_progression( + max_loop=0 + ) + + assert result == [ + reaction + ] + + assert result is not ( + session.reaction_metadata + ) + + +def test_reaction_progression_negative_loop_returns_existing_metadata(): + reaction = make_reaction() + + session = make_session( + reaction_metadata=[ + reaction + ] + ) + + progression = make_progression( + session + ) + + assert ( + progression.reaction_progression( + max_loop=-1 + ) + == [ + reaction + ] + ) + + +def test_reaction_progression_none_uses_session_iteration_depth( + monkeypatch, +): + session = make_session( + reaction_iteration_depth=0 + ) + + progression = make_progression( + session + ) + + # If None correctly resolves to zero from the session, + # no loop helpers should run. + monkeypatch.setattr( + progression, + "_populate_monomer_roles", + lambda: pytest.fail( + "loop unexpectedly started" + ), + ) + + assert ( + progression.reaction_progression( + max_loop=None + ) + == [] + ) + + +def test_reaction_progression_first_iteration_populates_monomers( + monkeypatch, +): + session = make_session() + + progression = make_progression( + session + ) + + calls = [] + + monkeypatch.setattr( + progression, + "_populate_monomer_roles", + lambda: calls.append( + "populate" + ), + ) + + monkeypatch.setattr( + progression, + "_set_is_looped_flag", + lambda roles: + calls.append( + "looped" + ), + ) + + monkeypatch.setattr( + progression, + "_prepare_products_for_idx_based_fg_detection", + lambda: [], + ) + + progression.fg_detector = ( + SimpleNamespace( + index_based_functional_groups_detector= + lambda roles: [] + ) + ) + + progression.reaction_progression( + max_loop=1 + ) + + assert calls == [ + "populate", + "looped", + ] + + assert ( + session + .reaction_progression_session + .iteration + == 1 + ) + + +def test_reaction_progression_stops_when_no_functional_groups( + monkeypatch, +): + session = make_session( + reaction_metadata=[ + make_reaction() + ] + ) + + progression = make_progression( + session + ) + + monkeypatch.setattr( + progression, + "_populate_monomer_roles", + lambda: None, + ) + + monkeypatch.setattr( + progression, + "_set_is_looped_flag", + lambda roles: None, + ) + + prepared_roles = [ + object() + ] + + monkeypatch.setattr( + progression, + "_prepare_products_for_idx_based_fg_detection", + lambda: prepared_roles, + ) + + progression.fg_detector = ( + SimpleNamespace( + index_based_functional_groups_detector= + lambda roles: [] + ) + ) + + result = progression.reaction_progression( + max_loop=3 + ) + + assert result == ( + session.reaction_metadata + ) + + assert ( + session + .reaction_progression_session + .iteration + == 1 + ) + + +def test_reaction_progression_stops_when_no_reactions( + monkeypatch, +): + initial_role = make_role() + + session = make_session( + monomer_roles=[ + initial_role + ] + ) + + progression = make_progression( + session + ) + + monkeypatch.setattr( + progression, + "_populate_monomer_roles", + lambda: None, + ) + + monkeypatch.setattr( + progression, + "_set_is_looped_flag", + lambda roles: None, + ) + + monkeypatch.setattr( + progression, + "_prepare_products_for_idx_based_fg_detection", + lambda: [ + object() + ], + ) + + new_fg_role = make_role( + name="new" + ) + + progression.fg_detector = ( + SimpleNamespace( + index_based_functional_groups_detector= + lambda roles: [ + new_fg_role + ] + ) + ) + + progression.rxn_detector = ( + SimpleNamespace( + index_based_reaction_detector= + lambda roles: [] + ) + ) + + result = progression.reaction_progression( + max_loop=3 + ) + + assert result == [] + + assert ( + new_fg_role + in session.monomer_roles + ) + + +def test_reaction_progression_converts_reaction_generator_to_list( + monkeypatch, +): + session = make_session() + + progression = make_progression( + session + ) + + monkeypatch.setattr( + progression, + "_populate_monomer_roles", + lambda: None, + ) + + monkeypatch.setattr( + progression, + "_set_is_looped_flag", + lambda roles: None, + ) + + monkeypatch.setattr( + progression, + "_prepare_products_for_idx_based_fg_detection", + lambda: [ + object() + ], + ) + + progression.fg_detector = ( + SimpleNamespace( + index_based_functional_groups_detector= + lambda roles: [ + object() + ] + ) + ) + + instance_1 = object() + instance_2 = object() + + progression.rxn_detector = ( + SimpleNamespace( + index_based_reaction_detector= + lambda roles: ( + item + for item in ( + instance_1, + instance_2, + ) + ) + ) + ) + + received = [] + + monkeypatch.setattr( + progression, + "_index_based_reaction_preparation", + lambda reaction_instances: + ( + received.append( + reaction_instances + ) + or [] + ), + ) + + monkeypatch.setattr( + progression, + "_annotate_radicals_before_deduplication", + lambda reactions: None, + ) + + progression.deduplication_detector = ( + SimpleNamespace( + compare_graphs_mol= + lambda reactions, deep_check: + reactions + ) + ) + + progression.reaction_progression( + max_loop=1 + ) + + assert received == [ + [ + instance_1, + instance_2, + ] + ] + + +def test_reaction_progression_passes_deep_search_to_deduplicator( + monkeypatch, +): + session = make_session( + deep_search=False + ) + + progression = make_progression( + session + ) + + monkeypatch.setattr( + progression, + "_populate_monomer_roles", + lambda: None, + ) + + monkeypatch.setattr( + progression, + "_set_is_looped_flag", + lambda roles: None, + ) + + monkeypatch.setattr( + progression, + "_prepare_products_for_idx_based_fg_detection", + lambda: [ + object() + ], + ) + + progression.fg_detector = ( + SimpleNamespace( + index_based_functional_groups_detector= + lambda roles: [ + object() + ] + ) + ) + + progression.rxn_detector = ( + SimpleNamespace( + index_based_reaction_detector= + lambda roles: [ + object() + ] + ) + ) + + prepared = make_reaction() + + monkeypatch.setattr( + progression, + "_index_based_reaction_preparation", + lambda reaction_instances: [ + prepared + ], + ) + + monkeypatch.setattr( + progression, + "_annotate_radicals_before_deduplication", + lambda reactions: None, + ) + + calls = [] + + progression.deduplication_detector = ( + SimpleNamespace( + compare_graphs_mol= + lambda reactions, deep_check: + ( + calls.append( + deep_check + ) + or reactions + ) + ) + ) + + progression.reaction_progression( + max_loop=1 + ) + + assert calls == [ + False + ] + + +def test_reaction_progression_annotates_radicals_before_deduplication( + monkeypatch, +): + session = make_session() + + progression = make_progression( + session + ) + + order = [] + + monkeypatch.setattr( + progression, + "_populate_monomer_roles", + lambda: None, + ) + + monkeypatch.setattr( + progression, + "_set_is_looped_flag", + lambda roles: None, + ) + + monkeypatch.setattr( + progression, + "_prepare_products_for_idx_based_fg_detection", + lambda: [ + object() + ], + ) + + progression.fg_detector = ( + SimpleNamespace( + index_based_functional_groups_detector= + lambda roles: [ + object() + ] + ) + ) + + progression.rxn_detector = ( + SimpleNamespace( + index_based_reaction_detector= + lambda roles: [ + object() + ] + ) + ) + + prepared = make_reaction() + + monkeypatch.setattr( + progression, + "_index_based_reaction_preparation", + lambda reaction_instances: [ + prepared + ], + ) + + monkeypatch.setattr( + progression, + "_annotate_radicals_before_deduplication", + lambda reactions: + order.append( + "radicals" + ), + ) + + progression.deduplication_detector = ( + SimpleNamespace( + compare_graphs_mol= + lambda reactions, deep_check: + ( + order.append( + "dedup" + ) + or reactions + ) + ) + ) + + progression.reaction_progression( + max_loop=1 + ) + + assert order == [ + "radicals", + "dedup", + ] + + +def test_reaction_progression_break_condition_stores_reactions( + monkeypatch, +): + initial = make_reaction( + reaction_id=1 + ) + + session = make_session( + reaction_metadata=[ + initial + ] + ) + + progression = make_progression( + session + ) + + monkeypatch.setattr( + progression, + "_populate_monomer_roles", + lambda: None, + ) + + monkeypatch.setattr( + progression, + "_set_is_looped_flag", + lambda roles: None, + ) + + monkeypatch.setattr( + progression, + "_prepare_products_for_idx_based_fg_detection", + lambda: [ + object() + ], + ) + + progression.fg_detector = ( + SimpleNamespace( + index_based_functional_groups_detector= + lambda roles: [ + object() + ] + ) + ) + + progression.rxn_detector = ( + SimpleNamespace( + index_based_reaction_detector= + lambda roles: [ + object() + ] + ) + ) + + duplicate = make_reaction( + reaction_id=2 + ) + + monkeypatch.setattr( + progression, + "_index_based_reaction_preparation", + lambda reaction_instances: [ + duplicate + ], + ) + + monkeypatch.setattr( + progression, + "_annotate_radicals_before_deduplication", + lambda reactions: None, + ) + + # Dedup leaves only the original reaction, so active pool size + # stays 1 -> break. + progression.deduplication_detector = ( + SimpleNamespace( + compare_graphs_mol= + lambda reactions, deep_check: [ + initial + ] + ) + ) + + result = progression.reaction_progression( + max_loop=5 + ) + + assert result == [ + initial + ] + + assert ( + session.reaction_metadata + == [ + initial + ] + ) \ No newline at end of file diff --git a/tests/unit/reaction_preparation/reaction_processor/test_utils.py b/tests/unit/reaction_preparation/reaction_processor/test_utils.py new file mode 100644 index 00000000..0baeaa9c --- /dev/null +++ b/tests/unit/reaction_preparation/reaction_processor/test_utils.py @@ -0,0 +1,1301 @@ +from pathlib import Path +from types import SimpleNamespace + +import pandas as pd +import pytest +from rdkit import Chem + +from AutoREACTER.reaction_preparation.reaction_processor.utils import ( + add_column_safe, + add_dict_as_new_columns, + compare_rdkit_molecules_canonical, + compare_set, + extract_unique_references, + prep_for_3d_molecule_generation, + prepare_paths, +) + + +# ============================================================================= +# prepare_paths +# ============================================================================= + + +def test_prepare_paths_creates_directory(tmp_path): + cache = tmp_path / "cache" + + result = prepare_paths( + cache, + "csv_cache", + ) + + assert result == ( + cache / "csv_cache" + ) + + assert result.exists() + assert result.is_dir() + + +def test_prepare_paths_creates_parent_directories(tmp_path): + cache = ( + tmp_path + / "a" + / "b" + / "c" + ) + + result = prepare_paths( + cache, + "csv_cache", + ) + + assert result.is_dir() + + +def test_prepare_paths_accepts_string_cache_path(tmp_path): + cache = tmp_path / "cache" + + result = prepare_paths( + str(cache), + "csv_cache", + ) + + assert isinstance( + result, + Path, + ) + + assert result == ( + cache / "csv_cache" + ) + + +def test_prepare_paths_accepts_nested_subdirectory(tmp_path): + cache = tmp_path / "cache" + + result = prepare_paths( + cache, + "one/two/three", + ) + + assert result == ( + cache + / "one" + / "two" + / "three" + ) + + assert result.is_dir() + + +def test_prepare_paths_is_idempotent(tmp_path): + cache = tmp_path / "cache" + + first = prepare_paths( + cache, + "csv_cache", + ) + + marker = first / "keep.txt" + marker.write_text( + "keep", + encoding="utf-8", + ) + + second = prepare_paths( + cache, + "csv_cache", + ) + + assert first == second + assert marker.exists() + + assert ( + marker.read_text( + encoding="utf-8" + ) + == "keep" + ) + + +# ============================================================================= +# add_dict_as_new_columns +# ============================================================================= + + +def test_add_dict_as_new_columns_adds_default_columns(): + df = pd.DataFrame( + { + "reactant_idx": [0, 1, 2], + } + ) + + mapping = { + 0: 2, + 1: 1, + 2: 0, + } + + result = add_dict_as_new_columns( + df, + mapping, + ) + + assert result is df + + assert ( + result[ + "template_reactant_idx" + ].tolist() + == [0, 1, 2] + ) + + assert ( + result[ + "template_product_idx" + ].tolist() + == [2, 1, 0] + ) + + +def test_add_dict_as_new_columns_uses_custom_titles(): + df = pd.DataFrame( + {"base": [10, 20]} + ) + + result = add_dict_as_new_columns( + df, + { + 5: 7, + 6: 8, + }, + titles=[ + "left_idx", + "right_idx", + ], + ) + + assert ( + result["left_idx"].tolist() + == [5, 6] + ) + + assert ( + result["right_idx"].tolist() + == [7, 8] + ) + + +def test_add_dict_as_new_columns_uses_nullable_integer_dtype(): + df = pd.DataFrame( + {"base": [10, 20, 30]} + ) + + result = add_dict_as_new_columns( + df, + { + 1: 4, + 2: 5, + }, + ) + + assert str( + result[ + "template_reactant_idx" + ].dtype + ) == "Int64" + + assert str( + result[ + "template_product_idx" + ].dtype + ) == "Int64" + + +def test_add_dict_as_new_columns_short_mapping_fills_remaining_rows_with_na(): + df = pd.DataFrame( + { + "base": [ + "a", + "b", + "c", + ] + } + ) + + result = add_dict_as_new_columns( + df, + { + 10: 20, + }, + ) + + values = result[ + "template_reactant_idx" + ].tolist() + + assert values[0] == 10 + assert pd.isna(values[1]) + assert pd.isna(values[2]) + + +def test_add_dict_as_new_columns_empty_mapping_creates_nullable_columns(): + df = pd.DataFrame( + { + "base": [1, 2], + } + ) + + result = add_dict_as_new_columns( + df, + {}, + ) + + assert ( + "template_reactant_idx" + in result.columns + ) + + assert ( + "template_product_idx" + in result.columns + ) + + assert result[ + "template_reactant_idx" + ].isna().all() + + assert result[ + "template_product_idx" + ].isna().all() + + +def test_add_dict_as_new_columns_preserves_existing_columns(): + df = pd.DataFrame( + { + "reactant_idx": [0, 1], + "product_idx": [1, 0], + } + ) + + add_dict_as_new_columns( + df, + { + 0: 1, + }, + ) + + assert ( + df["reactant_idx"].tolist() + == [0, 1] + ) + + assert ( + df["product_idx"].tolist() + == [1, 0] + ) + + +def test_add_dict_as_new_columns_is_position_safe_with_nondefault_dataframe_index(): + """ + Utility columns describe row-position data, not pandas index labels. + + A dataframe with a non-default index must therefore receive the values + in row order rather than turning them into NA through index alignment. + """ + df = pd.DataFrame( + { + "base": ["a", "b"], + }, + index=[10, 20], + ) + + result = add_dict_as_new_columns( + df, + { + 4: 8, + 5: 9, + }, + ) + + assert ( + result[ + "template_reactant_idx" + ].tolist() + == [4, 5] + ) + + assert ( + result[ + "template_product_idx" + ].tolist() + == [8, 9] + ) + + +# ============================================================================= +# add_column_safe +# ============================================================================= + + +def test_add_column_safe_adds_column(): + df = pd.DataFrame( + { + "base": [1, 2, 3], + } + ) + + result = add_column_safe( + df, + [10, 11, 12], + "edge_atoms", + ) + + assert result is df + + assert ( + result["edge_atoms"].tolist() + == [10, 11, 12] + ) + + +def test_add_column_safe_uses_nullable_integer_dtype(): + df = pd.DataFrame( + { + "base": [1, 2, 3], + } + ) + + result = add_column_safe( + df, + [10], + "edge_atoms", + ) + + assert ( + str( + result[ + "edge_atoms" + ].dtype + ) + == "Int64" + ) + + +def test_add_column_safe_short_list_fills_remaining_rows_with_na(): + df = pd.DataFrame( + { + "base": [1, 2, 3], + } + ) + + result = add_column_safe( + df, + [7], + "edge_atoms", + ) + + values = result[ + "edge_atoms" + ].tolist() + + assert values[0] == 7 + assert pd.isna(values[1]) + assert pd.isna(values[2]) + + +def test_add_column_safe_empty_list_creates_empty_nullable_column(): + df = pd.DataFrame( + { + "base": [1, 2], + } + ) + + result = add_column_safe( + df, + [], + "edge_atoms", + ) + + assert ( + "edge_atoms" + in result.columns + ) + + assert result[ + "edge_atoms" + ].isna().all() + + +def test_add_column_safe_overwrites_existing_column(): + df = pd.DataFrame( + { + "edge_atoms": [100, 200], + } + ) + + result = add_column_safe( + df, + [1, 2], + "edge_atoms", + ) + + assert ( + result[ + "edge_atoms" + ].tolist() + == [1, 2] + ) + + +def test_add_column_safe_is_position_safe_with_nondefault_dataframe_index(): + """ + Values should be assigned by dataframe row position, not pandas index + label alignment. + """ + df = pd.DataFrame( + { + "base": ["a", "b"], + }, + index=[50, 100], + ) + + result = add_column_safe( + df, + [3, 4], + "edge_atoms", + ) + + assert ( + result[ + "edge_atoms" + ].tolist() + == [3, 4] + ) + + +# ============================================================================= +# extract_unique_references +# ============================================================================= + + +def test_extract_unique_references_empty_input(): + assert ( + extract_unique_references([]) + == [] + ) + + +def test_extract_unique_references_extracts_string_values(): + reaction = SimpleNamespace( + references={ + "paper": "doi:one", + "source": "doi:two", + } + ) + + result = extract_unique_references( + [reaction] + ) + + assert result == [ + "doi:one", + "doi:two", + ] + + +def test_extract_unique_references_extracts_list_values(): + reaction = SimpleNamespace( + references={ + "papers": [ + "doi:one", + "doi:two", + ] + } + ) + + result = extract_unique_references( + [reaction] + ) + + assert result == [ + "doi:one", + "doi:two", + ] + + +def test_extract_unique_references_extracts_tuple_values(): + reaction = SimpleNamespace( + references={ + "papers": ( + "doi:one", + "doi:two", + ) + } + ) + + result = extract_unique_references( + [reaction] + ) + + assert result == [ + "doi:one", + "doi:two", + ] + + +def test_extract_unique_references_extracts_nested_dict_string_values(): + reaction = SimpleNamespace( + references={ + "papers": { + "primary": "doi:one", + "secondary": "doi:two", + } + } + ) + + result = extract_unique_references( + [reaction] + ) + + assert result == [ + "doi:one", + "doi:two", + ] + + +def test_extract_unique_references_removes_duplicates_preserving_first_order(): + reaction_1 = SimpleNamespace( + references={ + "a": "doi:one", + "b": [ + "doi:two", + "doi:one", + ], + } + ) + + reaction_2 = SimpleNamespace( + references={ + "c": { + "x": "doi:two", + "y": "doi:three", + } + } + ) + + result = extract_unique_references( + [ + reaction_1, + reaction_2, + ] + ) + + assert result == [ + "doi:one", + "doi:two", + "doi:three", + ] + + +def test_extract_unique_references_ignores_non_string_values(): + reaction = SimpleNamespace( + references={ + "number": 123, + "none": None, + "list": [ + 1, + "doi:one", + None, + ], + "dict": { + "a": 10, + "b": "doi:two", + }, + } + ) + + result = extract_unique_references( + [reaction] + ) + + assert result == [ + "doi:one", + "doi:two", + ] + + +def test_extract_unique_references_uses_legacy_reference_fallback(): + reaction = SimpleNamespace( + references=None, + reference={ + "paper": "legacy-doi", + }, + ) + + result = extract_unique_references( + [reaction] + ) + + assert result == [ + "legacy-doi", + ] + + +def test_extract_unique_references_uses_legacy_reference_when_references_attribute_missing(): + reaction = SimpleNamespace( + reference={ + "paper": "legacy-doi", + } + ) + + result = extract_unique_references( + [reaction] + ) + + assert result == [ + "legacy-doi", + ] + + +def test_extract_unique_references_empty_references_does_not_use_legacy_fallback(): + """ + An existing references dictionary is the preferred current API. + Legacy reference is only a fallback when references is unavailable/None. + """ + reaction = SimpleNamespace( + references={}, + reference={ + "paper": "legacy-doi", + }, + ) + + result = extract_unique_references( + [reaction] + ) + + assert result == [] + + +# ============================================================================= +# compare_set +# ============================================================================= + + +def make_metadata( + reactant_smiles, + product_smiles, +): + return SimpleNamespace( + reactant_combined_RDmol=Chem.MolFromSmiles( + reactant_smiles + ), + product_combined_RDmol=Chem.MolFromSmiles( + product_smiles + ), + ) + + +def test_compare_set_returns_true_for_empty_existing_list(): + reactant = Chem.MolFromSmiles( + "CC" + ) + product = Chem.MolFromSmiles( + "CO" + ) + + assert ( + compare_set( + [], + reactant, + product, + ) + is True + ) + + +def test_compare_set_returns_false_for_identical_pair(): + existing = [ + make_metadata( + "CC", + "CO", + ) + ] + + result = compare_set( + existing, + Chem.MolFromSmiles("CC"), + Chem.MolFromSmiles("CO"), + ) + + assert result is False + + +def test_compare_set_uses_canonical_structure_not_smiles_order(): + existing = [ + make_metadata( + "CCO", + "CCN", + ) + ] + + result = compare_set( + existing, + Chem.MolFromSmiles("OCC"), + Chem.MolFromSmiles("NCC"), + ) + + assert result is False + + +def test_compare_set_returns_true_when_reactant_differs(): + existing = [ + make_metadata( + "CC", + "CO", + ) + ] + + result = compare_set( + existing, + Chem.MolFromSmiles("CCC"), + Chem.MolFromSmiles("CO"), + ) + + assert result is True + + +def test_compare_set_returns_true_when_product_differs(): + existing = [ + make_metadata( + "CC", + "CO", + ) + ] + + result = compare_set( + existing, + Chem.MolFromSmiles("CC"), + Chem.MolFromSmiles("CN"), + ) + + assert result is True + + +def test_compare_set_ignores_atom_mapping_numbers(): + existing_reactant = Chem.MolFromSmiles( + "CC" + ) + existing_product = Chem.MolFromSmiles( + "CO" + ) + + existing_reactant.GetAtomWithIdx( + 0 + ).SetAtomMapNum(11) + + existing_product.GetAtomWithIdx( + 0 + ).SetAtomMapNum(22) + + metadata = SimpleNamespace( + reactant_combined_RDmol=existing_reactant, + product_combined_RDmol=existing_product, + ) + + new_reactant = Chem.MolFromSmiles( + "CC" + ) + new_product = Chem.MolFromSmiles( + "CO" + ) + + new_reactant.GetAtomWithIdx( + 1 + ).SetAtomMapNum(500) + + new_product.GetAtomWithIdx( + 1 + ).SetAtomMapNum(600) + + assert ( + compare_set( + [metadata], + new_reactant, + new_product, + ) + is False + ) + + +def test_compare_set_does_not_mutate_new_molecules_atom_maps(): + reactant = Chem.MolFromSmiles( + "CC" + ) + product = Chem.MolFromSmiles( + "CO" + ) + + reactant.GetAtomWithIdx( + 0 + ).SetAtomMapNum(123) + + product.GetAtomWithIdx( + 0 + ).SetAtomMapNum(456) + + compare_set( + [], + reactant, + product, + ) + + assert ( + reactant.GetAtomWithIdx( + 0 + ).GetAtomMapNum() + == 123 + ) + + assert ( + product.GetAtomWithIdx( + 0 + ).GetAtomMapNum() + == 456 + ) + + +def test_compare_set_does_not_mutate_existing_metadata_molecules(): + metadata = make_metadata( + "CC", + "CO", + ) + + metadata.reactant_combined_RDmol.GetAtomWithIdx( + 0 + ).SetAtomMapNum(101) + + metadata.product_combined_RDmol.GetAtomWithIdx( + 0 + ).SetAtomMapNum(202) + + compare_set( + [metadata], + Chem.MolFromSmiles("CC"), + Chem.MolFromSmiles("CO"), + ) + + assert ( + metadata + .reactant_combined_RDmol + .GetAtomWithIdx(0) + .GetAtomMapNum() + == 101 + ) + + assert ( + metadata + .product_combined_RDmol + .GetAtomWithIdx(0) + .GetAtomMapNum() + == 202 + ) + + +# ============================================================================= +# compare_rdkit_molecules_canonical +# ============================================================================= + + +def test_compare_rdkit_molecules_canonical_finds_identical_structure(): + smiles = [ + "CCO", + ] + + result_list, found = ( + compare_rdkit_molecules_canonical( + smiles, + "OCC", + ) + ) + + assert found is True + assert result_list is smiles + + assert smiles == [ + "CCO", + ] + + +def test_compare_rdkit_molecules_canonical_appends_new_structure(): + smiles = [ + "CCO", + ] + + result_list, found = ( + compare_rdkit_molecules_canonical( + smiles, + "CCN", + ) + ) + + assert found is False + assert result_list is smiles + + assert smiles == [ + "CCO", + "CCN", + ] + + +def test_compare_rdkit_molecules_canonical_appends_to_empty_list(): + smiles = [] + + result_list, found = ( + compare_rdkit_molecules_canonical( + smiles, + "CC", + ) + ) + + assert found is False + assert result_list is smiles + + assert smiles == [ + "CC", + ] + + +@pytest.mark.parametrize( + "candidate", + [ + None, + "", + ], +) +def test_compare_rdkit_molecules_canonical_rejects_empty_candidate_without_appending( + candidate, +): + smiles = [ + "CC", + ] + + result_list, found = ( + compare_rdkit_molecules_canonical( + smiles, + candidate, + ) + ) + + assert found is False + assert result_list is smiles + + assert smiles == [ + "CC", + ] + + +def test_compare_rdkit_molecules_canonical_rejects_invalid_candidate_without_appending(): + smiles = [ + "CC", + ] + + result_list, found = ( + compare_rdkit_molecules_canonical( + smiles, + "this-is-not-smiles", + ) + ) + + assert found is False + assert result_list is smiles + + assert smiles == [ + "CC", + ] + + +def test_compare_rdkit_molecules_canonical_invalid_existing_entry_does_not_hide_later_match(): + """ + One malformed cached entry should not prevent the function from checking + later valid entries for a chemical duplicate. + """ + smiles = [ + "this-is-not-smiles", + "CCO", + ] + + result_list, found = ( + compare_rdkit_molecules_canonical( + smiles, + "OCC", + ) + ) + + assert found is True + assert result_list is smiles + + assert smiles == [ + "this-is-not-smiles", + "CCO", + ] + + +# ============================================================================= +# prep_for_3d_molecule_generation +# ============================================================================= + + +def test_prep_for_3d_molecule_generation_builds_numbered_data_molecules(): + result = ( + prep_for_3d_molecule_generation( + [ + "C", + "CC", + ], + {}, + ) + ) + + assert set(result) == { + "data_1", + "data_2", + } + + assert isinstance( + result["data_1"], + Chem.Mol, + ) + + assert isinstance( + result["data_2"], + Chem.Mol, + ) + + +def test_prep_for_3d_molecule_generation_adds_explicit_hydrogens(): + result = ( + prep_for_3d_molecule_generation( + [ + "C", + ], + {}, + ) + ) + + methane = result[ + "data_1" + ] + + # CH4 = 1 carbon + 4 explicit hydrogen atoms. + assert ( + methane.GetNumAtoms() + == 5 + ) + + hydrogen_count = sum( + atom.GetAtomicNum() == 1 + for atom in methane.GetAtoms() + ) + + assert hydrogen_count == 4 + + +def test_prep_for_3d_molecule_generation_rejects_invalid_smiles(): + with pytest.raises( + ValueError, + match="Invalid SMILES", + ): + prep_for_3d_molecule_generation( + [ + "CC", + "not-a-smiles", + ], + {}, + ) + + +def test_prep_for_3d_molecule_generation_error_mentions_invalid_smiles(): + invalid = "definitely-not-smiles" + + with pytest.raises( + ValueError, + ) as exc_info: + prep_for_3d_molecule_generation( + [ + invalid, + ], + {}, + ) + + assert ( + invalid + in str(exc_info.value) + ) + + +def test_prep_for_3d_molecule_generation_adds_pre_and_post_reaction_molecules(): + reactant = Chem.MolFromSmiles( + "CC" + ) + product = Chem.MolFromSmiles( + "CO" + ) + + reaction_data = { + "reaction_1": { + "reactant": reactant, + "product": product, + } + } + + result = ( + prep_for_3d_molecule_generation( + [], + reaction_data, + ) + ) + + assert ( + result["pre_1"] + is reactant + ) + + assert ( + result["post_1"] + is product + ) + + +def test_prep_for_3d_molecule_generation_skips_entry_missing_reactant(): + product = Chem.MolFromSmiles( + "CO" + ) + + result = ( + prep_for_3d_molecule_generation( + [], + { + "reaction": { + "product": product, + } + }, + ) + ) + + assert result == {} + + +def test_prep_for_3d_molecule_generation_skips_entry_missing_product(): + reactant = Chem.MolFromSmiles( + "CC" + ) + + result = ( + prep_for_3d_molecule_generation( + [], + { + "reaction": { + "reactant": reactant, + } + }, + ) + ) + + assert result == {} + + +def test_prep_for_3d_molecule_generation_skips_entry_with_none_values(): + result = ( + prep_for_3d_molecule_generation( + [], + { + "reaction": { + "reactant": None, + "product": None, + } + }, + ) + ) + + assert result == {} + + +def test_prep_for_3d_molecule_generation_combines_input_and_reaction_molecules(): + reactant = Chem.MolFromSmiles( + "CO" + ) + product = Chem.MolFromSmiles( + "CN" + ) + + result = ( + prep_for_3d_molecule_generation( + [ + "C", + "CC", + ], + { + "rxn": { + "reactant": reactant, + "product": product, + } + }, + ) + ) + + assert set(result) == { + "data_1", + "data_2", + "pre_1", + "post_1", + } + + +def test_prep_for_3d_molecule_generation_preserves_reaction_dictionary_order_numbering(): + reactant_1 = Chem.MolFromSmiles( + "CC" + ) + product_1 = Chem.MolFromSmiles( + "CO" + ) + + reactant_2 = Chem.MolFromSmiles( + "CCC" + ) + product_2 = Chem.MolFromSmiles( + "CCO" + ) + + result = ( + prep_for_3d_molecule_generation( + [], + { + "first": { + "reactant": reactant_1, + "product": product_1, + }, + "second": { + "reactant": reactant_2, + "product": product_2, + }, + }, + ) + ) + + assert result[ + "pre_1" + ] is reactant_1 + + assert result[ + "post_1" + ] is product_1 + + assert result[ + "pre_2" + ] is reactant_2 + + assert result[ + "post_2" + ] is product_2 \ No newline at end of file diff --git a/tests/unit/reaction_preparation/reaction_processor/test_walker.py b/tests/unit/reaction_preparation/reaction_processor/test_walker.py new file mode 100644 index 00000000..2f2f419d --- /dev/null +++ b/tests/unit/reaction_preparation/reaction_processor/test_walker.py @@ -0,0 +1,511 @@ +from rdkit import Chem + +from AutoREACTER.reaction_preparation.reaction_processor.walker import ( + get_new_neighbors, + product_atom_walker, + reactant_atom_walker, + reaction_atom_walker, +) + + +# ============================================================================= +# Helpers +# ============================================================================= + + +def mol(smiles: str) -> Chem.Mol: + molecule = Chem.MolFromSmiles(smiles) + + assert molecule is not None + + return molecule + + +# ============================================================================= +# get_new_neighbors +# ============================================================================= + + +def test_get_new_neighbors_returns_unvisited_neighbors(): + # 0 - 1 - 2 - 3 + molecule = mol("CCCC") + + visited = { + 1: [1], + 2: [], + } + + result = get_new_neighbors( + molecule, + 1, + visited, + ) + + assert result == [0, 2] + + +def test_get_new_neighbors_excludes_atoms_from_previous_shells(): + molecule = mol("CCCC") + + visited = { + 1: [0], + 2: [1], + 3: [], + } + + result = get_new_neighbors( + molecule, + 1, + visited, + ) + + assert result == [2] + + +def test_get_new_neighbors_excludes_atoms_from_any_visited_shell(): + molecule = mol("CCCCC") + + visited = { + 1: [0], + 2: [4], + 3: [2], + 4: [], + } + + result = get_new_neighbors( + molecule, + 2, + visited, + ) + + # Atom 2 is connected to atoms 1 and 3. + assert result == [1, 3] + + +def test_get_new_neighbors_returns_empty_for_isolated_atom(): + molecule = mol("[He]") + + visited = { + 1: [0], + } + + result = get_new_neighbors( + molecule, + 0, + visited, + ) + + assert result == [] + + +def test_get_new_neighbors_does_not_revisit_ring_atoms(): + # Cyclohexane: + # + # 0 connected to 1 and 5. + molecule = mol("C1CCCCC1") + + visited = { + 1: [0], + 2: [1], + } + + result = get_new_neighbors( + molecule, + 1, + visited, + ) + + assert result == [2] + + +def test_get_new_neighbors_does_not_modify_visited(): + molecule = mol("CCCC") + + visited = { + 1: [1], + 2: [], + } + + original = { + key: value.copy() + for key, value + in visited.items() + } + + get_new_neighbors( + molecule, + 1, + visited, + ) + + assert visited == original + + +# ============================================================================= +# reactant_atom_walker +# ============================================================================= + + +def test_reactant_atom_walker_accepts_single_integer_start(): + molecule = mol("CCC") + + template_atoms, edge_atoms = ( + reactant_atom_walker( + molecule, + 1, + max_bonds=1, + ) + ) + + assert template_atoms == [1] + assert edge_atoms == [1] + + +def test_reactant_atom_walker_accepts_list_start(): + molecule = mol("CCCCC") + + template_atoms, edge_atoms = ( + reactant_atom_walker( + molecule, + [1, 3], + max_bonds=1, + ) + ) + + assert template_atoms == [1, 3] + assert edge_atoms == [1, 3] + + +def test_reactant_atom_walker_accepts_tuple_start(): + molecule = mol("CCCCC") + + template_atoms, edge_atoms = ( + reactant_atom_walker( + molecule, + (1, 3), + max_bonds=1, + ) + ) + + assert template_atoms == [1, 3] + assert edge_atoms == [1, 3] + + +def test_reactant_atom_walker_max_bonds_1_contains_seed_shell_only(): + molecule = mol("CC(C)C") + + template_atoms, edge_atoms = ( + reactant_atom_walker( + molecule, + 1, + max_bonds=1, + ) + ) + + assert template_atoms == [1] + assert edge_atoms == [1] + + +def test_reactant_atom_walker_max_bonds_2_reaches_one_bond_away(): + # 0 - 1 - 2 - 3 - 4 + molecule = mol("CCCCC") + + template_atoms, edge_atoms = ( + reactant_atom_walker( + molecule, + 2, + max_bonds=2, + ) + ) + + assert template_atoms == [ + 2, + 1, + 3, + ] + + assert edge_atoms == [ + 1, + 3, + ] + + +def test_reactant_atom_walker_max_bonds_3_reaches_two_bonds_away(): + # 0 - 1 - 2 - 3 - 4 - 5 - 6 + molecule = mol("CCCCCCC") + + template_atoms, edge_atoms = ( + reactant_atom_walker( + molecule, + 3, + max_bonds=3, + ) + ) + + assert template_atoms == [ + 3, + 2, + 4, + 1, + 5, + ] + + assert edge_atoms == [ + 1, + 5, + ] + + +def test_reactant_atom_walker_ring_does_not_revisit_atoms(): + molecule = mol("C1CCCCC1") + + template_atoms, edge_atoms = ( + reactant_atom_walker( + molecule, + 0, + max_bonds=2, + ) + ) + + assert template_atoms == [ + 0, + 1, + 5, + ] + + assert edge_atoms == [ + 1, + 5, + ] + + assert len(template_atoms) == len( + set(template_atoms) + ) + + +def test_reactant_atom_walker_stops_at_requested_shell_depth(): + molecule = mol( + "CCCCCCC" + ) + + template_atoms, edge_atoms = ( + reactant_atom_walker( + molecule, + 3, + max_bonds=2, + ) + ) + + # max_bonds=2 means: + # shell 1 = seed + # shell 2 = one bond away + assert template_atoms == [ + 3, + 2, + 4, + ] + + assert edge_atoms == [ + 2, + 4, + ] + + +def test_reactant_atom_walker_multiple_seeds_do_not_duplicate_shared_neighbor(): + molecule = mol( + "CCCCC" + ) + + # Need shell 2 to actually walk one bond from the seeds. + template_atoms, edge_atoms = ( + reactant_atom_walker( + molecule, + [1, 3], + max_bonds=2, + ) + ) + + assert template_atoms.count(2) == 1 + assert edge_atoms.count(2) == 1 + + +def test_reaction_atom_walker_combines_graph_walk_and_mapping(): + molecule = mol( + "CCCCC" + ) + + mapping = { + 0: 100, + 1: 101, + 2: 102, + 3: 103, + 4: 104, + } + + mapped, edge_atoms = ( + reaction_atom_walker( + molecule, + 2, + mapping, + max_bonds=2, + ) + ) + + assert mapped == { + 2: 102, + 1: 101, + 3: 103, + } + + assert edge_atoms == [ + 1, + 3, + ] + + +def test_reaction_atom_walker_keeps_edge_atoms_in_reactant_index_space(): + molecule = mol( + "CCCCC" + ) + + mapping = { + 0: 50, + 1: 51, + 2: 52, + 3: 53, + 4: 54, + } + + _, edge_atoms = ( + reaction_atom_walker( + molecule, + 2, + mapping, + max_bonds=2, + ) + ) + + assert edge_atoms == [ + 1, + 3, + ] + + assert 51 not in edge_atoms + assert 53 not in edge_atoms + + +def test_reaction_atom_walker_skips_template_atoms_missing_from_mapping(): + molecule = mol( + "CCCCC" + ) + + mapping = { + 1: 11, + 2: 12, + 3: 13, + } + + mapped, edge_atoms = ( + reaction_atom_walker( + molecule, + 2, + mapping, + max_bonds=2, + ) + ) + + assert mapped == { + 2: 12, + 1: 11, + 3: 13, + } + + assert edge_atoms == [ + 1, + 3, + ] + + +def test_reaction_atom_walker_multiple_start_atoms(): + molecule = mol( + "CCCCC" + ) + + mapping = { + idx: idx + 100 + for idx in range(5) + } + + mapped, edge_atoms = ( + reaction_atom_walker( + molecule, + [1, 3], + mapping, + max_bonds=2, + ) + ) + + assert mapped == { + 1: 101, + 3: 103, + 0: 100, + 2: 102, + 4: 104, + } + + assert edge_atoms == [ + 0, + 2, + 4, + ] + +def test_reaction_atom_walker_disconnected_components(): + molecule = mol( + "CC.CC" + ) + + mapping = { + 0: 10, + 1: 11, + 2: 12, + 3: 13, + } + + mapped, edge_atoms = ( + reaction_atom_walker( + molecule, + 0, + mapping, + max_bonds=3, + ) + ) + + assert mapped == { + 0: 10, + 1: 11, + } + + assert edge_atoms == [] + + +def test_reaction_atom_walker_does_not_modify_mapping(): + molecule = mol( + "CCC" + ) + + mapping = { + 0: 10, + 1: 11, + 2: 12, + } + + original = mapping.copy() + + reaction_atom_walker( + molecule, + 1, + mapping, + max_bonds=1, + ) + + assert mapping == original \ No newline at end of file diff --git a/tests/unit/reaction_preparation/reaction_processor/test_warning_asci.py b/tests/unit/reaction_preparation/reaction_processor/test_warning_asci.py new file mode 100644 index 00000000..60f6721c --- /dev/null +++ b/tests/unit/reaction_preparation/reaction_processor/test_warning_asci.py @@ -0,0 +1,145 @@ +from AutoREACTER.reaction_preparation.reaction_processor.warning_asci import ( + ascii_art, + print_warning, +) + + +# ============================================================================= +# ascii_art +# ============================================================================= + + +def test_ascii_art_uppercases_message(capsys): + ascii_art( + "reaction progression warning" + ) + + output = capsys.readouterr().out + + assert ( + "WARNING: REACTION PROGRESSION WARNING" + in output + ) + + +def test_ascii_art_prints_warning_prefix(capsys): + ascii_art( + "test message" + ) + + output = capsys.readouterr().out + + assert output.startswith( + "WARNING: TEST MESSAGE" + ) + + +def test_ascii_art_prints_banner(capsys): + ascii_art( + "test" + ) + + output = capsys.readouterr().out + + # Stable fragments from the ASCII banner. + assert "____" in output + assert "|_ _|" in output + assert "WARNING:" in output + + +def test_ascii_art_prints_multiline_output(capsys): + ascii_art( + "hello" + ) + + output = capsys.readouterr().out + + nonempty_lines = [ + line + for line in output.splitlines() + if line.strip() + ] + + # One warning line + several ASCII-art lines. + assert len(nonempty_lines) >= 6 + + +def test_ascii_art_handles_empty_message(capsys): + ascii_art("") + + output = capsys.readouterr().out + + assert output.startswith( + "WARNING:" + ) + + assert "____" in output + + +def test_ascii_art_does_not_return_value(): + result = ascii_art( + "warning" + ) + + assert result is None + + +# ============================================================================= +# print_warning +# ============================================================================= + + +def test_print_warning_prints_beta_phase_message( + capsys, +): + print_warning() + + output = capsys.readouterr().out + + assert ( + "ENTERING THE REACTION PROGRESSION LOOP " + "IS STILL IN THE BETA PHASE." + in output + ) + + +def test_print_warning_mentions_chemical_accuracy( + capsys, +): + print_warning() + + output = capsys.readouterr().out + + assert ( + "RESULTS CAN BE CHEMICALLY INACCURATE" + in output + ) + + +def test_print_warning_uses_warning_prefix( + capsys, +): + print_warning() + + output = capsys.readouterr().out + + assert output.startswith( + "WARNING:" + ) + + +def test_print_warning_prints_ascii_banner( + capsys, +): + print_warning() + + output = capsys.readouterr().out + + assert "____" in output + assert "|_ _|" in output + + +def test_print_warning_returns_none(): + result = print_warning() + + assert result is None \ No newline at end of file diff --git a/tests/unit/reaction_preparation/test_deduplication_detector.py b/tests/unit/reaction_preparation/test_deduplication_detector.py new file mode 100644 index 00000000..837fed66 --- /dev/null +++ b/tests/unit/reaction_preparation/test_deduplication_detector.py @@ -0,0 +1,3305 @@ +from pathlib import Path +from types import SimpleNamespace + +import networkx as nx +import pytest +from rdkit import Chem + +from AutoREACTER.reaction_preparation.deduplication_detector import ( + DeduplicationDetector, +) + + +# ============================================================================= +# Helpers +# ============================================================================= + + +def mol(smiles: str) -> Chem.Mol: + molecule = Chem.MolFromSmiles(smiles) + + assert molecule is not None + + return molecule + + +def make_phase_graph( + detector, + node_labels, + edges=(), +): + graph = nx.Graph() + + for node_id, label in node_labels.items(): + graph.add_node( + node_id, + **{ + detector.NODE_ATTRIBUTE: + label, + }, + ) + + for atom1, atom2, bond_label in edges: + graph.add_edge( + atom1, + atom2, + **{ + detector.EDGE_ATTRIBUTE: + bond_label, + }, + ) + + return graph + + +def make_metadata( + reactant_smiles="CC", + product_smiles="CC", + template_mapping=None, + full_mapping=None, + first_shell=None, + activity_stats=True, + reaction_id=1, + is_radical=False, +): + reactant = mol( + reactant_smiles + ) + + product = mol( + product_smiles + ) + + if full_mapping is None: + full_mapping = { + idx: idx + for idx in range( + min( + reactant.GetNumAtoms(), + product.GetNumAtoms(), + ) + ) + } + + if template_mapping is None: + template_mapping = ( + full_mapping.copy() + ) + + if first_shell is None: + first_shell = list( + template_mapping.keys() + ) + + return SimpleNamespace( + reaction_id=reaction_id, + reactant_combined_RDmol=reactant, + product_combined_RDmol=product, + template_reactant_to_product_mapping=template_mapping, + reactant_to_product_mapping=full_mapping, + first_shell=first_shell, + activity_stats=activity_stats, + is_radical=is_radical, + pre_reaction_file=None, + post_reaction_file=None, + map_file=None, + ) + + +def write_lammps_molecule( + path: Path, + atom_types, + bonds=(), +): + lines = [ + "# test molecule\n", + "\n", + "Coords\n", + "\n", + ] + + for atom_id in atom_types: + lines.append( + f"{atom_id} 0.0 0.0 0.0\n" + ) + + lines.extend( + [ + "\n", + "Types\n", + "\n", + ] + ) + + for atom_id, atom_type in ( + atom_types.items() + ): + lines.append( + f"{atom_id} {atom_type}\n" + ) + + lines.extend( + [ + "\n", + "Charges\n", + "\n", + ] + ) + + for atom_id in atom_types: + lines.append( + f"{atom_id} 0.0\n" + ) + + if bonds: + lines.extend( + [ + "\n", + "Bonds\n", + "\n", + ] + ) + + for ( + bond_id, + bond_type, + atom1, + atom2, + ) in bonds: + lines.append( + f"{bond_id} " + f"{bond_type} " + f"{atom1} " + f"{atom2}\n" + ) + + path.write_text( + "".join(lines), + encoding="utf-8", + ) + + return path + + +def write_map_file( + path: Path, + equivalences=(), + edge_ids=(), + initiator_ids=(), + delete_ids=(), + wildcards=(), +): + lines = [ + "# AutoREACTER test map\n", + "\n", + f"{len(edge_ids)} edgeIDs\n", + f"{len(equivalences)} equivalences\n", + ] + + if delete_ids: + lines.append( + f"{len(delete_ids)} deleteIDs\n" + ) + + lines.append( + f"{len(wildcards)} wildcards\n" + ) + + lines.extend( + [ + "\n", + "InitiatorIDs\n", + "\n", + ] + ) + + for atom_id in initiator_ids: + lines.append( + f"{atom_id}\n" + ) + + lines.extend( + [ + "\n", + "EdgeIDs\n", + "\n", + ] + ) + + for atom_id in edge_ids: + lines.append( + f"{atom_id}\n" + ) + + lines.extend( + [ + "\n", + "Equivalences\n", + "\n", + ] + ) + + for pre_id, post_id in equivalences: + lines.append( + f"{pre_id} {post_id}\n" + ) + + if delete_ids: + lines.extend( + [ + "\n", + "DeleteIDs\n", + "\n", + ] + ) + + for atom_id in delete_ids: + lines.append( + f"{atom_id}\n" + ) + + lines.extend( + [ + "\n", + "Wildcards\n", + "\n", + ] + ) + + for atom_id in wildcards: + lines.append( + f"{atom_id}\n" + ) + + path.write_text( + "".join(lines), + encoding="utf-8", + ) + + return path + + +# ============================================================================= +# Construction / cache isolation +# ============================================================================= + + +def test_constructor_initializes_expected_cache_groups(): + detector = ( + DeduplicationDetector() + ) + + assert detector.seen_reactions == { + detector.LAMMPS_COMPARISON_GROUP: [], + detector.RDKIT_COMPARISON_GROUP: [], + } + + assert ( + detector.seen_reaction_pairs + == { + detector.LAMMPS_COMPARISON_GROUP: [], + detector.RDKIT_COMPARISON_GROUP: [], + } + ) + + +def test_detector_instances_have_independent_caches(): + first = DeduplicationDetector() + second = DeduplicationDetector() + + first.seen_reactions[ + first.RDKIT_COMPARISON_GROUP + ].append( + nx.Graph() + ) + + assert ( + second.seen_reactions[ + second.RDKIT_COMPARISON_GROUP + ] + == [] + ) + + +# ============================================================================= +# clear_cache +# ============================================================================= + + +def test_clear_cache_clears_all_groups(): + detector = ( + DeduplicationDetector() + ) + + detector.seen_reactions[ + "rdkit" + ].append( + nx.Graph() + ) + + detector.seen_reaction_pairs[ + "lammps" + ].append( + ( + nx.Graph(), + nx.Graph(), + ) + ) + + detector.clear_cache() + + assert all( + not value + for value + in detector.seen_reactions.values() + ) + + assert all( + not value + for value + in detector.seen_reaction_pairs.values() + ) + + +def test_clear_cache_specific_group_only(): + detector = ( + DeduplicationDetector() + ) + + detector.seen_reactions[ + "rdkit" + ].append( + nx.Graph() + ) + + detector.seen_reactions[ + "lammps" + ].append( + nx.Graph() + ) + + detector.clear_cache( + "rdkit" + ) + + assert ( + detector.seen_reactions[ + "rdkit" + ] + == [] + ) + + assert len( + detector.seen_reactions[ + "lammps" + ] + ) == 1 + + +def test_clear_cache_creates_unknown_group(): + detector = ( + DeduplicationDetector() + ) + + detector.clear_cache( + "custom" + ) + + assert ( + detector.seen_reactions[ + "custom" + ] + == [] + ) + + assert ( + detector.seen_reaction_pairs[ + "custom" + ] + == [] + ) + + +# ============================================================================= +# is_duplicate_pair +# ============================================================================= + + +def test_duplicate_pair_first_pair_is_unique(): + detector = ( + DeduplicationDetector() + ) + + pre = make_phase_graph( + detector, + { + 0: "C", + 1: "O", + }, + [ + ( + 0, + 1, + "SINGLE", + ) + ], + ) + + post = make_phase_graph( + detector, + { + 0: "C", + 1: "O", + }, + [ + ( + 0, + 1, + "DOUBLE", + ) + ], + ) + + assert ( + detector.is_duplicate_pair( + pre, + post, + "test", + ) + is False + ) + + +def test_duplicate_pair_second_identical_pair_is_duplicate(): + detector = ( + DeduplicationDetector() + ) + + pre = make_phase_graph( + detector, + { + 0: "C", + 1: "O", + }, + [ + ( + 0, + 1, + "SINGLE", + ) + ], + ) + + post = make_phase_graph( + detector, + { + 0: "C", + 1: "O", + }, + [ + ( + 0, + 1, + "DOUBLE", + ) + ], + ) + + assert not detector.is_duplicate_pair( + pre, + post, + "test", + ) + + assert detector.is_duplicate_pair( + pre, + post, + "test", + ) + + +def test_duplicate_pair_requires_matching_pre_and_post_pair(): + detector = ( + DeduplicationDetector() + ) + + pre = make_phase_graph( + detector, + { + 0: "C", + }, + ) + + post_1 = make_phase_graph( + detector, + { + 0: "O", + }, + ) + + post_2 = make_phase_graph( + detector, + { + 0: "N", + }, + ) + + assert not detector.is_duplicate_pair( + pre, + post_1, + "test", + ) + + assert not detector.is_duplicate_pair( + pre, + post_2, + "test", + ) + + +def test_duplicate_pair_is_structure_based_not_node_id_based(): + detector = ( + DeduplicationDetector() + ) + + pre_1 = make_phase_graph( + detector, + { + 1: "C", + 2: "O", + }, + [ + ( + 1, + 2, + "SINGLE", + ) + ], + ) + + post_1 = make_phase_graph( + detector, + { + 1: "C", + 2: "O", + }, + [ + ( + 1, + 2, + "DOUBLE", + ) + ], + ) + + pre_2 = make_phase_graph( + detector, + { + 100: "C", + 200: "O", + }, + [ + ( + 100, + 200, + "SINGLE", + ) + ], + ) + + post_2 = make_phase_graph( + detector, + { + 100: "C", + 200: "O", + }, + [ + ( + 100, + 200, + "DOUBLE", + ) + ], + ) + + assert not detector.is_duplicate_pair( + pre_1, + post_1, + "test", + ) + + assert detector.is_duplicate_pair( + pre_2, + post_2, + "test", + ) + + +def test_duplicate_pair_cache_uses_copies(): + detector = ( + DeduplicationDetector() + ) + + pre = make_phase_graph( + detector, + { + 0: "C", + }, + ) + + post = make_phase_graph( + detector, + { + 0: "O", + }, + ) + + detector.is_duplicate_pair( + pre, + post, + "test", + ) + + pre.add_node( + 99, + atom_label="X", + ) + + cached_pre, _ = ( + detector + .seen_reaction_pairs[ + "test" + ][0] + ) + + assert 99 not in cached_pre + + +# ============================================================================= +# Coupled RDKit graph handling +# ============================================================================= + + +def test_couple_graphs_builds_pre_post_nodes_and_correspondence_edges(): + detector = ( + DeduplicationDetector() + ) + + pre = make_phase_graph( + detector, + { + 0: "C", + 1: "O", + }, + [ + ( + 0, + 1, + "SINGLE", + ) + ], + ) + + post = make_phase_graph( + detector, + { + 0: "C", + 1: "O", + }, + [ + ( + 0, + 1, + "DOUBLE", + ) + ], + ) + + coupled = detector._couple_graphs( + pre, + post, + ) + + assert set( + coupled.nodes + ) == { + ("pre", 0), + ("pre", 1), + ("post", 0), + ("post", 1), + } + + assert coupled.has_edge( + ("pre", 0), + ("post", 0), + ) + + assert ( + coupled.edges[ + ("pre", 0), + ("post", 0), + ]["relationship"] + == "atom_correspondence" + ) + + +def test_couple_graphs_requires_matching_atom_ids(): + detector = ( + DeduplicationDetector() + ) + + pre = make_phase_graph( + detector, + { + 0: "C", + 1: "O", + }, + ) + + post = make_phase_graph( + detector, + { + 0: "C", + 2: "O", + }, + ) + + with pytest.raises( + ValueError, + match="matching atom IDs", + ): + detector._couple_graphs( + pre, + post, + ) + + +def test_couple_graphs_preserves_radical_signature(): + detector = ( + DeduplicationDetector() + ) + + pre = make_phase_graph( + detector, + { + 0: "C", + }, + ) + + post = make_phase_graph( + detector, + { + 0: "C", + }, + ) + + pre.graph[ + detector.RADICAL_COUNT_ATTRIBUTE + ] = 1 + + pre.graph[ + detector.RADICAL_PRESENT_ATTRIBUTE + ] = True + + post.graph[ + detector.RADICAL_COUNT_ATTRIBUTE + ] = 0 + + post.graph[ + detector.RADICAL_PRESENT_ATTRIBUTE + ] = False + + coupled = detector._couple_graphs( + pre, + post, + ) + + assert coupled.graph[ + detector.RADICAL_SIGNATURE_ATTRIBUTE + ] == ( + 1, + 0, + True, + False, + ) + + +def test_add_phase_requires_node_label(): + detector = ( + DeduplicationDetector() + ) + + source = nx.Graph() + source.add_node(0) + + target = nx.Graph() + + with pytest.raises( + ValueError, + match="atom_label", + ): + detector._add_phase_to_coupled_graph( + source, + target, + "pre", + ) + + +def test_add_phase_requires_edge_label(): + detector = ( + DeduplicationDetector() + ) + + source = nx.Graph() + + source.add_node( + 0, + atom_label="C", + ) + + source.add_node( + 1, + atom_label="O", + ) + + source.add_edge( + 0, + 1, + ) + + target = nx.Graph() + + with pytest.raises( + ValueError, + match="bond_label", + ): + detector._add_phase_to_coupled_graph( + source, + target, + "pre", + ) + + +# ============================================================================= +# is_duplicate / coupled cache +# ============================================================================= + + +def test_is_duplicate_first_coupled_reaction_is_unique(): + detector = ( + DeduplicationDetector() + ) + + pre = make_phase_graph( + detector, + { + 0: "C", + }, + ) + + post = make_phase_graph( + detector, + { + 0: "O", + }, + ) + + assert ( + detector.is_duplicate( + pre, + post, + "test", + ) + is False + ) + + +def test_is_duplicate_second_equivalent_coupled_reaction_is_duplicate(): + detector = ( + DeduplicationDetector() + ) + + pre = make_phase_graph( + detector, + { + 0: "C", + }, + ) + + post = make_phase_graph( + detector, + { + 0: "O", + }, + ) + + detector.is_duplicate( + pre, + post, + "test", + ) + + assert detector.is_duplicate( + pre, + post, + "test", + ) + + +def test_coupled_duplicate_radical_signature_must_match(): + detector = ( + DeduplicationDetector() + ) + + pre_1 = make_phase_graph( + detector, + {0: "C"}, + ) + + post_1 = make_phase_graph( + detector, + {0: "C"}, + ) + + pre_1.graph[ + detector.RADICAL_PRESENT_ATTRIBUTE + ] = False + + post_1.graph[ + detector.RADICAL_PRESENT_ATTRIBUTE + ] = False + + pre_2 = make_phase_graph( + detector, + {0: "C"}, + ) + + post_2 = make_phase_graph( + detector, + {0: "C"}, + ) + + pre_2.graph[ + detector.RADICAL_PRESENT_ATTRIBUTE + ] = False + + post_2.graph[ + detector.RADICAL_PRESENT_ATTRIBUTE + ] = True + + assert not detector.is_duplicate( + pre_1, + post_1, + "test", + ) + + assert not detector.is_duplicate( + pre_2, + post_2, + "test", + ) + + +# ============================================================================= +# RDKit graph conversion +# ============================================================================= + + +def test_rdkit_mol_to_networkx_rejects_none(): + detector = ( + DeduplicationDetector() + ) + + with pytest.raises( + ValueError, + match="None RDKit molecule", + ): + detector.rdkit_mol_to_networkx( + None + ) + + +def test_rdkit_mol_to_networkx_builds_all_atoms(): + detector = ( + DeduplicationDetector() + ) + + molecule = mol( + "CCO" + ) + + graph = ( + detector.rdkit_mol_to_networkx( + molecule, + deep_check=False, + ) + ) + + assert graph.number_of_nodes() == 3 + + assert graph.number_of_edges() == 2 + + +def test_rdkit_graph_node_label_contains_element(): + detector = ( + DeduplicationDetector() + ) + + graph = ( + detector.rdkit_mol_to_networkx( + mol("CO"), + deep_check=False, + ) + ) + + assert graph.nodes[ + 0 + ][ + detector.NODE_ATTRIBUTE + ][0] == "C" + + assert graph.nodes[ + 1 + ][ + detector.NODE_ATTRIBUTE + ][0] == "O" + + +def test_rdkit_graph_edges_include_bond_type(): + detector = ( + DeduplicationDetector() + ) + + graph = ( + detector.rdkit_mol_to_networkx( + mol("C=C"), + deep_check=False, + ) + ) + + assert ( + graph.edges[ + 0, + 1, + ][detector.EDGE_ATTRIBUTE] + == "DOUBLE" + ) + + +def test_rdkit_graph_can_be_restricted_to_atom_indices(): + detector = ( + DeduplicationDetector() + ) + + graph = ( + detector.rdkit_mol_to_networkx( + mol("CCCC"), + atom_idxs={ + 1, + 2, + }, + deep_check=False, + ) + ) + + assert set( + graph.nodes + ) == { + 1, + 2, + } + + assert graph.number_of_edges() == 1 + + +def test_rdkit_graph_relabels_nodes(): + detector = ( + DeduplicationDetector() + ) + + graph = ( + detector.rdkit_mol_to_networkx( + mol("CO"), + atom_idxs={ + 0, + 1, + }, + idx_relabel={ + 0: 10, + 1: 20, + }, + deep_check=False, + ) + ) + + assert set( + graph.nodes + ) == { + 10, + 20, + } + + assert graph.has_edge( + 10, + 20, + ) + + +def test_rdkit_graph_relabel_requires_all_included_indices(): + detector = ( + DeduplicationDetector() + ) + + with pytest.raises( + ValueError, + match="does not contain entries", + ): + detector.rdkit_mol_to_networkx( + mol("CO"), + atom_idxs={ + 0, + 1, + }, + idx_relabel={ + 0: 10, + }, + ) + + +def test_rdkit_deep_check_includes_external_neighbor_signature(): + detector = ( + DeduplicationDetector() + ) + + molecule = mol( + "CCO" + ) + + graph = ( + detector.rdkit_mol_to_networkx( + molecule, + atom_idxs={ + 1, + }, + deep_check=True, + ) + ) + + label = graph.nodes[ + 1 + ][detector.NODE_ATTRIBUTE] + + assert label[0] == "C" + + signature = label[2] + + external_symbols = { + item[0] + for item in signature + } + + assert external_symbols == { + "C", + "O", + } + + +def test_rdkit_shallow_check_omits_external_environment_signature(): + detector = ( + DeduplicationDetector() + ) + + graph = ( + detector.rdkit_mol_to_networkx( + mol("CCO"), + atom_idxs={ + 1, + }, + deep_check=False, + ) + ) + + label = graph.nodes[ + 1 + ][detector.NODE_ATTRIBUTE] + + assert len(label) == 2 + + +# ============================================================================= +# Radical helpers +# ============================================================================= + + +def test_explicit_radical_atom_is_detected(): + detector = ( + DeduplicationDetector() + ) + + radical = mol( + "[CH3]" + ) + + atom = radical.GetAtomWithIdx( + 0 + ) + + assert ( + detector._is_radical_atom( + atom + ) + is True + ) + + +def test_normal_carbon_is_not_radical(): + detector = ( + DeduplicationDetector() + ) + + atom = ( + mol("C") + .GetAtomWithIdx(0) + ) + + assert ( + detector._is_radical_atom( + atom + ) + is False + ) + + +def test_count_radical_atoms(): + detector = ( + DeduplicationDetector() + ) + + molecule = mol( + "[CH3].[CH3]" + ) + + assert ( + detector._count_radical_atoms( + molecule + ) + == 2 + ) + + +# ============================================================================= +# Mapping selection +# ============================================================================= + + +def test_select_template_mapping(): + detector = ( + DeduplicationDetector() + ) + + metadata = make_metadata( + template_mapping={ + 0: 1, + }, + ) + + result = ( + detector + ._select_reactant_to_product_mapping( + metadata, + reaction_index=1, + index_source="template", + ) + ) + + assert result == { + 0: 1, + } + + +def test_select_template_mapping_requires_mapping(): + detector = ( + DeduplicationDetector() + ) + + metadata = make_metadata() + + metadata.template_reactant_to_product_mapping = None + + with pytest.raises( + ValueError, + match="template_reactant_to_product_mapping", + ): + detector._select_reactant_to_product_mapping( + metadata, + 1, + "template", + ) + + +def test_select_first_shell_mapping(): + detector = ( + DeduplicationDetector() + ) + + metadata = make_metadata( + full_mapping={ + 0: 2, + 1: 1, + 2: 0, + }, + first_shell=[ + 0, + 2, + ], + ) + + result = ( + detector + ._select_reactant_to_product_mapping( + metadata, + 1, + "first_shell", + ) + ) + + assert result == { + 0: 2, + 2: 0, + } + + +def test_select_first_shell_requires_indices(): + detector = ( + DeduplicationDetector() + ) + + metadata = make_metadata() + + metadata.first_shell = None + + with pytest.raises( + ValueError, + match="first_shell", + ): + detector._select_reactant_to_product_mapping( + metadata, + 1, + "first_shell", + ) + + +def test_select_first_shell_requires_full_mapping(): + detector = ( + DeduplicationDetector() + ) + + metadata = make_metadata() + + metadata.first_shell = [ + 0, + ] + + metadata.reactant_to_product_mapping = None + + with pytest.raises( + ValueError, + match="reactant_to_product_mapping", + ): + detector._select_reactant_to_product_mapping( + metadata, + 1, + "first_shell", + ) + + +def test_select_mapping_rejects_unknown_source(): + detector = ( + DeduplicationDetector() + ) + + metadata = make_metadata() + + with pytest.raises( + ValueError, + match="Unsupported index_source", + ): + detector._select_reactant_to_product_mapping( + metadata, + 1, + "wrong", + ) + + +# ============================================================================= +# compare_graphs_mol +# ============================================================================= + + +def test_compare_graphs_mol_keeps_first_unique_reaction(): + detector = ( + DeduplicationDetector() + ) + + metadata = make_metadata() + + result = ( + detector.compare_graphs_mol( + [ + metadata, + ] + ) + ) + + assert result == [ + metadata, + ] + + assert ( + metadata.activity_stats + is True + ) + + +def test_compare_graphs_mol_disables_later_duplicate(): + detector = ( + DeduplicationDetector() + ) + + first = make_metadata( + reaction_id=1, + ) + + second = make_metadata( + reaction_id=2, + ) + + result = ( + detector.compare_graphs_mol( + [ + first, + second, + ] + ) + ) + + assert result == [ + first, + ] + + assert ( + first.activity_stats + is True + ) + + assert ( + second.activity_stats + is False + ) + + +def test_compare_graphs_mol_repeated_same_object_is_not_disabled(): + detector = ( + DeduplicationDetector() + ) + + metadata = make_metadata() + + result = ( + detector.compare_graphs_mol( + [ + metadata, + metadata, + ] + ) + ) + + assert result == [ + metadata, + ] + + assert ( + metadata.activity_stats + is True + ) + + +def test_compare_graphs_mol_skips_already_inactive_reactions(): + detector = ( + DeduplicationDetector() + ) + + metadata = make_metadata( + activity_stats=False + ) + + result = ( + detector.compare_graphs_mol( + [ + metadata, + ] + ) + ) + + assert result == [] + + +def test_compare_graphs_mol_requires_reactant_molecule(): + detector = ( + DeduplicationDetector() + ) + + metadata = make_metadata() + + metadata.reactant_combined_RDmol = None + + with pytest.raises( + ValueError, + match="combined reactant", + ): + detector.compare_graphs_mol( + [ + metadata, + ] + ) + + +def test_compare_graphs_mol_requires_product_molecule(): + detector = ( + DeduplicationDetector() + ) + + metadata = make_metadata() + + metadata.product_combined_RDmol = None + + with pytest.raises( + ValueError, + match="combined product", + ): + detector.compare_graphs_mol( + [ + metadata, + ] + ) + + +def test_compare_graphs_mol_rejects_nonbijective_mapping(): + detector = ( + DeduplicationDetector() + ) + + metadata = make_metadata( + template_mapping={ + 0: 0, + 1: 0, + }, + ) + + with pytest.raises( + ValueError, + match="non-bijective", + ): + detector.compare_graphs_mol( + [ + metadata, + ] + ) + + +def test_compare_graphs_mol_supports_first_shell_source(): + detector = ( + DeduplicationDetector() + ) + + metadata = make_metadata( + template_mapping=None, + full_mapping={ + 0: 0, + 1: 1, + }, + first_shell=[ + 0, + ], + ) + + metadata.template_reactant_to_product_mapping = None + + result = detector.compare_graphs_mol( + [ + metadata, + ], + index_source="first_shell", + ) + + assert result == [ + metadata, + ] + + +def test_compare_graphs_mol_clears_rdkit_cache_each_pass(): + detector = ( + DeduplicationDetector() + ) + + first = make_metadata( + reaction_id=1, + ) + + second = make_metadata( + reaction_id=2, + ) + + assert detector.compare_graphs_mol( + [ + first, + ] + ) == [ + first, + ] + + # A fresh deduplication pass must not treat an equivalent reaction + # as a duplicate merely because it appeared in a previous call. + assert detector.compare_graphs_mol( + [ + second, + ] + ) == [ + second, + ] + + +def test_compare_graphs_mol_radical_metadata_changes_signature(): + detector = ( + DeduplicationDetector() + ) + + non_radical = make_metadata( + reaction_id=1, + is_radical=False, + ) + + radical = make_metadata( + reaction_id=2, + is_radical=True, + ) + + result = detector.compare_graphs_mol( + [ + non_radical, + radical, + ] + ) + + assert result == [ + non_radical, + radical, + ] + + +def test_compare_graphs_mol_forwards_deep_check( + monkeypatch, +): + detector = ( + DeduplicationDetector() + ) + + metadata = make_metadata() + + calls = [] + + original = ( + detector.rdkit_mol_to_networkx + ) + + def wrapper( + molecule, + atom_idxs=None, + idx_relabel=None, + deep_check=True, + ): + calls.append( + deep_check + ) + + return original( + molecule=molecule, + atom_idxs=atom_idxs, + idx_relabel=idx_relabel, + deep_check=deep_check, + ) + + monkeypatch.setattr( + detector, + "rdkit_mol_to_networkx", + wrapper, + ) + + detector.compare_graphs_mol( + [ + metadata, + ], + deep_check=False, + ) + + assert calls == [ + False, + False, + ] + + +# ============================================================================= +# LAMMPS template filename helpers +# ============================================================================= + + +@pytest.mark.parametrize( + "name, expected", + [ + ( + "template_pre_1.molecule", + "1", + ), + ( + "template_post_22.molecule", + "22", + ), + ( + "template_pre_1_homo2.molecule", + "1_homo2", + ), + ], +) +def test_lammps_reaction_id_from_template_path( + name, + expected, +): + result = ( + DeduplicationDetector + ._lammps_reaction_id_from_template_path( + name + ) + ) + + assert result == expected + + +@pytest.mark.parametrize( + "name", + [ + "RXN_1.map", + "pre_1.molecule", + "template_1.molecule", + "template_pre_1.txt", + ], +) +def test_lammps_reaction_id_rejects_bad_filename( + name, +): + with pytest.raises( + ValueError, + match="Could not infer reaction ID", + ): + ( + DeduplicationDetector + ._lammps_reaction_id_from_template_path( + name + ) + ) + + +def test_lammps_map_path_from_template_path( + tmp_path, +): + detector = ( + DeduplicationDetector() + ) + + template = ( + tmp_path + / "template_pre_42.molecule" + ) + + assert ( + detector + ._lammps_map_path_from_template_path( + template + ) + == tmp_path / "RXN_42.map" + ) + + +# ============================================================================= +# LAMMPS map parsing +# ============================================================================= + + +def test_is_lammps_map_section_header(): + detector = ( + DeduplicationDetector + ) + + assert detector._is_lammps_map_section_header( + "EdgeIDs" + ) + + assert detector._is_lammps_map_section_header( + "Equivalences extra" + ) + + assert not detector._is_lammps_map_section_header( + "1 2" + ) + + +@pytest.mark.parametrize( + "line", + [ + "11 edgeIDs", + "2 equivalences", + "0 wildcards", + " 5 EDGEIDS ", + ], +) +def test_is_lammps_count_line_true( + line, +): + assert ( + DeduplicationDetector + ._is_lammps_count_line( + line + ) + ) + + +def test_is_lammps_count_line_false(): + assert not ( + DeduplicationDetector + ._is_lammps_count_line( + "2 deleteIDs" + ) + ) + + +def test_read_lammps_equivalences( + tmp_path, +): + path = write_map_file( + tmp_path / "RXN_1.map", + equivalences=[ + ( + 1, + 10, + ), + ( + 2, + 20, + ), + ], + ) + + result = ( + DeduplicationDetector + ._read_lammps_equivalences( + path + ) + ) + + assert result == { + 1: 10, + 2: 20, + } + + +def test_read_lammps_equivalences_rejects_conflicting_pre_mapping( + tmp_path, +): + path = write_map_file( + tmp_path / "RXN_1.map", + equivalences=[ + ( + 1, + 10, + ), + ( + 1, + 20, + ), + ], + ) + + with pytest.raises( + ValueError, + match="Conflicting Equivalences", + ): + ( + DeduplicationDetector + ._read_lammps_equivalences( + path + ) + ) + + +def test_read_lammps_equivalences_rejects_nonbijective_post_mapping( + tmp_path, +): + path = write_map_file( + tmp_path / "RXN_1.map", + equivalences=[ + ( + 1, + 10, + ), + ( + 2, + 10, + ), + ], + ) + + with pytest.raises( + ValueError, + match="Non-bijective", + ): + ( + DeduplicationDetector + ._read_lammps_equivalences( + path + ) + ) + + +def test_read_lammps_edge_ids( + tmp_path, +): + path = write_map_file( + tmp_path / "RXN_1.map", + edge_ids=[ + 5, + 8, + 11, + ], + ) + + assert ( + DeduplicationDetector + ._read_lammps_edge_ids( + path + ) + == [ + 5, + 8, + 11, + ] + ) + + +def test_read_lammps_single_column_section( + tmp_path, +): + path = write_map_file( + tmp_path / "RXN_1.map", + initiator_ids=[ + 3, + 7, + ], + ) + + assert ( + DeduplicationDetector + ._read_lammps_single_column_section( + path, + "InitiatorIDs", + ) + == [ + 3, + 7, + ] + ) + + +def test_read_lammps_integer_section_ignores_non_integer_rows( + tmp_path, +): + path = tmp_path / "RXN_1.map" + + path.write_text( + """ +Equivalences + +1 10 +bad row +2 20 +""", + encoding="utf-8", + ) + + rows = ( + DeduplicationDetector + ._read_lammps_map_integer_section( + path, + "Equivalences", + ) + ) + + assert rows == [ + [ + 1, + 10, + ], + [ + 2, + 20, + ], + ] + + +# ============================================================================= +# Map text manipulation +# ============================================================================= + + +def test_remove_lammps_map_section(): + lines = [ + "# header\n", + "EdgeIDs\n", + "\n", + "1\n", + "2\n", + "\n", + "Equivalences\n", + "\n", + "1 1\n", + ] + + result = ( + DeduplicationDetector + ._remove_lammps_map_section( + lines, + "EdgeIDs", + ) + ) + + joined = "".join(result) + + assert "EdgeIDs" not in joined + assert "Equivalences" in joined + assert "1 1" in joined + + +def test_replace_lammps_count_lines(): + detector = ( + DeduplicationDetector() + ) + + lines = [ + "# header\n", + "\n", + "2 edgeIDs\n", + "5 equivalences\n", + "0 wildcards\n", + "\n", + "InitiatorIDs\n", + ] + + result = ( + detector + ._replace_lammps_count_lines( + lines, + edge_count=4, + equivalence_count=8, + wildcard_count=2, + ) + ) + + joined = "".join(result) + + assert "4 edgeIDs" in joined + assert "8 equivalences" in joined + assert "2 wildcards" in joined + + assert "5 equivalences" not in joined + + +# ============================================================================= +# Wildcard-map writing +# ============================================================================= + + +def test_write_lammps_wildcard_map_file( + tmp_path, +): + detector = ( + DeduplicationDetector() + ) + + path = write_map_file( + tmp_path / "RXN_1.map", + equivalences=[ + ( + 1, + 10, + ), + ( + 2, + 20, + ), + ], + edge_ids=[ + 1, + 2, + ], + initiator_ids=[ + 1, + 2, + ], + delete_ids=[ + 3, + ], + ) + + detector._write_lammps_wildcard_map_file( + path, + wildcard_ids=[ + 2, + 1, + 2, + ], + ) + + text = path.read_text( + encoding="utf-8" + ) + + assert "2 edgeIDs" in text + assert "2 equivalences" in text + assert "1 deleteIDs" in text + assert "2 wildcards" in text + + assert "Wildcards" in text + + wildcards = ( + detector + ._read_lammps_single_column_section( + path, + "Wildcards", + ) + ) + + assert wildcards == [ + 2, + 1, + ] + + +# ============================================================================= +# Wildcard graph handling +# ============================================================================= + + +def test_remove_nodes_if_present_returns_copy(): + graph = nx.Graph() + + graph.add_edges_from( + [ + ( + 1, + 2, + ), + ( + 2, + 3, + ), + ] + ) + + result = ( + DeduplicationDetector + ._remove_nodes_if_present( + graph, + [ + 2, + 999, + ], + ) + ) + + assert 2 not in result + + assert 2 in graph + + +def test_apply_lammps_wildcards_removes_pre_and_equivalent_post_nodes( + tmp_path, +): + detector = ( + DeduplicationDetector() + ) + + pre = make_phase_graph( + detector, + { + 1: "A", + 2: "B", + }, + [ + ( + 1, + 2, + "1", + ) + ], + ) + + post = make_phase_graph( + detector, + { + 10: "A", + 20: "B", + }, + [ + ( + 10, + 20, + "1", + ) + ], + ) + + map_path = write_map_file( + tmp_path / "RXN_1.map", + equivalences=[ + ( + 1, + 10, + ), + ( + 2, + 20, + ), + ], + edge_ids=[ + 2, + ], + ) + + new_pre, new_post, mapping = ( + detector._apply_lammps_wildcards( + pre, + post, + { + 1: 10, + 2: 20, + }, + map_path, + ) + ) + + assert set( + new_pre.nodes + ) == { + 1, + } + + assert set( + new_post.nodes + ) == { + 10, + } + + assert mapping == { + 1: 10, + } + + +# ============================================================================= +# LAMMPS graph coupling +# ============================================================================= + + +def test_couple_lammps_graphs_uses_explicit_equivalences(): + detector = ( + DeduplicationDetector() + ) + + pre = make_phase_graph( + detector, + { + 1: "A", + 2: "B", + }, + ) + + post = make_phase_graph( + detector, + { + 10: "A", + 20: "B", + }, + ) + + coupled = ( + detector + ._couple_lammps_graphs( + pre, + post, + { + 1: 20, + 2: 10, + }, + "test.map", + ) + ) + + assert coupled.has_edge( + ("pre", 1), + ("post", 20), + ) + + assert coupled.has_edge( + ("pre", 2), + ("post", 10), + ) + + +def test_couple_lammps_graphs_rejects_missing_pre_atom(): + detector = ( + DeduplicationDetector() + ) + + pre = make_phase_graph( + detector, + { + 1: "A", + }, + ) + + post = make_phase_graph( + detector, + { + 10: "A", + }, + ) + + with pytest.raises( + ValueError, + match="references pre atom", + ): + detector._couple_lammps_graphs( + pre, + post, + { + 2: 10, + }, + "test.map", + ) + + +def test_couple_lammps_graphs_rejects_missing_post_atom(): + detector = ( + DeduplicationDetector() + ) + + pre = make_phase_graph( + detector, + { + 1: "A", + }, + ) + + post = make_phase_graph( + detector, + { + 10: "A", + }, + ) + + with pytest.raises( + ValueError, + match="references post atom", + ): + detector._couple_lammps_graphs( + pre, + post, + { + 1: 20, + }, + "test.map", + ) + + +# ============================================================================= +# LAMMPS molecule parsing +# ============================================================================= + + +def test_lammps_molecule_to_networkx_rejects_missing_file( + tmp_path, +): + detector = ( + DeduplicationDetector() + ) + + with pytest.raises( + FileNotFoundError + ): + detector.lammps_molecule_to_networkx( + tmp_path + / "missing.molecule" + ) + + +def test_lammps_molecule_to_networkx_requires_types_section( + tmp_path, +): + detector = ( + DeduplicationDetector() + ) + + path = ( + tmp_path / "bad.molecule" + ) + + path.write_text( + """ +Coords + +1 0 0 0 +""", + encoding="utf-8", + ) + + with pytest.raises( + ValueError, + match="Types section", + ): + detector.lammps_molecule_to_networkx( + path + ) + + +def test_lammps_molecule_to_networkx_builds_nodes_and_bonds( + tmp_path, +): + detector = ( + DeduplicationDetector() + ) + + path = write_lammps_molecule( + tmp_path + / "template_pre_1.molecule", + { + 1: "3", + 2: "7", + }, + [ + ( + 1, + "2", + 1, + 2, + ) + ], + ) + + graph = ( + detector + .lammps_molecule_to_networkx( + path + ) + ) + + assert graph.nodes[ + 1 + ][detector.NODE_ATTRIBUTE] == "3" + + assert graph.nodes[ + 2 + ][detector.NODE_ATTRIBUTE] == "7" + + assert graph.edges[ + 1, + 2, + ][detector.EDGE_ATTRIBUTE] == "2" + + +def test_lammps_graph_ignores_coordinates_and_charges( + tmp_path, +): + detector = ( + DeduplicationDetector() + ) + + path = write_lammps_molecule( + tmp_path + / "template_pre_1.molecule", + { + 1: "5", + }, + ) + + graph = ( + detector + .lammps_molecule_to_networkx( + path + ) + ) + + assert graph.number_of_nodes() == 1 + + assert set( + graph.nodes[1] + ) == { + detector.NODE_ATTRIBUTE, + } + + +def test_add_lammps_atoms_rejects_bad_line( + tmp_path, +): + detector = ( + DeduplicationDetector() + ) + + graph = nx.Graph() + + with pytest.raises( + ValueError, + match="Invalid Types line", + ): + detector._add_lammps_atoms( + graph, + [ + "1", + ], + tmp_path + / "test.molecule", + ) + + +def test_add_lammps_atoms_rejects_invalid_id( + tmp_path, +): + detector = ( + DeduplicationDetector() + ) + + with pytest.raises( + ValueError, + match="Invalid atom ID", + ): + detector._add_lammps_atoms( + nx.Graph(), + [ + "abc 4", + ], + tmp_path + / "test.molecule", + ) + + +def test_add_lammps_atoms_rejects_duplicate_atom_id( + tmp_path, +): + detector = ( + DeduplicationDetector() + ) + + with pytest.raises( + ValueError, + match="Duplicate atom ID", + ): + detector._add_lammps_atoms( + nx.Graph(), + [ + "1 4", + "1 5", + ], + tmp_path + / "test.molecule", + ) + + +def test_add_lammps_bonds_rejects_short_line( + tmp_path, +): + detector = ( + DeduplicationDetector() + ) + + graph = nx.Graph() + + graph.add_node( + 1, + atom_label="1", + ) + + with pytest.raises( + ValueError, + match="Invalid Bonds line", + ): + detector._add_lammps_bonds( + graph, + [ + "1 2 1", + ], + tmp_path + / "test.molecule", + ) + + +def test_add_lammps_bonds_rejects_undefined_atom( + tmp_path, +): + detector = ( + DeduplicationDetector() + ) + + graph = nx.Graph() + + graph.add_node( + 1, + atom_label="1", + ) + + with pytest.raises( + ValueError, + match="undefined atom IDs", + ): + detector._add_lammps_bonds( + graph, + [ + "1 1 1 2", + ], + tmp_path + / "test.molecule", + ) + + +# ============================================================================= +# Complete LAMMPS template deduplication +# ============================================================================= + + +def test_lammps_template_pair_without_map_uses_uncoupled_pair_cache( + tmp_path, +): + detector = ( + DeduplicationDetector() + ) + + pre = write_lammps_molecule( + tmp_path + / "template_pre_1.molecule", + { + 1: "1", + 2: "2", + }, + [ + ( + 1, + "1", + 1, + 2, + ) + ], + ) + + post = write_lammps_molecule( + tmp_path + / "template_post_1.molecule", + { + 1: "1", + 2: "2", + }, + [ + ( + 1, + "2", + 1, + 2, + ) + ], + ) + + assert not ( + detector + .is_duplicate_lammps_template_pair( + pre, + post, + ) + ) + + assert ( + detector + .is_duplicate_lammps_template_pair( + pre, + post, + ) + ) + + +def test_lammps_template_pair_with_map_uses_coupled_cache( + tmp_path, +): + detector = ( + DeduplicationDetector() + ) + + pre = write_lammps_molecule( + tmp_path + / "template_pre_1.molecule", + { + 1: "1", + 2: "2", + }, + [ + ( + 1, + "1", + 1, + 2, + ) + ], + ) + + post = write_lammps_molecule( + tmp_path + / "template_post_1.molecule", + { + 10: "1", + 20: "2", + }, + [ + ( + 1, + "2", + 10, + 20, + ) + ], + ) + + map_path = write_map_file( + tmp_path + / "RXN_1.map", + equivalences=[ + ( + 1, + 10, + ), + ( + 2, + 20, + ), + ], + ) + + assert not ( + detector + .is_duplicate_lammps_template_pair( + pre, + post, + map_file_path=map_path, + ) + ) + + assert ( + detector + .is_duplicate_lammps_template_pair( + pre, + post, + map_file_path=map_path, + ) + ) + + +def test_lammps_template_pair_existing_map_requires_equivalences( + tmp_path, +): + detector = ( + DeduplicationDetector() + ) + + pre = write_lammps_molecule( + tmp_path + / "template_pre_1.molecule", + { + 1: "1", + }, + ) + + post = write_lammps_molecule( + tmp_path + / "template_post_1.molecule", + { + 1: "1", + }, + ) + + map_path = ( + tmp_path / "RXN_1.map" + ) + + map_path.write_text( + """ +EdgeIDs + +1 +""", + encoding="utf-8", + ) + + with pytest.raises( + ValueError, + match="No Equivalences", + ): + ( + detector + .is_duplicate_lammps_template_pair( + pre, + post, + map_file_path=map_path, + ) + ) + + +# ============================================================================= +# compare_graphs +# ============================================================================= + + +def test_compare_graphs_skips_non_pre_files( + tmp_path, +): + detector = ( + DeduplicationDetector() + ) + + path = ( + tmp_path + / "template_post_1.molecule" + ) + + path.write_text( + "", + encoding="utf-8", + ) + + assert ( + detector.compare_graphs( + [ + path, + ] + ) + == {} + ) + + +def test_compare_graphs_skips_missing_post_file( + tmp_path, + capsys, +): + detector = ( + DeduplicationDetector() + ) + + pre = ( + tmp_path + / "template_pre_1.molecule" + ) + + pre.write_text( + "", + encoding="utf-8", + ) + + result = ( + detector.compare_graphs( + [ + pre, + ] + ) + ) + + assert result == {} + + assert ( + "does not exist" + in capsys.readouterr().out + ) + + +def test_compare_graphs_records_result( + tmp_path, + monkeypatch, +): + detector = ( + DeduplicationDetector() + ) + + pre = ( + tmp_path + / "template_pre_1.molecule" + ) + + post = ( + tmp_path + / "template_post_1.molecule" + ) + + pre.write_text( + "", + encoding="utf-8", + ) + + post.write_text( + "", + encoding="utf-8", + ) + + monkeypatch.setattr( + detector, + "is_duplicate_lammps_template_pair", + lambda **kwargs: True, + ) + + result = detector.compare_graphs( + [ + pre, + ] + ) + + assert result == { + str(pre): True, + } + + +# ============================================================================= +# write_wildcard_maps +# ============================================================================= + + +def test_write_wildcard_maps_skips_inactive(): + detector = ( + DeduplicationDetector() + ) + + metadata = make_metadata( + activity_stats=False + ) + + assert ( + detector.write_wildcard_maps( + [ + metadata, + ] + ) + == [] + ) + + +def test_write_wildcard_maps_disables_missing_map_definition(): + detector = ( + DeduplicationDetector() + ) + + metadata = make_metadata() + + metadata.map_file = None + + result = ( + detector.write_wildcard_maps( + [ + metadata, + ] + ) + ) + + assert result == [] + + assert ( + metadata.activity_stats + is False + ) + + +def test_write_wildcard_maps_disables_missing_map_file( + tmp_path, +): + detector = ( + DeduplicationDetector() + ) + + metadata = make_metadata() + + metadata.map_file = ( + tmp_path + / "missing.map" + ) + + result = ( + detector.write_wildcard_maps( + [ + metadata, + ] + ) + ) + + assert result == [] + + assert ( + metadata.activity_stats + is False + ) + + +def test_write_wildcard_maps_writes_active_map( + tmp_path, +): + detector = ( + DeduplicationDetector() + ) + + map_path = write_map_file( + tmp_path + / "RXN_1.map", + equivalences=[ + ( + 1, + 1, + ) + ], + edge_ids=[ + 1, + ], + ) + + metadata = make_metadata() + + metadata.map_file = map_path + + result = ( + detector.write_wildcard_maps( + [ + metadata, + ] + ) + ) + + assert result == [ + metadata, + ] + + wildcards = ( + detector + ._read_lammps_single_column_section( + map_path, + "Wildcards", + ) + ) + + assert wildcards == [ + 1, + ] + + +# ============================================================================= +# compare_lammps_templates orchestration +# ============================================================================= + + +def test_compare_lammps_templates_skips_inactive(): + detector = ( + DeduplicationDetector() + ) + + metadata = make_metadata( + activity_stats=False + ) + + assert ( + detector.compare_lammps_templates( + [ + metadata, + ] + ) + == [] + ) + + +def test_compare_lammps_templates_disables_missing_file_definitions(): + detector = ( + DeduplicationDetector() + ) + + metadata = make_metadata() + + result = ( + detector.compare_lammps_templates( + [ + metadata, + ] + ) + ) + + assert result == [] + + assert ( + metadata.activity_stats + is False + ) + + +def test_compare_lammps_templates_disables_missing_pre_file( + tmp_path, +): + detector = ( + DeduplicationDetector() + ) + + metadata = make_metadata() + + metadata.pre_reaction_file = ( + tmp_path + / "missing_pre.molecule" + ) + + post = ( + tmp_path + / "template_post_1.molecule" + ) + + post.write_text( + "", + encoding="utf-8", + ) + + metadata.post_reaction_file = post + + result = ( + detector.compare_lammps_templates( + [ + metadata, + ] + ) + ) + + assert result == [] + + assert ( + metadata.activity_stats + is False + ) + + +def test_compare_lammps_templates_disables_duplicate( + tmp_path, + monkeypatch, +): + detector = ( + DeduplicationDetector() + ) + + pre = ( + tmp_path + / "template_pre_1.molecule" + ) + + post = ( + tmp_path + / "template_post_1.molecule" + ) + + pre.write_text( + "", + encoding="utf-8", + ) + + post.write_text( + "", + encoding="utf-8", + ) + + metadata = make_metadata() + + metadata.pre_reaction_file = pre + metadata.post_reaction_file = post + + monkeypatch.setattr( + detector, + "is_duplicate_lammps_template_pair", + lambda **kwargs: True, + ) + + result = ( + detector.compare_lammps_templates( + [ + metadata, + ] + ) + ) + + assert result == [] + + assert ( + metadata.activity_stats + is False + ) + + +def test_compare_lammps_templates_retains_unique( + tmp_path, + monkeypatch, +): + detector = ( + DeduplicationDetector() + ) + + pre = ( + tmp_path + / "template_pre_1.molecule" + ) + + post = ( + tmp_path + / "template_post_1.molecule" + ) + + pre.write_text( + "", + encoding="utf-8", + ) + + post.write_text( + "", + encoding="utf-8", + ) + + metadata = make_metadata() + + metadata.pre_reaction_file = pre + metadata.post_reaction_file = post + + monkeypatch.setattr( + detector, + "is_duplicate_lammps_template_pair", + lambda **kwargs: False, + ) + + result = ( + detector.compare_lammps_templates( + [ + metadata, + ] + ) + ) + + assert result == [ + metadata, + ] + + assert ( + metadata.activity_stats + is True + ) diff --git a/tests/unit/sim_setup/test_simulation_setup.py b/tests/unit/sim_setup/test_simulation_setup.py new file mode 100644 index 00000000..b26c52a7 --- /dev/null +++ b/tests/unit/sim_setup/test_simulation_setup.py @@ -0,0 +1,927 @@ +from types import SimpleNamespace + +import pytest + +import AutoREACTER.sim_setup.simulation_setup as sim_setup_module +from AutoREACTER.sim_setup.simulation_setup import ( + SimulationSetupManager, +) + + +# ============================================================================= +# Helpers +# ============================================================================= + + +def make_session( + tmp_path, + *, + inputs=None, + reacter_files=None, +): + if inputs is None: + inputs = SimpleNamespace( + simulation_name="Polymer", + simulations=[], + write_second_reaction_stage=False, + ) + + if reacter_files is None: + reacter_files = SimpleNamespace() + + return SimpleNamespace( + inputs=inputs, + reacter_files=reacter_files, + output_dir=tmp_path, + ) + + +# ============================================================================= +# setup_and_write_simulation +# ============================================================================= + + +def test_setup_and_write_simulation_creates_property_calculator( + tmp_path, + monkeypatch, +): + inputs = SimpleNamespace( + simulation_name="Polymer", + ) + + session = make_session( + tmp_path, + inputs=inputs, + ) + + captured = {} + + class FakeCalculator: + def __init__(self, setup): + captured["setup"] = setup + + def process_all(self): + return inputs + + monkeypatch.setattr( + sim_setup_module, + "SystemPropertyCalculations", + FakeCalculator, + ) + + monkeypatch.setattr( + SimulationSetupManager, + "generate_input_files", + lambda self, **kwargs: None, + ) + + manager = SimulationSetupManager() + + manager.setup_and_write_simulation( + session + ) + + assert ( + captured["setup"] + is inputs + ) + + +def test_setup_and_write_simulation_calls_process_all_once( + tmp_path, + monkeypatch, +): + inputs = SimpleNamespace( + simulation_name="Polymer", + ) + + session = make_session( + tmp_path, + inputs=inputs, + ) + + calls = [] + + class FakeCalculator: + def __init__(self, setup): + self.setup = setup + + def process_all(self): + calls.append( + "process_all" + ) + + return self.setup + + monkeypatch.setattr( + sim_setup_module, + "SystemPropertyCalculations", + FakeCalculator, + ) + + monkeypatch.setattr( + SimulationSetupManager, + "generate_input_files", + lambda self, **kwargs: None, + ) + + manager = SimulationSetupManager() + + manager.setup_and_write_simulation( + session + ) + + assert calls == [ + "process_all" + ] + + +def test_setup_and_write_simulation_passes_updated_setup_to_writer_layer( + tmp_path, + monkeypatch, +): + original_setup = SimpleNamespace( + simulation_name="Original", + ) + + updated_setup = SimpleNamespace( + simulation_name="Updated", + ) + + reacter_files = SimpleNamespace( + template_files=[] + ) + + session = make_session( + tmp_path, + inputs=original_setup, + reacter_files=reacter_files, + ) + + class FakeCalculator: + def __init__(self, setup): + assert ( + setup + is original_setup + ) + + def process_all(self): + return updated_setup + + monkeypatch.setattr( + sim_setup_module, + "SystemPropertyCalculations", + FakeCalculator, + ) + + captured = {} + + def fake_generate( + self, + *, + setup, + reacter_files, + run_dir, + ): + captured["setup"] = setup + captured["reacter_files"] = reacter_files + captured["run_dir"] = run_dir + + monkeypatch.setattr( + SimulationSetupManager, + "generate_input_files", + fake_generate, + ) + + manager = SimulationSetupManager() + + manager.setup_and_write_simulation( + session + ) + + assert ( + captured["setup"] + is updated_setup + ) + + assert ( + captured["reacter_files"] + is reacter_files + ) + + assert ( + captured["run_dir"] + == tmp_path + ) + + +def test_setup_and_write_simulation_uses_session_reacter_files( + tmp_path, + monkeypatch, +): + setup = SimpleNamespace( + simulation_name="Polymer", + ) + + reacter_files = SimpleNamespace( + marker="reaction-files" + ) + + session = make_session( + tmp_path, + inputs=setup, + reacter_files=reacter_files, + ) + + class FakeCalculator: + def __init__(self, setup): + self.setup = setup + + def process_all(self): + return self.setup + + monkeypatch.setattr( + sim_setup_module, + "SystemPropertyCalculations", + FakeCalculator, + ) + + captured = {} + + def fake_generate( + self, + *, + setup, + reacter_files, + run_dir, + ): + captured["reacter_files"] = ( + reacter_files + ) + + monkeypatch.setattr( + SimulationSetupManager, + "generate_input_files", + fake_generate, + ) + + manager = SimulationSetupManager() + + manager.setup_and_write_simulation( + session + ) + + assert ( + captured["reacter_files"] + is reacter_files + ) + + +def test_setup_and_write_simulation_uses_session_output_dir( + tmp_path, + monkeypatch, +): + setup = SimpleNamespace( + simulation_name="Polymer", + ) + + session = make_session( + tmp_path, + inputs=setup, + ) + + class FakeCalculator: + def __init__(self, setup): + self.setup = setup + + def process_all(self): + return self.setup + + monkeypatch.setattr( + sim_setup_module, + "SystemPropertyCalculations", + FakeCalculator, + ) + + captured = {} + + def fake_generate( + self, + *, + setup, + reacter_files, + run_dir, + ): + captured["run_dir"] = run_dir + + monkeypatch.setattr( + SimulationSetupManager, + "generate_input_files", + fake_generate, + ) + + manager = SimulationSetupManager() + + manager.setup_and_write_simulation( + session + ) + + assert ( + captured["run_dir"] + == tmp_path + ) + + +def test_setup_and_write_simulation_calls_calculation_before_generation( + tmp_path, + monkeypatch, +): + setup = SimpleNamespace( + simulation_name="Polymer", + ) + + session = make_session( + tmp_path, + inputs=setup, + ) + + events = [] + + class FakeCalculator: + def __init__(self, setup): + events.append( + "calculator_init" + ) + + self.setup = setup + + def process_all(self): + events.append( + "process_all" + ) + + return self.setup + + monkeypatch.setattr( + sim_setup_module, + "SystemPropertyCalculations", + FakeCalculator, + ) + + def fake_generate( + self, + **kwargs, + ): + events.append( + "generate_input_files" + ) + + monkeypatch.setattr( + SimulationSetupManager, + "generate_input_files", + fake_generate, + ) + + manager = SimulationSetupManager() + + manager.setup_and_write_simulation( + session + ) + + assert events == [ + "calculator_init", + "process_all", + "generate_input_files", + ] + + +def test_setup_and_write_simulation_returns_none( + tmp_path, + monkeypatch, +): + setup = SimpleNamespace( + simulation_name="Polymer", + ) + + session = make_session( + tmp_path, + inputs=setup, + ) + + class FakeCalculator: + def __init__(self, setup): + self.setup = setup + + def process_all(self): + return self.setup + + monkeypatch.setattr( + sim_setup_module, + "SystemPropertyCalculations", + FakeCalculator, + ) + + monkeypatch.setattr( + SimulationSetupManager, + "generate_input_files", + lambda self, **kwargs: None, + ) + + manager = SimulationSetupManager() + + result = ( + manager + .setup_and_write_simulation( + session + ) + ) + + assert result is None + + +def test_setup_and_write_simulation_prints_success_message( + tmp_path, + monkeypatch, + capsys, +): + setup = SimpleNamespace( + simulation_name="Polymer", + ) + + session = make_session( + tmp_path, + inputs=setup, + ) + + class FakeCalculator: + def __init__(self, setup): + self.setup = setup + + def process_all(self): + return self.setup + + monkeypatch.setattr( + sim_setup_module, + "SystemPropertyCalculations", + FakeCalculator, + ) + + monkeypatch.setattr( + SimulationSetupManager, + "generate_input_files", + lambda self, **kwargs: None, + ) + + manager = SimulationSetupManager() + + manager.setup_and_write_simulation( + session + ) + + captured = ( + capsys.readouterr() + ) + + expected_path = ( + tmp_path + / "LAMMPS_input_files" + ) + + assert ( + "[SUCCESS]" + in captured.out + ) + + assert ( + "All 5 simulation stages written" + in captured.out + ) + + assert ( + str(expected_path) + in captured.out + ) + + +# ============================================================================= +# Failure propagation +# ============================================================================= + + +def test_calculation_error_propagates_and_generation_is_not_called( + tmp_path, + monkeypatch, +): + session = make_session( + tmp_path + ) + + class FakeCalculator: + def __init__(self, setup): + pass + + def process_all(self): + raise ValueError( + "calculation failed" + ) + + monkeypatch.setattr( + sim_setup_module, + "SystemPropertyCalculations", + FakeCalculator, + ) + + called = [] + + monkeypatch.setattr( + SimulationSetupManager, + "generate_input_files", + lambda self, **kwargs: + called.append(True), + ) + + manager = SimulationSetupManager() + + with pytest.raises( + ValueError, + match="calculation failed", + ): + manager.setup_and_write_simulation( + session + ) + + assert called == [] + + +def test_generation_error_propagates( + tmp_path, + monkeypatch, +): + setup = SimpleNamespace( + simulation_name="Polymer", + ) + + session = make_session( + tmp_path, + inputs=setup, + ) + + class FakeCalculator: + def __init__(self, setup): + self.setup = setup + + def process_all(self): + return self.setup + + monkeypatch.setattr( + sim_setup_module, + "SystemPropertyCalculations", + FakeCalculator, + ) + + def fake_generate( + self, + **kwargs, + ): + raise RuntimeError( + "writer failed" + ) + + monkeypatch.setattr( + SimulationSetupManager, + "generate_input_files", + fake_generate, + ) + + manager = SimulationSetupManager() + + with pytest.raises( + RuntimeError, + match="writer failed", + ): + manager.setup_and_write_simulation( + session + ) + + +def test_success_message_not_printed_when_generation_fails( + tmp_path, + monkeypatch, + capsys, +): + setup = SimpleNamespace( + simulation_name="Polymer", + ) + + session = make_session( + tmp_path, + inputs=setup, + ) + + class FakeCalculator: + def __init__(self, setup): + self.setup = setup + + def process_all(self): + return self.setup + + monkeypatch.setattr( + sim_setup_module, + "SystemPropertyCalculations", + FakeCalculator, + ) + + def fake_generate( + self, + **kwargs, + ): + raise RuntimeError( + "writer failed" + ) + + monkeypatch.setattr( + SimulationSetupManager, + "generate_input_files", + fake_generate, + ) + + manager = SimulationSetupManager() + + with pytest.raises( + RuntimeError + ): + manager.setup_and_write_simulation( + session + ) + + captured = ( + capsys.readouterr() + ) + + assert ( + "[SUCCESS]" + not in captured.out + ) + + +# ============================================================================= +# generate_input_files +# ============================================================================= + + +def test_generate_input_files_constructs_writer_with_reacter_files( + tmp_path, + monkeypatch, +): + reacter_files = SimpleNamespace( + marker="rf" + ) + + setup = SimpleNamespace( + simulation_name="Polymer", + ) + + captured = {} + + class FakeWriter: + def __init__( + self, + *, + reacter_files, + ): + captured[ + "reacter_files" + ] = reacter_files + + def write_all_files( + self, + run_dir, + setup, + ): + pass + + monkeypatch.setattr( + sim_setup_module, + "Writer", + FakeWriter, + ) + + manager = SimulationSetupManager() + + manager.generate_input_files( + setup=setup, + reacter_files=reacter_files, + run_dir=tmp_path, + ) + + assert ( + captured["reacter_files"] + is reacter_files + ) + + +def test_generate_input_files_calls_write_all_files_with_expected_arguments( + tmp_path, + monkeypatch, +): + reacter_files = SimpleNamespace( + marker="rf" + ) + + setup = SimpleNamespace( + simulation_name="Polymer", + write_second_reaction_stage=False, + ) + + captured = {} + + class FakeWriter: + def __init__( + self, + *, + reacter_files, + ): + self.reacter_files = ( + reacter_files + ) + + def write_all_files( + self, + run_dir, + passed_setup, + ): + captured["run_dir"] = ( + run_dir + ) + + captured["setup"] = ( + passed_setup + ) + + monkeypatch.setattr( + sim_setup_module, + "Writer", + FakeWriter, + ) + + manager = SimulationSetupManager() + + manager.generate_input_files( + setup=setup, + reacter_files=reacter_files, + run_dir=tmp_path, + ) + + assert ( + captured["run_dir"] + == tmp_path + ) + + assert ( + captured["setup"] + is setup + ) + + +def test_generate_input_files_does_not_modify_second_stage_flag( + tmp_path, + monkeypatch, +): + """ + SimulationSetupManager passes the setup object through unchanged. + + Whether stage 2 is generated belongs to Writer. + """ + reacter_files = SimpleNamespace() + + setup = SimpleNamespace( + simulation_name="Polymer", + write_second_reaction_stage=False, + ) + + observed = {} + + class FakeWriter: + def __init__( + self, + *, + reacter_files, + ): + pass + + def write_all_files( + self, + run_dir, + passed_setup, + ): + observed["flag"] = ( + passed_setup + .write_second_reaction_stage + ) + + monkeypatch.setattr( + sim_setup_module, + "Writer", + FakeWriter, + ) + + manager = SimulationSetupManager() + + manager.generate_input_files( + setup=setup, + reacter_files=reacter_files, + run_dir=tmp_path, + ) + + assert ( + observed["flag"] + is False + ) + + assert ( + setup.write_second_reaction_stage + is False + ) + + +def test_generate_input_files_returns_none( + tmp_path, + monkeypatch, +): + class FakeWriter: + def __init__( + self, + *, + reacter_files, + ): + pass + + def write_all_files( + self, + run_dir, + setup, + ): + return None + + monkeypatch.setattr( + sim_setup_module, + "Writer", + FakeWriter, + ) + + manager = SimulationSetupManager() + + result = ( + manager + .generate_input_files( + setup=SimpleNamespace(), + reacter_files=( + SimpleNamespace() + ), + run_dir=tmp_path, + ) + ) + + assert result is None + + +def test_writer_error_propagates_from_generate_input_files( + tmp_path, + monkeypatch, +): + class FakeWriter: + def __init__( + self, + *, + reacter_files, + ): + pass + + def write_all_files( + self, + run_dir, + setup, + ): + raise RuntimeError( + "LAMMPS writing failed" + ) + + monkeypatch.setattr( + sim_setup_module, + "Writer", + FakeWriter, + ) + + manager = SimulationSetupManager() + + with pytest.raises( + RuntimeError, + match="LAMMPS writing failed", + ): + manager.generate_input_files( + setup=SimpleNamespace(), + reacter_files=( + SimpleNamespace() + ), + run_dir=tmp_path, + ) \ No newline at end of file diff --git a/tests/unit/sim_setup/test_system_property_calculations.py b/tests/unit/sim_setup/test_system_property_calculations.py new file mode 100644 index 00000000..d38199ce --- /dev/null +++ b/tests/unit/sim_setup/test_system_property_calculations.py @@ -0,0 +1,1503 @@ +import math +from types import SimpleNamespace + +import pytest +from rdkit import Chem + +from AutoREACTER.sim_setup.system_property_calculations import ( + CM_2_A3, + N_A, + NoneMonomerError, + SystemPropertyCalculations, +) + + +# ============================================================================= +# Helpers +# ============================================================================= + + +def make_monomer( + *, + name="monomer", + monomer_id=1, + status=True, + rdkit_mol=None, + num_atoms=None, + molecular_weight=None, + count=None, +): + return SimpleNamespace( + id=monomer_id, + name=name, + status=status, + rdkit_mol=rdkit_mol, + num_atoms=num_atoms, + molecular_weight=molecular_weight, + count=count, + ) + + +def make_simulation( + *, + tag="sim1", + total_atoms=100, + density=1.0, + monomer_ratios=None, + monomer_counts=None, +): + return SimpleNamespace( + tag=tag, + total_atoms=total_atoms, + density=density, + monomer_ratios=( + {} + if monomer_ratios is None + else monomer_ratios + ), + monomer_counts=monomer_counts, + initial_box_volume=None, + initial_box_length=None, + ) + + +def make_setup( + *, + monomers=None, + simulations=None, + composition_method="ratio", +): + return SimpleNamespace( + monomers=list( + monomers or [] + ), + simulations=list( + simulations or [] + ), + composition_method=composition_method, + ) + + +# ============================================================================= +# Constants +# ============================================================================= + + +def test_avogadro_constant(): + assert N_A == pytest.approx( + 6.02214076e23 + ) + + +def test_cm3_to_angstrom3_conversion(): + assert CM_2_A3 == pytest.approx( + 1.0e24 + ) + + +# ============================================================================= +# Exception +# ============================================================================= + + +def test_none_monomer_error_is_exception(): + assert issubclass( + NoneMonomerError, + Exception, + ) + + +# ============================================================================= +# Constructor +# ============================================================================= + + +def test_constructor_stores_simulation_setup(): + setup = make_setup() + + calculator = ( + SystemPropertyCalculations( + setup + ) + ) + + assert ( + calculator.simulation_setup + is setup + ) + + +# ============================================================================= +# process_all +# ============================================================================= + + +def test_process_all_calls_steps_in_order( + monkeypatch, +): + setup = make_setup() + + calculator = ( + SystemPropertyCalculations( + setup + ) + ) + + events = [] + + monkeypatch.setattr( + calculator, + "_populate_monomer_properties", + lambda: + events.append( + "populate" + ), + ) + + monkeypatch.setattr( + calculator, + "_calculate_replica_properties", + lambda: + events.append( + "calculate" + ), + ) + + result = ( + calculator.process_all() + ) + + assert events == [ + "populate", + "calculate", + ] + + assert result is setup + + +def test_process_all_returns_same_setup_object( + monkeypatch, +): + setup = make_setup() + + calculator = ( + SystemPropertyCalculations( + setup + ) + ) + + monkeypatch.setattr( + calculator, + "_populate_monomer_properties", + lambda: None, + ) + + monkeypatch.setattr( + calculator, + "_calculate_replica_properties", + lambda: None, + ) + + result = ( + calculator.process_all() + ) + + assert result is setup + + +# ============================================================================= +# _populate_monomer_properties +# ============================================================================= + + +def test_populate_active_monomer_counts_explicit_hydrogens(): + methane = ( + Chem.MolFromSmiles( + "C" + ) + ) + + monomer = make_monomer( + name="methane", + rdkit_mol=methane, + ) + + setup = make_setup( + monomers=[ + monomer + ] + ) + + calculator = ( + SystemPropertyCalculations( + setup + ) + ) + + calculator._populate_monomer_properties() + + # CH4 = one carbon + four hydrogens. + assert monomer.num_atoms == 5 + + +def test_populate_does_not_modify_original_rdkit_molecule(): + methane = ( + Chem.MolFromSmiles( + "C" + ) + ) + + assert ( + methane.GetNumAtoms() + == 1 + ) + + monomer = make_monomer( + name="methane", + rdkit_mol=methane, + ) + + calculator = ( + SystemPropertyCalculations( + make_setup( + monomers=[ + monomer + ] + ) + ) + ) + + calculator._populate_monomer_properties() + + # AddHs() is applied to a temporary molecule. + assert ( + methane.GetNumAtoms() + == 1 + ) + + assert monomer.num_atoms == 5 + + +def test_populate_sets_molecular_weight(): + monomer = make_monomer( + name="methane", + rdkit_mol=( + Chem.MolFromSmiles( + "C" + ) + ), + ) + + calculator = ( + SystemPropertyCalculations( + make_setup( + monomers=[ + monomer + ] + ) + ) + ) + + calculator._populate_monomer_properties() + + assert monomer.molecular_weight == pytest.approx( + 16.043, + rel=1.0e-3, + ) + + +def test_populate_ethanol_full_atom_count(): + # C2H6O = 9 atoms total. + monomer = make_monomer( + name="ethanol", + rdkit_mol=( + Chem.MolFromSmiles( + "CCO" + ) + ), + ) + + calculator = ( + SystemPropertyCalculations( + make_setup( + monomers=[ + monomer + ] + ) + ) + ) + + calculator._populate_monomer_properties() + + assert monomer.num_atoms == 9 + + +def test_populate_skips_inactive_monomer(): + monomer = make_monomer( + name="inactive", + status=False, + rdkit_mol=None, + num_atoms=999, + molecular_weight=888, + ) + + calculator = ( + SystemPropertyCalculations( + make_setup( + monomers=[ + monomer + ] + ) + ) + ) + + calculator._populate_monomer_properties() + + assert monomer.num_atoms == 999 + + assert ( + monomer.molecular_weight + == 888 + ) + + +def test_populate_active_none_molecule_raises(): + monomer = make_monomer( + name="broken", + monomer_id=42, + status=True, + rdkit_mol=None, + ) + + calculator = ( + SystemPropertyCalculations( + make_setup( + monomers=[ + monomer + ] + ) + ) + ) + + with pytest.raises( + NoneMonomerError, + match=( + "Monomer with ID 42 " + "has no RDKit Mol object" + ), + ): + calculator._populate_monomer_properties() + + +def test_populate_prints_info_message( + capsys, +): + calculator = ( + SystemPropertyCalculations( + make_setup() + ) + ) + + calculator._populate_monomer_properties() + + assert ( + "Populating monomer properties" + in capsys.readouterr().out + ) + + +def test_populate_multiple_active_monomers(): + methane = make_monomer( + name="methane", + rdkit_mol=( + Chem.MolFromSmiles( + "C" + ) + ), + ) + + ethane = make_monomer( + name="ethane", + rdkit_mol=( + Chem.MolFromSmiles( + "CC" + ) + ), + ) + + calculator = ( + SystemPropertyCalculations( + make_setup( + monomers=[ + methane, + ethane, + ] + ) + ) + ) + + calculator._populate_monomer_properties() + + assert methane.num_atoms == 5 + + # C2H6 + assert ethane.num_atoms == 8 + + +# ============================================================================= +# _get_monomer_counts +# ============================================================================= + + +def test_get_monomer_counts_requires_total_atoms(): + monomer = make_monomer( + name="A", + num_atoms=10, + ) + + simulation = make_simulation( + tag="test", + total_atoms=None, + monomer_ratios={ + "A": 1, + }, + ) + + calculator = ( + SystemPropertyCalculations( + make_setup() + ) + ) + + with pytest.raises( + ValueError, + match=( + "Simulation 'test' is " + "missing 'total_atoms'" + ), + ): + calculator._get_monomer_counts( + simulation, + { + "A": monomer + }, + ) + + +def test_get_monomer_counts_basic_ratio_calculation(): + """ + atoms per ratio unit: + + A: ratio 2 * 10 atoms = 20 + B: ratio 1 * 20 atoms = 20 + ---- + 40 + + total_atoms = 100 + multiplier = 100 / 40 = 2.5 + + A count = ceil(2 * 2.5) = 5 + B count = ceil(1 * 2.5) = 3 + """ + monomer_a = make_monomer( + name="A", + num_atoms=10, + ) + + monomer_b = make_monomer( + name="B", + num_atoms=20, + ) + + simulation = make_simulation( + tag="sim", + total_atoms=100, + monomer_ratios={ + "A": 2, + "B": 1, + }, + ) + + calculator = ( + SystemPropertyCalculations( + make_setup() + ) + ) + + calculator._get_monomer_counts( + simulation, + { + "A": monomer_a, + "B": monomer_b, + }, + ) + + assert simulation.monomer_counts == { + "A": 5, + "B": 3, + } + + +def test_ratio_counting_can_overshoot_requested_total_atoms(): + """ + Characterization of current ceil-based behavior. + + 5 A molecules * 10 atoms = 50 + 3 B molecules * 20 atoms = 60 + total actual atoms = 110 + + Requested total_atoms was 100. + """ + monomer_a = make_monomer( + name="A", + num_atoms=10, + ) + + monomer_b = make_monomer( + name="B", + num_atoms=20, + ) + + simulation = make_simulation( + total_atoms=100, + monomer_ratios={ + "A": 2, + "B": 1, + }, + ) + + calculator = ( + SystemPropertyCalculations( + make_setup() + ) + ) + + calculator._get_monomer_counts( + simulation, + { + "A": monomer_a, + "B": monomer_b, + }, + ) + + actual_atoms = ( + simulation.monomer_counts["A"] + * monomer_a.num_atoms + + simulation.monomer_counts["B"] + * monomer_b.num_atoms + ) + + assert actual_atoms == 110 + + assert actual_atoms >= ( + simulation.total_atoms + ) + + +def test_get_monomer_counts_initializes_none_dictionary(): + monomer = make_monomer( + name="A", + num_atoms=10, + ) + + simulation = make_simulation( + total_atoms=100, + monomer_ratios={ + "A": 1, + }, + monomer_counts=None, + ) + + calculator = ( + SystemPropertyCalculations( + make_setup() + ) + ) + + calculator._get_monomer_counts( + simulation, + { + "A": monomer + }, + ) + + assert isinstance( + simulation.monomer_counts, + dict, + ) + + assert ( + simulation.monomer_counts["A"] + == 10 + ) + + +def test_get_monomer_counts_preserves_existing_dictionary_entries(): + monomer = make_monomer( + name="A", + num_atoms=10, + ) + + simulation = make_simulation( + total_atoms=100, + monomer_ratios={ + "A": 1, + }, + monomer_counts={ + "existing": 99 + }, + ) + + calculator = ( + SystemPropertyCalculations( + make_setup() + ) + ) + + calculator._get_monomer_counts( + simulation, + { + "A": monomer + }, + ) + + assert simulation.monomer_counts == { + "existing": 99, + "A": 10, + } + + +def test_get_monomer_counts_records_count_on_monomer(): + monomer = make_monomer( + name="A", + num_atoms=10, + count=None, + ) + + simulation = make_simulation( + tag="500K", + total_atoms=100, + monomer_ratios={ + "A": 1, + }, + ) + + calculator = ( + SystemPropertyCalculations( + make_setup() + ) + ) + + calculator._get_monomer_counts( + simulation, + { + "A": monomer + }, + ) + + assert monomer.count == { + "500K": 10 + } + + +def test_get_monomer_counts_preserves_other_simulation_counts(): + monomer = make_monomer( + name="A", + num_atoms=10, + count={ + "old_sim": 4 + }, + ) + + simulation = make_simulation( + tag="new_sim", + total_atoms=100, + monomer_ratios={ + "A": 1, + }, + ) + + calculator = ( + SystemPropertyCalculations( + make_setup() + ) + ) + + calculator._get_monomer_counts( + simulation, + { + "A": monomer + }, + ) + + assert monomer.count == { + "old_sim": 4, + "new_sim": 10, + } + + +def test_get_monomer_counts_ignores_ratio_names_not_active(): + monomer = make_monomer( + name="A", + num_atoms=10, + ) + + simulation = make_simulation( + total_atoms=100, + monomer_ratios={ + "A": 1, + "inactive_or_unknown": 999, + }, + ) + + calculator = ( + SystemPropertyCalculations( + make_setup() + ) + ) + + calculator._get_monomer_counts( + simulation, + { + "A": monomer + }, + ) + + assert simulation.monomer_counts == { + "A": 10 + } + + +def test_get_monomer_counts_no_active_ratio_monomers_raises(): + simulation = make_simulation( + tag="empty", + total_atoms=100, + monomer_ratios={ + "missing": 1, + }, + ) + + calculator = ( + SystemPropertyCalculations( + make_setup() + ) + ) + + with pytest.raises( + ValueError, + match=( + "has no active monomers " + "to calculate ratios" + ), + ): + calculator._get_monomer_counts( + simulation, + {}, + ) + + +def test_get_monomer_counts_minimum_is_one(): + monomer_a = make_monomer( + name="A", + num_atoms=1000, + ) + + monomer_b = make_monomer( + name="B", + num_atoms=1, + ) + + simulation = make_simulation( + total_atoms=1, + monomer_ratios={ + "A": 1, + "B": 1, + }, + ) + + calculator = ( + SystemPropertyCalculations( + make_setup() + ) + ) + + calculator._get_monomer_counts( + simulation, + { + "A": monomer_a, + "B": monomer_b, + }, + ) + + assert ( + simulation.monomer_counts["A"] + >= 1 + ) + + assert ( + simulation.monomer_counts["B"] + >= 1 + ) + + +# ============================================================================= +# _calculate_box_dimensions +# ============================================================================= + + +def test_calculate_box_dimensions_requires_counts(): + simulation = make_simulation( + tag="missing_counts", + monomer_counts=None, + ) + + calculator = ( + SystemPropertyCalculations( + make_setup() + ) + ) + + with pytest.raises( + ValueError, + match=( + "Simulation 'missing_counts' " + "lacks monomer counts" + ), + ): + calculator._calculate_box_dimensions( + simulation, + {}, + ) + + +def test_calculate_box_dimensions_exact_formula(): + monomer = make_monomer( + name="A", + molecular_weight=100.0, + ) + + simulation = make_simulation( + density=1.0, + monomer_counts={ + "A": 10, + }, + ) + + calculator = ( + SystemPropertyCalculations( + make_setup() + ) + ) + + calculator._calculate_box_dimensions( + simulation, + { + "A": monomer + }, + ) + + total_mass_g = ( + 10 * 100.0 + ) / N_A + + initial_density = ( + 1.0 / 4.0 + ) + + expected_volume = ( + total_mass_g + / initial_density + * CM_2_A3 + ) + + expected_length = round( + math.pow( + expected_volume, + 1.0 / 3.0, + ), + 2, + ) + + assert ( + simulation.initial_box_volume + == pytest.approx( + expected_volume + ) + ) + + assert ( + simulation.initial_box_length + == expected_length + ) + + +def test_initial_box_uses_quarter_target_density(): + monomer = make_monomer( + name="A", + molecular_weight=50.0, + ) + + simulation = make_simulation( + density=2.0, + monomer_counts={ + "A": 4, + }, + ) + + calculator = ( + SystemPropertyCalculations( + make_setup() + ) + ) + + calculator._calculate_box_dimensions( + simulation, + { + "A": monomer + }, + ) + + total_mass_g = ( + 4 * 50.0 + ) / N_A + + target_volume = ( + total_mass_g + / 2.0 + * CM_2_A3 + ) + + # Initial density = target density / 4, + # therefore initial volume = target volume * 4. + assert ( + simulation.initial_box_volume + == pytest.approx( + target_volume * 4.0 + ) + ) + + +def test_box_length_is_cube_root_of_initial_volume(): + monomer = make_monomer( + name="A", + molecular_weight=75.0, + ) + + simulation = make_simulation( + density=1.2, + monomer_counts={ + "A": 15, + }, + ) + + calculator = ( + SystemPropertyCalculations( + make_setup() + ) + ) + + calculator._calculate_box_dimensions( + simulation, + { + "A": monomer + }, + ) + + expected = round( + simulation.initial_box_volume + ** ( + 1.0 / 3.0 + ), + 2, + ) + + assert ( + simulation.initial_box_length + == expected + ) + + +def test_box_length_is_rounded_to_two_decimal_places(): + monomer = make_monomer( + name="A", + molecular_weight=123.456, + ) + + simulation = make_simulation( + density=1.234, + monomer_counts={ + "A": 17, + }, + ) + + calculator = ( + SystemPropertyCalculations( + make_setup() + ) + ) + + calculator._calculate_box_dimensions( + simulation, + { + "A": monomer + }, + ) + + assert ( + simulation.initial_box_length + == round( + simulation.initial_box_length, + 2, + ) + ) + + +def test_box_mass_sums_multiple_active_monomers(): + monomer_a = make_monomer( + name="A", + molecular_weight=100.0, + ) + + monomer_b = make_monomer( + name="B", + molecular_weight=50.0, + ) + + simulation = make_simulation( + density=1.0, + monomer_counts={ + "A": 2, + "B": 4, + }, + ) + + calculator = ( + SystemPropertyCalculations( + make_setup() + ) + ) + + calculator._calculate_box_dimensions( + simulation, + { + "A": monomer_a, + "B": monomer_b, + }, + ) + + expected_mass = ( + (2 * 100.0) + + (4 * 50.0) + ) / N_A + + expected_volume = ( + expected_mass + / 0.25 + * CM_2_A3 + ) + + assert ( + simulation.initial_box_volume + == pytest.approx( + expected_volume + ) + ) + + +def test_box_calculation_ignores_counts_for_unknown_or_inactive_monomers(): + monomer = make_monomer( + name="A", + molecular_weight=100.0, + ) + + simulation = make_simulation( + density=1.0, + monomer_counts={ + "A": 2, + "unknown": 100000, + }, + ) + + calculator = ( + SystemPropertyCalculations( + make_setup() + ) + ) + + calculator._calculate_box_dimensions( + simulation, + { + "A": monomer + }, + ) + + expected_mass = ( + 2 * 100.0 + ) / N_A + + expected_volume = ( + expected_mass + / 0.25 + * CM_2_A3 + ) + + assert ( + simulation.initial_box_volume + == pytest.approx( + expected_volume + ) + ) + + +def test_box_calculation_empty_active_mass_produces_zero_box(): + """ + Characterization of current behavior. + + If monomer_counts exists but none of its names correspond to an + active monomer, total mass remains zero. + """ + simulation = make_simulation( + density=1.0, + monomer_counts={ + "unknown": 5, + }, + ) + + calculator = ( + SystemPropertyCalculations( + make_setup() + ) + ) + + calculator._calculate_box_dimensions( + simulation, + {}, + ) + + assert ( + simulation.initial_box_volume + == 0.0 + ) + + assert ( + simulation.initial_box_length + == 0.0 + ) + + +# ============================================================================= +# _calculate_replica_properties +# ============================================================================= + + +def test_calculate_replica_properties_builds_only_active_monomer_lookup( + monkeypatch, +): + active = make_monomer( + name="active", + status=True, + ) + + inactive = make_monomer( + name="inactive", + status=False, + ) + + simulation = make_simulation() + + setup = make_setup( + monomers=[ + active, + inactive, + ], + simulations=[ + simulation + ], + composition_method="count", + ) + + calculator = ( + SystemPropertyCalculations( + setup + ) + ) + + captured = [] + + monkeypatch.setattr( + calculator, + "_calculate_box_dimensions", + lambda sim, monomers: + captured.append( + monomers + ), + ) + + calculator._calculate_replica_properties() + + assert list( + captured[0].keys() + ) == [ + "active" + ] + + assert ( + captured[0]["active"] + is active + ) + + +def test_ratio_mode_calculates_counts_before_box( + monkeypatch, +): + simulation = make_simulation() + + setup = make_setup( + simulations=[ + simulation + ], + composition_method="ratio", + ) + + calculator = ( + SystemPropertyCalculations( + setup + ) + ) + + events = [] + + monkeypatch.setattr( + calculator, + "_get_monomer_counts", + lambda sim, monomers: + events.append( + "counts" + ), + ) + + monkeypatch.setattr( + calculator, + "_calculate_box_dimensions", + lambda sim, monomers: + events.append( + "box" + ), + ) + + calculator._calculate_replica_properties() + + assert events == [ + "counts", + "box", + ] + + +def test_non_ratio_mode_skips_count_calculation( + monkeypatch, +): + simulation = make_simulation( + monomer_counts={ + "A": 1 + }, + ) + + setup = make_setup( + simulations=[ + simulation + ], + composition_method="count", + ) + + calculator = ( + SystemPropertyCalculations( + setup + ) + ) + + monkeypatch.setattr( + calculator, + "_get_monomer_counts", + lambda *args: + pytest.fail( + "_get_monomer_counts must not " + "run in count mode" + ), + ) + + calls = [] + + monkeypatch.setattr( + calculator, + "_calculate_box_dimensions", + lambda sim, monomers: + calls.append( + sim + ), + ) + + calculator._calculate_replica_properties() + + assert calls == [ + simulation + ] + + +def test_calculate_replica_properties_processes_all_simulations( + monkeypatch, +): + sim1 = make_simulation( + tag="sim1" + ) + + sim2 = make_simulation( + tag="sim2" + ) + + sim3 = make_simulation( + tag="sim3" + ) + + setup = make_setup( + simulations=[ + sim1, + sim2, + sim3, + ], + composition_method="count", + ) + + calculator = ( + SystemPropertyCalculations( + setup + ) + ) + + processed = [] + + monkeypatch.setattr( + calculator, + "_calculate_box_dimensions", + lambda sim, monomers: + processed.append( + sim.tag + ), + ) + + calculator._calculate_replica_properties() + + assert processed == [ + "sim1", + "sim2", + "sim3", + ] + + +# ============================================================================= +# End-to-end property calculation +# ============================================================================= + + +def test_process_all_real_small_ratio_system(): + """ + Small integration test across: + + RDKit properties + ↓ + ratio counts + ↓ + mass / box calculation + """ + methane = make_monomer( + name="methane", + rdkit_mol=( + Chem.MolFromSmiles( + "C" + ) + ), + ) + + ethane = make_monomer( + name="ethane", + rdkit_mol=( + Chem.MolFromSmiles( + "CC" + ) + ), + ) + + simulation = make_simulation( + tag="small", + total_atoms=100, + density=1.0, + monomer_ratios={ + "methane": 1.0, + "ethane": 1.0, + }, + ) + + setup = make_setup( + monomers=[ + methane, + ethane, + ], + simulations=[ + simulation + ], + composition_method="ratio", + ) + + calculator = ( + SystemPropertyCalculations( + setup + ) + ) + + result = ( + calculator.process_all() + ) + + assert result is setup + + assert methane.num_atoms == 5 + assert ethane.num_atoms == 8 + + assert ( + simulation.monomer_counts[ + "methane" + ] + > 0 + ) + + assert ( + simulation.monomer_counts[ + "ethane" + ] + > 0 + ) + + assert ( + simulation.initial_box_volume + > 0 + ) + + assert ( + simulation.initial_box_length + > 0 + ) + + assert ( + methane.count["small"] + == simulation + .monomer_counts[ + "methane" + ] + ) + + assert ( + ethane.count["small"] + == simulation + .monomer_counts[ + "ethane" + ] + ) \ No newline at end of file diff --git a/tests/unit/sim_setup/writers/test_densification_writer.py b/tests/unit/sim_setup/writers/test_densification_writer.py new file mode 100644 index 00000000..8504c5eb --- /dev/null +++ b/tests/unit/sim_setup/writers/test_densification_writer.py @@ -0,0 +1,1857 @@ +from pathlib import Path +from types import SimpleNamespace + +import pytest + +import AutoREACTER.sim_setup.writers.densification_writer as dens_module +from AutoREACTER.sim_setup.writers.densification_writer import ( + DensificationWriter, +) + + +# ============================================================================= +# Helpers +# ============================================================================= + + +def make_settings( + *, + neighbor=None, + neigh_modify=None, +): + return SimpleNamespace( + units="real", + dimension="3", + boundary="p p p", + atom_style="full", + bond_style="class2", + angle_style="class2", + dihedral_style="class2", + improper_style="class2", + special_bonds="lj/coul 0 0 1", + pair_style="lj/class2/coul/long 12.0", + kspace_style="pppm 1.0e-4", + pair_modify="mix sixthpower", + neighbor=neighbor, + neigh_modify=neigh_modify, + ) + + +def make_monomer( + *, + name="mma", + data_id="data1", + molecule_file=None, +): + return SimpleNamespace( + name=name, + data_id=data_id, + lmp_molecule_file=molecule_file, + ) + + +def make_reacter_files( + *, + force_field_data, + molecule_files=None, + template_files=None, +): + return SimpleNamespace( + force_field_data=Path(force_field_data), + molecule_files=list( + molecule_files or [] + ), + template_files=list( + template_files or [] + ), + ) + + +def make_simulation( + *, + tag="sim1", + initial_box_length=40.0, + temperature=500.0, + density=1.2, + monomer_counts=None, +): + return SimpleNamespace( + tag=tag, + initial_box_length=initial_box_length, + temperature=temperature, + density=density, + monomer_counts=( + {"mma": 10} + if monomer_counts is None + else monomer_counts + ), + ) + + +def make_writer_without_init( + tmp_path, + *, + settings=None, + reacter_files=None, + sim_name="Test", +): + writer = object.__new__( + DensificationWriter + ) + + writer.settings = ( + settings + if settings is not None + else make_settings() + ) + + if reacter_files is None: + ff_file = ( + tmp_path / "force_field.data" + ) + + ff_file.write_text( + "ff", + encoding="utf-8", + ) + + reacter_files = make_reacter_files( + force_field_data=ff_file, + ) + + writer.reacter_files = ( + reacter_files + ) + + writer.erate = -0.001 + writer.timestep = 0.01 + writer.out_dir = tmp_path + writer.sim_name = sim_name + + return writer + + +# ============================================================================= +# Constructor +# ============================================================================= + + +def test_constructor_stores_configuration( + tmp_path, + monkeypatch, +): + settings = make_settings() + + ff_file = ( + tmp_path / "force_field.data" + ) + + ff_file.write_text( + "ff", + encoding="utf-8", + ) + + reacter_files = make_reacter_files( + force_field_data=ff_file, + ) + + simulation = make_simulation() + + calls = [] + + monkeypatch.setattr( + DensificationWriter, + "write_lammps_densification_file", + lambda self, simulation: + ( + calls.append(simulation) + or "in.test_densification" + ), + ) + + writer = DensificationWriter( + out_dir=tmp_path, + settings=settings, + reacter_files=reacter_files, + simulation=simulation, + sim_name="Test", + ) + + assert writer.settings is settings + + assert ( + writer.reacter_files + is reacter_files + ) + + assert writer.out_dir == tmp_path + + assert writer.sim_name == "Test" + + assert writer.erate == pytest.approx( + -0.001 + ) + + assert writer.timestep == pytest.approx( + 0.01 + ) + + assert calls == [ + simulation + ] + + assert ( + writer.in_dense_file_name + == "in.test_densification" + ) + + +# ============================================================================= +# _write_empty_box_data +# ============================================================================= + + +def test_write_empty_box_creates_file( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + output_dir = ( + tmp_path / "box" + ) + + output_dir.mkdir() + + simulation = make_simulation( + initial_box_length=40.0, + ) + + writer._write_empty_box_data( + simulation, + output_dir, + ) + + path = ( + output_dir + / "empty_box.data" + ) + + assert path.is_file() + + +def test_write_empty_box_is_centered_at_origin( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + output_dir = ( + tmp_path / "box" + ) + + output_dir.mkdir() + + writer._write_empty_box_data( + make_simulation( + initial_box_length=40.0, + ), + output_dir, + ) + + text = ( + output_dir + / "empty_box.data" + ).read_text( + encoding="utf-8" + ) + + assert ( + "-20.00 20.00 xlo xhi" + in text + ) + + assert ( + "-20.00 20.00 ylo yhi" + in text + ) + + assert ( + "-20.00 20.00 zlo zhi" + in text + ) + + +def test_write_empty_box_contains_zero_topology_counts( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + output_dir = ( + tmp_path / "box" + ) + + output_dir.mkdir() + + writer._write_empty_box_data( + make_simulation(), + output_dir, + ) + + text = ( + output_dir + / "empty_box.data" + ).read_text( + encoding="utf-8" + ) + + assert "0 atoms" in text + assert "0 bonds" in text + assert "0 angles" in text + assert "0 dihedrals" in text + assert "0 impropers" in text + + +def test_write_empty_box_rounds_bounds_to_two_decimals( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + output_dir = ( + tmp_path / "box" + ) + + output_dir.mkdir() + + writer._write_empty_box_data( + make_simulation( + initial_box_length=33.333, + ), + output_dir, + ) + + text = ( + output_dir + / "empty_box.data" + ).read_text( + encoding="utf-8" + ) + + assert ( + "-16.67 16.67 xlo xhi" + in text + ) + + +# ============================================================================= +# _get_force_field_types +# ============================================================================= + + +def test_get_force_field_types_reads_all_counts( + tmp_path, +): + ff_file = ( + tmp_path / "ff.data" + ) + + ff_file.write_text( + """LAMMPS data file + +12 atom types +8 bond types +6 angle types +4 dihedral types +3 improper types + +Masses +""", + encoding="utf-8", + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + force_field_data=ff_file, + ) + ), + ) + + result = ( + writer._get_force_field_types() + ) + + assert result == { + "atom": 12, + "bond": 8, + "angle": 6, + "dihedral": 4, + "improper": 3, + } + + +def test_get_force_field_types_defaults_improper_to_zero( + tmp_path, +): + ff_file = ( + tmp_path / "ff.data" + ) + + ff_file.write_text( + """10 atom types +5 bond types +4 angle types +3 dihedral types + +Masses +""", + encoding="utf-8", + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + force_field_data=ff_file, + ) + ), + ) + + result = ( + writer._get_force_field_types() + ) + + assert ( + result["improper"] + == 0 + ) + + +def test_get_force_field_types_missing_file_raises( + tmp_path, +): + missing = ( + tmp_path / "missing.data" + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + force_field_data=missing, + ) + ), + ) + + with pytest.raises( + FileNotFoundError, + match=( + "Force field data file " + "not found" + ), + ): + writer._get_force_field_types() + + +@pytest.mark.parametrize( + "missing_key", + [ + "atom", + "bond", + "angle", + "dihedral", + ], +) +def test_get_force_field_types_requires_core_headers( + tmp_path, + missing_key, +): + values = { + "atom": 10, + "bond": 8, + "angle": 6, + "dihedral": 4, + } + + lines = [] + + for key, value in ( + values.items() + ): + if key != missing_key: + lines.append( + f"{value} {key} types" + ) + + lines.append("") + lines.append("Masses") + + ff_file = ( + tmp_path / "ff.data" + ) + + ff_file.write_text( + "\n".join(lines), + encoding="utf-8", + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + force_field_data=ff_file, + ) + ), + ) + + with pytest.raises( + ValueError, + match=( + f"Required LAMMPS header " + f"'{missing_key} types'" + ), + ): + writer._get_force_field_types() + + +def test_get_force_field_types_stops_at_mass_section( + tmp_path, +): + ff_file = ( + tmp_path / "ff.data" + ) + + ff_file.write_text( + """10 atom types + +Masses + +8 bond types +6 angle types +4 dihedral types +""", + encoding="utf-8", + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + force_field_data=ff_file, + ) + ), + ) + + with pytest.raises( + ValueError, + match="bond types", + ): + writer._get_force_field_types() + + +# ============================================================================= +# _run_calculation +# ============================================================================= + + +def test_default_run_calculation( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + total_steps, thermo = ( + writer._run_calculation() + ) + + assert total_steps == 50000 + assert thermo == 1000 + + +@pytest.mark.parametrize( + ( + "timestep, " + "expected_steps, " + "expected_thermo" + ), + [ + ( + 0.01, + 50000, + 1000, + ), + ( + 0.1, + 5000, + 100, + ), + ( + 1.0, + 500, + 10, + ), + ( + 10.0, + 50, + 1, + ), + ( + 100.0, + 5, + 1, + ), + ], +) +def test_run_calculation_rounding_branches( + tmp_path, + timestep, + expected_steps, + expected_thermo, +): + writer = make_writer_without_init( + tmp_path + ) + + writer.erate = -0.001 + writer.timestep = timestep + + total_steps, thermo = ( + writer._run_calculation() + ) + + assert ( + total_steps + == expected_steps + ) + + assert ( + thermo + == expected_thermo + ) + + +def test_run_calculation_positive_erate_raises( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + writer.erate = 0.001 + + with pytest.raises( + ValueError, + match=( + "Calculated negative steps" + ), + ): + writer._run_calculation() + + +# ============================================================================= +# write_lammps_densification_file +# ============================================================================= + + +def test_write_densification_creates_stage_directory_and_script( + tmp_path, + monkeypatch, +): + ff_file = ( + tmp_path / "force_field.data" + ) + + ff_file.write_text( + "ff", + encoding="utf-8", + ) + + monomer_file = ( + tmp_path / "mma.molecule" + ) + + monomer_file.write_text( + "mol", + encoding="utf-8", + ) + + rf = make_reacter_files( + force_field_data=ff_file, + molecule_files=[ + make_monomer( + name="mma", + molecule_file=monomer_file, + ) + ], + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=rf, + sim_name="Polymer", + ) + + monkeypatch.setattr( + writer, + "_get_force_field_types", + lambda: { + "atom": 10, + "bond": 20, + "angle": 30, + "dihedral": 40, + "improper": 5, + }, + ) + + monkeypatch.setattr( + writer, + "_run_calculation", + lambda: ( + 12345, + 250, + ), + ) + + monkeypatch.setattr( + writer, + "_copy_required_files", + lambda dest_dir: None, + ) + + monkeypatch.setattr( + dens_module.random, + "randint", + lambda a, b: 12345, + ) + + simulation = make_simulation( + tag="500K", + temperature=500.0, + density=1.23, + monomer_counts={ + "mma": 25, + }, + ) + + filename = ( + writer + .write_lammps_densification_file( + simulation + ) + ) + + stage_dir = ( + tmp_path + / "1_densification" + ) + + assert stage_dir.is_dir() + + assert filename == ( + "in.Polymer_500K_densification" + ) + + assert ( + stage_dir / filename + ).is_file() + + assert ( + stage_dir + / "empty_box.data" + ).is_file() + + +def test_write_densification_contains_force_field_settings( + tmp_path, + monkeypatch, +): + ff_file = ( + tmp_path / "force_field.data" + ) + + ff_file.write_text( + "ff", + encoding="utf-8", + ) + + writer = make_writer_without_init( + tmp_path, + settings=make_settings( + neighbor="2.0 bin", + neigh_modify=( + "delay 0 every 1 " + "check yes" + ), + ), + reacter_files=( + make_reacter_files( + force_field_data=ff_file, + ) + ), + ) + + monkeypatch.setattr( + writer, + "_get_force_field_types", + lambda: { + "atom": 1, + "bond": 2, + "angle": 3, + "dihedral": 4, + "improper": 5, + }, + ) + + monkeypatch.setattr( + writer, + "_run_calculation", + lambda: ( + 1000, + 100, + ), + ) + + monkeypatch.setattr( + writer, + "_copy_required_files", + lambda dest_dir: None, + ) + + monkeypatch.setattr( + dens_module.random, + "randint", + lambda a, b: 11111, + ) + + filename = ( + writer + .write_lammps_densification_file( + make_simulation() + ) + ) + + text = ( + tmp_path + / "1_densification" + / filename + ).read_text( + encoding="utf-8" + ) + + assert ( + "units real" + in text + ) + + assert ( + "dimension 3" + in text + ) + + assert ( + "boundary p p p" + in text + ) + + assert ( + "atom_style full" + in text + ) + + assert ( + "bond_style class2" + in text + ) + + assert ( + "angle_style class2" + in text + ) + + assert ( + "dihedral_style class2" + in text + ) + + assert ( + "improper_style class2" + in text + ) + + assert ( + "pair_style " + "lj/class2/coul/long 12.0" + in text + ) + + assert ( + "neighbor " + "2.0 bin" + in text + ) + + assert ( + "neigh_modify " + "delay 0 every 1 check yes" + in text + ) + + +def test_write_densification_omits_optional_neighbor_commands_when_none( + tmp_path, + monkeypatch, +): + ff_file = ( + tmp_path / "force_field.data" + ) + + ff_file.write_text( + "ff", + encoding="utf-8", + ) + + writer = make_writer_without_init( + tmp_path, + settings=make_settings( + neighbor=None, + neigh_modify=None, + ), + reacter_files=( + make_reacter_files( + force_field_data=ff_file, + ) + ), + ) + + monkeypatch.setattr( + writer, + "_get_force_field_types", + lambda: { + "atom": 1, + "bond": 1, + "angle": 1, + "dihedral": 1, + "improper": 0, + }, + ) + + monkeypatch.setattr( + writer, + "_run_calculation", + lambda: ( + 1000, + 100, + ), + ) + + monkeypatch.setattr( + writer, + "_copy_required_files", + lambda dest_dir: None, + ) + + monkeypatch.setattr( + dens_module.random, + "randint", + lambda a, b: 11111, + ) + + filename = ( + writer + .write_lammps_densification_file( + make_simulation() + ) + ) + + text = ( + tmp_path + / "1_densification" + / filename + ).read_text( + encoding="utf-8" + ) + + assert ( + "\nneighbor " + not in text + ) + + assert ( + "\nneigh_modify " + not in text + ) + + +def test_write_densification_uses_force_field_type_counts( + tmp_path, + monkeypatch, +): + ff_file = ( + tmp_path / "force_field.data" + ) + + ff_file.write_text( + "ff", + encoding="utf-8", + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + force_field_data=ff_file, + ) + ), + ) + + monkeypatch.setattr( + writer, + "_get_force_field_types", + lambda: { + "atom": 11, + "bond": 22, + "angle": 33, + "dihedral": 44, + "improper": 55, + }, + ) + + monkeypatch.setattr( + writer, + "_run_calculation", + lambda: ( + 1000, + 100, + ), + ) + + monkeypatch.setattr( + writer, + "_copy_required_files", + lambda dest_dir: None, + ) + + monkeypatch.setattr( + dens_module.random, + "randint", + lambda a, b: 11111, + ) + + filename = ( + writer + .write_lammps_densification_file( + make_simulation() + ) + ) + + text = ( + tmp_path + / "1_densification" + / filename + ).read_text( + encoding="utf-8" + ) + + assert ( + "extra/atom/types 11" + in text + ) + + assert ( + "extra/bond/types 22" + in text + ) + + assert ( + "extra/angle/types 33" + in text + ) + + assert ( + "extra/dihedral/types 44" + in text + ) + + assert ( + "extra/improper/types 55" + in text + ) + + +def test_write_densification_defines_molecules_and_inserts_counts( + tmp_path, + monkeypatch, +): + ff_file = ( + tmp_path / "force_field.data" + ) + + ff_file.write_text( + "ff", + encoding="utf-8", + ) + + mma_file = ( + tmp_path / "mma.molecule" + ) + + tegdma_file = ( + tmp_path / "tegdma.molecule" + ) + + mma_file.write_text( + "mma", + encoding="utf-8", + ) + + tegdma_file.write_text( + "tegdma", + encoding="utf-8", + ) + + rf = make_reacter_files( + force_field_data=ff_file, + molecule_files=[ + make_monomer( + name="mma", + molecule_file=mma_file, + ), + make_monomer( + name="tegdma", + molecule_file=tegdma_file, + ), + ], + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=rf, + ) + + monkeypatch.setattr( + writer, + "_get_force_field_types", + lambda: { + "atom": 1, + "bond": 1, + "angle": 1, + "dihedral": 1, + "improper": 0, + }, + ) + + monkeypatch.setattr( + writer, + "_run_calculation", + lambda: ( + 5000, + 100, + ), + ) + + monkeypatch.setattr( + writer, + "_copy_required_files", + lambda dest_dir: None, + ) + + seeds = iter( + [ + 11111, + 22222, + 33333, + ] + ) + + monkeypatch.setattr( + dens_module.random, + "randint", + lambda a, b: + next(seeds), + ) + + simulation = make_simulation( + monomer_counts={ + "mma": 25, + "tegdma": 5, + "unknown": 999, + }, + ) + + filename = ( + writer + .write_lammps_densification_file( + simulation + ) + ) + + text = ( + tmp_path + / "1_densification" + / filename + ).read_text( + encoding="utf-8" + ) + + assert ( + "mol_1 mma.molecule" + in text + ) + + assert ( + "mol_2 tegdma.molecule" + in text + ) + + assert ( + "random 25 11111 NULL " + "overlap 2.0 maxtry 100 " + "mol mol_1 11111" + in text + ) + + assert ( + "random 5 22222 NULL " + "overlap 2.0 maxtry 100 " + "mol mol_2 22222" + in text + ) + + assert ( + "random 999" + not in text + ) + + assert ( + "all create 500.0 33333 " + "dist gaussian" + in text + ) + + +def test_molecule_name_falls_back_to_data_id( + tmp_path, + monkeypatch, +): + ff_file = ( + tmp_path / "force_field.data" + ) + + ff_file.write_text( + "ff", + encoding="utf-8", + ) + + mol_file = ( + tmp_path / "data42.molecule" + ) + + mol_file.write_text( + "mol", + encoding="utf-8", + ) + + rf = make_reacter_files( + force_field_data=ff_file, + molecule_files=[ + make_monomer( + name=None, + data_id="data42", + molecule_file=mol_file, + ) + ], + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=rf, + ) + + monkeypatch.setattr( + writer, + "_get_force_field_types", + lambda: { + "atom": 1, + "bond": 1, + "angle": 1, + "dihedral": 1, + "improper": 0, + }, + ) + + monkeypatch.setattr( + writer, + "_run_calculation", + lambda: ( + 100, + 10, + ), + ) + + monkeypatch.setattr( + writer, + "_copy_required_files", + lambda dest_dir: None, + ) + + monkeypatch.setattr( + dens_module.random, + "randint", + lambda a, b: 12345, + ) + + filename = ( + writer + .write_lammps_densification_file( + make_simulation( + monomer_counts={ + "data42": 7 + } + ) + ) + ) + + text = ( + tmp_path + / "1_densification" + / filename + ).read_text( + encoding="utf-8" + ) + + assert ( + "mol_1 data42.molecule" + in text + ) + + assert ( + "random 7 12345" + in text + ) + + +def test_write_densification_contains_run_conditions( + tmp_path, + monkeypatch, +): + ff_file = ( + tmp_path / "force_field.data" + ) + + ff_file.write_text( + "ff", + encoding="utf-8", + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + force_field_data=ff_file, + ) + ), + ) + + monkeypatch.setattr( + writer, + "_get_force_field_types", + lambda: { + "atom": 1, + "bond": 1, + "angle": 1, + "dihedral": 1, + "improper": 0, + }, + ) + + monkeypatch.setattr( + writer, + "_run_calculation", + lambda: ( + 45678, + 321, + ), + ) + + monkeypatch.setattr( + writer, + "_copy_required_files", + lambda dest_dir: None, + ) + + monkeypatch.setattr( + dens_module.random, + "randint", + lambda a, b: 55555, + ) + + simulation = make_simulation( + temperature=373.0, + density=1.25, + ) + + filename = ( + writer + .write_lammps_densification_file( + simulation + ) + ) + + text = ( + tmp_path + / "1_densification" + / filename + ).read_text( + encoding="utf-8" + ) + + assert ( + "nvt temp 373.0 373.0 100.0" + in text + ) + + assert ( + "x erate -0.001 " + "y erate -0.001 " + "z erate -0.001" + in text + ) + + assert ( + "v_my_den > 1.25 " + "error continue" + in text + ) + + # Do not make these assertions depend on cosmetic alignment spacing. + script_lines = ( + text.splitlines() + ) + + assert any( + line.split() + == [ + "thermo", + "321", + ] + for line in script_lines + ) + + assert any( + line.split() + == [ + "run", + "45678", + ] + for line in script_lines + ) + + assert any( + line.split() + == [ + "timestep", + "0.01", + ] + for line in script_lines + ) + + +def test_write_densification_calls_copy_required_files_with_stage_dir( + tmp_path, + monkeypatch, +): + ff_file = ( + tmp_path / "force_field.data" + ) + + ff_file.write_text( + "ff", + encoding="utf-8", + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + force_field_data=ff_file, + ) + ), + ) + + monkeypatch.setattr( + writer, + "_get_force_field_types", + lambda: { + "atom": 1, + "bond": 1, + "angle": 1, + "dihedral": 1, + "improper": 0, + }, + ) + + monkeypatch.setattr( + writer, + "_run_calculation", + lambda: ( + 100, + 10, + ), + ) + + monkeypatch.setattr( + dens_module.random, + "randint", + lambda a, b: 12345, + ) + + calls = [] + + monkeypatch.setattr( + writer, + "_copy_required_files", + lambda dest_dir: + calls.append( + dest_dir + ), + ) + + writer.write_lammps_densification_file( + make_simulation() + ) + + assert calls == [ + tmp_path + / "1_densification" + ] + + +# ============================================================================= +# _copy_required_files +# ============================================================================= + + +def test_copy_required_files_copies_force_field( + tmp_path, +): + ff_file = ( + tmp_path / "force_field.data" + ) + + ff_file.write_text( + "FF", + encoding="utf-8", + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + force_field_data=ff_file, + ) + ), + ) + + dest = ( + tmp_path / "dest" + ) + + dest.mkdir() + + writer._copy_required_files( + dest + ) + + copied = ( + dest + / "force_field.data" + ) + + assert copied.is_file() + + assert ( + copied.read_text( + encoding="utf-8" + ) + == "FF" + ) + + +def test_copy_required_files_copies_monomer_molecules( + tmp_path, +): + ff_file = ( + tmp_path / "force_field.data" + ) + + ff_file.write_text( + "FF", + encoding="utf-8", + ) + + mma = ( + tmp_path / "mma.molecule" + ) + + tegdma = ( + tmp_path / "tegdma.molecule" + ) + + mma.write_text( + "MMA", + encoding="utf-8", + ) + + tegdma.write_text( + "TEGDMA", + encoding="utf-8", + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + force_field_data=ff_file, + molecule_files=[ + make_monomer( + name="mma", + molecule_file=mma, + ), + make_monomer( + name="tegdma", + molecule_file=tegdma, + ), + ], + ) + ), + ) + + dest = ( + tmp_path / "dest" + ) + + dest.mkdir() + + writer._copy_required_files( + dest + ) + + assert ( + dest + / "mma.molecule" + ).read_text( + encoding="utf-8" + ) == "MMA" + + assert ( + dest + / "tegdma.molecule" + ).read_text( + encoding="utf-8" + ) == "TEGDMA" + + +def test_copy_required_files_ignores_missing_force_field( + tmp_path, +): + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + force_field_data=( + tmp_path + / "missing.data" + ), + ) + ), + ) + + dest = ( + tmp_path / "dest" + ) + + dest.mkdir() + + writer._copy_required_files( + dest + ) + + assert list( + dest.iterdir() + ) == [] + + +def test_copy_required_files_ignores_missing_molecule_file( + tmp_path, +): + ff_file = ( + tmp_path / "ff.data" + ) + + ff_file.write_text( + "ff", + encoding="utf-8", + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + force_field_data=ff_file, + molecule_files=[ + make_monomer( + molecule_file=( + tmp_path + / "missing.molecule" + ) + ) + ], + ) + ), + ) + + dest = ( + tmp_path / "dest" + ) + + dest.mkdir() + + writer._copy_required_files( + dest + ) + + assert not ( + dest + / "missing.molecule" + ).exists() + + +def test_copy_required_files_ignores_none_molecule_path( + tmp_path, +): + ff_file = ( + tmp_path / "ff.data" + ) + + ff_file.write_text( + "ff", + encoding="utf-8", + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + force_field_data=ff_file, + molecule_files=[ + make_monomer( + molecule_file=None + ) + ], + ) + ), + ) + + dest = ( + tmp_path / "dest" + ) + + dest.mkdir() + + writer._copy_required_files( + dest + ) + + assert ( + dest / "ff.data" + ).is_file() + + +def test_densification_does_not_copy_reaction_maps( + tmp_path, +): + """ + Reaction maps do NOT belong in 1_densification. + + Both: + RXN_N.map + and the optional: + RXN_N_with_delete_ids.map + + belong to the REACTER/reaction workflow. + """ + ff_file = ( + tmp_path + / "force_field.data" + ) + + ff_file.write_text( + "ff", + encoding="utf-8", + ) + + standard_map = ( + tmp_path + / "RXN_1.map" + ) + + delete_map = ( + tmp_path + / "RXN_1_with_delete_ids.map" + ) + + standard_map.write_text( + "STANDARD", + encoding="utf-8", + ) + + delete_map.write_text( + "DELETE", + encoding="utf-8", + ) + + template = SimpleNamespace( + map_file=standard_map, + map_file_with_delete_ids=delete_map, + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + force_field_data=ff_file, + template_files=[ + template + ], + ) + ), + ) + + dest = ( + tmp_path + / "densification" + ) + + dest.mkdir() + + writer._copy_required_files( + dest + ) + + assert not ( + dest + / "RXN_1.map" + ).exists() + + assert not ( + dest + / "RXN_1_with_delete_ids.map" + ).exists() \ No newline at end of file diff --git a/tests/unit/sim_setup/writers/test_lammps_settings.py b/tests/unit/sim_setup/writers/test_lammps_settings.py new file mode 100644 index 00000000..a44b7f81 --- /dev/null +++ b/tests/unit/sim_setup/writers/test_lammps_settings.py @@ -0,0 +1,869 @@ +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from AutoREACTER.sim_setup.writers.lammps_settings import ( + LammpsInitialSettings, + LammpsSettings, +) + + +# ============================================================================= +# Helpers +# ============================================================================= + + +def make_reacter_files( + in_file, +): + return SimpleNamespace( + in_file=Path(in_file), + ) + + +# ============================================================================= +# LammpsSettings dataclass +# ============================================================================= + + +def test_lammps_settings_stores_all_values(): + settings = LammpsSettings( + units="real", + dimension="3", + boundary="p p p", + atom_style="full", + bond_style="class2", + angle_style="class2", + dihedral_style="class2", + improper_style="class2", + special_bonds="lj/coul 0 0 1", + pair_style="lj/class2/coul/long 8.5", + kspace_style="pppm 1e-4", + pair_modify="tail yes mix sixthpower", + neighbor="2.0 bin", + neigh_modify="delay 0 every 1 check yes", + ) + + assert settings.units == "real" + assert settings.dimension == "3" + assert settings.boundary == "p p p" + assert settings.atom_style == "full" + assert settings.bond_style == "class2" + assert settings.angle_style == "class2" + assert settings.dihedral_style == "class2" + assert settings.improper_style == "class2" + assert settings.special_bonds == "lj/coul 0 0 1" + assert settings.pair_style == "lj/class2/coul/long 8.5" + assert settings.kspace_style == "pppm 1e-4" + assert settings.pair_modify == "tail yes mix sixthpower" + assert settings.neighbor == "2.0 bin" + assert settings.neigh_modify == "delay 0 every 1 check yes" + + +def test_lammps_settings_uses_slots(): + settings = LammpsSettings( + units="real", + dimension="3", + boundary="p p p", + atom_style="full", + bond_style="class2", + angle_style="class2", + dihedral_style="class2", + improper_style="class2", + special_bonds="lj/coul 0 0 1", + pair_style="lj/class2/coul/long 8.5", + kspace_style="pppm 1e-4", + pair_modify="tail yes mix sixthpower", + neighbor=None, + neigh_modify=None, + ) + + with pytest.raises(AttributeError): + settings.extra = "not allowed" + + +# ============================================================================= +# Constructor +# ============================================================================= + + +def test_constructor_stores_reacter_files( + tmp_path, +): + in_file = tmp_path / "in.create_atoms.script" + + reacter_files = make_reacter_files( + in_file + ) + + result = LammpsInitialSettings( + reacter_files + ) + + assert result.reacter_files is reacter_files + + +def test_constructor_stores_lunar_input_file_location( + tmp_path, +): + in_file = tmp_path / "in.create_atoms.script" + + reacter_files = make_reacter_files( + in_file + ) + + result = LammpsInitialSettings( + reacter_files + ) + + assert ( + result.lunar_in_file_location + == in_file + ) + + +# ============================================================================= +# _defults +# ============================================================================= + + +def test_defaults_returns_lammps_settings( + tmp_path, +): + initial = LammpsInitialSettings( + make_reacter_files( + tmp_path / "unused.script" + ) + ) + + result = initial._defults() + + assert isinstance( + result, + LammpsSettings, + ) + + +def test_defaults_exact_values( + tmp_path, +): + initial = LammpsInitialSettings( + make_reacter_files( + tmp_path / "unused.script" + ) + ) + + result = initial._defults() + + assert result.units == "real" + assert result.dimension == "3" + assert result.boundary == "p p p" + assert result.atom_style == "full" + + assert result.bond_style == "class2" + assert result.angle_style == "class2" + assert result.dihedral_style == "class2" + assert result.improper_style == "class2" + + assert result.special_bonds == ( + "lj/coul 0 0 1" + ) + + assert result.pair_style == ( + "lj/class2/coul/long 8.5" + ) + + assert result.kspace_style == ( + "pppm 1e-4" + ) + + assert result.pair_modify == ( + "tail yes mix sixthpower" + ) + + assert result.neighbor is None + assert result.neigh_modify is None + + +def test_defaults_returns_new_object_each_time( + tmp_path, +): + initial = LammpsInitialSettings( + make_reacter_files( + tmp_path / "unused.script" + ) + ) + + first = initial._defults() + second = initial._defults() + + assert first is not second + + +# ============================================================================= +# get_LUNAR_lammps_settings - defaults +# ============================================================================= + + +def test_missing_lunar_input_file_uses_defaults( + tmp_path, +): + initial = LammpsInitialSettings( + make_reacter_files( + tmp_path / "missing.script" + ) + ) + + result = ( + initial + .get_LUNAR_lammps_settings() + ) + + assert result.units == "real" + assert result.dimension == "3" + assert result.boundary == "p p p" + assert result.atom_style == "full" + + assert result.bond_style == "class2" + assert result.angle_style == "class2" + assert result.dihedral_style == "class2" + assert result.improper_style == "class2" + + assert result.special_bonds == ( + "lj/coul 0 0 1" + ) + + assert result.pair_style == ( + "lj/class2/coul/long 8.5" + ) + + assert result.kspace_style == ( + "pppm 1e-4" + ) + + assert result.pair_modify == ( + "tail yes mix sixthpower" + ) + + assert result.neighbor is None + assert result.neigh_modify is None + + +def test_missing_lunar_input_file_prints_warning( + tmp_path, + capsys, +): + missing = ( + tmp_path / "missing.script" + ) + + initial = LammpsInitialSettings( + make_reacter_files( + missing + ) + ) + + initial.get_LUNAR_lammps_settings() + + output = ( + capsys.readouterr().out + ) + + assert ( + "Could not read LAMMPS input file" + in output + ) + + assert str(missing) in output + + assert ( + "Proceeding with default LAMMPS settings" + in output + ) + + +def test_empty_lunar_input_file_uses_defaults( + tmp_path, +): + in_file = ( + tmp_path / "in.script" + ) + + in_file.write_text( + "", + encoding="utf-8", + ) + + initial = LammpsInitialSettings( + make_reacter_files( + in_file + ) + ) + + result = ( + initial + .get_LUNAR_lammps_settings() + ) + + assert result.units == "real" + + assert result.pair_style == ( + "lj/class2/coul/long 8.5" + ) + + assert result.neighbor is None + + +# ============================================================================= +# Override parsing +# ============================================================================= + + +def test_lunar_file_overrides_single_setting( + tmp_path, +): + in_file = tmp_path / "in.script" + + in_file.write_text( + "units metal\n", + encoding="utf-8", + ) + + initial = LammpsInitialSettings( + make_reacter_files( + in_file + ) + ) + + result = ( + initial + .get_LUNAR_lammps_settings() + ) + + assert result.units == "metal" + + # Everything else remains default. + assert result.dimension == "3" + + assert result.atom_style == "full" + + +def test_lunar_file_overrides_multiple_settings( + tmp_path, +): + in_file = tmp_path / "in.script" + + in_file.write_text( + """units real +dimension 2 +boundary f f p +atom_style molecular +bond_style harmonic +angle_style harmonic +dihedral_style opls +improper_style cvff +special_bonds lj/coul 0.0 0.0 0.5 +pair_style lj/cut 10.0 +kspace_style ewald 1e-5 +pair_modify mix arithmetic +neighbor 3.0 bin +neigh_modify delay 5 every 2 check no +""", + encoding="utf-8", + ) + + initial = LammpsInitialSettings( + make_reacter_files( + in_file + ) + ) + + result = ( + initial + .get_LUNAR_lammps_settings() + ) + + assert result.units == "real" + assert result.dimension == "2" + assert result.boundary == "f f p" + + assert ( + result.atom_style + == "molecular" + ) + + assert ( + result.bond_style + == "harmonic" + ) + + assert ( + result.angle_style + == "harmonic" + ) + + assert ( + result.dihedral_style + == "opls" + ) + + assert ( + result.improper_style + == "cvff" + ) + + assert ( + result.special_bonds + == "lj/coul 0.0 0.0 0.5" + ) + + assert ( + result.pair_style + == "lj/cut 10.0" + ) + + assert ( + result.kspace_style + == "ewald 1e-5" + ) + + assert ( + result.pair_modify + == "mix arithmetic" + ) + + assert ( + result.neighbor + == "3.0 bin" + ) + + assert ( + result.neigh_modify + == "delay 5 every 2 check no" + ) + + +def test_inline_comments_are_removed( + tmp_path, +): + in_file = tmp_path / "in.script" + + in_file.write_text( + """ +pair_style lj/class2/coul/long 12.0 # cutoff +kspace_style pppm 1.0e-4 # electrostatics +neighbor 2.0 bin # neighbor list +""", + encoding="utf-8", + ) + + initial = LammpsInitialSettings( + make_reacter_files( + in_file + ) + ) + + result = ( + initial + .get_LUNAR_lammps_settings() + ) + + assert ( + result.pair_style + == "lj/class2/coul/long 12.0" + ) + + assert ( + result.kspace_style + == "pppm 1.0e-4" + ) + + assert ( + result.neighbor + == "2.0 bin" + ) + + +def test_full_comment_lines_are_ignored( + tmp_path, +): + in_file = tmp_path / "in.script" + + in_file.write_text( + """# units metal +# pair_style lj/cut 15.0 +units real +""", + encoding="utf-8", + ) + + initial = LammpsInitialSettings( + make_reacter_files( + in_file + ) + ) + + result = ( + initial + .get_LUNAR_lammps_settings() + ) + + assert result.units == "real" + + assert ( + result.pair_style + == "lj/class2/coul/long 8.5" + ) + + +def test_blank_lines_are_ignored( + tmp_path, +): + in_file = tmp_path / "in.script" + + in_file.write_text( + """ + +units metal + + +pair_style lj/cut 9.0 + + +""", + encoding="utf-8", + ) + + initial = LammpsInitialSettings( + make_reacter_files( + in_file + ) + ) + + result = ( + initial + .get_LUNAR_lammps_settings() + ) + + assert result.units == "metal" + + assert ( + result.pair_style + == "lj/cut 9.0" + ) + + +# ============================================================================= +# Prefix matching behavior +# ============================================================================= + + +def test_keyword_must_begin_clean_line( + tmp_path, +): + in_file = tmp_path / "in.script" + + in_file.write_text( + """variable units string metal +units real +""", + encoding="utf-8", + ) + + initial = LammpsInitialSettings( + make_reacter_files( + in_file + ) + ) + + result = ( + initial + .get_LUNAR_lammps_settings() + ) + + assert result.units == "real" + + +def test_leading_whitespace_before_keyword_is_allowed( + tmp_path, +): + in_file = tmp_path / "in.script" + + in_file.write_text( + " units metal\n", + encoding="utf-8", + ) + + initial = LammpsInitialSettings( + make_reacter_files( + in_file + ) + ) + + result = ( + initial + .get_LUNAR_lammps_settings() + ) + + assert result.units == "metal" + + +def test_empty_value_does_not_override_default( + tmp_path, +): + in_file = tmp_path / "in.script" + + in_file.write_text( + "units\n", + encoding="utf-8", + ) + + initial = LammpsInitialSettings( + make_reacter_files( + in_file + ) + ) + + result = ( + initial + .get_LUNAR_lammps_settings() + ) + + assert result.units == "real" + + +# ============================================================================= +# Current duplicate-line behavior +# ============================================================================= + + +def test_last_matching_line_wins( + tmp_path, +): + """ + Characterization of current nested-loop behavior. + + Every matching line updates the same setting, so the last one wins. + """ + in_file = tmp_path / "in.script" + + in_file.write_text( + """pair_style lj/cut 8.0 +pair_style lj/cut 10.0 +pair_style lj/class2/coul/long 12.0 +""", + encoding="utf-8", + ) + + initial = LammpsInitialSettings( + make_reacter_files( + in_file + ) + ) + + result = ( + initial + .get_LUNAR_lammps_settings() + ) + + assert ( + result.pair_style + == "lj/class2/coul/long 12.0" + ) + + +def test_last_neighbor_line_wins( + tmp_path, +): + in_file = tmp_path / "in.script" + + in_file.write_text( + """neighbor 1.0 bin +neighbor 2.0 bin +neighbor 3.0 bin +""", + encoding="utf-8", + ) + + initial = LammpsInitialSettings( + make_reacter_files( + in_file + ) + ) + + result = ( + initial + .get_LUNAR_lammps_settings() + ) + + assert ( + result.neighbor + == "3.0 bin" + ) + + +# ============================================================================= +# Current startswith semantics +# ============================================================================= + + +def test_keyword_matching_currently_uses_startswith( + tmp_path, +): + """ + Characterization test. + + Current code uses: + + clean_line.startswith(keyword) + + rather than requiring a whitespace boundary after the keyword. + + Therefore a line such as "units_extra metal" is interpreted as a + units setting with value "_extra metal". + + This is odd, but do not change production during characterization. + """ + in_file = tmp_path / "in.script" + + in_file.write_text( + "units_extra metal\n", + encoding="utf-8", + ) + + initial = LammpsInitialSettings( + make_reacter_files( + in_file + ) + ) + + result = ( + initial + .get_LUNAR_lammps_settings() + ) + + assert ( + result.units + == "_extra metal" + ) + + +def test_pair_style_does_not_override_pair_modify( + tmp_path, +): + in_file = tmp_path / "in.script" + + in_file.write_text( + """pair_style lj/cut 10.0 +pair_modify mix arithmetic +""", + encoding="utf-8", + ) + + initial = LammpsInitialSettings( + make_reacter_files( + in_file + ) + ) + + result = ( + initial + .get_LUNAR_lammps_settings() + ) + + assert ( + result.pair_style + == "lj/cut 10.0" + ) + + assert ( + result.pair_modify + == "mix arithmetic" + ) + + +# ============================================================================= +# Realistic LUNAR-style input +# ============================================================================= + + +def test_realistic_lunar_settings_file( + tmp_path, +): + in_file = ( + tmp_path + / "in.create_atoms.script" + ) + + in_file.write_text( + """# LUNAR generated settings + +units real +dimension 3 +boundary p p p +atom_style full + +bond_style class2 +angle_style class2 +dihedral_style class2 +improper_style class2 + +pair_style lj/class2/coul/long 12.0 +kspace_style pppm 1.0e-4 +pair_modify mix sixthpower + +neighbor 2.0 bin +neigh_modify delay 0 every 1 check yes one 5000 page 100000 +""", + encoding="utf-8", + ) + + initial = LammpsInitialSettings( + make_reacter_files( + in_file + ) + ) + + result = ( + initial + .get_LUNAR_lammps_settings() + ) + + assert result.units == "real" + assert result.dimension == "3" + assert result.boundary == "p p p" + assert result.atom_style == "full" + + assert result.bond_style == "class2" + assert result.angle_style == "class2" + assert result.dihedral_style == "class2" + assert result.improper_style == "class2" + + assert ( + result.pair_style + == "lj/class2/coul/long 12.0" + ) + + assert ( + result.kspace_style + == "pppm 1.0e-4" + ) + + assert ( + result.pair_modify + == "mix sixthpower" + ) + + assert ( + result.neighbor + == "2.0 bin" + ) + + assert ( + result.neigh_modify + == ( + "delay 0 every 1 check yes " + "one 5000 page 100000" + ) + ) \ No newline at end of file diff --git a/tests/unit/sim_setup/writers/test_post_eq_writer.py b/tests/unit/sim_setup/writers/test_post_eq_writer.py new file mode 100644 index 00000000..859d1b3d --- /dev/null +++ b/tests/unit/sim_setup/writers/test_post_eq_writer.py @@ -0,0 +1,1355 @@ +from types import SimpleNamespace + +import pytest + +from AutoREACTER.sim_setup.writers.post_eq_writer import ( + PostEqWriter, +) + + +# ============================================================================= +# Helpers +# ============================================================================= + + +def make_settings( + *, + neighbor=None, + neigh_modify=None, +): + return SimpleNamespace( + units="real", + dimension="3", + boundary="p p p", + atom_style="full", + bond_style="class2", + angle_style="class2", + dihedral_style="class2", + improper_style="class2", + special_bonds="lj/coul 0 0 1", + pair_style="lj/class2/coul/long 12.0", + kspace_style="pppm 1.0e-4", + pair_modify="mix sixthpower", + neighbor=neighbor, + neigh_modify=neigh_modify, + ) + + +def make_simulation( + *, + tag="sim1", + temperature=500.0, +): + return SimpleNamespace( + tag=tag, + temperature=temperature, + ) + + +def make_writer_without_init( + tmp_path, + *, + settings=None, + sim_name="Test", +): + writer = object.__new__( + PostEqWriter + ) + + writer.settings = ( + settings + if settings is not None + else make_settings() + ) + + writer.out_dir = tmp_path + writer.sim_name = sim_name + + return writer + + +def read_script( + tmp_path, + filename, +): + return ( + tmp_path + / "5_post_equilibration" + / filename + ).read_text( + encoding="utf-8" + ) + + +def command_lines(text): + """ + Return tokenized active LAMMPS commands. + + Comments and blank lines are ignored so tests do not depend on + cosmetic alignment spacing. + """ + commands = [] + + for line in text.splitlines(): + stripped = line.strip() + + if not stripped: + continue + + if stripped.startswith("#"): + continue + + commands.append( + stripped.split() + ) + + return commands + + +# ============================================================================= +# Constructor +# ============================================================================= + + +def test_constructor_stores_configuration_and_writes_file( + tmp_path, + monkeypatch, +): + settings = make_settings() + + simulation = make_simulation() + + calls = [] + + def fake_write( + self, + *, + simulation, + write_second_reaction_stage=True, + ): + calls.append( + ( + simulation, + write_second_reaction_stage, + ) + ) + + return "in.test_post_equilibration" + + monkeypatch.setattr( + PostEqWriter, + "write_post_eq_file", + fake_write, + ) + + writer = PostEqWriter( + out_dir=tmp_path, + settings=settings, + simulation=simulation, + sim_name="Polymer", + ) + + assert writer.settings is settings + assert writer.out_dir == tmp_path + assert writer.sim_name == "Polymer" + + assert calls == [ + ( + simulation, + True, + ) + ] + + +def test_constructor_forwards_false_second_stage_flag( + tmp_path, + monkeypatch, +): + calls = [] + + def fake_write( + self, + *, + simulation, + write_second_reaction_stage=True, + ): + calls.append( + write_second_reaction_stage + ) + + return "in.test" + + monkeypatch.setattr( + PostEqWriter, + "write_post_eq_file", + fake_write, + ) + + PostEqWriter( + out_dir=tmp_path, + settings=make_settings(), + simulation=make_simulation(), + sim_name="Polymer", + write_second_reaction_stage=False, + ) + + assert calls == [ + False + ] + + +# ============================================================================= +# File generation +# ============================================================================= + + +def test_write_post_eq_creates_stage_directory( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + writer.write_post_eq_file( + make_simulation() + ) + + assert ( + tmp_path + / "5_post_equilibration" + ).is_dir() + + +def test_write_post_eq_creates_expected_input_file( + tmp_path, +): + writer = make_writer_without_init( + tmp_path, + sim_name="Polymer", + ) + + filename = writer.write_post_eq_file( + make_simulation( + tag="500K" + ) + ) + + assert filename == ( + "in.Polymer_500K_post_equilibration" + ) + + assert ( + tmp_path + / "5_post_equilibration" + / filename + ).is_file() + + +def test_write_post_eq_returns_only_filename( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = writer.write_post_eq_file( + make_simulation() + ) + + assert isinstance( + filename, + str, + ) + + assert "/" not in filename + assert "\\" not in filename + + +def test_script_header_contains_tag_and_autoreacter( + tmp_path, +): + writer = make_writer_without_init( + tmp_path, + sim_name="Epoxy", + ) + + filename = writer.write_post_eq_file( + make_simulation( + tag="373K" + ) + ) + + text = read_script( + tmp_path, + filename, + ) + + assert ( + "#Epoxy_373K " + "Post-Equilibration Script" + in text + ) + + assert "Generated" in text + + assert ( + "by AutoREACTER" + in text + ) + + +# ============================================================================= +# Input reacted data selection +# ============================================================================= + + +def test_default_reads_second_reaction_stage_output( + tmp_path, +): + writer = make_writer_without_init( + tmp_path, + sim_name="Polymer", + ) + + filename = writer.write_post_eq_file( + make_simulation( + tag="500K" + ), + write_second_reaction_stage=True, + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "read_data", + "Polymer_500K_reacted_1M-3.5_5.0A.data", + "&", + ] in commands + + +def test_false_second_stage_reads_first_reaction_output( + tmp_path, +): + writer = make_writer_without_init( + tmp_path, + sim_name="Polymer", + ) + + filename = writer.write_post_eq_file( + make_simulation( + tag="500K" + ), + write_second_reaction_stage=False, + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "read_data", + "Polymer_500K_reacted_0M-1M_3.5A.data", + "&", + ] in commands + + +def test_read_data_includes_extra_topology_capacity( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = writer.write_post_eq_file( + make_simulation() + ) + + text = read_script( + tmp_path, + filename, + ) + + assert ( + "extra/bond/per/atom 50" + in text + ) + + assert ( + "extra/angle/per/atom 50" + in text + ) + + assert ( + "extra/dihedral/per/atom 50" + in text + ) + + assert ( + "extra/improper/per/atom 50" + in text + ) + + assert ( + "extra/special/per/atom 50" + in text + ) + + +# ============================================================================= +# LAMMPS settings +# ============================================================================= + + +def test_script_contains_core_lammps_settings( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = writer.write_post_eq_file( + make_simulation() + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "units", + "real", + ] in commands + + assert [ + "dimension", + "3", + ] in commands + + assert [ + "boundary", + "p", + "p", + "p", + ] in commands + + assert [ + "atom_style", + "full", + ] in commands + + +def test_script_contains_force_field_styles( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = writer.write_post_eq_file( + make_simulation() + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "angle_style", + "class2", + ] in commands + + assert [ + "bond_style", + "class2", + ] in commands + + assert [ + "dihedral_style", + "class2", + ] in commands + + assert [ + "improper_style", + "class2", + ] in commands + + +def test_script_contains_pair_settings( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = writer.write_post_eq_file( + make_simulation() + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "pair_style", + "lj/class2/coul/long", + "12.0", + ] in commands + + assert [ + "kspace_style", + "pppm", + "1.0e-4", + ] in commands + + assert [ + "pair_modify", + "mix", + "sixthpower", + ] in commands + + +def test_neighbor_is_written_when_defined( + tmp_path, +): + writer = make_writer_without_init( + tmp_path, + settings=make_settings( + neighbor="2.0 bin", + ), + ) + + filename = writer.write_post_eq_file( + make_simulation() + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "neighbor", + "2.0", + "bin", + ] in commands + + +def test_neigh_modify_is_written_when_defined( + tmp_path, +): + writer = make_writer_without_init( + tmp_path, + settings=make_settings( + neigh_modify=( + "delay 0 every 1 " + "check yes one 5000 " + "page 100000" + ), + ), + ) + + filename = writer.write_post_eq_file( + make_simulation() + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "neigh_modify", + "delay", + "0", + "every", + "1", + "check", + "yes", + "one", + "5000", + "page", + "100000", + ] in commands + + +def test_neighbor_commands_omitted_when_none( + tmp_path, +): + writer = make_writer_without_init( + tmp_path, + settings=make_settings( + neighbor=None, + neigh_modify=None, + ), + ) + + filename = writer.write_post_eq_file( + make_simulation() + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + keywords = [ + command[0] + for command in commands + ] + + assert "neighbor" not in keywords + assert "neigh_modify" not in keywords + + +# ============================================================================= +# Setup / minimization +# ============================================================================= + + +def test_script_contains_minimization( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = writer.write_post_eq_file( + make_simulation() + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "minimize", + "0.0", + "1.0e-8", + "1000", + "100000", + ] in commands + + +def test_script_resets_timestep( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = writer.write_post_eq_file( + make_simulation() + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "reset_timestep", + "0", + ] in commands + + +def test_script_uses_one_fs_timestep( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = writer.write_post_eq_file( + make_simulation() + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "timestep", + "1", + ] in commands + + +def test_script_uses_thermo_1000( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = writer.write_post_eq_file( + make_simulation() + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "thermo", + "1000", + ] in commands + + +def test_script_contains_expected_thermo_style( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = writer.write_post_eq_file( + make_simulation() + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "thermo_style", + "custom", + "step", + "time", + "temp", + "press", + "density", + "vol", + "pe", + "ke", + "etotal", + ] in commands + + +# ============================================================================= +# Dump / restart +# ============================================================================= + + +def test_script_contains_expected_dump( + tmp_path, +): + writer = make_writer_without_init( + tmp_path, + sim_name="Polymer", + ) + + filename = writer.write_post_eq_file( + make_simulation( + tag="500K" + ) + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "dump", + "dump_1", + "all", + "xyz", + "100", + "Polymer_500K_post_equilibration.xyz", + ] in commands + + +def test_script_sets_dump_type_labels( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = writer.write_post_eq_file( + make_simulation() + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "dump_modify", + "dump_1", + "types", + "labels", + ] in commands + + +def test_script_contains_rotating_restart_files( + tmp_path, +): + writer = make_writer_without_init( + tmp_path, + sim_name="Polymer", + ) + + filename = writer.write_post_eq_file( + make_simulation( + tag="500K" + ) + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "restart", + "100", + "Polymer_500K_post_equilibration_backup1.restart", + "Polymer_500K_post_equilibration_backup2.restart", + ] in commands + + +# ============================================================================= +# Stage 1 - NVT cool to 300 K +# ============================================================================= + + +def test_stage_1_nvt_cools_from_sim_temperature_to_300( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = writer.write_post_eq_file( + make_simulation( + temperature=523.0 + ) + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "fix", + "nvt_1", + "all", + "nvt", + "temp", + "523.0", + "300.0", + "100.0", + ] in commands + + +def test_stage_1_runs_100000_steps( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = writer.write_post_eq_file( + make_simulation() + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + run_commands = [ + command + for command in commands + if command[0] == "run" + ] + + assert run_commands[0] == [ + "run", + "100000", + ] + + +def test_stage_1_unfixes_nvt_1( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = writer.write_post_eq_file( + make_simulation() + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "unfix", + "nvt_1", + ] in commands + + +# ============================================================================= +# Stage 2 - NPT +# ============================================================================= + + +def test_stage_2_uses_npt_at_300_k_and_zero_pressure( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = writer.write_post_eq_file( + make_simulation() + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "fix", + "npt_2", + "all", + "npt", + "temp", + "300.0", + "300.0", + "100.0", + "iso", + "0.0", + "0.0", + "1000.0", + ] in commands + + +def test_stage_2_unfixes_npt_2( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = writer.write_post_eq_file( + make_simulation() + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "unfix", + "npt_2", + ] in commands + + +# ============================================================================= +# Stage 3 - final NVT +# ============================================================================= + + +def test_stage_3_final_nvt_is_at_300_k( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = writer.write_post_eq_file( + make_simulation() + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "fix", + "nvt_3", + "all", + "nvt", + "temp", + "300.0", + "300.0", + "100.0", + ] in commands + + +def test_stage_3_unfixes_nvt_3( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = writer.write_post_eq_file( + make_simulation() + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "unfix", + "nvt_3", + ] in commands + + +def test_exactly_three_run_commands_each_100000_steps( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = writer.write_post_eq_file( + make_simulation() + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + run_commands = [ + command + for command in commands + if command[0] == "run" + ] + + assert run_commands == [ + [ + "run", + "100000", + ], + [ + "run", + "100000", + ], + [ + "run", + "100000", + ], + ] + + +def test_exactly_three_active_fix_commands( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = writer.write_post_eq_file( + make_simulation() + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + fixes = [ + command + for command in commands + if command[0] == "fix" + ] + + assert len(fixes) == 3 + + assert fixes[0][1] == "nvt_1" + assert fixes[1][1] == "npt_2" + assert fixes[2][1] == "nvt_3" + + +# ============================================================================= +# Output / cleanup +# ============================================================================= + + +def test_script_undumps_trajectory( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = writer.write_post_eq_file( + make_simulation() + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "undump", + "dump_1", + ] in commands + + +def test_script_writes_post_equilibrated_data( + tmp_path, +): + writer = make_writer_without_init( + tmp_path, + sim_name="Polymer", + ) + + filename = writer.write_post_eq_file( + make_simulation( + tag="500K" + ) + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "write_data", + "Polymer_500K_post_equilibrated.data", + ] in commands + + +# ============================================================================= +# Workflow order +# ============================================================================= + + +def test_major_commands_are_in_expected_order( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = writer.write_post_eq_file( + make_simulation() + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + keywords = [ + command[0] + for command in commands + ] + + read_index = keywords.index( + "read_data" + ) + + minimize_index = keywords.index( + "minimize" + ) + + first_fix_index = keywords.index( + "fix" + ) + + first_run_index = keywords.index( + "run" + ) + + write_index = keywords.index( + "write_data" + ) + + assert ( + read_index + < minimize_index + < first_fix_index + < first_run_index + < write_index + ) + + +def test_fix_and_unfix_order_is_preserved( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = writer.write_post_eq_file( + make_simulation() + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + fix_sequence = [ + command[:2] + for command in commands + if command[0] in { + "fix", + "unfix", + } + ] + + assert fix_sequence == [ + [ + "fix", + "nvt_1", + ], + [ + "unfix", + "nvt_1", + ], + [ + "fix", + "npt_2", + ], + [ + "unfix", + "npt_2", + ], + [ + "fix", + "nvt_3", + ], + [ + "unfix", + "nvt_3", + ], + ] + + +# ============================================================================= +# Reaction-map separation +# ============================================================================= + + +def test_post_equilibration_script_contains_no_reaction_map_commands( + tmp_path, +): + """ + Post-equilibration consumes an already-reacted data file. + + Standard RXN_N.map files and optional RXN_N_with_delete_ids.map files + are reaction-stage inputs and must not be referenced here. + """ + writer = make_writer_without_init( + tmp_path + ) + + filename = writer.write_post_eq_file( + make_simulation() + ) + + text = read_script( + tmp_path, + filename, + ) + + assert "RXN_" not in text + + assert ".map" not in text + + +def test_post_equilibration_contains_no_bond_react_fix( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = writer.write_post_eq_file( + make_simulation() + ) + + text = read_script( + tmp_path, + filename, + ) + + assert "bond/react" not in text diff --git a/tests/unit/sim_setup/writers/test_pre_eq_writer.py b/tests/unit/sim_setup/writers/test_pre_eq_writer.py new file mode 100644 index 00000000..4dec901c --- /dev/null +++ b/tests/unit/sim_setup/writers/test_pre_eq_writer.py @@ -0,0 +1,1215 @@ +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from AutoREACTER.sim_setup.writers.pre_eq_writer import ( + PreEqWriter, +) + + +# ============================================================================= +# Helpers +# ============================================================================= + + +def make_settings( + *, + neighbor=None, + neigh_modify=None, +): + return SimpleNamespace( + units="real", + dimension="3", + boundary="p p p", + atom_style="full", + bond_style="class2", + angle_style="class2", + dihedral_style="class2", + improper_style="class2", + special_bonds="lj/coul 0 0 1", + pair_style="lj/class2/coul/long 12.0", + kspace_style="pppm 1.0e-4", + pair_modify="mix sixthpower", + neighbor=neighbor, + neigh_modify=neigh_modify, + ) + + +def make_simulation( + *, + tag="sim1", + temperature=500.0, +): + return SimpleNamespace( + tag=tag, + temperature=temperature, + ) + + +def make_writer_without_init( + tmp_path, + *, + settings=None, + sim_name="Test", +): + writer = object.__new__( + PreEqWriter + ) + + writer.settings = ( + settings + if settings is not None + else make_settings() + ) + + writer.out_dir = tmp_path + writer.sim_name = sim_name + + return writer + + +def read_script( + tmp_path, + filename, +): + return ( + tmp_path + / "2_pre_equilibration" + / filename + ).read_text( + encoding="utf-8" + ) + + +def command_lines(text): + """ + Return tokenized non-comment, non-empty LAMMPS commands. + + This avoids fragile assertions based on cosmetic column spacing. + """ + result = [] + + for line in text.splitlines(): + stripped = line.strip() + + if not stripped: + continue + + if stripped.startswith("#"): + continue + + result.append( + stripped.split() + ) + + return result + + +# ============================================================================= +# Constructor +# ============================================================================= + + +def test_constructor_stores_configuration( + tmp_path, + monkeypatch, +): + settings = make_settings() + + simulation = make_simulation() + + calls = [] + + monkeypatch.setattr( + PreEqWriter, + "write_pre_eq_file", + lambda self, simulation: + ( + calls.append(simulation) + or "in.test_pre_equilibration" + ), + ) + + writer = PreEqWriter( + out_dir=tmp_path, + settings=settings, + simulation=simulation, + sim_name="Polymer", + ) + + assert writer.settings is settings + assert writer.out_dir == tmp_path + assert writer.sim_name == "Polymer" + + assert calls == [ + simulation + ] + + assert ( + writer.in_pre_eq_file_name + == "in.test_pre_equilibration" + ) + + +# ============================================================================= +# Basic file generation +# ============================================================================= + + +def test_write_pre_eq_creates_stage_directory( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + writer.write_pre_eq_file( + make_simulation() + ) + + assert ( + tmp_path + / "2_pre_equilibration" + ).is_dir() + + +def test_write_pre_eq_creates_input_file( + tmp_path, +): + writer = make_writer_without_init( + tmp_path, + sim_name="Polymer", + ) + + filename = writer.write_pre_eq_file( + make_simulation( + tag="500K" + ) + ) + + assert filename == ( + "in.Polymer_500K_pre_equilibration" + ) + + assert ( + tmp_path + / "2_pre_equilibration" + / filename + ).is_file() + + +def test_write_pre_eq_returns_only_filename( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = writer.write_pre_eq_file( + make_simulation() + ) + + assert isinstance( + filename, + str, + ) + + assert "/" not in filename + assert "\\" not in filename + + +def test_script_header_contains_tag_and_autoreacter( + tmp_path, +): + writer = make_writer_without_init( + tmp_path, + sim_name="Epoxy", + ) + + filename = writer.write_pre_eq_file( + make_simulation( + tag="373K" + ) + ) + + text = read_script( + tmp_path, + filename, + ) + + assert ( + "# Epoxy_373K " + "Pre-Equilibration Script" + in text + ) + + assert ( + "Generated" + in text + ) + + assert ( + "by AutoREACTER" + in text + ) + + +# ============================================================================= +# LAMMPS settings +# ============================================================================= + + +def test_script_contains_core_lammps_settings( + tmp_path, +): + settings = make_settings() + + writer = make_writer_without_init( + tmp_path, + settings=settings, + ) + + filename = writer.write_pre_eq_file( + make_simulation() + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "units", + "real", + ] in commands + + assert [ + "dimension", + "3", + ] in commands + + assert [ + "boundary", + "p", + "p", + "p", + ] in commands + + assert [ + "atom_style", + "full", + ] in commands + + +def test_script_contains_force_field_styles( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = writer.write_pre_eq_file( + make_simulation() + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "bond_style", + "class2", + ] in commands + + assert [ + "angle_style", + "class2", + ] in commands + + assert [ + "dihedral_style", + "class2", + ] in commands + + assert [ + "improper_style", + "class2", + ] in commands + + +def test_script_contains_pair_settings( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = writer.write_pre_eq_file( + make_simulation() + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "pair_style", + "lj/class2/coul/long", + "12.0", + ] in commands + + assert [ + "kspace_style", + "pppm", + "1.0e-4", + ] in commands + + assert [ + "pair_modify", + "mix", + "sixthpower", + ] in commands + + +def test_optional_neighbor_setting_is_written( + tmp_path, +): + writer = make_writer_without_init( + tmp_path, + settings=make_settings( + neighbor="2.0 bin", + ), + ) + + filename = writer.write_pre_eq_file( + make_simulation() + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "neighbor", + "2.0", + "bin", + ] in commands + + +def test_optional_neigh_modify_setting_is_written( + tmp_path, +): + writer = make_writer_without_init( + tmp_path, + settings=make_settings( + neigh_modify=( + "delay 0 every 1 " + "check yes one 5000 " + "page 100000" + ), + ), + ) + + filename = writer.write_pre_eq_file( + make_simulation() + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "neigh_modify", + "delay", + "0", + "every", + "1", + "check", + "yes", + "one", + "5000", + "page", + "100000", + ] in commands + + +def test_optional_neighbor_commands_are_omitted_when_none( + tmp_path, +): + writer = make_writer_without_init( + tmp_path, + settings=make_settings( + neighbor=None, + neigh_modify=None, + ), + ) + + filename = writer.write_pre_eq_file( + make_simulation() + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + keywords = [ + command[0] + for command in commands + ] + + assert "neighbor" not in keywords + assert "neigh_modify" not in keywords + + +# ============================================================================= +# Input from densification +# ============================================================================= + + +def test_script_reads_shrinked_box_from_previous_stage( + tmp_path, +): + writer = make_writer_without_init( + tmp_path, + sim_name="Polymer", + ) + + filename = writer.write_pre_eq_file( + make_simulation( + tag="500K" + ) + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "read_data", + "Polymer_500K_shrinked_box.data", + "&", + ] in commands + + +def test_read_data_includes_extra_topology_capacity( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = writer.write_pre_eq_file( + make_simulation() + ) + + text = read_script( + tmp_path, + filename, + ) + + assert ( + "extra/bond/per/atom 50" + in text + ) + + assert ( + "extra/angle/per/atom 50" + in text + ) + + assert ( + "extra/dihedral/per/atom 50" + in text + ) + + assert ( + "extra/improper/per/atom 50" + in text + ) + + assert ( + "extra/special/per/atom 50" + in text + ) + + +# ============================================================================= +# Minimization / base simulation setup +# ============================================================================= + + +def test_script_contains_initial_minimization( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = writer.write_pre_eq_file( + make_simulation() + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "minimize", + "0.0", + "1.0e-8", + "1000", + "100000", + ] in commands + + +def test_script_resets_timestep( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = writer.write_pre_eq_file( + make_simulation() + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "reset_timestep", + "0", + ] in commands + + +def test_script_uses_one_fs_timestep( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = writer.write_pre_eq_file( + make_simulation() + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "timestep", + "1", + ] in commands + + +def test_script_uses_thermo_interval_1000( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = writer.write_pre_eq_file( + make_simulation() + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "thermo", + "1000", + ] in commands + + +def test_script_contains_expected_thermo_style( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = writer.write_pre_eq_file( + make_simulation() + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "thermo_style", + "custom", + "step", + "time", + "temp", + "press", + "density", + "vol", + "pe", + "ke", + "etotal", + ] in commands + + +# ============================================================================= +# Dump / restart +# ============================================================================= + + +def test_script_contains_expected_dump( + tmp_path, +): + writer = make_writer_without_init( + tmp_path, + sim_name="Polymer", + ) + + filename = writer.write_pre_eq_file( + make_simulation( + tag="298K" + ) + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "dump", + "dump_2", + "all", + "xyz", + "1000", + "Polymer_298K_pre_equilibration.xyz", + ] in commands + + +def test_script_sets_dump_type_labels( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = writer.write_pre_eq_file( + make_simulation() + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "dump_modify", + "dump_2", + "types", + "labels", + ] in commands + + +def test_script_contains_two_restart_files( + tmp_path, +): + writer = make_writer_without_init( + tmp_path, + sim_name="Polymer", + ) + + filename = writer.write_pre_eq_file( + make_simulation( + tag="500K" + ) + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "restart", + "100", + "Polymer_500K_pre_equilibration_backup1.restart", + "Polymer_500K_pre_equilibration_backup2.restart", + ] in commands + + +# ============================================================================= +# Stage 1 - NVT temperature ramp +# ============================================================================= + + +def test_stage_1_ramps_from_298_15_to_target_temperature( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = writer.write_pre_eq_file( + make_simulation( + temperature=523.0 + ) + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "fix", + "nvt_1", + "all", + "nvt", + "temp", + "298.15", + "523.0", + "100.0", + ] in commands + + +def test_stage_1_runs_50000_steps( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = writer.write_pre_eq_file( + make_simulation() + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + run_commands = [ + command + for command in commands + if command[0] == "run" + ] + + assert run_commands[0] == [ + "run", + "50000", + ] + + +def test_stage_1_unfixes_nvt_1( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = writer.write_pre_eq_file( + make_simulation() + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "unfix", + "nvt_1", + ] in commands + + +# ============================================================================= +# Current Stage 2 behavior +# ============================================================================= + + +def test_stage_2_is_final_nvt_at_target_temperature( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = writer.write_pre_eq_file( + make_simulation( + temperature=373.0 + ) + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "fix", + "nvt_3", + "all", + "nvt", + "temp", + "373.0", + "373.0", + "100.0", + ] in commands + + +def test_stage_2_runs_50000_steps( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = writer.write_pre_eq_file( + make_simulation() + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + run_commands = [ + command + for command in commands + if command[0] == "run" + ] + + assert run_commands == [ + [ + "run", + "50000", + ], + [ + "run", + "50000", + ], + ] + + +def test_stage_2_unfixes_nvt_3( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = writer.write_pre_eq_file( + make_simulation() + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "unfix", + "nvt_3", + ] in commands + + +def test_current_script_contains_no_active_npt_fix( + tmp_path, +): + """ + Characterization of CURRENT runtime behavior. + + The docstring/comments still refer to an NPT stage, but the NPT code + itself is commented out in production. Generated scripts therefore + contain no active NPT command. + """ + writer = make_writer_without_init( + tmp_path + ) + + filename = writer.write_pre_eq_file( + make_simulation() + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + active_npt_commands = [ + command + for command in commands + if "npt" in command + ] + + assert active_npt_commands == [] + + +def test_current_script_has_exactly_two_active_fix_commands( + tmp_path, +): + """ + Current equilibration workflow contains: + nvt_1 + nvt_3 + + No active npt_2. + """ + writer = make_writer_without_init( + tmp_path + ) + + filename = writer.write_pre_eq_file( + make_simulation() + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + fix_commands = [ + command + for command in commands + if command[0] == "fix" + ] + + assert len(fix_commands) == 2 + + assert fix_commands[0][1] == "nvt_1" + assert fix_commands[1][1] == "nvt_3" + + +# ============================================================================= +# Cleanup / output +# ============================================================================= + + +def test_script_undumps_dump_2( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = writer.write_pre_eq_file( + make_simulation() + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "undump", + "dump_2", + ] in commands + + +def test_script_writes_pre_equilibrated_data( + tmp_path, +): + writer = make_writer_without_init( + tmp_path, + sim_name="Polymer", + ) + + filename = writer.write_pre_eq_file( + make_simulation( + tag="500K" + ) + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "write_data", + "Polymer_500K_pre_equilibrated.data", + ] in commands + + +# ============================================================================= +# Ordering / workflow characterization +# ============================================================================= + + +def test_major_commands_are_in_expected_order( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = writer.write_pre_eq_file( + make_simulation() + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + keywords = [ + command[0] + for command in commands + ] + + read_index = keywords.index( + "read_data" + ) + + minimize_index = keywords.index( + "minimize" + ) + + first_fix_index = keywords.index( + "fix" + ) + + first_run_index = keywords.index( + "run" + ) + + write_index = keywords.index( + "write_data" + ) + + assert ( + read_index + < minimize_index + < first_fix_index + < first_run_index + < write_index + ) + + +def test_two_nvt_stages_use_simulation_temperature( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = writer.write_pre_eq_file( + make_simulation( + temperature=600.0 + ) + ) + + commands = command_lines( + read_script( + tmp_path, + filename, + ) + ) + + nvt_1 = next( + command + for command in commands + if ( + command[0] == "fix" + and command[1] == "nvt_1" + ) + ) + + nvt_3 = next( + command + for command in commands + if ( + command[0] == "fix" + and command[1] == "nvt_3" + ) + ) + + assert nvt_1 == [ + "fix", + "nvt_1", + "all", + "nvt", + "temp", + "298.15", + "600.0", + "100.0", + ] + + assert nvt_3 == [ + "fix", + "nvt_3", + "all", + "nvt", + "temp", + "600.0", + "600.0", + "100.0", + ] \ No newline at end of file diff --git a/tests/unit/sim_setup/writers/test_rxn_first_stage_writer.py b/tests/unit/sim_setup/writers/test_rxn_first_stage_writer.py new file mode 100644 index 00000000..60a37cc7 --- /dev/null +++ b/tests/unit/sim_setup/writers/test_rxn_first_stage_writer.py @@ -0,0 +1,2167 @@ +from pathlib import Path +from types import SimpleNamespace + +import pytest + +import AutoREACTER.sim_setup.writers.rxn_first_stage_writer as rxn_module +from AutoREACTER.sim_setup.writers.rxn_first_stage_writer import ( + RxnFirstStageWriter, +) + + +# ============================================================================= +# Helpers +# ============================================================================= + + +def make_settings( + *, + neighbor=None, + neigh_modify=None, +): + return SimpleNamespace( + units="real", + dimension="3", + boundary="p p p", + atom_style="full", + bond_style="class2", + angle_style="class2", + dihedral_style="class2", + improper_style="class2", + special_bonds="lj/coul 0 0 1", + pair_style="lj/class2/coul/long 12.0", + kspace_style="pppm 1.0e-4", + pair_modify="mix sixthpower", + neighbor=neighbor, + neigh_modify=neigh_modify, + ) + + +def make_simulation( + *, + tag="sim1", + temperature=500.0, +): + return SimpleNamespace( + tag=tag, + temperature=temperature, + ) + + +def make_template( + *, + reaction_id=1, + activity_stats=True, + pre_reaction_file=None, + post_reaction_file=None, + map_file=None, + map_file_with_delete_ids=None, +): + return SimpleNamespace( + reaction_id=reaction_id, + activity_stats=activity_stats, + pre_reaction_file=pre_reaction_file, + post_reaction_file=post_reaction_file, + map_file=map_file, + map_file_with_delete_ids=map_file_with_delete_ids, + ) + + +def make_reacter_files( + *, + template_files=None, +): + return SimpleNamespace( + template_files=list( + template_files or [] + ), + ) + + +def make_real_template( + tmp_path, + *, + reaction_id=1, + activity_stats=True, + with_delete_map=False, +): + pre = ( + tmp_path + / f"template_pre_{reaction_id}.molecule" + ) + + post = ( + tmp_path + / f"template_post_{reaction_id}.molecule" + ) + + standard_map = ( + tmp_path + / f"RXN_{reaction_id}.map" + ) + + pre.write_text( + "PRE", + encoding="utf-8", + ) + + post.write_text( + "POST", + encoding="utf-8", + ) + + standard_map.write_text( + "STANDARD MAP", + encoding="utf-8", + ) + + delete_map = None + + if with_delete_map: + delete_map = ( + tmp_path + / ( + f"RXN_{reaction_id}" + "_with_delete_ids.map" + ) + ) + + delete_map.write_text( + "DELETE MAP", + encoding="utf-8", + ) + + template = make_template( + reaction_id=reaction_id, + activity_stats=activity_stats, + pre_reaction_file=pre, + post_reaction_file=post, + map_file=standard_map, + map_file_with_delete_ids=delete_map, + ) + + return ( + template, + pre, + post, + standard_map, + delete_map, + ) + + +def make_writer_without_init( + tmp_path, + *, + settings=None, + reacter_files=None, + sim_name="Test", +): + writer = object.__new__( + RxnFirstStageWriter + ) + + writer.settings = ( + settings + if settings is not None + else make_settings() + ) + + writer.out_dir = tmp_path + writer.sim_name = sim_name + + writer.reacter_files = ( + reacter_files + if reacter_files is not None + else make_reacter_files() + ) + + return writer + + +def read_script( + tmp_path, + filename, +): + return ( + tmp_path + / "3_reaction_first_stage" + / filename + ).read_text( + encoding="utf-8" + ) + + +def active_lines(text): + """ + Return non-empty, non-comment lines. + + Important here because comments deliberately mention the optional + _with_delete_ids.map filename. + """ + result = [] + + for line in text.splitlines(): + stripped = line.strip() + + if not stripped: + continue + + if stripped.startswith("#"): + continue + + result.append( + stripped + ) + + return result + + +def command_tokens(text): + return [ + line.split() + for line in active_lines(text) + ] + + +# ============================================================================= +# Constructor +# ============================================================================= + + +def test_constructor_stores_configuration( + tmp_path, + monkeypatch, +): + settings = make_settings() + + reacter_files = ( + make_reacter_files() + ) + + simulation = ( + make_simulation() + ) + + calls = [] + + monkeypatch.setattr( + RxnFirstStageWriter, + "write_first_stage_reaction_files", + lambda self, simulation: + ( + calls.append(simulation) + or "in.test_reaction" + ), + ) + + writer = RxnFirstStageWriter( + out_dir=tmp_path, + settings=settings, + reacter_files=reacter_files, + simulation=simulation, + sim_name="Polymer", + ) + + assert writer.settings is settings + + assert ( + writer.reacter_files + is reacter_files + ) + + assert writer.out_dir == tmp_path + + assert ( + writer.sim_name + == "Polymer" + ) + + assert calls == [ + simulation + ] + + assert ( + writer.first_stage_file_name + == "in.test_reaction" + ) + + +# ============================================================================= +# Basic file generation +# ============================================================================= + + +def test_writer_creates_first_reaction_stage_directory( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + writer.write_first_stage_reaction_files( + make_simulation() + ) + + assert ( + tmp_path + / "3_reaction_first_stage" + ).is_dir() + + +def test_writer_creates_expected_input_file( + tmp_path, +): + writer = make_writer_without_init( + tmp_path, + sim_name="Polymer", + ) + + filename = ( + writer + .write_first_stage_reaction_files( + make_simulation( + tag="500K" + ) + ) + ) + + assert filename == ( + "in.Polymer_500K_reaction" + ) + + assert ( + tmp_path + / "3_reaction_first_stage" + / filename + ).is_file() + + +def test_writer_returns_filename_only( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = ( + writer + .write_first_stage_reaction_files( + make_simulation() + ) + ) + + assert isinstance( + filename, + str, + ) + + assert "/" not in filename + assert "\\" not in filename + + +def test_script_header_contains_tag( + tmp_path, +): + writer = make_writer_without_init( + tmp_path, + sim_name="Epoxy", + ) + + filename = ( + writer + .write_first_stage_reaction_files( + make_simulation( + tag="373K" + ) + ) + ) + + text = read_script( + tmp_path, + filename, + ) + + assert ( + "# Epoxy_373K " + "First Reaction Stage Script" + in text + ) + + assert ( + "by AutoREACTER" + in text + ) + + +# ============================================================================= +# LAMMPS settings +# ============================================================================= + + +def test_script_contains_initialization_settings( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = ( + writer + .write_first_stage_reaction_files( + make_simulation() + ) + ) + + commands = command_tokens( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "units", + "real", + ] in commands + + assert [ + "dimension", + "3", + ] in commands + + assert [ + "boundary", + "p", + "p", + "p", + ] in commands + + assert [ + "atom_style", + "full", + ] in commands + + +def test_script_contains_force_field_styles( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = ( + writer + .write_first_stage_reaction_files( + make_simulation() + ) + ) + + commands = command_tokens( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "angle_style", + "class2", + ] in commands + + assert [ + "bond_style", + "class2", + ] in commands + + assert [ + "dihedral_style", + "class2", + ] in commands + + assert [ + "improper_style", + "class2", + ] in commands + + +def test_script_contains_pair_settings( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = ( + writer + .write_first_stage_reaction_files( + make_simulation() + ) + ) + + commands = command_tokens( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "pair_style", + "lj/class2/coul/long", + "12.0", + ] in commands + + assert [ + "kspace_style", + "pppm", + "1.0e-4", + ] in commands + + assert [ + "pair_modify", + "mix", + "sixthpower", + ] in commands + + +def test_optional_neighbor_settings_are_written( + tmp_path, +): + writer = make_writer_without_init( + tmp_path, + settings=make_settings( + neighbor="2.0 bin", + neigh_modify=( + "delay 0 every 1 " + "check yes" + ), + ), + ) + + filename = ( + writer + .write_first_stage_reaction_files( + make_simulation() + ) + ) + + commands = command_tokens( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "neighbor", + "2.0", + "bin", + ] in commands + + assert [ + "neigh_modify", + "delay", + "0", + "every", + "1", + "check", + "yes", + ] in commands + + +def test_optional_neighbor_settings_omitted_when_none( + tmp_path, +): + writer = make_writer_without_init( + tmp_path, + settings=make_settings( + neighbor=None, + neigh_modify=None, + ), + ) + + filename = ( + writer + .write_first_stage_reaction_files( + make_simulation() + ) + ) + + commands = command_tokens( + read_script( + tmp_path, + filename, + ) + ) + + keywords = [ + command[0] + for command in commands + ] + + assert "neighbor" not in keywords + assert "neigh_modify" not in keywords + + +# ============================================================================= +# Input data +# ============================================================================= + + +def test_reads_pre_equilibrated_data( + tmp_path, +): + writer = make_writer_without_init( + tmp_path, + sim_name="Polymer", + ) + + filename = ( + writer + .write_first_stage_reaction_files( + make_simulation( + tag="500K" + ) + ) + ) + + commands = command_tokens( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "read_data", + "Polymer_500K_pre_equilibrated.data", + "&", + ] in commands + + +def test_read_data_reserves_extra_topology_capacity( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = ( + writer + .write_first_stage_reaction_files( + make_simulation() + ) + ) + + text = read_script( + tmp_path, + filename, + ) + + assert ( + "extra/bond/per/atom 50" + in text + ) + + assert ( + "extra/angle/per/atom 50" + in text + ) + + assert ( + "extra/dihedral/per/atom 50" + in text + ) + + assert ( + "extra/improper/per/atom 50" + in text + ) + + assert ( + "extra/special/per/atom 50" + in text + ) + + +# ============================================================================= +# Minimization / velocity / timestep +# ============================================================================= + + +def test_script_contains_expected_minimization( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = ( + writer + .write_first_stage_reaction_files( + make_simulation() + ) + ) + + commands = command_tokens( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "minimize", + "1.0e-4", + "1.0e-6", + "1000", + "10000", + ] in commands + + +def test_velocity_uses_temperature_and_random_seed( + tmp_path, + monkeypatch, +): + monkeypatch.setattr( + rxn_module.random, + "randint", + lambda a, b: 123456, + ) + + writer = make_writer_without_init( + tmp_path + ) + + filename = ( + writer + .write_first_stage_reaction_files( + make_simulation( + temperature=523.0 + ) + ) + ) + + commands = command_tokens( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "velocity", + "all", + "create", + "523.0", + "123456", + "dist", + "gaussian", + ] in commands + + +def test_script_uses_one_fs_timestep( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = ( + writer + .write_first_stage_reaction_files( + make_simulation() + ) + ) + + commands = command_tokens( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "timestep", + "1.0", + ] in commands + + +def test_script_uses_thermo_interval_100( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = ( + writer + .write_first_stage_reaction_files( + make_simulation() + ) + ) + + commands = command_tokens( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "thermo", + "100", + ] in commands + + +def test_script_resets_timestep( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = ( + writer + .write_first_stage_reaction_files( + make_simulation() + ) + ) + + commands = command_tokens( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "reset_timestep", + "0", + ] in commands + + +# ============================================================================= +# Reaction template definitions +# ============================================================================= + + +def test_active_template_writes_pre_and_post_molecule_commands( + tmp_path, +): + ( + template, + pre, + post, + _, + _, + ) = make_real_template( + tmp_path, + reaction_id=7, + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + filename = ( + writer + .write_first_stage_reaction_files( + make_simulation() + ) + ) + + commands = command_tokens( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "molecule", + "mol_pre_7", + pre.name, + ] in commands + + assert [ + "molecule", + "mol_post_7", + post.name, + ] in commands + + +def test_inactive_template_is_not_written( + tmp_path, +): + ( + template, + _, + _, + _, + _, + ) = make_real_template( + tmp_path, + reaction_id=9, + activity_stats=False, + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + filename = ( + writer + .write_first_stage_reaction_files( + make_simulation() + ) + ) + + text = read_script( + tmp_path, + filename, + ) + + assert "mol_pre_9" not in text + assert "mol_post_9" not in text + assert "rxn_stp_9" not in text + + +def test_missing_activity_stats_defaults_to_active( + tmp_path, +): + ( + template, + _, + _, + _, + _, + ) = make_real_template( + tmp_path, + reaction_id=3, + ) + + del template.activity_stats + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + filename = ( + writer + .write_first_stage_reaction_files( + make_simulation() + ) + ) + + text = read_script( + tmp_path, + filename, + ) + + assert "mol_pre_3" in text + assert "rxn_stp_3" in text + + +def test_multiple_active_templates_are_written( + tmp_path, +): + template_1, *_ = ( + make_real_template( + tmp_path, + reaction_id=1, + ) + ) + + template_2, *_ = ( + make_real_template( + tmp_path, + reaction_id=2, + ) + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template_1, + template_2, + ] + ) + ), + ) + + filename = ( + writer + .write_first_stage_reaction_files( + make_simulation() + ) + ) + + text = read_script( + tmp_path, + filename, + ) + + assert "rxn_stp_1" in text + assert "rxn_stp_2" in text + + +# ============================================================================= +# Standard map behavior +# ============================================================================= + + +def test_standard_map_is_used_when_no_delete_map_exists( + tmp_path, +): + ( + template, + _, + _, + standard_map, + _, + ) = make_real_template( + tmp_path, + reaction_id=4, + with_delete_map=False, + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + filename = ( + writer + .write_first_stage_reaction_files( + make_simulation() + ) + ) + + lines = active_lines( + read_script( + tmp_path, + filename, + ) + ) + + react_line = next( + line + for line in lines + if "rxn_stp_4" in line + ) + + assert ( + standard_map.name + in react_line + ) + + +def test_standard_map_remains_default_even_when_delete_map_exists( + tmp_path, +): + """ + Core AutoREACTER behavior. + + The supplementary DeleteIDs map is supplied to the user, but the generated + LAMMPS script MUST continue to use RXN_N.map by default. + """ + ( + template, + _, + _, + standard_map, + delete_map, + ) = make_real_template( + tmp_path, + reaction_id=5, + with_delete_map=True, + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + filename = ( + writer + .write_first_stage_reaction_files( + make_simulation() + ) + ) + + lines = active_lines( + read_script( + tmp_path, + filename, + ) + ) + + react_line = next( + line + for line in lines + if "rxn_stp_5" in line + ) + + assert ( + standard_map.name + in react_line + ) + + assert ( + delete_map.name + not in react_line + ) + + +def test_delete_map_is_never_automatically_selected( + tmp_path, +): + ( + template, + _, + _, + _, + delete_map, + ) = make_real_template( + tmp_path, + reaction_id=8, + with_delete_map=True, + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + filename = ( + writer + .write_first_stage_reaction_files( + make_simulation() + ) + ) + + active = "\n".join( + active_lines( + read_script( + tmp_path, + filename, + ) + ) + ) + + assert ( + delete_map.name + not in active + ) + + +def test_reaction_command_contains_expected_options( + tmp_path, +): + ( + template, + _, + _, + standard_map, + _, + ) = make_real_template( + tmp_path, + reaction_id=2, + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + filename = ( + writer + .write_first_stage_reaction_files( + make_simulation() + ) + ) + + lines = active_lines( + read_script( + tmp_path, + filename, + ) + ) + + react_line = next( + line + for line in lines + if "rxn_stp_2" in line + ) + + tokens = react_line.split() + + assert tokens[:2] == [ + "react", + "rxn_stp_2", + ] + + assert "all" in tokens + assert "1" in tokens + assert "0.0" in tokens + assert "3.5" in tokens + + assert ( + "mol_pre_2" + in tokens + ) + + assert ( + "mol_post_2" + in tokens + ) + + assert ( + standard_map.name + in tokens + ) + + assert tokens[-4:] == [ + "stabilize_steps", + "60", + "rescale_charges", + "yes", + ] + + +# ============================================================================= +# fix bond/react +# ============================================================================= + + +def test_fix_bond_react_header_is_written( + tmp_path, +): + template, *_ = ( + make_real_template( + tmp_path, + reaction_id=1, + ) + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + filename = ( + writer + .write_first_stage_reaction_files( + make_simulation() + ) + ) + + text = read_script( + tmp_path, + filename, + ) + + assert ( + "rxns all bond/react " + "stabilization yes " + "statted_grp 0.03" + in text + ) + + +def test_multiple_reactions_are_joined_into_same_fix( + tmp_path, +): + template_1, *_ = ( + make_real_template( + tmp_path, + reaction_id=1, + ) + ) + + template_2, *_ = ( + make_real_template( + tmp_path, + reaction_id=2, + ) + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template_1, + template_2, + ] + ) + ), + ) + + filename = ( + writer + .write_first_stage_reaction_files( + make_simulation() + ) + ) + + text = read_script( + tmp_path, + filename, + ) + + assert "rxn_stp_1" in text + assert "rxn_stp_2" in text + + assert ( + " & " + in text + ) + + +# ============================================================================= +# Thermostat / output +# ============================================================================= + + +def test_active_nvt_uses_simulation_temperature( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = ( + writer + .write_first_stage_reaction_files( + make_simulation( + temperature=373.0 + ) + ) + ) + + commands = command_tokens( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "fix", + "1", + "statted_grp_REACT", + "nvt", + "temp", + "373.0", + "373.0", + "100.0", + ] in commands + + +def test_npt_command_is_currently_commented_out( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = ( + writer + .write_first_stage_reaction_files( + make_simulation() + ) + ) + + active = active_lines( + read_script( + tmp_path, + filename, + ) + ) + + assert not any( + " npt " in f" {line} " + for line in active + ) + + +def test_thermo_style_contains_reaction_output( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = ( + writer + .write_first_stage_reaction_files( + make_simulation() + ) + ) + + commands = command_tokens( + read_script( + tmp_path, + filename, + ) + ) + + thermo_style = next( + command + for command in commands + if command[0] == "thermo_style" + ) + + assert "custom" in thermo_style + assert "step" in thermo_style + assert "time" in thermo_style + assert "temp" in thermo_style + assert "f_rxns[*]" in thermo_style + assert "press" in thermo_style + assert "density" in thermo_style + assert "vol" in thermo_style + assert "pe" in thermo_style + assert "ke" in thermo_style + assert "etotal" in thermo_style + + +def test_reaction_stage_runs_one_million_steps( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = ( + writer + .write_first_stage_reaction_files( + make_simulation() + ) + ) + + commands = command_tokens( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "run", + "1000000", + ] in commands + + +def test_output_names_use_first_stage_range( + tmp_path, +): + writer = make_writer_without_init( + tmp_path, + sim_name="Polymer", + ) + + filename = ( + writer + .write_first_stage_reaction_files( + make_simulation( + tag="500K" + ) + ) + ) + + text = read_script( + tmp_path, + filename, + ) + + output_base = ( + "Polymer_500K" + "_reacted_0M-1M_3.5A" + ) + + assert ( + f"{output_base}.xyz" + in text + ) + + assert ( + f"{output_base}_backup1.restart" + in text + ) + + assert ( + f"{output_base}_backup2.restart" + in text + ) + + assert ( + f"{output_base}.restart" + in text + ) + + assert ( + f"{output_base}.data" + in text + ) + + +def test_write_data_uses_nofix( + tmp_path, +): + writer = make_writer_without_init( + tmp_path + ) + + filename = ( + writer + .write_first_stage_reaction_files( + make_simulation() + ) + ) + + commands = command_tokens( + read_script( + tmp_path, + filename, + ) + ) + + write_commands = [ + command + for command in commands + if command[0] == "write_data" + ] + + assert len( + write_commands + ) == 1 + + assert ( + write_commands[0][-1] + == "nofix" + ) + + +# ============================================================================= +# _copy_required_files +# ============================================================================= + + +def test_copy_required_files_copies_standard_map_and_templates( + tmp_path, +): + ( + template, + pre, + post, + standard_map, + _, + ) = make_real_template( + tmp_path, + reaction_id=1, + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + dest = ( + tmp_path / "reaction" + ) + + dest.mkdir() + + writer._copy_required_files( + dest + ) + + assert ( + dest / standard_map.name + ).is_file() + + assert ( + dest / pre.name + ).is_file() + + assert ( + dest / post.name + ).is_file() + + +def test_copy_required_files_copies_optional_delete_map_when_present( + tmp_path, +): + """ + Supplementary DeleteIDs map is copied for the user. + + It is NOT automatically used by the generated LAMMPS script. + """ + ( + template, + _, + _, + standard_map, + delete_map, + ) = make_real_template( + tmp_path, + reaction_id=2, + with_delete_map=True, + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + dest = ( + tmp_path / "reaction" + ) + + dest.mkdir() + + writer._copy_required_files( + dest + ) + + assert ( + dest / standard_map.name + ).is_file() + + assert ( + dest / delete_map.name + ).is_file() + + +def test_copy_required_files_without_delete_map_still_succeeds( + tmp_path, +): + ( + template, + pre, + post, + standard_map, + delete_map, + ) = make_real_template( + tmp_path, + reaction_id=3, + with_delete_map=False, + ) + + assert delete_map is None + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + dest = ( + tmp_path / "reaction" + ) + + dest.mkdir() + + writer._copy_required_files( + dest + ) + + assert ( + dest / standard_map.name + ).is_file() + + assert ( + dest / pre.name + ).is_file() + + assert ( + dest / post.name + ).is_file() + + +def test_copy_required_files_missing_delete_map_attribute_still_succeeds( + tmp_path, +): + ( + template, + _, + _, + standard_map, + _, + ) = make_real_template( + tmp_path, + reaction_id=4, + ) + + del ( + template + .map_file_with_delete_ids + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + dest = ( + tmp_path / "reaction" + ) + + dest.mkdir() + + writer._copy_required_files( + dest + ) + + assert ( + dest / standard_map.name + ).is_file() + + +def test_copy_required_files_missing_standard_map_raises( + tmp_path, +): + pre = ( + tmp_path / "pre.molecule" + ) + + post = ( + tmp_path / "post.molecule" + ) + + pre.write_text( + "PRE", + encoding="utf-8", + ) + + post.write_text( + "POST", + encoding="utf-8", + ) + + missing_map = ( + tmp_path / "missing.map" + ) + + template = make_template( + reaction_id=1, + pre_reaction_file=pre, + post_reaction_file=post, + map_file=missing_map, + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + dest = ( + tmp_path / "dest" + ) + + dest.mkdir() + + with pytest.raises( + FileNotFoundError, + match=( + "Required reaction file " + "not found" + ), + ): + writer._copy_required_files( + dest + ) + + +def test_copy_required_files_missing_pre_template_raises( + tmp_path, +): + post = ( + tmp_path / "post.molecule" + ) + + map_file = ( + tmp_path / "RXN_1.map" + ) + + post.write_text( + "POST", + encoding="utf-8", + ) + + map_file.write_text( + "MAP", + encoding="utf-8", + ) + + template = make_template( + reaction_id=1, + pre_reaction_file=( + tmp_path + / "missing_pre.molecule" + ), + post_reaction_file=post, + map_file=map_file, + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + dest = ( + tmp_path / "dest" + ) + + dest.mkdir() + + with pytest.raises( + FileNotFoundError, + ): + writer._copy_required_files( + dest + ) + + +def test_copy_required_files_missing_post_template_raises( + tmp_path, +): + pre = ( + tmp_path / "pre.molecule" + ) + + map_file = ( + tmp_path / "RXN_1.map" + ) + + pre.write_text( + "PRE", + encoding="utf-8", + ) + + map_file.write_text( + "MAP", + encoding="utf-8", + ) + + template = make_template( + reaction_id=1, + pre_reaction_file=pre, + post_reaction_file=( + tmp_path + / "missing_post.molecule" + ), + map_file=map_file, + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + dest = ( + tmp_path / "dest" + ) + + dest.mkdir() + + with pytest.raises( + FileNotFoundError, + ): + writer._copy_required_files( + dest + ) + + +def test_copy_required_files_missing_optional_delete_map_path_raises_when_declared( + tmp_path, +): + """ + Current behavior: + + The delete map is optional in the sense that the attribute may be None. + + But if metadata explicitly points to a delete map, that declared file + must actually exist for it to be copied. + """ + ( + template, + _, + _, + _, + _, + ) = make_real_template( + tmp_path, + reaction_id=6, + ) + + template.map_file_with_delete_ids = ( + tmp_path + / "RXN_6_with_delete_ids.map" + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + dest = ( + tmp_path / "dest" + ) + + dest.mkdir() + + with pytest.raises( + FileNotFoundError, + match=( + "Required reaction file " + "not found" + ), + ): + writer._copy_required_files( + dest + ) + + +def test_copy_required_files_ignores_inactive_templates( + tmp_path, +): + template = make_template( + reaction_id=99, + activity_stats=False, + pre_reaction_file=( + tmp_path + / "missing_pre.molecule" + ), + post_reaction_file=( + tmp_path + / "missing_post.molecule" + ), + map_file=( + tmp_path + / "missing.map" + ), + map_file_with_delete_ids=( + tmp_path + / "missing_delete.map" + ), + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + dest = ( + tmp_path / "dest" + ) + + dest.mkdir() + + writer._copy_required_files( + dest + ) + + assert list( + dest.iterdir() + ) == [] + + +# ============================================================================= +# Full delete-map integration +# ============================================================================= + + +def test_delete_map_is_supplied_but_standard_map_remains_in_generated_script( + tmp_path, +): + """ + End-to-end characterization of the intended map behavior. + + Both maps are copied: + RXN_10.map + RXN_10_with_delete_ids.map + + But the generated fix bond/react command references: + RXN_10.map + """ + ( + template, + pre, + post, + standard_map, + delete_map, + ) = make_real_template( + tmp_path, + reaction_id=10, + with_delete_map=True, + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + sim_name="Polymer", + ) + + filename = ( + writer + .write_first_stage_reaction_files( + make_simulation( + tag="500K" + ) + ) + ) + + stage_dir = ( + tmp_path + / "3_reaction_first_stage" + ) + + assert ( + stage_dir / pre.name + ).is_file() + + assert ( + stage_dir / post.name + ).is_file() + + assert ( + stage_dir / standard_map.name + ).is_file() + + assert ( + stage_dir / delete_map.name + ).is_file() + + text = read_script( + tmp_path, + filename, + ) + + active = "\n".join( + active_lines(text) + ) + + assert ( + standard_map.name + in active + ) + + assert ( + delete_map.name + not in active + ) \ No newline at end of file diff --git a/tests/unit/sim_setup/writers/test_rxn_second_stage_writer.py b/tests/unit/sim_setup/writers/test_rxn_second_stage_writer.py new file mode 100644 index 00000000..c3a594d4 --- /dev/null +++ b/tests/unit/sim_setup/writers/test_rxn_second_stage_writer.py @@ -0,0 +1,2402 @@ +from types import SimpleNamespace + +import pytest + +import AutoREACTER.sim_setup.writers.rxn_second_stage_writer as rxn_module +from AutoREACTER.sim_setup.writers.rxn_second_stage_writer import ( + RxnSecondStageWriter, +) + + +# ============================================================================= +# Helpers +# ============================================================================= + + +def make_settings( + *, + neighbor=None, + neigh_modify=None, +): + return SimpleNamespace( + units="real", + dimension="3", + boundary="p p p", + atom_style="full", + bond_style="class2", + angle_style="class2", + dihedral_style="class2", + improper_style="class2", + special_bonds="lj/coul 0 0 1", + pair_style="lj/class2/coul/long 12.0", + kspace_style="pppm 1.0e-4", + pair_modify="mix sixthpower", + neighbor=neighbor, + neigh_modify=neigh_modify, + ) + + +def make_simulation( + *, + tag="sim1", + temperature=500.0, +): + return SimpleNamespace( + tag=tag, + temperature=temperature, + ) + + +def make_template( + *, + reaction_id=1, + activity_stats=True, + pre_reaction_file=None, + post_reaction_file=None, + map_file=None, + map_file_with_delete_ids=None, +): + return SimpleNamespace( + reaction_id=reaction_id, + activity_stats=activity_stats, + pre_reaction_file=pre_reaction_file, + post_reaction_file=post_reaction_file, + map_file=map_file, + map_file_with_delete_ids=map_file_with_delete_ids, + ) + + +def make_reacter_files( + *, + template_files=None, +): + return SimpleNamespace( + template_files=list( + template_files or [] + ), + ) + + +def make_real_template( + tmp_path, + *, + reaction_id=1, + activity_stats=True, + with_delete_map=False, +): + pre = ( + tmp_path + / f"template_pre_{reaction_id}.molecule" + ) + + post = ( + tmp_path + / f"template_post_{reaction_id}.molecule" + ) + + standard_map = ( + tmp_path + / f"RXN_{reaction_id}.map" + ) + + pre.write_text( + "PRE", + encoding="utf-8", + ) + + post.write_text( + "POST", + encoding="utf-8", + ) + + standard_map.write_text( + "STANDARD MAP", + encoding="utf-8", + ) + + delete_map = None + + if with_delete_map: + delete_map = ( + tmp_path + / ( + f"RXN_{reaction_id}" + "_with_delete_ids.map" + ) + ) + + delete_map.write_text( + "DELETE MAP", + encoding="utf-8", + ) + + template = make_template( + reaction_id=reaction_id, + activity_stats=activity_stats, + pre_reaction_file=pre, + post_reaction_file=post, + map_file=standard_map, + map_file_with_delete_ids=delete_map, + ) + + return ( + template, + pre, + post, + standard_map, + delete_map, + ) + + +def make_writer_without_init( + tmp_path, + *, + settings=None, + reacter_files=None, + sim_name="Test", +): + writer = object.__new__( + RxnSecondStageWriter + ) + + writer.settings = ( + settings + if settings is not None + else make_settings() + ) + + writer.out_dir = tmp_path + writer.sim_name = sim_name + + writer.reacter_files = ( + reacter_files + if reacter_files is not None + else make_reacter_files() + ) + + return writer + + +def read_script( + tmp_path, + filename, +): + return ( + tmp_path + / "4_reaction_second_stage" + / filename + ).read_text( + encoding="utf-8" + ) + + +def active_lines(text): + """ + Return non-empty, non-comment LAMMPS lines. + + Comments are ignored because the generated file intentionally + mentions RXN_i_with_delete_ids.map in explanatory comments. + """ + result = [] + + for line in text.splitlines(): + stripped = line.strip() + + if not stripped: + continue + + if stripped.startswith("#"): + continue + + result.append( + stripped + ) + + return result + + +def command_tokens(text): + return [ + line.split() + for line in active_lines(text) + ] + + +# ============================================================================= +# Constructor +# ============================================================================= + + +def test_constructor_stores_configuration( + tmp_path, + monkeypatch, +): + settings = make_settings() + + reacter_files = ( + make_reacter_files() + ) + + simulation = ( + make_simulation() + ) + + calls = [] + + monkeypatch.setattr( + RxnSecondStageWriter, + "write_second_stage_reaction_files", + lambda self, simulation: + ( + calls.append(simulation) + or "in.test_stage_2" + ), + ) + + writer = RxnSecondStageWriter( + out_dir=tmp_path, + settings=settings, + reacter_files=reacter_files, + simulation=simulation, + sim_name="Polymer", + ) + + assert writer.settings is settings + + assert ( + writer.reacter_files + is reacter_files + ) + + assert writer.out_dir == tmp_path + + assert ( + writer.sim_name + == "Polymer" + ) + + assert calls == [ + simulation + ] + + assert ( + writer.second_stage_file_name + == "in.test_stage_2" + ) + + +# ============================================================================= +# Active templates +# ============================================================================= + + +def test_no_active_templates_raises( + tmp_path, +): + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[] + ) + ), + ) + + with pytest.raises( + ValueError, + match=( + "No active reaction templates " + "available for the second reaction stage" + ), + ): + writer.write_second_stage_reaction_files( + make_simulation() + ) + + +def test_all_inactive_templates_raise( + tmp_path, +): + template = make_template( + reaction_id=1, + activity_stats=False, + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + with pytest.raises( + ValueError, + match="No active reaction templates", + ): + writer.write_second_stage_reaction_files( + make_simulation() + ) + + +def test_missing_activity_stats_defaults_to_active( + tmp_path, +): + template, *_ = ( + make_real_template( + tmp_path, + reaction_id=3, + ) + ) + + del template.activity_stats + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + filename = ( + writer + .write_second_stage_reaction_files( + make_simulation() + ) + ) + + text = read_script( + tmp_path, + filename, + ) + + assert "rxn_stp_3" in text + + +# ============================================================================= +# File generation +# ============================================================================= + + +def test_writer_creates_second_stage_directory( + tmp_path, +): + template, *_ = ( + make_real_template( + tmp_path + ) + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + writer.write_second_stage_reaction_files( + make_simulation() + ) + + assert ( + tmp_path + / "4_reaction_second_stage" + ).is_dir() + + +def test_writer_creates_expected_input_file( + tmp_path, +): + template, *_ = ( + make_real_template( + tmp_path + ) + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + sim_name="Polymer", + ) + + filename = ( + writer + .write_second_stage_reaction_files( + make_simulation( + tag="500K" + ) + ) + ) + + assert filename == ( + "in.Polymer_500K_reaction_stage_2" + ) + + assert ( + tmp_path + / "4_reaction_second_stage" + / filename + ).is_file() + + +def test_writer_returns_filename_only( + tmp_path, +): + template, *_ = ( + make_real_template( + tmp_path + ) + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + filename = ( + writer + .write_second_stage_reaction_files( + make_simulation() + ) + ) + + assert isinstance( + filename, + str, + ) + + assert "/" not in filename + assert "\\" not in filename + + +def test_header_contains_tag_and_autoreacter( + tmp_path, +): + template, *_ = ( + make_real_template( + tmp_path + ) + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + sim_name="Epoxy", + ) + + filename = ( + writer + .write_second_stage_reaction_files( + make_simulation( + tag="500K" + ) + ) + ) + + text = read_script( + tmp_path, + filename, + ) + + assert ( + "# Epoxy_500K " + "Second Reaction Stage Script" + in text + ) + + assert ( + "by AutoREACTER" + in text + ) + + +# ============================================================================= +# LAMMPS initialization +# ============================================================================= + + +def test_script_contains_initialization_settings( + tmp_path, +): + template, *_ = ( + make_real_template( + tmp_path + ) + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + filename = ( + writer + .write_second_stage_reaction_files( + make_simulation() + ) + ) + + commands = command_tokens( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "units", + "real", + ] in commands + + assert [ + "dimension", + "3", + ] in commands + + assert [ + "boundary", + "p", + "p", + "p", + ] in commands + + assert [ + "atom_style", + "full", + ] in commands + + +def test_script_contains_force_field_styles( + tmp_path, +): + template, *_ = ( + make_real_template( + tmp_path + ) + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + filename = ( + writer + .write_second_stage_reaction_files( + make_simulation() + ) + ) + + commands = command_tokens( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "angle_style", + "class2", + ] in commands + + assert [ + "bond_style", + "class2", + ] in commands + + assert [ + "dihedral_style", + "class2", + ] in commands + + assert [ + "improper_style", + "class2", + ] in commands + + +def test_script_contains_pair_settings( + tmp_path, +): + template, *_ = ( + make_real_template( + tmp_path + ) + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + filename = ( + writer + .write_second_stage_reaction_files( + make_simulation() + ) + ) + + commands = command_tokens( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "pair_style", + "lj/class2/coul/long", + "12.0", + ] in commands + + assert [ + "kspace_style", + "pppm", + "1.0e-4", + ] in commands + + assert [ + "pair_modify", + "mix", + "sixthpower", + ] in commands + + +def test_optional_neighbor_settings_are_written( + tmp_path, +): + template, *_ = ( + make_real_template( + tmp_path + ) + ) + + writer = make_writer_without_init( + tmp_path, + settings=make_settings( + neighbor="2.0 bin", + neigh_modify=( + "delay 0 every 1 " + "check yes" + ), + ), + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + filename = ( + writer + .write_second_stage_reaction_files( + make_simulation() + ) + ) + + commands = command_tokens( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "neighbor", + "2.0", + "bin", + ] in commands + + assert [ + "neigh_modify", + "delay", + "0", + "every", + "1", + "check", + "yes", + ] in commands + + +def test_optional_neighbor_settings_omitted_when_none( + tmp_path, +): + template, *_ = ( + make_real_template( + tmp_path + ) + ) + + writer = make_writer_without_init( + tmp_path, + settings=make_settings( + neighbor=None, + neigh_modify=None, + ), + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + filename = ( + writer + .write_second_stage_reaction_files( + make_simulation() + ) + ) + + commands = command_tokens( + read_script( + tmp_path, + filename, + ) + ) + + keywords = [ + command[0] + for command in commands + ] + + assert "neighbor" not in keywords + assert "neigh_modify" not in keywords + + +# ============================================================================= +# Input structure +# ============================================================================= + + +def test_reads_first_stage_reacted_data( + tmp_path, +): + template, *_ = ( + make_real_template( + tmp_path + ) + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + sim_name="Polymer", + ) + + filename = ( + writer + .write_second_stage_reaction_files( + make_simulation( + tag="500K" + ) + ) + ) + + commands = command_tokens( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "read_data", + "Polymer_500K_reacted_0M-1M_3.5A.data", + "&", + ] in commands + + +def test_read_data_reserves_extra_topology_capacity( + tmp_path, +): + template, *_ = ( + make_real_template( + tmp_path + ) + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + filename = ( + writer + .write_second_stage_reaction_files( + make_simulation() + ) + ) + + text = read_script( + tmp_path, + filename, + ) + + assert "extra/bond/per/atom 50" in text + assert "extra/angle/per/atom 50" in text + assert "extra/dihedral/per/atom 50" in text + assert "extra/improper/per/atom 50" in text + assert "extra/special/per/atom 50" in text + + +# ============================================================================= +# Minimization / velocity / timestep +# ============================================================================= + + +def test_script_contains_minimization( + tmp_path, +): + template, *_ = ( + make_real_template( + tmp_path + ) + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + filename = ( + writer + .write_second_stage_reaction_files( + make_simulation() + ) + ) + + commands = command_tokens( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "minimize", + "1.0e-4", + "1.0e-6", + "1000", + "10000", + ] in commands + + +def test_velocity_uses_simulation_temperature_and_seed( + tmp_path, + monkeypatch, +): + template, *_ = ( + make_real_template( + tmp_path + ) + ) + + monkeypatch.setattr( + rxn_module.random, + "randint", + lambda a, b: 123456, + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + filename = ( + writer + .write_second_stage_reaction_files( + make_simulation( + temperature=523.0 + ) + ) + ) + + commands = command_tokens( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "velocity", + "all", + "create", + "523.0", + "123456", + "dist", + "gaussian", + ] in commands + + +def test_second_stage_timestep_thermo_and_reset( + tmp_path, +): + template, *_ = ( + make_real_template( + tmp_path + ) + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + filename = ( + writer + .write_second_stage_reaction_files( + make_simulation() + ) + ) + + commands = command_tokens( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "timestep", + "1.0", + ] in commands + + assert [ + "thermo", + "100", + ] in commands + + assert [ + "reset_timestep", + "1000000", + ] in commands + + +# ============================================================================= +# Reaction definitions +# ============================================================================= + + +def test_active_template_writes_pre_and_post_molecules( + tmp_path, +): + ( + template, + pre, + post, + _, + _, + ) = make_real_template( + tmp_path, + reaction_id=7, + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + filename = ( + writer + .write_second_stage_reaction_files( + make_simulation() + ) + ) + + commands = command_tokens( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "molecule", + "mol_pre_7", + pre.name, + ] in commands + + assert [ + "molecule", + "mol_post_7", + post.name, + ] in commands + + +def test_inactive_template_not_written_when_active_template_exists( + tmp_path, +): + active, *_ = ( + make_real_template( + tmp_path, + reaction_id=1, + ) + ) + + inactive, *_ = ( + make_real_template( + tmp_path, + reaction_id=2, + activity_stats=False, + ) + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + active, + inactive, + ] + ) + ), + ) + + filename = ( + writer + .write_second_stage_reaction_files( + make_simulation() + ) + ) + + text = read_script( + tmp_path, + filename, + ) + + assert "rxn_stp_1" in text + assert "rxn_stp_2" not in text + + +def test_reaction_uses_five_angstrom_cutoff( + tmp_path, +): + ( + template, + _, + _, + standard_map, + _, + ) = make_real_template( + tmp_path, + reaction_id=4, + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + filename = ( + writer + .write_second_stage_reaction_files( + make_simulation() + ) + ) + + react_line = next( + line + for line in active_lines( + read_script( + tmp_path, + filename, + ) + ) + if "rxn_stp_4" in line + ) + + tokens = ( + react_line.split() + ) + + assert "0.0" in tokens + assert "5.0" in tokens + + assert ( + standard_map.name + in tokens + ) + + +def test_reaction_uses_stabilize_steps_200( + tmp_path, +): + template, *_ = ( + make_real_template( + tmp_path, + reaction_id=5, + ) + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + filename = ( + writer + .write_second_stage_reaction_files( + make_simulation() + ) + ) + + react_line = next( + line + for line in active_lines( + read_script( + tmp_path, + filename, + ) + ) + if "rxn_stp_5" in line + ) + + assert ( + react_line.split()[-4:] + == [ + "stabilize_steps", + "200", + "rescale_charges", + "yes", + ] + ) + + +# ============================================================================= +# Standard map behavior +# ============================================================================= + + +def test_standard_map_is_used_when_no_delete_map_exists( + tmp_path, +): + ( + template, + _, + _, + standard_map, + _, + ) = make_real_template( + tmp_path, + reaction_id=6, + with_delete_map=False, + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + filename = ( + writer + .write_second_stage_reaction_files( + make_simulation() + ) + ) + + react_line = next( + line + for line in active_lines( + read_script( + tmp_path, + filename, + ) + ) + if "rxn_stp_6" in line + ) + + assert ( + standard_map.name + in react_line + ) + + +def test_standard_map_remains_default_when_delete_map_exists( + tmp_path, +): + """ + AutoREACTER supplies the optional delete-ID map to the user, + but the generated LAMMPS command must still use RXN_N.map. + """ + ( + template, + _, + _, + standard_map, + delete_map, + ) = make_real_template( + tmp_path, + reaction_id=10, + with_delete_map=True, + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + filename = ( + writer + .write_second_stage_reaction_files( + make_simulation() + ) + ) + + react_line = next( + line + for line in active_lines( + read_script( + tmp_path, + filename, + ) + ) + if "rxn_stp_10" in line + ) + + assert ( + standard_map.name + in react_line + ) + + assert ( + delete_map.name + not in react_line + ) + + +def test_delete_map_is_never_automatically_selected( + tmp_path, +): + ( + template, + _, + _, + _, + delete_map, + ) = make_real_template( + tmp_path, + reaction_id=12, + with_delete_map=True, + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + filename = ( + writer + .write_second_stage_reaction_files( + make_simulation() + ) + ) + + active = "\n".join( + active_lines( + read_script( + tmp_path, + filename, + ) + ) + ) + + assert ( + delete_map.name + not in active + ) + + +# ============================================================================= +# fix bond/react thermo vector +# ============================================================================= + + +def test_single_reaction_uses_explicit_f_rxns_1( + tmp_path, +): + template, *_ = ( + make_real_template( + tmp_path, + reaction_id=17, + ) + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + filename = ( + writer + .write_second_stage_reaction_files( + make_simulation() + ) + ) + + commands = command_tokens( + read_script( + tmp_path, + filename, + ) + ) + + thermo = next( + command + for command in commands + if command[0] == "thermo_style" + ) + + assert ( + "f_rxns[1]" + in thermo + ) + + assert ( + "f_rxns[*]" + not in thermo + ) + + +def test_multiple_reactions_use_contiguous_thermo_indices( + tmp_path, +): + templates = [ + make_real_template( + tmp_path, + reaction_id=reaction_id, + )[0] + for reaction_id in ( + 4, + 17, + 68, + ) + ] + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=templates + ) + ), + ) + + filename = ( + writer + .write_second_stage_reaction_files( + make_simulation() + ) + ) + + commands = command_tokens( + read_script( + tmp_path, + filename, + ) + ) + + thermo = next( + command + for command in commands + if command[0] == "thermo_style" + ) + + assert "f_rxns[1]" in thermo + assert "f_rxns[2]" in thermo + assert "f_rxns[3]" in thermo + + assert "f_rxns[4]" not in thermo + + +def test_thermo_indices_do_not_use_reaction_ids( + tmp_path, +): + template_17, *_ = ( + make_real_template( + tmp_path, + reaction_id=17, + ) + ) + + template_68, *_ = ( + make_real_template( + tmp_path, + reaction_id=68, + ) + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template_17, + template_68, + ] + ) + ), + ) + + filename = ( + writer + .write_second_stage_reaction_files( + make_simulation() + ) + ) + + text = read_script( + tmp_path, + filename, + ) + + assert "f_rxns[1]" in text + assert "f_rxns[2]" in text + + assert "f_rxns[17]" not in text + assert "f_rxns[68]" not in text + + +# ============================================================================= +# Thermostat / run +# ============================================================================= + + +def test_active_nvt_uses_simulation_temperature( + tmp_path, +): + template, *_ = ( + make_real_template( + tmp_path + ) + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + filename = ( + writer + .write_second_stage_reaction_files( + make_simulation( + temperature=373.0 + ) + ) + ) + + commands = command_tokens( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "fix", + "1", + "statted_grp_REACT", + "nvt", + "temp", + "373.0", + "373.0", + "100.0", + ] in commands + + +def test_npt_command_remains_commented_out( + tmp_path, +): + template, *_ = ( + make_real_template( + tmp_path + ) + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + filename = ( + writer + .write_second_stage_reaction_files( + make_simulation() + ) + ) + + assert not any( + " npt " in f" {line} " + for line in active_lines( + read_script( + tmp_path, + filename, + ) + ) + ) + + +def test_second_stage_runs_2500000_steps( + tmp_path, +): + template, *_ = ( + make_real_template( + tmp_path + ) + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + filename = ( + writer + .write_second_stage_reaction_files( + make_simulation() + ) + ) + + commands = command_tokens( + read_script( + tmp_path, + filename, + ) + ) + + assert [ + "run", + "2500000", + ] in commands + + +# ============================================================================= +# Output naming +# ============================================================================= + + +def test_output_names_use_second_stage_range( + tmp_path, +): + template, *_ = ( + make_real_template( + tmp_path + ) + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + sim_name="Polymer", + ) + + filename = ( + writer + .write_second_stage_reaction_files( + make_simulation( + tag="500K" + ) + ) + ) + + text = read_script( + tmp_path, + filename, + ) + + output_base = ( + "Polymer_500K" + "_reacted_1M-3.5_5.0A" + ) + + assert ( + f"{output_base}.xyz" + in text + ) + + assert ( + f"{output_base}_backup1.restart" + in text + ) + + assert ( + f"{output_base}_backup2.restart" + in text + ) + + assert ( + f"{output_base}.restart" + in text + ) + + assert ( + f"{output_base}.data" + in text + ) + + +def test_final_write_data_uses_nofix( + tmp_path, +): + template, *_ = ( + make_real_template( + tmp_path + ) + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + filename = ( + writer + .write_second_stage_reaction_files( + make_simulation() + ) + ) + + commands = command_tokens( + read_script( + tmp_path, + filename, + ) + ) + + write_data = [ + command + for command in commands + if command[0] == "write_data" + ] + + assert len( + write_data + ) == 1 + + assert ( + write_data[0][-1] + == "nofix" + ) + + +# ============================================================================= +# _copy_required_files +# ============================================================================= + + +def test_copy_required_files_copies_standard_map_and_templates( + tmp_path, +): + ( + template, + pre, + post, + standard_map, + _, + ) = make_real_template( + tmp_path, + reaction_id=1, + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + dest = ( + tmp_path / "stage2" + ) + + dest.mkdir() + + writer._copy_required_files( + dest + ) + + assert ( + dest / pre.name + ).is_file() + + assert ( + dest / post.name + ).is_file() + + assert ( + dest / standard_map.name + ).is_file() + + +def test_copy_required_files_copies_optional_delete_map( + tmp_path, +): + """ + DeleteIDs map is copied for the user, but is not automatically used. + """ + ( + template, + _, + _, + standard_map, + delete_map, + ) = make_real_template( + tmp_path, + reaction_id=2, + with_delete_map=True, + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + dest = ( + tmp_path / "stage2" + ) + + dest.mkdir() + + writer._copy_required_files( + dest + ) + + assert ( + dest / standard_map.name + ).is_file() + + assert ( + dest / delete_map.name + ).is_file() + + +def test_no_delete_map_is_valid( + tmp_path, +): + ( + template, + pre, + post, + standard_map, + delete_map, + ) = make_real_template( + tmp_path, + reaction_id=3, + with_delete_map=False, + ) + + assert delete_map is None + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + dest = ( + tmp_path / "stage2" + ) + + dest.mkdir() + + writer._copy_required_files( + dest + ) + + assert ( + dest / standard_map.name + ).is_file() + + assert ( + dest / pre.name + ).is_file() + + assert ( + dest / post.name + ).is_file() + + +def test_missing_delete_map_attribute_is_valid( + tmp_path, +): + ( + template, + _, + _, + standard_map, + _, + ) = make_real_template( + tmp_path, + reaction_id=4, + ) + + del ( + template + .map_file_with_delete_ids + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + dest = ( + tmp_path / "stage2" + ) + + dest.mkdir() + + writer._copy_required_files( + dest + ) + + assert ( + dest / standard_map.name + ).is_file() + + +def test_missing_standard_map_raises( + tmp_path, +): + pre = ( + tmp_path / "pre.molecule" + ) + + post = ( + tmp_path / "post.molecule" + ) + + pre.write_text( + "PRE", + encoding="utf-8", + ) + + post.write_text( + "POST", + encoding="utf-8", + ) + + template = make_template( + reaction_id=1, + pre_reaction_file=pre, + post_reaction_file=post, + map_file=( + tmp_path + / "missing.map" + ), + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + dest = ( + tmp_path / "stage2" + ) + + dest.mkdir() + + with pytest.raises( + FileNotFoundError, + match=( + "Required reaction file " + "not found" + ), + ): + writer._copy_required_files( + dest + ) + + +def test_missing_pre_template_raises( + tmp_path, +): + post = ( + tmp_path / "post.molecule" + ) + + standard_map = ( + tmp_path / "RXN_1.map" + ) + + post.write_text( + "POST", + encoding="utf-8", + ) + + standard_map.write_text( + "MAP", + encoding="utf-8", + ) + + template = make_template( + reaction_id=1, + pre_reaction_file=( + tmp_path + / "missing_pre.molecule" + ), + post_reaction_file=post, + map_file=standard_map, + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + dest = ( + tmp_path / "stage2" + ) + + dest.mkdir() + + with pytest.raises( + FileNotFoundError, + ): + writer._copy_required_files( + dest + ) + + +def test_missing_post_template_raises( + tmp_path, +): + pre = ( + tmp_path / "pre.molecule" + ) + + standard_map = ( + tmp_path / "RXN_1.map" + ) + + pre.write_text( + "PRE", + encoding="utf-8", + ) + + standard_map.write_text( + "MAP", + encoding="utf-8", + ) + + template = make_template( + reaction_id=1, + pre_reaction_file=pre, + post_reaction_file=( + tmp_path + / "missing_post.molecule" + ), + map_file=standard_map, + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + dest = ( + tmp_path / "stage2" + ) + + dest.mkdir() + + with pytest.raises( + FileNotFoundError, + ): + writer._copy_required_files( + dest + ) + + +def test_declared_but_missing_delete_map_raises( + tmp_path, +): + template, *_ = ( + make_real_template( + tmp_path, + reaction_id=5, + ) + ) + + template.map_file_with_delete_ids = ( + tmp_path + / "RXN_5_with_delete_ids.map" + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + ) + + dest = ( + tmp_path / "stage2" + ) + + dest.mkdir() + + with pytest.raises( + FileNotFoundError, + match=( + "Required reaction file " + "not found" + ), + ): + writer._copy_required_files( + dest + ) + + +def test_inactive_templates_are_not_copied( + tmp_path, +): + active, *_ = ( + make_real_template( + tmp_path, + reaction_id=1, + ) + ) + + inactive = make_template( + reaction_id=2, + activity_stats=False, + map_file=( + tmp_path + / "missing.map" + ), + pre_reaction_file=( + tmp_path + / "missing_pre" + ), + post_reaction_file=( + tmp_path + / "missing_post" + ), + map_file_with_delete_ids=( + tmp_path + / "missing_delete" + ), + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + active, + inactive, + ] + ) + ), + ) + + dest = ( + tmp_path / "stage2" + ) + + dest.mkdir() + + writer._copy_required_files( + dest + ) + + assert ( + dest / "RXN_1.map" + ).is_file() + + assert not ( + dest / "missing.map" + ).exists() + + +# ============================================================================= +# Delete-map full integration +# ============================================================================= + + +def test_delete_map_is_copied_but_standard_map_remains_used( + tmp_path, +): + """ + Full contract: + + Files supplied: + RXN_20.map + RXN_20_with_delete_ids.map + + Generated LAMMPS script: + uses RXN_20.map + """ + ( + template, + pre, + post, + standard_map, + delete_map, + ) = make_real_template( + tmp_path, + reaction_id=20, + with_delete_map=True, + ) + + writer = make_writer_without_init( + tmp_path, + reacter_files=( + make_reacter_files( + template_files=[ + template + ] + ) + ), + sim_name="Polymer", + ) + + filename = ( + writer + .write_second_stage_reaction_files( + make_simulation( + tag="500K" + ) + ) + ) + + stage_dir = ( + tmp_path + / "4_reaction_second_stage" + ) + + assert ( + stage_dir / pre.name + ).is_file() + + assert ( + stage_dir / post.name + ).is_file() + + assert ( + stage_dir + / standard_map.name + ).is_file() + + assert ( + stage_dir + / delete_map.name + ).is_file() + + active = "\n".join( + active_lines( + read_script( + tmp_path, + filename, + ) + ) + ) + + assert ( + standard_map.name + in active + ) + + assert ( + delete_map.name + not in active + ) diff --git a/tests/unit/sim_setup/writers/test_writer.py b/tests/unit/sim_setup/writers/test_writer.py new file mode 100644 index 00000000..51a061d7 --- /dev/null +++ b/tests/unit/sim_setup/writers/test_writer.py @@ -0,0 +1,1173 @@ +from pathlib import Path +from types import SimpleNamespace + +import pytest + +import AutoREACTER.sim_setup.writers.writer as writer_module +from AutoREACTER.sim_setup.writers.writer import Writer + + +# ============================================================================= +# Helpers +# ============================================================================= + + +def make_reacter_files(): + return SimpleNamespace( + in_file=Path("in.create_atoms.script"), + force_field_data=Path("force_field.data"), + molecule_files=[], + template_files=[], + ) + + +def make_simulation( + *, + tag="sim1", + temperature=500.0, +): + return SimpleNamespace( + tag=tag, + temperature=temperature, + ) + + +def make_setup( + *, + simulation_name="Polymer", + simulations=None, + write_second_reaction_stage=True, +): + return SimpleNamespace( + simulation_name=simulation_name, + simulations=list( + simulations or [] + ), + write_second_reaction_stage=write_second_reaction_stage, + ) + + +class FakeInitialSettings: + instances = [] + + def __init__(self, reacter_files): + self.reacter_files = reacter_files + self.get_calls = 0 + + self.settings = SimpleNamespace( + units="real", + ) + + self.__class__.instances.append( + self + ) + + def get_LUNAR_lammps_settings(self): + self.get_calls += 1 + + return self.settings + + +# ============================================================================= +# Constructor +# ============================================================================= + + +def test_constructor_stores_reacter_files( + monkeypatch, +): + FakeInitialSettings.instances.clear() + + monkeypatch.setattr( + writer_module, + "LammpsInitialSettings", + FakeInitialSettings, + ) + + reacter_files = ( + make_reacter_files() + ) + + writer = Writer( + reacter_files=reacter_files + ) + + assert ( + writer.reacter_files + is reacter_files + ) + + +def test_constructor_creates_lammps_initial_settings( + monkeypatch, +): + FakeInitialSettings.instances.clear() + + monkeypatch.setattr( + writer_module, + "LammpsInitialSettings", + FakeInitialSettings, + ) + + reacter_files = ( + make_reacter_files() + ) + + writer = Writer( + reacter_files=reacter_files + ) + + assert ( + len(FakeInitialSettings.instances) + == 1 + ) + + instance = ( + FakeInitialSettings.instances[0] + ) + + assert ( + instance.reacter_files + is reacter_files + ) + + assert ( + writer.lammps_initial_setup + is instance + ) + + +def test_constructor_gets_lunar_settings_once( + monkeypatch, +): + FakeInitialSettings.instances.clear() + + monkeypatch.setattr( + writer_module, + "LammpsInitialSettings", + FakeInitialSettings, + ) + + writer = Writer( + reacter_files=( + make_reacter_files() + ) + ) + + instance = ( + FakeInitialSettings.instances[0] + ) + + assert ( + instance.get_calls + == 1 + ) + + assert ( + writer.settings + is instance.settings + ) + + +# ============================================================================= +# Main output directories +# ============================================================================= + + +def test_write_all_files_creates_lammps_input_files_directory( + tmp_path, + monkeypatch, +): + FakeInitialSettings.instances.clear() + + monkeypatch.setattr( + writer_module, + "LammpsInitialSettings", + FakeInitialSettings, + ) + + writer = Writer( + make_reacter_files() + ) + + setup = make_setup( + simulations=[] + ) + + writer.write_all_files( + tmp_path, + setup, + ) + + assert ( + tmp_path + / "LAMMPS_input_files" + ).is_dir() + + +def test_write_all_files_creates_simulation_subdirectory( + tmp_path, + monkeypatch, +): + FakeInitialSettings.instances.clear() + + monkeypatch.setattr( + writer_module, + "LammpsInitialSettings", + FakeInitialSettings, + ) + + # Prevent real writer execution. + monkeypatch.setattr( + writer_module, + "DensificationWriter", + lambda **kwargs: None, + ) + + monkeypatch.setattr( + writer_module, + "PreEqWriter", + lambda **kwargs: None, + ) + + monkeypatch.setattr( + writer_module, + "RxnFirstStageWriter", + lambda **kwargs: None, + ) + + monkeypatch.setattr( + writer_module, + "RxnSecondStageWriter", + lambda **kwargs: None, + ) + + monkeypatch.setattr( + writer_module, + "PostEqWriter", + lambda **kwargs: None, + ) + + writer = Writer( + make_reacter_files() + ) + + setup = make_setup( + simulation_name="Styrene", + simulations=[ + make_simulation( + tag="298K" + ) + ], + ) + + writer.write_all_files( + tmp_path, + setup, + ) + + assert ( + tmp_path + / "LAMMPS_input_files" + / "Styrene_298K" + ).is_dir() + + +def test_each_simulation_gets_own_subdirectory( + tmp_path, + monkeypatch, +): + FakeInitialSettings.instances.clear() + + monkeypatch.setattr( + writer_module, + "LammpsInitialSettings", + FakeInitialSettings, + ) + + monkeypatch.setattr( + writer_module, + "DensificationWriter", + lambda **kwargs: None, + ) + + monkeypatch.setattr( + writer_module, + "PreEqWriter", + lambda **kwargs: None, + ) + + monkeypatch.setattr( + writer_module, + "RxnFirstStageWriter", + lambda **kwargs: None, + ) + + monkeypatch.setattr( + writer_module, + "RxnSecondStageWriter", + lambda **kwargs: None, + ) + + monkeypatch.setattr( + writer_module, + "PostEqWriter", + lambda **kwargs: None, + ) + + writer = Writer( + make_reacter_files() + ) + + setup = make_setup( + simulation_name="Styrene", + simulations=[ + make_simulation( + tag="298K" + ), + make_simulation( + tag="373K" + ), + make_simulation( + tag="523K" + ), + ], + ) + + writer.write_all_files( + tmp_path, + setup, + ) + + base = ( + tmp_path + / "LAMMPS_input_files" + ) + + assert ( + base / "Styrene_298K" + ).is_dir() + + assert ( + base / "Styrene_373K" + ).is_dir() + + assert ( + base / "Styrene_523K" + ).is_dir() + + +# ============================================================================= +# Stage orchestration +# ============================================================================= + + +def test_all_five_stages_are_called_when_second_stage_enabled( + tmp_path, + monkeypatch, +): + FakeInitialSettings.instances.clear() + + monkeypatch.setattr( + writer_module, + "LammpsInitialSettings", + FakeInitialSettings, + ) + + events = [] + + def fake_stage(name): + def factory(**kwargs): + events.append( + ( + name, + kwargs, + ) + ) + + return SimpleNamespace() + + return factory + + monkeypatch.setattr( + writer_module, + "DensificationWriter", + fake_stage("densification"), + ) + + monkeypatch.setattr( + writer_module, + "PreEqWriter", + fake_stage("pre_eq"), + ) + + monkeypatch.setattr( + writer_module, + "RxnFirstStageWriter", + fake_stage("rxn_first"), + ) + + monkeypatch.setattr( + writer_module, + "RxnSecondStageWriter", + fake_stage("rxn_second"), + ) + + monkeypatch.setattr( + writer_module, + "PostEqWriter", + fake_stage("post_eq"), + ) + + simulation = make_simulation() + + setup = make_setup( + simulations=[ + simulation + ], + write_second_reaction_stage=True, + ) + + writer = Writer( + make_reacter_files() + ) + + writer.write_all_files( + tmp_path, + setup, + ) + + assert [ + name + for name, _ + in events + ] == [ + "densification", + "pre_eq", + "rxn_first", + "rxn_second", + "post_eq", + ] + + +def test_second_stage_is_skipped_when_disabled( + tmp_path, + monkeypatch, +): + FakeInitialSettings.instances.clear() + + monkeypatch.setattr( + writer_module, + "LammpsInitialSettings", + FakeInitialSettings, + ) + + events = [] + + def fake_stage(name): + def factory(**kwargs): + events.append(name) + return SimpleNamespace() + + return factory + + monkeypatch.setattr( + writer_module, + "DensificationWriter", + fake_stage("densification"), + ) + + monkeypatch.setattr( + writer_module, + "PreEqWriter", + fake_stage("pre_eq"), + ) + + monkeypatch.setattr( + writer_module, + "RxnFirstStageWriter", + fake_stage("rxn_first"), + ) + + monkeypatch.setattr( + writer_module, + "RxnSecondStageWriter", + fake_stage("rxn_second"), + ) + + monkeypatch.setattr( + writer_module, + "PostEqWriter", + fake_stage("post_eq"), + ) + + writer = Writer( + make_reacter_files() + ) + + writer.write_all_files( + tmp_path, + make_setup( + simulations=[ + make_simulation() + ], + write_second_reaction_stage=False, + ), + ) + + assert events == [ + "densification", + "pre_eq", + "rxn_first", + "post_eq", + ] + + assert ( + "rxn_second" + not in events + ) + + +# ============================================================================= +# Common arguments +# ============================================================================= + + +def test_densification_receives_expected_arguments( + tmp_path, + monkeypatch, +): + FakeInitialSettings.instances.clear() + + monkeypatch.setattr( + writer_module, + "LammpsInitialSettings", + FakeInitialSettings, + ) + + captured = {} + + monkeypatch.setattr( + writer_module, + "DensificationWriter", + lambda **kwargs: + captured.update(kwargs), + ) + + monkeypatch.setattr( + writer_module, + "PreEqWriter", + lambda **kwargs: None, + ) + + monkeypatch.setattr( + writer_module, + "RxnFirstStageWriter", + lambda **kwargs: None, + ) + + monkeypatch.setattr( + writer_module, + "RxnSecondStageWriter", + lambda **kwargs: None, + ) + + monkeypatch.setattr( + writer_module, + "PostEqWriter", + lambda **kwargs: None, + ) + + reacter_files = ( + make_reacter_files() + ) + + simulation = ( + make_simulation( + tag="500K" + ) + ) + + writer = Writer( + reacter_files + ) + + writer.write_all_files( + tmp_path, + make_setup( + simulation_name="Polymer", + simulations=[ + simulation + ], + ), + ) + + expected_dir = ( + tmp_path + / "LAMMPS_input_files" + / "Polymer_500K" + ) + + assert ( + captured["out_dir"] + == expected_dir + ) + + assert ( + captured["settings"] + is writer.settings + ) + + assert ( + captured["reacter_files"] + is reacter_files + ) + + assert ( + captured["simulation"] + is simulation + ) + + assert ( + captured["sim_name"] + == "Polymer" + ) + + +def test_pre_eq_receives_expected_arguments( + tmp_path, + monkeypatch, +): + FakeInitialSettings.instances.clear() + + monkeypatch.setattr( + writer_module, + "LammpsInitialSettings", + FakeInitialSettings, + ) + + captured = {} + + monkeypatch.setattr( + writer_module, + "DensificationWriter", + lambda **kwargs: None, + ) + + monkeypatch.setattr( + writer_module, + "PreEqWriter", + lambda **kwargs: + captured.update(kwargs), + ) + + monkeypatch.setattr( + writer_module, + "RxnFirstStageWriter", + lambda **kwargs: None, + ) + + monkeypatch.setattr( + writer_module, + "RxnSecondStageWriter", + lambda **kwargs: None, + ) + + monkeypatch.setattr( + writer_module, + "PostEqWriter", + lambda **kwargs: None, + ) + + simulation = make_simulation() + + writer = Writer( + make_reacter_files() + ) + + writer.write_all_files( + tmp_path, + make_setup( + simulation_name="Polymer", + simulations=[ + simulation + ], + ), + ) + + assert ( + captured["settings"] + is writer.settings + ) + + assert ( + captured["simulation"] + is simulation + ) + + assert ( + captured["sim_name"] + == "Polymer" + ) + + assert ( + "reacter_files" + not in captured + ) + + +def test_first_reaction_stage_receives_reacter_files( + tmp_path, + monkeypatch, +): + FakeInitialSettings.instances.clear() + + monkeypatch.setattr( + writer_module, + "LammpsInitialSettings", + FakeInitialSettings, + ) + + captured = {} + + monkeypatch.setattr( + writer_module, + "DensificationWriter", + lambda **kwargs: None, + ) + + monkeypatch.setattr( + writer_module, + "PreEqWriter", + lambda **kwargs: None, + ) + + monkeypatch.setattr( + writer_module, + "RxnFirstStageWriter", + lambda **kwargs: + captured.update(kwargs), + ) + + monkeypatch.setattr( + writer_module, + "RxnSecondStageWriter", + lambda **kwargs: None, + ) + + monkeypatch.setattr( + writer_module, + "PostEqWriter", + lambda **kwargs: None, + ) + + reacter_files = ( + make_reacter_files() + ) + + writer = Writer( + reacter_files + ) + + writer.write_all_files( + tmp_path, + make_setup( + simulations=[ + make_simulation() + ], + ), + ) + + assert ( + captured["reacter_files"] + is reacter_files + ) + + +def test_second_reaction_stage_receives_reacter_files( + tmp_path, + monkeypatch, +): + FakeInitialSettings.instances.clear() + + monkeypatch.setattr( + writer_module, + "LammpsInitialSettings", + FakeInitialSettings, + ) + + captured = {} + + monkeypatch.setattr( + writer_module, + "DensificationWriter", + lambda **kwargs: None, + ) + + monkeypatch.setattr( + writer_module, + "PreEqWriter", + lambda **kwargs: None, + ) + + monkeypatch.setattr( + writer_module, + "RxnFirstStageWriter", + lambda **kwargs: None, + ) + + monkeypatch.setattr( + writer_module, + "RxnSecondStageWriter", + lambda **kwargs: + captured.update(kwargs), + ) + + monkeypatch.setattr( + writer_module, + "PostEqWriter", + lambda **kwargs: None, + ) + + reacter_files = ( + make_reacter_files() + ) + + writer = Writer( + reacter_files + ) + + writer.write_all_files( + tmp_path, + make_setup( + simulations=[ + make_simulation() + ], + write_second_reaction_stage=True, + ), + ) + + assert ( + captured["reacter_files"] + is reacter_files + ) + + +# ============================================================================= +# Post-equilibration flag forwarding +# ============================================================================= + + +@pytest.mark.parametrize( + "write_second_stage", + [ + True, + False, + ], +) +def test_post_eq_receives_second_stage_flag( + tmp_path, + monkeypatch, + write_second_stage, +): + FakeInitialSettings.instances.clear() + + monkeypatch.setattr( + writer_module, + "LammpsInitialSettings", + FakeInitialSettings, + ) + + captured = {} + + monkeypatch.setattr( + writer_module, + "DensificationWriter", + lambda **kwargs: None, + ) + + monkeypatch.setattr( + writer_module, + "PreEqWriter", + lambda **kwargs: None, + ) + + monkeypatch.setattr( + writer_module, + "RxnFirstStageWriter", + lambda **kwargs: None, + ) + + monkeypatch.setattr( + writer_module, + "RxnSecondStageWriter", + lambda **kwargs: None, + ) + + monkeypatch.setattr( + writer_module, + "PostEqWriter", + lambda **kwargs: + captured.update(kwargs), + ) + + writer = Writer( + make_reacter_files() + ) + + writer.write_all_files( + tmp_path, + make_setup( + simulations=[ + make_simulation() + ], + write_second_reaction_stage=( + write_second_stage + ), + ), + ) + + assert ( + captured[ + "write_second_reaction_stage" + ] + is write_second_stage + ) + + +# ============================================================================= +# Multiple simulations +# ============================================================================= + + +def test_all_stages_run_for_each_simulation( + tmp_path, + monkeypatch, +): + FakeInitialSettings.instances.clear() + + monkeypatch.setattr( + writer_module, + "LammpsInitialSettings", + FakeInitialSettings, + ) + + calls = [] + + def fake_stage(name): + def factory(**kwargs): + calls.append( + ( + name, + kwargs["simulation"].tag, + ) + ) + + return SimpleNamespace() + + return factory + + monkeypatch.setattr( + writer_module, + "DensificationWriter", + fake_stage("dense"), + ) + + monkeypatch.setattr( + writer_module, + "PreEqWriter", + fake_stage("pre"), + ) + + monkeypatch.setattr( + writer_module, + "RxnFirstStageWriter", + fake_stage("rxn1"), + ) + + monkeypatch.setattr( + writer_module, + "RxnSecondStageWriter", + fake_stage("rxn2"), + ) + + monkeypatch.setattr( + writer_module, + "PostEqWriter", + fake_stage("post"), + ) + + writer = Writer( + make_reacter_files() + ) + + writer.write_all_files( + tmp_path, + make_setup( + simulations=[ + make_simulation( + tag="298K" + ), + make_simulation( + tag="500K" + ), + ], + write_second_reaction_stage=True, + ), + ) + + assert calls == [ + ("dense", "298K"), + ("pre", "298K"), + ("rxn1", "298K"), + ("rxn2", "298K"), + ("post", "298K"), + ("dense", "500K"), + ("pre", "500K"), + ("rxn1", "500K"), + ("rxn2", "500K"), + ("post", "500K"), + ] + + +def test_same_settings_object_is_shared_across_all_stages( + tmp_path, + monkeypatch, +): + FakeInitialSettings.instances.clear() + + monkeypatch.setattr( + writer_module, + "LammpsInitialSettings", + FakeInitialSettings, + ) + + received_settings = [] + + def fake_stage(**kwargs): + received_settings.append( + kwargs["settings"] + ) + + return SimpleNamespace() + + monkeypatch.setattr( + writer_module, + "DensificationWriter", + fake_stage, + ) + + monkeypatch.setattr( + writer_module, + "PreEqWriter", + fake_stage, + ) + + monkeypatch.setattr( + writer_module, + "RxnFirstStageWriter", + fake_stage, + ) + + monkeypatch.setattr( + writer_module, + "RxnSecondStageWriter", + fake_stage, + ) + + monkeypatch.setattr( + writer_module, + "PostEqWriter", + fake_stage, + ) + + writer = Writer( + make_reacter_files() + ) + + writer.write_all_files( + tmp_path, + make_setup( + simulations=[ + make_simulation() + ], + write_second_reaction_stage=True, + ), + ) + + assert len( + received_settings + ) == 5 + + assert all( + settings + is writer.settings + for settings + in received_settings + ) + + +# ============================================================================= +# Empty setup +# ============================================================================= + + +def test_empty_simulation_list_creates_only_root_lammps_directory( + tmp_path, + monkeypatch, +): + FakeInitialSettings.instances.clear() + + monkeypatch.setattr( + writer_module, + "LammpsInitialSettings", + FakeInitialSettings, + ) + + writer = Writer( + make_reacter_files() + ) + + writer.write_all_files( + tmp_path, + make_setup( + simulations=[] + ), + ) + + root = ( + tmp_path + / "LAMMPS_input_files" + ) + + assert root.is_dir() + + assert list( + root.iterdir() + ) == [] + + +def test_write_all_files_returns_none( + tmp_path, + monkeypatch, +): + FakeInitialSettings.instances.clear() + + monkeypatch.setattr( + writer_module, + "LammpsInitialSettings", + FakeInitialSettings, + ) + + writer = Writer( + make_reacter_files() + ) + + result = writer.write_all_files( + tmp_path, + make_setup( + simulations=[] + ), + ) + + assert result is None \ No newline at end of file diff --git a/tests/unit/test_arx_cli.py b/tests/unit/test_arx_cli.py new file mode 100644 index 00000000..5d77b89c --- /dev/null +++ b/tests/unit/test_arx_cli.py @@ -0,0 +1,2001 @@ +from contextlib import contextmanager +import importlib +import os +from pathlib import Path +from types import SimpleNamespace + +import pytest +from PIL import Image as PILImage + + +cli_module = importlib.import_module( + "AutoREACTER.arx_cli" +) + +ARXCLI = cli_module.ARXCLI +ErrorHandler = cli_module.ErrorHandler +NoReactionGenerated = ( + cli_module.NoReactionGenerated +) + + +# ============================================================================= +# Helpers +# ============================================================================= + + +def make_cli(tmp_path): + """ + Create an ARXCLI instance without running its real constructor. + + This is useful for unit-testing individual controller methods without + triggering read_input(), RDKit detection, force-field generation, or + any other heavy pipeline component. + """ + cli = ARXCLI.__new__(ARXCLI) + + output_dir = tmp_path / "output" + images_dir = output_dir / "images" + + output_dir.mkdir( + parents=True, + exist_ok=True, + ) + images_dir.mkdir( + parents=True, + exist_ok=True, + ) + + cli.input = tmp_path / "input.json" + + cli.session = SimpleNamespace( + output_dir=output_dir, + images_dir=images_dir, + reaction_instances=[], + non_reactants=[], + ) + + cli.img_dir = images_dir + + cli.error_handler = ( + ErrorHandler().waterfall_order() + ) + + cli._fg_detected = False + cli._reactions_detected = False + cli._reactions_selected = False + cli._non_reactants_detected = False + cli._non_reactants_selected = False + + return cli + + +# ============================================================================= +# ErrorHandler +# ============================================================================= + + +def test_error_handler_waterfall_order(): + handler = ErrorHandler() + + result = handler.waterfall_order() + + assert result == { + "select_reactions": False, + "select_non_reactants": False, + "process": False, + } + + +def test_error_handler_returns_independent_dicts(): + handler = ErrorHandler() + + first = handler.waterfall_order() + second = handler.waterfall_order() + + first["select_reactions"] = True + + assert ( + second["select_reactions"] + is False + ) + + +# ============================================================================= +# ARXCLI constructor +# ============================================================================= + + +def test_arxcli_constructor_initializes_expected_state( + tmp_path, + monkeypatch, +): + input_path = tmp_path / "input.json" + + session = SimpleNamespace( + output_dir=tmp_path / "output", + images_dir=tmp_path / "output" / "images", + reaction_instances=[], + non_reactants=[], + ) + + read_calls = [] + saved_input_paths = [] + saved_images = [] + ensure_calls = [] + + fake_monomer_image = object() + + def fake_read_input(path): + read_calls.append(path) + return session + + class FakeInputParser: + def initial_molecules_image_grid( + self, + passed_session, + ): + assert passed_session is session + return fake_monomer_image + + def fake_save_input( + self, + path, + ): + saved_input_paths.append(path) + + def fake_save_image( + self, + image, + path, + is_non_reactant=False, + ): + saved_images.append( + ( + image, + Path(path), + is_non_reactant, + ) + ) + + def fake_ensure_reactions(self): + ensure_calls.append(True) + + # Verify flags are initialized before bootstrap. + assert self._fg_detected is False + assert self._reactions_detected is False + assert self._reactions_selected is False + + assert ( + self._non_reactants_detected + is False + ) + + assert ( + self._non_reactants_selected + is False + ) + + monkeypatch.setattr( + cli_module, + "read_input", + fake_read_input, + ) + + monkeypatch.setattr( + cli_module, + "InputParser", + FakeInputParser, + ) + + monkeypatch.setattr( + ARXCLI, + "_save_input_json", + fake_save_input, + ) + + monkeypatch.setattr( + ARXCLI, + "_save_rdkit_img", + fake_save_image, + ) + + monkeypatch.setattr( + ARXCLI, + "_ensure_reactions_detected", + fake_ensure_reactions, + ) + + cli = ARXCLI(input_path) + + assert cli.input == input_path + assert cli.session is session + + assert ( + cli.img_dir + == session.images_dir + ) + + assert read_calls == [ + input_path.resolve() + ] + + assert saved_input_paths == [ + input_path.resolve() + ] + + assert saved_images == [ + ( + fake_monomer_image, + session.images_dir + / "monomers.png", + False, + ) + ] + + assert ensure_calls == [ + True + ] + + assert cli.error_handler == { + "select_reactions": False, + "select_non_reactants": False, + "process": False, + } + + +def test_arxcli_instances_do_not_share_waterfall_state( + tmp_path, + monkeypatch, +): + input_1 = tmp_path / "one.json" + input_2 = tmp_path / "two.json" + + session_1 = SimpleNamespace( + output_dir=tmp_path / "out1", + images_dir=tmp_path / "out1" / "images", + ) + + session_2 = SimpleNamespace( + output_dir=tmp_path / "out2", + images_dir=tmp_path / "out2" / "images", + ) + + sessions = iter( + [ + session_1, + session_2, + ] + ) + + monkeypatch.setattr( + cli_module, + "read_input", + lambda path: next(sessions), + ) + + class FakeInputParser: + def initial_molecules_image_grid( + self, + session, + ): + return object() + + monkeypatch.setattr( + cli_module, + "InputParser", + FakeInputParser, + ) + + monkeypatch.setattr( + ARXCLI, + "_save_input_json", + lambda self, path: None, + ) + + monkeypatch.setattr( + ARXCLI, + "_save_rdkit_img", + lambda self, image, path, + is_non_reactant=False: None, + ) + + monkeypatch.setattr( + ARXCLI, + "_ensure_reactions_detected", + lambda self: None, + ) + + cli_1 = ARXCLI(input_1) + cli_2 = ARXCLI(input_2) + + cli_1.error_handler[ + "select_reactions" + ] = True + + assert ( + cli_2.error_handler[ + "select_reactions" + ] + is False + ) + + +# ============================================================================= +# show_molecules +# ============================================================================= + + +def test_show_molecules_delegates_to_input_parser( + tmp_path, + monkeypatch, +): + cli = make_cli(tmp_path) + + expected = object() + calls = [] + + class FakeInputParser: + def initial_molecules_image_grid( + self, + session, + ): + calls.append(session) + return expected + + monkeypatch.setattr( + cli_module, + "InputParser", + FakeInputParser, + ) + + result = cli.show_molecules() + + assert result is expected + assert calls == [ + cli.session + ] + + +# ============================================================================= +# show_functional_groups +# ============================================================================= + + +def test_show_functional_groups_triggers_detection_when_needed( + tmp_path, + monkeypatch, +): + cli = make_cli(tmp_path) + + expected = object() + ensure_calls = [] + + def fake_ensure(): + ensure_calls.append(True) + cli._fg_detected = True + + monkeypatch.setattr( + cli, + "_ensure_fg_detected", + fake_ensure, + ) + + class FakeDetector: + def functional_group_highlighted_molecules_image_grid( + self, + session, + ): + assert session is cli.session + return expected + + monkeypatch.setattr( + cli_module, + "FunctionalGroupsDetector", + FakeDetector, + ) + + result = cli.show_functional_groups() + + assert result is expected + assert ensure_calls == [ + True + ] + + +def test_show_functional_groups_does_not_redetect_when_already_detected( + tmp_path, + monkeypatch, +): + cli = make_cli(tmp_path) + + cli._fg_detected = True + + ensure_calls = [] + + monkeypatch.setattr( + cli, + "_ensure_fg_detected", + lambda: ensure_calls.append(True), + ) + + expected = object() + + class FakeDetector: + def functional_group_highlighted_molecules_image_grid( + self, + session, + ): + return expected + + monkeypatch.setattr( + cli_module, + "FunctionalGroupsDetector", + FakeDetector, + ) + + result = cli.show_functional_groups() + + assert result is expected + assert ensure_calls == [] + + +# ============================================================================= +# show_reactions +# ============================================================================= + + +def test_show_reactions_triggers_detection_when_needed( + tmp_path, + monkeypatch, +): + cli = make_cli(tmp_path) + + expected = object() + ensure_calls = [] + + def fake_ensure(): + ensure_calls.append(True) + cli._reactions_detected = True + + monkeypatch.setattr( + cli, + "_ensure_reactions_detected", + fake_ensure, + ) + + class FakeDetector: + def available_reaction_image_grid( + self, + session, + ): + assert session is cli.session + return expected + + monkeypatch.setattr( + cli_module, + "ReactionDetector", + FakeDetector, + ) + + result = cli.show_reactions() + + assert result is expected + assert ensure_calls == [ + True + ] + + +def test_show_reactions_does_not_redetect_when_already_detected( + tmp_path, + monkeypatch, +): + cli = make_cli(tmp_path) + + cli._reactions_detected = True + + ensure_calls = [] + + monkeypatch.setattr( + cli, + "_ensure_reactions_detected", + lambda: ensure_calls.append(True), + ) + + expected = object() + + class FakeDetector: + def available_reaction_image_grid( + self, + session, + ): + return expected + + monkeypatch.setattr( + cli_module, + "ReactionDetector", + FakeDetector, + ) + + result = cli.show_reactions() + + assert result is expected + assert ensure_calls == [] + + +# ============================================================================= +# select_reactions +# ============================================================================= + + +def test_select_reactions_ensures_detection_first( + tmp_path, + monkeypatch, +): + cli = make_cli(tmp_path) + + cli.session.reaction_instances = [ + object() + ] + + calls = [] + + def fake_ensure(): + calls.append("detect") + cli._reactions_detected = True + + monkeypatch.setattr( + cli, + "_ensure_reactions_detected", + fake_ensure, + ) + + class FakeDetector: + def reaction_selection( + self, + session, + ): + assert session is cli.session + calls.append("select") + + monkeypatch.setattr( + cli_module, + "ReactionDetector", + FakeDetector, + ) + + cli.select_reactions() + + assert calls == [ + "detect", + "select", + ] + + assert cli._reactions_selected is True + + assert ( + cli.error_handler[ + "select_reactions" + ] + is True + ) + + +def test_select_reactions_raises_when_none_detected( + tmp_path, +): + cli = make_cli(tmp_path) + + cli._reactions_detected = True + cli.session.reaction_instances = [] + + with pytest.raises( + RuntimeError, + match="No reactions detected", + ): + cli.select_reactions() + + assert ( + cli._reactions_selected + is False + ) + + assert ( + cli.error_handler[ + "select_reactions" + ] + is False + ) + + +def test_select_reactions_is_idempotent( + tmp_path, + monkeypatch, +): + cli = make_cli(tmp_path) + + cli._reactions_detected = True + cli._reactions_selected = True + + cli.session.reaction_instances = [ + object() + ] + + calls = [] + + class FakeDetector: + def reaction_selection( + self, + session, + ): + calls.append(True) + + monkeypatch.setattr( + cli_module, + "ReactionDetector", + FakeDetector, + ) + + cli.select_reactions() + cli.select_reactions() + + assert calls == [] + + +# ============================================================================= +# show_non_reactants +# ============================================================================= + + +def test_show_non_reactants_returns_none_when_no_visualization( + tmp_path, + monkeypatch, +): + cli = make_cli(tmp_path) + + ensure_calls = [] + save_calls = [] + + monkeypatch.setattr( + cli, + "_ensure_non_reactants_detected", + lambda: ensure_calls.append(True), + ) + + class FakeDetector: + def non_reactants_to_visualization( + self, + session, + ): + assert session is cli.session + return None + + monkeypatch.setattr( + cli_module, + "NonReactantsDetector", + FakeDetector, + ) + + monkeypatch.setattr( + cli, + "_save_rdkit_img", + lambda *args, **kwargs: + save_calls.append(True), + ) + + result = cli.show_non_reactants() + + assert result is None + + assert ensure_calls == [ + True + ] + + assert save_calls == [] + + +def test_show_non_reactants_saves_visualization( + tmp_path, + monkeypatch, +): + cli = make_cli(tmp_path) + + image = object() + saved = [] + + monkeypatch.setattr( + cli, + "_ensure_non_reactants_detected", + lambda: None, + ) + + class FakeDetector: + def non_reactants_to_visualization( + self, + session, + ): + return image + + monkeypatch.setattr( + cli_module, + "NonReactantsDetector", + FakeDetector, + ) + + def fake_save( + img, + path, + is_non_reactant=False, + ): + saved.append( + ( + img, + Path(path), + is_non_reactant, + ) + ) + + monkeypatch.setattr( + cli, + "_save_rdkit_img", + fake_save, + ) + + result = cli.show_non_reactants() + + assert result is image + + assert saved == [ + ( + image, + cli.img_dir + / "non_reactants.png", + True, + ) + ] + + +# ============================================================================= +# select_non_reactants +# ============================================================================= + + +def test_select_non_reactants_calls_selection_when_candidates_exist( + tmp_path, + monkeypatch, +): + cli = make_cli(tmp_path) + + cli.session.non_reactants = [ + object() + ] + + calls = [] + + monkeypatch.setattr( + cli, + "_ensure_non_reactants_detected", + lambda: calls.append("detect"), + ) + + class FakeDetector: + def non_reactant_selection( + self, + session, + ): + assert session is cli.session + calls.append("select") + + monkeypatch.setattr( + cli_module, + "NonReactantsDetector", + FakeDetector, + ) + + cli.select_non_reactants() + + assert calls == [ + "detect", + "select", + ] + + assert ( + cli._non_reactants_selected + is True + ) + + assert ( + cli.error_handler[ + "select_non_reactants" + ] + is True + ) + + +def test_select_non_reactants_completes_when_none_exist( + tmp_path, + monkeypatch, +): + cli = make_cli(tmp_path) + + cli.session.non_reactants = [] + + selection_calls = [] + + monkeypatch.setattr( + cli, + "_ensure_non_reactants_detected", + lambda: None, + ) + + class FakeDetector: + def non_reactant_selection( + self, + session, + ): + selection_calls.append(True) + + monkeypatch.setattr( + cli_module, + "NonReactantsDetector", + FakeDetector, + ) + + cli.select_non_reactants() + + assert selection_calls == [] + + assert ( + cli._non_reactants_selected + is True + ) + + assert ( + cli.error_handler[ + "select_non_reactants" + ] + is True + ) + + +def test_select_non_reactants_is_idempotent( + tmp_path, + monkeypatch, +): + cli = make_cli(tmp_path) + + cli._non_reactants_selected = True + cli.session.non_reactants = [ + object() + ] + + calls = [] + + monkeypatch.setattr( + cli, + "_ensure_non_reactants_detected", + lambda: calls.append("detect"), + ) + + class FakeDetector: + def non_reactant_selection( + self, + session, + ): + calls.append("select") + + monkeypatch.setattr( + cli_module, + "NonReactantsDetector", + FakeDetector, + ) + + cli.select_non_reactants() + + # Detection helper is called first by the current implementation, + # but selection itself must not happen again. + assert calls == [ + "detect" + ] + + +# ============================================================================= +# prepare_reactions +# ============================================================================= + + +def test_prepare_reactions_delegates_and_marks_process_complete( + tmp_path, + monkeypatch, +): + cli = make_cli(tmp_path) + + calls = [] + + class FakePrepareReactions: + def __init__( + self, + session, + ): + assert session is cli.session + calls.append("init") + + def prepare_reactions( + self, + session, + ): + assert session is cli.session + calls.append("prepare") + + monkeypatch.setattr( + cli_module, + "PrepareReactions", + FakePrepareReactions, + ) + + result = cli.prepare_reactions() + + assert result is None + + assert calls == [ + "init", + "prepare", + ] + + assert ( + cli.error_handler["process"] + is True + ) + + +def test_show_reaction_templates_delegates_highlight_type( + tmp_path, + monkeypatch, +): + cli = make_cli(tmp_path) + + expected = object() + calls = [] + + class FakePrepareReactions: + def __init__( + self, + session, + ): + assert session is cli.session + + def reaction_templates_highlighted_image_grid( + self, + session, + highlight_type, + ): + calls.append( + ( + session, + highlight_type, + ) + ) + return expected + + monkeypatch.setattr( + cli_module, + "PrepareReactions", + FakePrepareReactions, + ) + + result = cli.show_reaction_templates( + highlight_type="edge" + ) + + assert result is expected + + assert calls == [ + ( + cli.session, + "edge", + ) + ] + + +# ============================================================================= +# process guards +# ============================================================================= + + +def test_process_requires_reaction_selection( + tmp_path, +): + cli = make_cli(tmp_path) + + with pytest.raises( + RuntimeError, + match="Reactions have not been selected", + ): + cli.process() + + +def test_process_requires_non_reactant_selection( + tmp_path, +): + cli = make_cli(tmp_path) + + cli.error_handler[ + "select_reactions" + ] = True + + with pytest.raises( + RuntimeError, + match="Non-reactants have not been selected", + ): + cli.process() + + +def test_process_requires_reaction_preparation( + tmp_path, +): + cli = make_cli(tmp_path) + + cli.error_handler[ + "select_reactions" + ] = True + + cli.error_handler[ + "select_non_reactants" + ] = True + + with pytest.raises( + RuntimeError, + match="Processing has not been completed", + ): + cli.process() + + +# ============================================================================= +# process pipeline +# ============================================================================= + + +def test_process_runs_pipeline_in_correct_order( + tmp_path, + monkeypatch, +): + cli = make_cli(tmp_path) + + cli.error_handler[ + "select_reactions" + ] = True + + cli.error_handler[ + "select_non_reactants" + ] = True + + cli.error_handler[ + "process" + ] = True + + calls = [] + + @contextmanager + def fake_writer( + filename="AutoREACTER.log", + ): + calls.append("writer_enter") + yield + calls.append("writer_exit") + + monkeypatch.setattr( + cli, + "_writer", + fake_writer, + ) + + class FakeMolecule3DPreparation: + def __init__( + self, + session, + ): + assert session is cli.session + + def prepare_molecule_3d_geometry( + self, + session, + ): + assert session is cli.session + calls.append("3d") + + class FakeFFWrapper: + def __init__( + self, + session, + ): + assert session is cli.session + + def generate_force_field_files( + self, + session, + ): + assert session is cli.session + calls.append("ff") + + class FakeREACTERFilesBuilder: + def __init__( + self, + session, + ): + assert session is cli.session + + def molecule_template_preparation( + self, + session, + ): + assert session is cli.session + calls.append("reacter") + + class FakeSimulationSetupManager: + def setup_and_write_simulation( + self, + session, + ): + assert session is cli.session + calls.append("simulation") + + class FakePrepareReactions: + def __init__( + self, + session, + ): + assert session is cli.session + + def reaction_templates_highlighted_image_grid( + self, + session, + highlight_type, + ): + assert session is cli.session + + calls.append( + f"highlight:{highlight_type}" + ) + + return ( + f"image-{highlight_type}" + ) + + monkeypatch.setattr( + cli_module, + "Molecule3DPreparation", + FakeMolecule3DPreparation, + ) + + monkeypatch.setattr( + cli_module, + "FFWrapper", + FakeFFWrapper, + ) + + monkeypatch.setattr( + cli_module, + "REACTERFilesBuilder", + FakeREACTERFilesBuilder, + ) + + monkeypatch.setattr( + cli_module, + "SimulationSetupManager", + FakeSimulationSetupManager, + ) + + monkeypatch.setattr( + cli_module, + "PrepareReactions", + FakePrepareReactions, + ) + + def fake_save( + image, + path, + is_non_reactant=False, + ): + calls.append( + f"save:{Path(path).name}" + ) + + monkeypatch.setattr( + cli, + "_save_rdkit_img", + fake_save, + ) + + cli.process() + + assert calls == [ + "writer_enter", + "3d", + "ff", + "reacter", + "simulation", + "highlight:template", + "save:templates_template.png", + "highlight:edge", + "save:templates_edge.png", + "highlight:initiators", + "save:templates_initiators.png", + "highlight:delete", + "save:templates_delete.png", + "writer_exit", + ] + + assert ( + cli.error_handler["process"] + is True + ) + + +# ============================================================================= +# _save_input_json +# ============================================================================= + + +def test_save_input_json_copies_to_output_directory( + tmp_path, +): + cli = make_cli(tmp_path) + + source = tmp_path / "original.json" + + source.write_text( + '{"hello": "world"}', + encoding="utf-8", + ) + + cli._save_input_json(source) + + destination = ( + cli.session.output_dir + / "input.json" + ) + + assert destination.exists() + + assert ( + destination.read_text( + encoding="utf-8" + ) + == '{"hello": "world"}' + ) + + +def test_save_input_json_overwrites_existing_copy( + tmp_path, +): + cli = make_cli(tmp_path) + + source = tmp_path / "source.json" + + source.write_text( + "new content", + encoding="utf-8", + ) + + destination = ( + cli.session.output_dir + / "input.json" + ) + + destination.write_text( + "old content", + encoding="utf-8", + ) + + cli._save_input_json(source) + + assert ( + destination.read_text( + encoding="utf-8" + ) + == "new content" + ) + + +# ============================================================================= +# _ensure_fg_detected +# ============================================================================= + + +def test_ensure_fg_detected_runs_detector_once( + tmp_path, + monkeypatch, +): + cli = make_cli(tmp_path) + + calls = [] + + class FakeDetector: + def functional_groups_detector( + self, + session, + ): + assert session is cli.session + calls.append("detect") + + monkeypatch.setattr( + cli_module, + "FunctionalGroupsDetector", + FakeDetector, + ) + + cli._ensure_fg_detected() + cli._ensure_fg_detected() + + assert calls == [ + "detect" + ] + + assert cli._fg_detected is True + + +def test_ensure_fg_detected_sets_flag_only_after_success( + tmp_path, + monkeypatch, +): + cli = make_cli(tmp_path) + + class FakeDetector: + def functional_groups_detector( + self, + session, + ): + raise RuntimeError( + "detector failed" + ) + + monkeypatch.setattr( + cli_module, + "FunctionalGroupsDetector", + FakeDetector, + ) + + with pytest.raises( + RuntimeError, + match="detector failed", + ): + cli._ensure_fg_detected() + + assert cli._fg_detected is False + + +# ============================================================================= +# _ensure_reactions_detected +# ============================================================================= + + +def test_ensure_reactions_detected_runs_reaction_detection_once( + tmp_path, + monkeypatch, +): + cli = make_cli(tmp_path) + + calls = [] + saved = [] + + fg_image = object() + reaction_image = object() + + class FakeFGDetector: + def functional_groups_detector( + self, + session, + ): + calls.append("fg_detect") + + def functional_group_highlighted_molecules_image_grid( + self, + session, + ): + calls.append("fg_image") + return fg_image + + class FakeReactionDetector: + def reaction_detector( + self, + session, + ): + calls.append("reaction_detect") + + def available_reaction_image_grid( + self, + session, + ): + calls.append("reaction_image") + return reaction_image + + monkeypatch.setattr( + cli_module, + "FunctionalGroupsDetector", + FakeFGDetector, + ) + + monkeypatch.setattr( + cli_module, + "ReactionDetector", + FakeReactionDetector, + ) + + def fake_save( + image, + path, + is_non_reactant=False, + ): + saved.append( + ( + image, + Path(path).name, + ) + ) + + monkeypatch.setattr( + cli, + "_save_rdkit_img", + fake_save, + ) + + cli._ensure_reactions_detected() + cli._ensure_reactions_detected() + + assert calls.count( + "fg_detect" + ) == 1 + + assert calls.count( + "reaction_detect" + ) == 1 + + assert calls.count( + "reaction_image" + ) == 1 + + # Current implementation regenerates/saves the FG visualization + # whenever this private helper is called, even after reaction detection + # has already completed. Reaction detection itself remains idempotent. + assert calls.count( + "fg_image" + ) == 2 + + assert cli._fg_detected is True + + assert ( + cli._reactions_detected + is True + ) + + assert ( + ( + reaction_image, + "reactions.png", + ) + in saved + ) + + +def test_ensure_reactions_detected_does_not_mark_success_after_detector_error( + tmp_path, + monkeypatch, +): + cli = make_cli(tmp_path) + + class FakeFGDetector: + def functional_groups_detector( + self, + session, + ): + pass + + def functional_group_highlighted_molecules_image_grid( + self, + session, + ): + return object() + + class FakeReactionDetector: + def reaction_detector( + self, + session, + ): + raise RuntimeError( + "reaction failure" + ) + + monkeypatch.setattr( + cli_module, + "FunctionalGroupsDetector", + FakeFGDetector, + ) + + monkeypatch.setattr( + cli_module, + "ReactionDetector", + FakeReactionDetector, + ) + + monkeypatch.setattr( + cli, + "_save_rdkit_img", + lambda *args, **kwargs: None, + ) + + with pytest.raises( + RuntimeError, + match="reaction failure", + ): + cli._ensure_reactions_detected() + + assert cli._fg_detected is True + + assert ( + cli._reactions_detected + is False + ) + + +# ============================================================================= +# _ensure_non_reactants_detected +# ============================================================================= + + +def test_ensure_non_reactants_detected_selects_reactions_first( + tmp_path, + monkeypatch, +): + cli = make_cli(tmp_path) + + calls = [] + + def fake_select_reactions(): + calls.append("select_reactions") + cli._reactions_selected = True + + monkeypatch.setattr( + cli, + "select_reactions", + fake_select_reactions, + ) + + class FakeDetector: + def non_monomer_detector( + self, + session, + ): + assert session is cli.session + calls.append( + "detect_non_reactants" + ) + + monkeypatch.setattr( + cli_module, + "NonReactantsDetector", + FakeDetector, + ) + + cli._ensure_non_reactants_detected() + + assert calls == [ + "select_reactions", + "detect_non_reactants", + ] + + assert ( + cli._non_reactants_detected + is True + ) + + +def test_ensure_non_reactants_detected_is_idempotent( + tmp_path, + monkeypatch, +): + cli = make_cli(tmp_path) + + cli._reactions_selected = True + + calls = [] + + class FakeDetector: + def non_monomer_detector( + self, + session, + ): + calls.append(True) + + monkeypatch.setattr( + cli_module, + "NonReactantsDetector", + FakeDetector, + ) + + cli._ensure_non_reactants_detected() + cli._ensure_non_reactants_detected() + + assert calls == [ + True + ] + + +# ============================================================================= +# _save_rdkit_img +# ============================================================================= + + +def test_save_rdkit_img_creates_parent_directories( + tmp_path, +): + cli = make_cli(tmp_path) + + image = PILImage.new( + "RGB", + (2, 2), + ) + + path = ( + tmp_path + / "nested" + / "more" + / "image.png" + ) + + cli._save_rdkit_img( + image, + path, + ) + + assert path.exists() + + +def test_save_rdkit_img_saves_pil_image( + tmp_path, +): + cli = make_cli(tmp_path) + + image = PILImage.new( + "RGB", + (3, 3), + ) + + path = tmp_path / "image.png" + + cli._save_rdkit_img( + image, + path, + ) + + assert path.exists() + + loaded = PILImage.open(path) + + assert loaded.size == ( + 3, + 3, + ) + + +@pytest.mark.parametrize( + "data", + [ + b"\x89PNGtest", + bytearray(b"\x89PNGtest"), + ], +) +def test_save_rdkit_img_writes_raw_bytes( + tmp_path, + data, +): + cli = make_cli(tmp_path) + + path = tmp_path / "raw.bin" + + cli._save_rdkit_img( + data, + path, + ) + + assert ( + path.read_bytes() + == bytes(data) + ) + + +def test_save_rdkit_img_writes_image_data_bytes( + tmp_path, +): + cli = make_cli(tmp_path) + + image = SimpleNamespace( + data=b"image-data" + ) + + path = tmp_path / "image.bin" + + cli._save_rdkit_img( + image, + path, + ) + + assert ( + path.read_bytes() + == b"image-data" + ) + + +def test_save_rdkit_img_writes_image_data_string( + tmp_path, +): + cli = make_cli(tmp_path) + + image = SimpleNamespace( + data="hello" + ) + + path = tmp_path / "image.svg" + + cli._save_rdkit_img( + image, + path, + ) + + assert ( + path.read_text( + encoding="utf-8" + ) + == "hello" + ) + + +def test_save_rdkit_img_writes_direct_svg_string( + tmp_path, +): + cli = make_cli(tmp_path) + + path = tmp_path / "image.svg" + + cli._save_rdkit_img( + "direct", + path, + ) + + assert ( + path.read_text( + encoding="utf-8" + ) + == "direct" + ) + + +def test_save_rdkit_img_allows_none_for_non_reactants( + tmp_path, +): + cli = make_cli(tmp_path) + + path = tmp_path / "none.png" + + result = cli._save_rdkit_img( + None, + path, + is_non_reactant=True, + ) + + assert result is None + assert not path.exists() + + +def test_save_rdkit_img_rejects_none_for_required_image( + tmp_path, +): + cli = make_cli(tmp_path) + + with pytest.raises( + NoReactionGenerated, + match="No reaction was generated", + ): + cli._save_rdkit_img( + None, + tmp_path / "missing.png", + ) + + +def test_save_rdkit_img_rejects_unsupported_type( + tmp_path, +): + cli = make_cli(tmp_path) + + with pytest.raises( + TypeError, + match="Unsupported image type", + ): + cli._save_rdkit_img( + 12345, + tmp_path / "bad.png", + ) + + +# ============================================================================= +# _writer +# ============================================================================= + + +def test_writer_writes_python_and_os_output_to_log( + tmp_path, + capfd, +): + cli = make_cli(tmp_path) + + with cli._writer(): + print( + "python-output", + flush=True, + ) + + os.write( + 1, + b"os-output\n", + ) + + log_path = ( + cli.session.output_dir + / "AutoREACTER.log" + ) + + log_text = log_path.read_text( + encoding="utf-8" + ) + + assert "python-output" in log_text + assert "os-output" in log_text + + terminal_output = ( + capfd.readouterr().out + ) + + assert "python-output" in terminal_output + assert "os-output" in terminal_output + + +def test_writer_supports_custom_filename( + tmp_path, +): + cli = make_cli(tmp_path) + + with cli._writer( + "custom.log" + ): + print( + "custom-output", + flush=True, + ) + + log_path = ( + cli.session.output_dir + / "custom.log" + ) + + assert log_path.exists() + + assert ( + "custom-output" + in log_path.read_text( + encoding="utf-8" + ) + ) + + +def test_writer_restores_stdout_after_exception( + tmp_path, + capfd, +): + cli = make_cli(tmp_path) + + with pytest.raises( + RuntimeError, + match="boom", + ): + with cli._writer( + "exception.log" + ): + print( + "before-exception", + flush=True, + ) + + raise RuntimeError( + "boom" + ) + + print( + "after-exception", + flush=True, + ) + + terminal_output = ( + capfd.readouterr().out + ) + + assert ( + "after-exception" + in terminal_output + ) + + log_path = ( + cli.session.output_dir + / "exception.log" + ) + + log_text = log_path.read_text( + encoding="utf-8" + ) + + assert ( + "before-exception" + in log_text + ) + + assert ( + "after-exception" + not in log_text + ) + + +def test_writer_appends_to_existing_log( + tmp_path, +): + cli = make_cli(tmp_path) + + with cli._writer(): + print( + "first-run", + flush=True, + ) + + with cli._writer(): + print( + "second-run", + flush=True, + ) + + log_path = ( + cli.session.output_dir + / "AutoREACTER.log" + ) + + log_text = log_path.read_text( + encoding="utf-8" + ) + + assert "first-run" in log_text + assert "second-run" in log_text + + +# ============================================================================= +# __repr__ +# ============================================================================= + + +def test_repr_with_input_path( + tmp_path, +): + cli = ARXCLI.__new__( + ARXCLI + ) + + cli.input = ( + tmp_path / "input.json" + ) + + assert repr(cli) == ( + f"ARXCLI(input={cli.input})" + ) + + +def test_repr_without_input(): + cli = ARXCLI.__new__( + ARXCLI + ) + + assert repr(cli) == "ARXCLI()" \ No newline at end of file diff --git a/tests/unit/test_cache.py b/tests/unit/test_cache.py new file mode 100644 index 00000000..834ea13e --- /dev/null +++ b/tests/unit/test_cache.py @@ -0,0 +1,1105 @@ +from pathlib import Path +from types import SimpleNamespace + +import pytest + +import AutoREACTER.cache as cache_module +from AutoREACTER.cache import ( + GetCacheDir, + RunDirectoryManager, +) + + +# ============================================================================= +# Helpers +# ============================================================================= + + +def make_fake_reacter_files(old_base: Path): + """ + Build a lightweight REACTERFiles-like object. + + The production function only requires these attributes, so using + SimpleNamespace keeps cache tests independent from the force-field and + reaction-template builders. + """ + force_field_data = old_base / "force_field.data" + in_file = old_base / "in.create_atoms.script" + molecule_file = old_base / "monomer.molecule" + map_file = old_base / "RXN_1.map" + pre_file = old_base / "template_pre_1.molecule" + post_file = old_base / "template_post_1.molecule" + + for path in [ + force_field_data, + in_file, + molecule_file, + map_file, + pre_file, + post_file, + ]: + path.parent.mkdir( + parents=True, + exist_ok=True, + ) + path.write_text( + path.name, + encoding="utf-8", + ) + + molecule = SimpleNamespace( + molecule_files=SimpleNamespace( + lmp_molecule_file=molecule_file, + ) + ) + + template = SimpleNamespace( + map_file=map_file, + pre_reaction_file=SimpleNamespace( + lmp_molecule_file=pre_file, + ), + post_reaction_file=SimpleNamespace( + lmp_molecule_file=post_file, + ), + ) + + return SimpleNamespace( + force_field_data=force_field_data, + in_file=in_file, + molecule_files=[molecule], + template_files=[template], + ) + + +# ============================================================================= +# GetCacheDir construction +# ============================================================================= + + +def test_get_cache_dir_creates_staging_directory( + tmp_path, + monkeypatch, +): + monkeypatch.setattr( + cache_module.tempfile, + "gettempdir", + lambda: str(tmp_path), + ) + + cache = GetCacheDir() + + assert cache.staging_dir.exists() + assert cache.staging_dir.is_dir() + assert cache.staging_dir.parent == tmp_path + + +def test_get_cache_dir_default_clears_existing_staging_contents( + tmp_path, + monkeypatch, +): + monkeypatch.setattr( + cache_module.tempfile, + "gettempdir", + lambda: str(tmp_path), + ) + + staging = ( + tmp_path + / "AutoREACTER_staging" + ) + staging.mkdir() + + old_file = staging / "old.txt" + old_file.write_text( + "old", + encoding="utf-8", + ) + + old_directory = staging / "nested" + old_directory.mkdir() + + ( + old_directory / "nested.txt" + ).write_text( + "nested", + encoding="utf-8", + ) + + cache = GetCacheDir() + + assert cache.staging_dir == staging + assert staging.exists() + assert list(staging.iterdir()) == [] + + +def test_get_cache_dir_clear_staging_true_clears_contents( + tmp_path, + monkeypatch, +): + monkeypatch.setattr( + cache_module.tempfile, + "gettempdir", + lambda: str(tmp_path), + ) + + staging = ( + tmp_path + / "AutoREACTER_staging" + ) + staging.mkdir() + + old_file = staging / "old.txt" + old_file.write_text( + "old", + encoding="utf-8", + ) + + GetCacheDir( + clear_staging=True + ) + + assert not old_file.exists() + + +def test_get_cache_dir_clear_staging_false_preserves_contents( + tmp_path, + monkeypatch, +): + """ + The constructor documents clear_staging=False as disabling automatic + staging cleanup. Existing temporary data must therefore remain untouched. + """ + monkeypatch.setattr( + cache_module.tempfile, + "gettempdir", + lambda: str(tmp_path), + ) + + staging = ( + tmp_path + / "AutoREACTER_staging" + ) + staging.mkdir() + + old_file = staging / "keep.txt" + old_file.write_text( + "keep me", + encoding="utf-8", + ) + + cache = GetCacheDir( + clear_staging=False + ) + + assert cache.staging_dir == staging + assert old_file.exists() + + assert ( + old_file.read_text( + encoding="utf-8" + ) + == "keep me" + ) + + +# ============================================================================= +# clear_staging_dir +# ============================================================================= + + +def test_clear_staging_dir_removes_files( + tmp_path, +): + cache = GetCacheDir.__new__( + GetCacheDir + ) + + cache.staging_dir = ( + tmp_path / "staging" + ) + cache.staging_dir.mkdir() + + file_path = ( + cache.staging_dir / "file.txt" + ) + file_path.write_text( + "data", + encoding="utf-8", + ) + + cache.clear_staging_dir() + + assert not file_path.exists() + assert cache.staging_dir.exists() + + +def test_clear_staging_dir_removes_nested_directories( + tmp_path, +): + cache = GetCacheDir.__new__( + GetCacheDir + ) + + cache.staging_dir = ( + tmp_path / "staging" + ) + + nested = ( + cache.staging_dir + / "a" + / "b" + / "c" + ) + nested.mkdir( + parents=True + ) + + ( + nested / "data.txt" + ).write_text( + "data", + encoding="utf-8", + ) + + cache.clear_staging_dir() + + assert cache.staging_dir.exists() + assert list( + cache.staging_dir.iterdir() + ) == [] + + +def test_clear_staging_dir_creates_missing_root( + tmp_path, +): + cache = GetCacheDir.__new__( + GetCacheDir + ) + + cache.staging_dir = ( + tmp_path + / "missing_staging" + ) + + assert not cache.staging_dir.exists() + + cache.clear_staging_dir() + + assert cache.staging_dir.is_dir() + + +def test_clear_staging_dir_prints_success( + tmp_path, + capsys, +): + cache = GetCacheDir.__new__( + GetCacheDir + ) + + cache.staging_dir = ( + tmp_path / "staging" + ) + cache.staging_dir.mkdir() + + cache.clear_staging_dir() + + output = capsys.readouterr().out + + assert ( + "[OK] Cleared staging cache:" + in output + ) + + assert str( + cache.staging_dir + ) in output + + +def test_clear_staging_dir_continues_after_failed_item( + tmp_path, + monkeypatch, + capsys, +): + cache = GetCacheDir.__new__( + GetCacheDir + ) + + cache.staging_dir = ( + tmp_path / "staging" + ) + cache.staging_dir.mkdir() + + failing_dir = ( + cache.staging_dir + / "cannot_remove" + ) + failing_dir.mkdir() + + removable_file = ( + cache.staging_dir + / "remove_me.txt" + ) + removable_file.write_text( + "remove", + encoding="utf-8", + ) + + real_rmtree = ( + cache_module.shutil.rmtree + ) + + def fake_rmtree(path, *args, **kwargs): + if Path(path) == failing_dir: + raise PermissionError( + "simulated failure" + ) + + return real_rmtree( + path, + *args, + **kwargs, + ) + + monkeypatch.setattr( + cache_module.shutil, + "rmtree", + fake_rmtree, + ) + + cache.clear_staging_dir() + + assert failing_dir.exists() + assert not removable_file.exists() + + output = capsys.readouterr().out + + assert ( + "[WARN] Failed to remove staging cache item" + in output + ) + + assert ( + "[WARN] Staging cache partially cleared" + in output + ) + + +# ============================================================================= +# RunDirectoryManager construction +# ============================================================================= + + +def test_run_directory_manager_creates_base_directory( + tmp_path, +): + base_dir = ( + tmp_path + / "runs" + / "nested" + ) + + assert not base_dir.exists() + + manager = RunDirectoryManager( + base_dir + ) + + assert manager.base_dir == base_dir + assert base_dir.is_dir() + + +def test_run_directory_manager_accepts_string_path( + tmp_path, +): + base_dir = tmp_path / "runs" + + manager = RunDirectoryManager( + str(base_dir) + ) + + assert isinstance( + manager.base_dir, + Path, + ) + + assert ( + manager.base_dir + == base_dir + ) + + +# ============================================================================= +# remove_path +# ============================================================================= + + +def test_remove_path_removes_file( + tmp_path, +): + manager = RunDirectoryManager( + tmp_path / "runs" + ) + + path = tmp_path / "file.txt" + path.write_text( + "data", + encoding="utf-8", + ) + + manager.remove_path(path) + + assert not path.exists() + + +def test_remove_path_removes_directory_recursively( + tmp_path, +): + manager = RunDirectoryManager( + tmp_path / "runs" + ) + + directory = tmp_path / "folder" + nested = directory / "nested" + nested.mkdir( + parents=True + ) + + ( + nested / "data.txt" + ).write_text( + "data", + encoding="utf-8", + ) + + manager.remove_path( + directory + ) + + assert not directory.exists() + + +def test_remove_path_nonexistent_path_is_noop( + tmp_path, +): + manager = RunDirectoryManager( + tmp_path / "runs" + ) + + missing = ( + tmp_path / "missing" + ) + + manager.remove_path(missing) + + assert not missing.exists() + + +# ============================================================================= +# move_into_run +# ============================================================================= + + +def test_move_into_run_moves_all_contents( + tmp_path, +): + manager = RunDirectoryManager( + tmp_path / "runs" + ) + + source = tmp_path / "source" + destination = ( + tmp_path / "destination" + ) + + source.mkdir() + destination.mkdir() + + ( + source / "one.txt" + ).write_text( + "one", + encoding="utf-8", + ) + + ( + source / "two.txt" + ).write_text( + "two", + encoding="utf-8", + ) + + result = manager.move_into_run( + source, + destination, + ) + + assert result == destination + + assert ( + destination / "one.txt" + ).read_text( + encoding="utf-8" + ) == "one" + + assert ( + destination / "two.txt" + ).read_text( + encoding="utf-8" + ) == "two" + + assert list(source.iterdir()) == [] + + +def test_move_into_run_moves_directories( + tmp_path, +): + manager = RunDirectoryManager( + tmp_path / "runs" + ) + + source = tmp_path / "source" + destination = ( + tmp_path / "destination" + ) + + source.mkdir() + destination.mkdir() + + nested = source / "nested" + nested.mkdir() + + ( + nested / "data.txt" + ).write_text( + "nested data", + encoding="utf-8", + ) + + manager.move_into_run( + source, + destination, + ) + + moved_file = ( + destination + / "nested" + / "data.txt" + ) + + assert moved_file.exists() + + assert ( + moved_file.read_text( + encoding="utf-8" + ) + == "nested data" + ) + + +def test_move_into_run_overwrites_existing_file( + tmp_path, +): + manager = RunDirectoryManager( + tmp_path / "runs" + ) + + source = tmp_path / "source" + destination = ( + tmp_path / "destination" + ) + + source.mkdir() + destination.mkdir() + + source_file = ( + source / "same.txt" + ) + source_file.write_text( + "new", + encoding="utf-8", + ) + + existing = ( + destination / "same.txt" + ) + existing.write_text( + "old", + encoding="utf-8", + ) + + manager.move_into_run( + source, + destination, + ) + + assert ( + existing.read_text( + encoding="utf-8" + ) + == "new" + ) + + +def test_move_into_run_overwrites_existing_directory( + tmp_path, +): + manager = RunDirectoryManager( + tmp_path / "runs" + ) + + source = tmp_path / "source" + destination = ( + tmp_path / "destination" + ) + + source.mkdir() + destination.mkdir() + + incoming = source / "folder" + incoming.mkdir() + + ( + incoming / "new.txt" + ).write_text( + "new", + encoding="utf-8", + ) + + existing = ( + destination / "folder" + ) + existing.mkdir() + + ( + existing / "old.txt" + ).write_text( + "old", + encoding="utf-8", + ) + + manager.move_into_run( + source, + destination, + ) + + assert not ( + destination + / "folder" + / "old.txt" + ).exists() + + assert ( + destination + / "folder" + / "new.txt" + ).exists() + + +def test_move_into_run_preserves_unrelated_destination_files( + tmp_path, +): + manager = RunDirectoryManager( + tmp_path / "runs" + ) + + source = tmp_path / "source" + destination = ( + tmp_path / "destination" + ) + + source.mkdir() + destination.mkdir() + + ( + source / "incoming.txt" + ).write_text( + "incoming", + encoding="utf-8", + ) + + unrelated = ( + destination / "keep.txt" + ) + unrelated.write_text( + "keep", + encoding="utf-8", + ) + + manager.move_into_run( + source, + destination, + ) + + assert unrelated.exists() + + assert ( + unrelated.read_text( + encoding="utf-8" + ) + == "keep" + ) + + +def test_move_into_run_prints_destination( + tmp_path, + capsys, +): + manager = RunDirectoryManager( + tmp_path / "runs" + ) + + source = tmp_path / "source" + destination = ( + tmp_path / "destination" + ) + + source.mkdir() + destination.mkdir() + + manager.move_into_run( + source, + destination, + ) + + output = capsys.readouterr().out + + assert "[OK] Moved files" in output + assert str(destination) in output + + +# ============================================================================= +# move_reacter_files +# ============================================================================= + + +def test_move_reacter_files_requires_reacter_directory( + tmp_path, +): + manager = RunDirectoryManager( + tmp_path / "runs" + ) + + staging = tmp_path / "staging" + final = tmp_path / "final" + + staging.mkdir() + final.mkdir() + + fake_reacter_files = ( + SimpleNamespace() + ) + + expected = ( + staging + / "lunar" + / "REACTER_files" + ) + + with pytest.raises( + FileNotFoundError, + match="REACTER files not found", + ): + manager.move_reacter_files( + fake_reacter_files, + staging, + final, + ) + + assert not expected.exists() + + +def test_move_reacter_files_moves_physical_files( + tmp_path, +): + manager = RunDirectoryManager( + tmp_path / "runs" + ) + + staging = tmp_path / "staging" + final = tmp_path / "final" + + old_base = ( + staging + / "lunar" + / "REACTER_files" + ) + + old_base.mkdir( + parents=True + ) + final.mkdir() + + reacter_files = ( + make_fake_reacter_files( + old_base + ) + ) + + manager.move_reacter_files( + reacter_files, + staging, + final, + ) + + expected_names = { + "force_field.data", + "in.create_atoms.script", + "monomer.molecule", + "RXN_1.map", + "template_pre_1.molecule", + "template_post_1.molecule", + } + + assert { + item.name + for item in final.iterdir() + } == expected_names + + assert ( + list(old_base.iterdir()) + == [] + ) + + +def test_move_reacter_files_remaps_run_level_paths( + tmp_path, +): + manager = RunDirectoryManager( + tmp_path / "runs" + ) + + staging = tmp_path / "staging" + final = tmp_path / "final" + + old_base = ( + staging + / "lunar" + / "REACTER_files" + ) + + old_base.mkdir( + parents=True + ) + final.mkdir() + + reacter_files = ( + make_fake_reacter_files( + old_base + ) + ) + + result = manager.move_reacter_files( + reacter_files, + staging, + final, + ) + + assert result is reacter_files + + assert ( + result.force_field_data + == final / "force_field.data" + ) + + assert ( + result.in_file + == final / "in.create_atoms.script" + ) + + +def test_move_reacter_files_remaps_molecule_file_paths( + tmp_path, +): + manager = RunDirectoryManager( + tmp_path / "runs" + ) + + staging = tmp_path / "staging" + final = tmp_path / "final" + + old_base = ( + staging + / "lunar" + / "REACTER_files" + ) + + old_base.mkdir( + parents=True + ) + final.mkdir() + + reacter_files = ( + make_fake_reacter_files( + old_base + ) + ) + + manager.move_reacter_files( + reacter_files, + staging, + final, + ) + + molecule_path = ( + reacter_files + .molecule_files[0] + .molecule_files + .lmp_molecule_file + ) + + assert ( + molecule_path + == final / "monomer.molecule" + ) + + +def test_move_reacter_files_remaps_template_paths( + tmp_path, +): + manager = RunDirectoryManager( + tmp_path / "runs" + ) + + staging = tmp_path / "staging" + final = tmp_path / "final" + + old_base = ( + staging + / "lunar" + / "REACTER_files" + ) + + old_base.mkdir( + parents=True + ) + final.mkdir() + + reacter_files = ( + make_fake_reacter_files( + old_base + ) + ) + + manager.move_reacter_files( + reacter_files, + staging, + final, + ) + + template = ( + reacter_files.template_files[0] + ) + + assert ( + template.map_file + == final / "RXN_1.map" + ) + + assert ( + template + .pre_reaction_file + .lmp_molecule_file + == final / "template_pre_1.molecule" + ) + + assert ( + template + .post_reaction_file + .lmp_molecule_file + == final / "template_post_1.molecule" + ) + + +def test_move_reacter_files_preserves_paths_outside_old_base( + tmp_path, +): + manager = RunDirectoryManager( + tmp_path / "runs" + ) + + staging = tmp_path / "staging" + final = tmp_path / "final" + + old_base = ( + staging + / "lunar" + / "REACTER_files" + ) + + old_base.mkdir( + parents=True + ) + final.mkdir() + + reacter_files = ( + make_fake_reacter_files( + old_base + ) + ) + + external_file = ( + tmp_path / "external.data" + ) + external_file.write_text( + "external", + encoding="utf-8", + ) + + reacter_files.force_field_data = ( + external_file + ) + + manager.move_reacter_files( + reacter_files, + staging, + final, + ) + + assert ( + reacter_files.force_field_data + == external_file + ) + + +def test_move_reacter_files_prints_success( + tmp_path, + capsys, +): + manager = RunDirectoryManager( + tmp_path / "runs" + ) + + staging = tmp_path / "staging" + final = tmp_path / "final" + + old_base = ( + staging + / "lunar" + / "REACTER_files" + ) + + old_base.mkdir( + parents=True + ) + final.mkdir() + + reacter_files = ( + make_fake_reacter_files( + old_base + ) + ) + + manager.move_reacter_files( + reacter_files, + staging, + final, + ) + + output = capsys.readouterr().out + + assert ( + "[OK] REACTER files moved" + in output + ) + + assert str(final) in output \ No newline at end of file diff --git a/tests/unit/test_initialization.py b/tests/unit/test_initialization.py new file mode 100644 index 00000000..2dc5a423 --- /dev/null +++ b/tests/unit/test_initialization.py @@ -0,0 +1,276 @@ +import importlib + +import pytest + +from AutoREACTER.initialization import Initialization + + +# ============================================================================= +# Constructor +# ============================================================================= + + +def test_initialization_runs_all_steps_in_order(monkeypatch): + calls = [] + + monkeypatch.setattr( + Initialization, + "moldule_imports", + classmethod( + lambda cls: calls.append("modules") + ), + ) + + monkeypatch.setattr( + Initialization, + "ASCII_Mupt_reaction_LAMMPS", + classmethod( + lambda cls: calls.append("ascii") + ), + ) + + monkeypatch.setattr( + Initialization, + "print_version", + classmethod( + lambda cls: calls.append("version") + ), + ) + + Initialization() + + assert calls == [ + "modules", + "ascii", + "version", + ] + + +# ============================================================================= +# Required module imports +# ============================================================================= + + +def test_moldule_imports_imports_all_required_modules( + monkeypatch, + capsys, +): + imported_modules = [] + + def fake_import_module(name): + imported_modules.append(name) + return object() + + monkeypatch.setattr( + importlib, + "import_module", + fake_import_module, + ) + + Initialization.moldule_imports() + + assert imported_modules == [ + "rdkit", + "pandas", + "numpy", + "networkx", + ] + + output = capsys.readouterr().out + + assert ( + "All required modules are successfully imported." + in output + ) + + +def test_moldule_imports_stops_when_module_is_missing( + monkeypatch, +): + imported_modules = [] + + def fake_import_module(name): + imported_modules.append(name) + + if name == "numpy": + error = ModuleNotFoundError( + "No module named 'numpy'" + ) + error.name = "numpy" + raise error + + return object() + + monkeypatch.setattr( + importlib, + "import_module", + fake_import_module, + ) + + with pytest.raises(RuntimeError): + Initialization.moldule_imports() + + assert imported_modules == [ + "rdkit", + "pandas", + "numpy", + ] + + +def test_moldule_imports_missing_module_error_mentions_module_name( + monkeypatch, +): + def fake_import_module(name): + if name == "pandas": + error = ModuleNotFoundError( + "No module named 'pandas'" + ) + error.name = "pandas" + raise error + + return object() + + monkeypatch.setattr( + importlib, + "import_module", + fake_import_module, + ) + + with pytest.raises( + RuntimeError, + match="pandas", + ): + Initialization.moldule_imports() + + +def test_moldule_imports_missing_module_error_has_helpful_message( + monkeypatch, +): + def fake_import_module(name): + error = ModuleNotFoundError( + f"No module named '{name}'" + ) + error.name = name + raise error + + monkeypatch.setattr( + importlib, + "import_module", + fake_import_module, + ) + + with pytest.raises(RuntimeError) as exc_info: + Initialization.moldule_imports() + + message = str(exc_info.value) + + assert "Required module not found" in message + assert "Please install the missing module" in message + assert "Exiting program" in message + + +def test_moldule_imports_does_not_print_success_when_import_fails( + monkeypatch, + capsys, +): + def fake_import_module(name): + error = ModuleNotFoundError( + f"No module named '{name}'" + ) + error.name = name + raise error + + monkeypatch.setattr( + importlib, + "import_module", + fake_import_module, + ) + + with pytest.raises(RuntimeError): + Initialization.moldule_imports() + + output = capsys.readouterr().out + + assert ( + "All required modules are successfully imported." + not in output + ) + + +# ============================================================================= +# ASCII banner +# ============================================================================= + + +def test_ascii_banner_prints_autoreacter_name( + capsys, +): + Initialization.ASCII_Mupt_reaction_LAMMPS() + + output = capsys.readouterr().out + + assert output.strip() != "" + + # The banner is ASCII art, so use stable fragments rather + # than depending on every whitespace character. + assert "oooo" in output + assert "888" in output + + +def test_ascii_banner_contains_multiple_lines( + capsys, +): + Initialization.ASCII_Mupt_reaction_LAMMPS() + + output = capsys.readouterr().out + + lines = [ + line + for line in output.splitlines() + if line.strip() + ] + + assert len(lines) >= 7 + + +# ============================================================================= +# Version printing +# ============================================================================= + + +def test_print_version_uses_autoreacter_version( + monkeypatch, + capsys, +): + import AutoREACTER + + monkeypatch.setattr( + AutoREACTER, + "__version__", + "9.9.9-test", + ) + + Initialization.print_version() + + output = capsys.readouterr().out + + assert ( + "AutoREACTER version: 9.9.9-test" + in output + ) + + +def test_print_version_prints_exact_prefix( + capsys, +): + import AutoREACTER + + Initialization.print_version() + + output = capsys.readouterr().out.strip() + + assert output.startswith( + "AutoREACTER version:" + ) + + assert AutoREACTER.__version__ in output \ No newline at end of file diff --git a/tests/unit/test_input_parser.py b/tests/unit/test_input_parser.py new file mode 100644 index 00000000..085a6f49 --- /dev/null +++ b/tests/unit/test_input_parser.py @@ -0,0 +1,2040 @@ +import math +from types import SimpleNamespace + +import pytest +from rdkit import Chem + +from AutoREACTER.input_parser import ( + CompatibilityError, + DuplicateMonomerError, + InputConflictError, + InputError, + InputParser, + InputSchemaError, + MonomerEntry, + NumericFieldError, + Simulation, + SimulationSetup, + SmilesValidationError, +) + + +# ============================================================================= +# Fixtures / helpers +# ============================================================================= + + +@pytest.fixture +def parser(): + return InputParser() + + +def make_counts_input(): + return { + "simulation_name": "counts_demo", + "simulations": [ + { + "tag": "small", + "temperature": 300, + "density": 0.8, + "monomer_counts": { + "ethanol": 10, + "ethylamine": 20, + }, + }, + { + "tag": "large", + "temperature": 400, + "density": 1.0, + "monomer_counts": { + "ethanol": 100, + "ethylamine": 200, + }, + }, + ], + "monomers": [ + { + "name": "ethanol", + "smiles": "CCO", + }, + { + "name": "ethylamine", + "smiles": "CCN", + }, + ], + } + + +def make_ratio_input(): + return { + "simulation_name": "ratio_demo", + "simulations": [ + { + "tag": "small", + "temperature": 300, + "density": 0.8, + "total_atoms": 10000, + "monomer_ratios": { + "ethanol": 1.0, + "ethylamine": 2.0, + }, + }, + { + "tag": "large", + "temperature": 400, + "density": 1.0, + "total_atoms": 100000, + "monomer_ratios": { + "ethanol": 1.0, + "ethylamine": 2.0, + }, + }, + ], + "monomers": [ + { + "name": "ethanol", + "smiles": "CCO", + }, + { + "name": "ethylamine", + "smiles": "CCN", + }, + ], + } + + +# ============================================================================= +# Exception hierarchy +# ============================================================================= + + +@pytest.mark.parametrize( + "exception_class", + [ + InputSchemaError, + InputConflictError, + NumericFieldError, + SmilesValidationError, + DuplicateMonomerError, + CompatibilityError, + ], +) +def test_input_exceptions_inherit_from_input_error( + exception_class, +): + assert issubclass( + exception_class, + InputError, + ) + + +# ============================================================================= +# Dataclasses +# ============================================================================= + + +def test_monomer_entry_defaults(): + monomer = MonomerEntry( + id=1, + data_id="data_1", + name="ethanol", + smiles="CCO", + count={"sim": 10}, + ratio=None, + ) + + assert monomer.rdkit_mol is None + assert monomer.molecule_3Dmol_path is None + assert monomer.lmp_molecule_file is None + assert monomer.num_atoms is None + assert monomer.molecular_weight is None + assert monomer.status is True + + +def test_monomer_entry_uses_slots(): + monomer = MonomerEntry( + id=1, + data_id="data_1", + name="ethanol", + smiles="CCO", + count={"sim": 10}, + ratio=None, + ) + + with pytest.raises(AttributeError): + monomer.random_attribute = 10 + + +def test_simulation_defaults(): + simulation = Simulation( + tag="sim", + temperature=300.0, + density=1.0, + ) + + assert simulation.monomer_counts is None + assert simulation.monomer_ratios is None + assert simulation.total_atoms is None + assert simulation.initial_box_volume is None + assert simulation.initial_box_length is None + + +def test_simulation_setup_workflow_defaults(): + setup = SimulationSetup( + simulation_name="demo", + temperature=[300.0], + density=[1.0], + force_field="PCFF", + monomers=[], + ) + + assert setup.deep_search is True + assert setup.loop is True + assert setup.reaction_iteration_depth == 5 + assert setup.wildcards is False + + assert ( + setup.deduplicate_reaction_templates + is True + ) + + assert ( + setup.write_second_reaction_stage + is False + ) + + +# ============================================================================= +# validate_basic_format +# ============================================================================= + + +def test_validate_basic_format_accepts_required_keys( + parser, +): + parser.validate_basic_format( + { + "simulation_name": "demo", + "simulations": [], + "monomers": [], + } + ) + + +@pytest.mark.parametrize( + "value", + [ + None, + [], + "string", + 123, + ], +) +def test_validate_basic_format_requires_dictionary( + parser, + value, +): + with pytest.raises( + InputSchemaError, + match="Expected input to be a dictionary", + ): + parser.validate_basic_format(value) + + +@pytest.mark.parametrize( + "missing_key", + [ + "simulation_name", + "simulations", + "monomers", + ], +) +def test_validate_basic_format_requires_each_core_key( + parser, + missing_key, +): + data = { + "simulation_name": "demo", + "simulations": [], + "monomers": [], + } + + del data[missing_key] + + with pytest.raises( + InputSchemaError, + match="Missing required key", + ): + parser.validate_basic_format(data) + + +# ============================================================================= +# Composition-mode detection +# ============================================================================= + + +def test_get_inputs_mode_detects_counts(parser): + simulations = [ + { + "monomer_counts": { + "a": 1, + } + } + ] + + assert ( + parser._get_inputs_mode(simulations) + == "counts" + ) + + +def test_get_inputs_mode_detects_ratio(parser): + simulations = [ + { + "monomer_ratios": { + "a": 1.0, + } + } + ] + + assert ( + parser._get_inputs_mode(simulations) + == "ratio" + ) + + +@pytest.mark.parametrize( + "simulations", + [ + [], + None, + {}, + "bad", + ], +) +def test_get_inputs_mode_requires_nonempty_list( + parser, + simulations, +): + with pytest.raises(InputSchemaError): + parser._get_inputs_mode(simulations) + + +def test_get_inputs_mode_requires_dict_entries( + parser, +): + with pytest.raises(InputSchemaError): + parser._get_inputs_mode( + ["not a dictionary"] + ) + + +def test_get_inputs_mode_rejects_counts_and_ratios_together( + parser, +): + with pytest.raises( + InputConflictError, + match="not both", + ): + parser._get_inputs_mode( + [ + { + "monomer_counts": { + "a": 1, + }, + "monomer_ratios": { + "a": 1.0, + }, + } + ] + ) + + +def test_get_inputs_mode_rejects_missing_composition( + parser, +): + with pytest.raises(InputSchemaError): + parser._get_inputs_mode( + [ + { + "tag": "sim", + } + ] + ) + + +def test_get_inputs_mode_rejects_mixed_modes( + parser, +): + with pytest.raises( + InputConflictError, + match="same composition method", + ): + parser._get_inputs_mode( + [ + { + "monomer_counts": { + "a": 1, + } + }, + { + "monomer_ratios": { + "a": 1.0, + } + }, + ] + ) + + +# ============================================================================= +# Temperature / density validation +# ============================================================================= + + +@pytest.mark.parametrize( + "value, expected", + [ + (1, 1.0), + (300, 300.0), + (298.15, 298.15), + ], +) +def test_validate_temperature_accepts_positive_numbers( + parser, + value, + expected, +): + assert ( + parser._validate_temperature(value) + == expected + ) + + +@pytest.mark.parametrize( + "value", + [ + 0, + -1, + -300.0, + True, + False, + "300", + None, + ], +) +def test_validate_temperature_rejects_invalid_values( + parser, + value, +): + with pytest.raises(NumericFieldError): + parser._validate_temperature(value) + + +@pytest.mark.parametrize( + "value, expected", + [ + (1, 1.0), + (0.8, 0.8), + (1.25, 1.25), + ], +) +def test_validate_density_accepts_positive_numbers( + parser, + value, + expected, +): + assert parser._validate_density(value) == expected + + +@pytest.mark.parametrize( + "value", + [ + 0, + -1, + True, + False, + "0.8", + None, + ], +) +def test_validate_density_rejects_invalid_values( + parser, + value, +): + with pytest.raises(NumericFieldError): + parser._validate_density(value) + + +# ============================================================================= +# Force-field validation +# ============================================================================= + + +def test_force_field_defaults_to_pcff(parser): + assert ( + parser._validate_force_field(None) + == "PCFF" + ) + + +@pytest.mark.parametrize( + "raw, expected", + [ + ("PCFF", "PCFF"), + ("pcff", "PCFF"), + ("PCFF-IFF", "PCFF-IFF"), + ("pcff-iff", "PCFF-IFF"), + ("CVFF", "CVFF"), + ("cvff", "CVFF"), + ("CVFF-IFF", "CVFF-IFF"), + ("clayff", "Clay-FF"), + ("Clay-FF", "Clay-FF"), + ("dreiding", "DREIDING"), + ("drieding", "DREIDING"), + ], +) +def test_force_field_aliases_are_normalized( + parser, + raw, + expected, +): + assert ( + parser._validate_force_field(raw) + == expected + ) + + +def test_compass_normalizes_to_declared_canonical_name( + parser, +): + """ + ForceFieldType declares the canonical spelling as 'Compass'. + + This test intentionally checks that the runtime normalizer agrees + with that public contract. + """ + assert ( + parser._validate_force_field("compass") + == "Compass" + ) + + +@pytest.mark.parametrize( + "raw", + [ + "", + " ", + 123, + [], + {}, + ], +) +def test_force_field_rejects_invalid_schema( + parser, + raw, +): + with pytest.raises(InputSchemaError): + parser._validate_force_field(raw) + + +def test_force_field_rejects_unknown_name(parser): + with pytest.raises( + InputSchemaError, + match="Unsupported force field", + ): + parser._validate_force_field( + "not-a-force-field" + ) + + +@pytest.mark.parametrize( + "raw", + [ + "OPLSAA", + "opls", + "opls-aa", + "GAFF", + "gaff", + ], +) +def test_current_lunar_workflow_rejects_incompatible_force_fields( + parser, + raw, +): + with pytest.raises(CompatibilityError): + parser._validate_force_field(raw) + + +# ============================================================================= +# Workflow option normalization +# ============================================================================= + + +@pytest.mark.parametrize( + "raw, expected", + [ + ("deep_search", "deepsearch"), + ("deep-search", "deepsearch"), + ("Deep Search", "deepsearch"), + (" DEEP_SEARCH ", "deepsearch"), + ("write_second_reaction_stage", "writesecondreactionstage"), + ], +) +def test_normalized_option_key( + parser, + raw, + expected, +): + assert ( + parser._normalized_option_key(raw) + == expected + ) + + +def test_get_workflow_option_returns_default( + parser, +): + assert ( + parser._get_workflow_option( + inputs={}, + key="wildcards", + default=True, + ) + is True + ) + + +def test_get_workflow_option_accepts_alias( + parser, +): + value = parser._get_workflow_option( + inputs={ + "use-wildcards": False, + }, + key="wildcards", + default=True, + aliases=[ + "use_wildcards", + ], + ) + + assert value is False + + +def test_get_workflow_option_accepts_equivalent_duplicate_alias_values( + parser, +): + value = parser._get_workflow_option( + inputs={ + "wildcards": False, + "use-wildcards": False, + }, + key="wildcards", + default=True, + aliases=[ + "use_wildcards", + ], + ) + + assert value is False + + +def test_get_workflow_option_rejects_conflicting_alias_values( + parser, +): + with pytest.raises( + InputConflictError, + match="Conflicting values", + ): + parser._get_workflow_option( + inputs={ + "wildcards": True, + "use-wildcards": False, + }, + key="wildcards", + default=True, + aliases=[ + "use_wildcards", + ], + ) + + +# ============================================================================= +# Boolean workflow options +# ============================================================================= + + +@pytest.mark.parametrize( + "value", + [ + True, + "true", + "TRUE", + "yes", + "Y", + "on", + "1", + ], +) +def test_validate_bool_option_true_values( + parser, + value, +): + result = parser._validate_bool_option( + inputs={ + "feature": value, + }, + key="feature", + default=False, + ) + + assert result is True + + +@pytest.mark.parametrize( + "value", + [ + False, + "false", + "FALSE", + "no", + "N", + "off", + "0", + ], +) +def test_validate_bool_option_false_values( + parser, + value, +): + result = parser._validate_bool_option( + inputs={ + "feature": value, + }, + key="feature", + default=True, + ) + + assert result is False + + +@pytest.mark.parametrize( + "value", + [ + 1, + 0, + 2, + None, + [], + "maybe", + ], +) +def test_validate_bool_option_rejects_invalid_values( + parser, + value, +): + with pytest.raises(InputSchemaError): + parser._validate_bool_option( + inputs={ + "feature": value, + }, + key="feature", + default=True, + ) + + +# ============================================================================= +# Reaction iteration depth +# ============================================================================= + + +def test_reaction_iteration_depth_defaults_to_five( + parser, +): + assert ( + parser._validate_reaction_iteration_depth( + {} + ) + == 5 + ) + + +@pytest.mark.parametrize( + "value, expected", + [ + (0, 0), + (1, 1), + (5, 5), + (20, 20), + (False, 0), + (True, 5), + ("0", 0), + ("1", 1), + ("10", 10), + ("false", 0), + ("no", 0), + ("off", 0), + ("none", 0), + ("true", 5), + ("yes", 5), + ("on", 5), + ], +) +def test_reaction_iteration_depth_accepts_supported_values( + parser, + value, + expected, +): + result = parser._validate_reaction_iteration_depth( + { + "reaction_iteration_depth": value, + } + ) + + assert result == expected + + +@pytest.mark.parametrize( + "alias", + [ + "rxn_iteration_depth", + "reaction_depth", + "iteration_depth", + "max_loop_count", + "max_iterations", + "iterations", + "loop", + ], +) +def test_reaction_iteration_depth_accepts_aliases( + parser, + alias, +): + result = parser._validate_reaction_iteration_depth( + { + alias: 7, + } + ) + + assert result == 7 + + +@pytest.mark.parametrize( + "value", + [ + -1, + -10, + 1.5, + None, + [], + {}, + "hello", + "2.5", + ], +) +def test_reaction_iteration_depth_rejects_invalid_values( + parser, + value, +): + with pytest.raises(InputSchemaError): + parser._validate_reaction_iteration_depth( + { + "reaction_iteration_depth": value, + } + ) + + +def test_reaction_iteration_depth_rejects_conflicting_aliases( + parser, +): + with pytest.raises(InputConflictError): + parser._validate_reaction_iteration_depth( + { + "reaction_iteration_depth": 5, + "loop": 3, + } + ) + + +def test_validate_loop_zero_disables_loop(parser): + assert ( + parser._validate_loop( + { + "reaction_iteration_depth": 0, + } + ) + == (False, None) + ) + + +def test_validate_loop_positive_depth_enables_loop( + parser, +): + assert ( + parser._validate_loop( + { + "reaction_iteration_depth": 8, + } + ) + == (True, 8) + ) + + +# ============================================================================= +# SMILES validation +# ============================================================================= + + +def test_validate_smiles_returns_canonical_smiles( + parser, +): + smiles, mol = parser._validate_smiles( + "OCC" + ) + + assert smiles == "CCO" + assert isinstance(mol, Chem.Mol) + + +def test_validate_smiles_strips_whitespace( + parser, +): + smiles, _ = parser._validate_smiles( + " CCO " + ) + + assert smiles == "CCO" + + +@pytest.mark.parametrize( + "value", + [ + None, + "", + " ", + 123, + [], + ], +) +def test_validate_smiles_requires_nonempty_string( + parser, + value, +): + with pytest.raises(SmilesValidationError): + parser._validate_smiles(value) + + +def test_validate_smiles_rejects_invalid_rdkit_smiles( + parser, +): + with pytest.raises( + SmilesValidationError, + match="Invalid SMILES", + ): + parser._validate_smiles( + "C1CC" + ) + + +# ============================================================================= +# Duplicate SMILES +# ============================================================================= + + +def test_validate_no_duplicate_smiles_adds_new_smiles( + parser, +): + seen = [] + + returned = parser.validate_no_duplicate_smiles( + "CCO", + seen, + ) + + assert returned is seen + assert seen == ["CCO"] + + +def test_validate_no_duplicate_smiles_rejects_duplicate( + parser, +): + seen = ["CCO"] + + with pytest.raises( + DuplicateMonomerError, + match="Duplicate monomer", + ): + parser.validate_no_duplicate_smiles( + "CCO", + seen, + ) + + +def test_validate_inputs_detects_canonical_smiles_duplicates( + parser, +): + data = make_counts_input() + + data["monomers"] = [ + { + "name": "one", + "smiles": "CCO", + }, + { + "name": "two", + "smiles": "OCC", + }, + ] + + for simulation in data["simulations"]: + simulation["monomer_counts"] = { + "one": 1, + "two": 1, + } + + with pytest.raises(DuplicateMonomerError): + parser.validate_inputs(data) + + +# ============================================================================= +# Derived molecular properties +# ============================================================================= + + +def test_derive_molecule_properties_includes_hydrogens( + parser, +): + mol = Chem.MolFromSmiles("C") + + num_atoms, molecular_weight = ( + parser._derive_molecule_properties( + mol + ) + ) + + # methane = 1 C + 4 H + assert num_atoms == 5 + + assert molecular_weight == pytest.approx( + 16.043, + rel=1e-3, + ) + + +def test_int_to_dict(parser): + assert parser._int_to_dict(42) == { + "_": 42 + } + + +# ============================================================================= +# Simulation validation +# ============================================================================= + + +def test_validate_simulations_counts_mode( + parser, +): + systems = make_counts_input()[ + "simulations" + ] + + result = parser._validate_simulations( + systems, + "counts", + ) + + assert result["method"] == "counts" + + assert result["temperatures"] == [ + 300.0, + 400.0, + ] + + assert result["density"] == [ + 0.8, + 1.0, + ] + + assert len(result["simulations"]) == 2 + + assert all( + isinstance(simulation, Simulation) + for simulation in result["simulations"] + ) + + +def test_validate_simulations_ratio_mode( + parser, +): + systems = make_ratio_input()[ + "simulations" + ] + + result = parser._validate_simulations( + systems, + "ratio", + ) + + assert result["method"] == "ratio" + + assert ( + result["simulations"][0].total_atoms + == 10000 + ) + + assert ( + result["simulations"][1].total_atoms + == 100000 + ) + + +def test_validate_simulations_rejects_duplicate_tags( + parser, +): + systems = make_counts_input()[ + "simulations" + ] + + systems[1]["tag"] = "small" + + with pytest.raises( + InputSchemaError, + match="Duplicate system tag", + ): + parser._validate_simulations( + systems, + "counts", + ) + + +@pytest.mark.parametrize( + "tag", + [ + "", + " ", + None, + 123, + ], +) +def test_validate_simulations_requires_valid_tag( + parser, + tag, +): + systems = make_counts_input()[ + "simulations" + ] + + systems[0]["tag"] = tag + + with pytest.raises(InputSchemaError): + parser._validate_simulations( + systems, + "counts", + ) + + +def test_counts_mode_rejects_total_atoms( + parser, +): + systems = make_counts_input()[ + "simulations" + ] + + systems[0]["total_atoms"] = 10000 + + with pytest.raises( + InputSchemaError, + match="total_atoms", + ): + parser._validate_simulations( + systems, + "counts", + ) + + +@pytest.mark.parametrize( + "value", + [ + -1, + 1.5, + True, + "10", + ], +) +def test_counts_mode_rejects_invalid_counts( + parser, + value, +): + systems = make_counts_input()[ + "simulations" + ] + + systems[0]["monomer_counts"][ + "ethanol" + ] = value + + with pytest.raises(NumericFieldError): + parser._validate_simulations( + systems, + "counts", + ) + + +def test_counts_mode_allows_zero_count( + parser, +): + systems = make_counts_input()[ + "simulations" + ] + + systems[0]["monomer_counts"][ + "ethanol" + ] = 0 + + result = parser._validate_simulations( + systems, + "counts", + ) + + assert ( + result["simulations"][0] + .monomer_counts["ethanol"] + == 0 + ) + + +@pytest.mark.parametrize( + "value", + [ + 0, + -1, + True, + 1.5, + "10000", + None, + ], +) +def test_ratio_mode_requires_positive_integer_total_atoms( + parser, + value, +): + systems = make_ratio_input()[ + "simulations" + ] + + systems[0]["total_atoms"] = value + + with pytest.raises(NumericFieldError): + parser._validate_simulations( + systems, + "ratio", + ) + + +@pytest.mark.parametrize( + "value", + [ + -1, + True, + "1.0", + None, + ], +) +def test_ratio_mode_rejects_invalid_ratio_values( + parser, + value, +): + systems = make_ratio_input()[ + "simulations" + ] + + systems[0]["monomer_ratios"][ + "ethanol" + ] = value + + with pytest.raises(NumericFieldError): + parser._validate_simulations( + systems, + "ratio", + ) + + +def test_ratio_mode_allows_zero_ratio( + parser, +): + systems = make_ratio_input()[ + "simulations" + ] + + for system in systems: + system["monomer_ratios"][ + "ethanol" + ] = 0 + + result = parser._validate_simulations( + systems, + "ratio", + ) + + assert ( + result["simulations"][0] + .monomer_ratios["ethanol"] + == 0 + ) + + +def test_ratio_mode_requires_identical_ratios_between_systems( + parser, +): + systems = make_ratio_input()[ + "simulations" + ] + + systems[1]["monomer_ratios"][ + "ethanol" + ] = 3.0 + + with pytest.raises( + InputSchemaError, + match="identical", + ): + parser._validate_simulations( + systems, + "ratio", + ) + + +# ============================================================================= +# System/monomer key consistency +# ============================================================================= + + +def test_system_monomer_keys_accept_exact_match( + parser, +): + data = make_counts_input() + + parser._validate_system_monomer_keys( + data, + data["simulations"], + "counts", + ) + + +def test_system_monomer_keys_reject_unknown_name( + parser, +): + data = make_counts_input() + + data["simulations"][0][ + "monomer_counts" + ]["unknown"] = 10 + + with pytest.raises( + InputSchemaError, + match="unknown monomer", + ): + parser._validate_system_monomer_keys( + data, + data["simulations"], + "counts", + ) + + +def test_system_monomer_keys_reject_missing_name( + parser, +): + data = make_counts_input() + + del data["simulations"][0][ + "monomer_counts" + ]["ethanol"] + + with pytest.raises( + InputSchemaError, + match="missing monomer", + ): + parser._validate_system_monomer_keys( + data, + data["simulations"], + "counts", + ) + + +def test_missing_monomer_name_receives_data_id_name( + parser, +): + data = { + "monomers": [ + { + "smiles": "CCO", + } + ] + } + + systems = [ + { + "tag": "sim", + "monomer_counts": { + "data_1": 2, + }, + } + ] + + parser._validate_system_monomer_keys( + data, + systems, + "counts", + ) + + assert ( + data["monomers"][0]["name"] + == "data_1" + ) + + +# ============================================================================= +# Monomer-entry validation +# ============================================================================= + + +def test_validate_monomer_entry_counts_mode( + parser, +): + data = make_counts_input() + + monomers = parser._validate_monomer_entry( + data, + "counts", + data["simulations"], + ) + + assert len(monomers) == 2 + + ethanol = monomers[0] + + assert ethanol.id == 1 + assert ethanol.data_id == "data_1" + assert ethanol.name == "ethanol" + assert ethanol.smiles == "CCO" + + assert ethanol.count == { + "small": 10, + "large": 100, + } + + assert ethanol.ratio is None + assert isinstance(ethanol.rdkit_mol, Chem.Mol) + assert ethanol.num_atoms is not None + assert ethanol.molecular_weight is not None + + +def test_validate_monomer_entry_ratio_mode( + parser, +): + data = make_ratio_input() + + monomers = parser._validate_monomer_entry( + data, + "ratio", + data["simulations"], + ) + + ethanol = monomers[0] + + assert ethanol.count is None + assert ethanol.ratio == 1.0 + + +def test_validate_monomer_entry_requires_list( + parser, +): + with pytest.raises(InputSchemaError): + parser._validate_monomer_entry( + { + "monomers": {}, + }, + "counts", + [], + ) + + +def test_validate_monomer_entry_rejects_non_dictionary_entry( + parser, +): + with pytest.raises(InputSchemaError): + parser._validate_monomer_entry( + { + "monomers": [ + "bad entry" + ], + }, + "counts", + [], + ) + + +# ============================================================================= +# Legacy composition validator +# ============================================================================= + + +def test_validate_composition_accepts_counts_targets( + parser, +): + composition = { + "targets": [ + { + "tag": "one", + }, + { + "tag": "two", + }, + ] + } + + assert ( + parser._validate_composition( + composition, + "counts", + ) + is composition + ) + + +def test_validate_composition_requires_targets( + parser, +): + with pytest.raises(InputSchemaError): + parser._validate_composition( + {}, + "counts", + ) + + +def test_validate_composition_rejects_duplicate_tags( + parser, +): + with pytest.raises(InputSchemaError): + parser._validate_composition( + { + "targets": [ + { + "tag": "same", + }, + { + "tag": "same", + }, + ] + }, + "counts", + ) + + +def test_validate_composition_ratio_requires_total_atoms( + parser, +): + with pytest.raises(NumericFieldError): + parser._validate_composition( + { + "targets": [ + { + "tag": "one", + } + ] + }, + "ratio", + ) + + +def test_validate_composition_counts_rejects_total_atoms( + parser, +): + with pytest.raises(InputSchemaError): + parser._validate_composition( + { + "targets": [ + { + "tag": "one", + "total_atoms": 1000, + } + ] + }, + "counts", + ) + + +# ============================================================================= +# Legacy numeric validator +# ============================================================================= + + +def test_legacy_numeric_validator_accepts_valid_input( + parser, +): + parser._validate_numeric_fields( + { + "density": 1.0, + "temperature": [ + 300, + 400, + ], + "number_of_monomers": { + "a": 5, + "b": 10, + }, + } + ) + + +def test_legacy_numeric_validator_rejects_bad_density( + parser, +): + with pytest.raises(NumericFieldError): + parser._validate_numeric_fields( + { + "density": 0, + "temperature": 300, + "number_of_monomers": { + "a": 1, + }, + } + ) + + +def test_legacy_numeric_validator_rejects_bad_temperature( + parser, +): + with pytest.raises(NumericFieldError): + parser._validate_numeric_fields( + { + "density": 1.0, + "temperature": -10, + "number_of_monomers": { + "a": 1, + }, + } + ) + + +def test_legacy_numeric_validator_rejects_bad_monomer_count( + parser, +): + with pytest.raises(NumericFieldError): + parser._validate_numeric_fields( + { + "density": 1.0, + "temperature": 300, + "number_of_monomers": { + "a": 0, + }, + } + ) + + +# ============================================================================= +# Full validate_inputs(): counts mode +# ============================================================================= + + +def test_validate_inputs_counts_mode_end_to_end( + parser, +): + data = make_counts_input() + + result = parser.validate_inputs(data) + + assert isinstance(result, SimulationSetup) + + assert ( + result.simulation_name + == "counts_demo" + ) + + assert result.composition_method == "counts" + + assert result.temperature == [ + 300.0, + 400.0, + ] + + assert result.density == [ + 0.8, + 1.0, + ] + + assert result.force_field == "PCFF" + assert len(result.monomers) == 2 + assert len(result.simulations) == 2 + + assert result.loop is True + assert result.max_loop_count == 5 + assert result.reaction_iteration_depth == 5 + + assert result.deep_search is True + assert result.wildcards is False + + assert ( + result.deduplicate_reaction_templates + is True + ) + + assert ( + result.write_second_reaction_stage + is True + ) + + +def test_validate_inputs_preserves_raw_input_reference( + parser, +): + data = make_counts_input() + + result = parser.validate_inputs(data) + + assert result.input_json is data + + +def test_validate_inputs_zero_iteration_depth_disables_loop( + parser, +): + data = make_counts_input() + + data["reaction_iteration_depth"] = 0 + + result = parser.validate_inputs(data) + + assert result.loop is False + assert result.max_loop_count is None + assert result.reaction_iteration_depth == 0 + + +def test_validate_inputs_workflow_options_can_be_disabled( + parser, +): + data = make_counts_input() + + data.update( + { + "deep_search": False, + "wildcards": False, + "deduplicate_reaction_templates": False, + "write_second_reaction_stage": False, + } + ) + + result = parser.validate_inputs(data) + + assert result.deep_search is False + assert result.wildcards is False + + assert ( + result.deduplicate_reaction_templates + is False + ) + + assert ( + result.write_second_reaction_stage + is False + ) + + +def test_validate_inputs_accepts_workflow_aliases( + parser, +): + data = make_counts_input() + + data["deep-search"] = "no" + data["use-wildcards"] = "false" + data["template_dedup"] = "off" + data["stage_2"] = "0" + data["iterations"] = "3" + + result = parser.validate_inputs(data) + + assert result.deep_search is False + assert result.wildcards is False + + assert ( + result.deduplicate_reaction_templates + is False + ) + + assert ( + result.write_second_reaction_stage + is False + ) + + assert result.reaction_iteration_depth == 3 + assert result.max_loop_count == 3 + + +# ============================================================================= +# Full validate_inputs(): ratio mode +# ============================================================================= + + +def test_validate_inputs_ratio_mode_end_to_end( + parser, +): + data = make_ratio_input() + + result = parser.validate_inputs(data) + + assert isinstance(result, SimulationSetup) + + assert result.composition_method == "ratio" + + assert result.temperature == [ + 300.0, + 400.0, + ] + + assert result.density == [ + 0.8, + 1.0, + ] + + assert len(result.monomers) == 2 + assert len(result.simulations) == 2 + + assert result.monomers[0].count is None + assert result.monomers[0].ratio == 1.0 + + assert ( + result.simulations[0].total_atoms + == 10000 + ) + + +# ============================================================================= +# Public-path malformed input checks +# ============================================================================= + + +def test_validate_inputs_rejects_non_dictionary_monomer_with_schema_error( + parser, +): + """ + Public validation should translate malformed monomer entries into the + parser's own schema exception rather than leaking AttributeError. + """ + data = make_counts_input() + + data["monomers"] = [ + "not-a-dictionary" + ] + + data["simulations"] = [ + { + "tag": "sim", + "temperature": 300, + "density": 1.0, + "monomer_counts": { + "data_1": 1, + }, + } + ] + + with pytest.raises(InputSchemaError): + parser.validate_inputs(data) + + +@pytest.mark.parametrize( + "simulation_name", + [ + "", + " ", + None, + 123, + ], +) +def test_validate_inputs_requires_nonempty_string_simulation_name( + parser, + simulation_name, +): + """ + simulation_name is part of the public schema and later becomes an + output-directory name, so invalid values should fail here. + """ + data = make_counts_input() + + data["simulation_name"] = simulation_name + + with pytest.raises(InputSchemaError): + parser.validate_inputs(data) + + +# ============================================================================= +# Molecule representation / image generation +# ============================================================================= + + +def test_molecule_representation_returns_molecules_and_legends( + parser, +): + mol_1 = Chem.MolFromSmiles("CCO") + mol_2 = Chem.MolFromSmiles("CCN") + + setup = SimulationSetup( + simulation_name="demo", + temperature=[300.0], + density=[1.0], + force_field="PCFF", + monomers=[ + MonomerEntry( + id=1, + data_id="data_1", + name="ethanol", + smiles="CCO", + count={"sim": 1}, + ratio=None, + rdkit_mol=mol_1, + ), + MonomerEntry( + id=2, + data_id="data_2", + name="ethylamine", + smiles="CCN", + count={"sim": 1}, + ratio=None, + rdkit_mol=mol_2, + ), + ], + ) + + molecules, legends = ( + parser.molecule_representation_of_initial_molecules( + setup + ) + ) + + assert molecules == [ + mol_1, + mol_2, + ] + + assert legends == [ + "ethanol", + "ethylamine", + ] + + +def test_molecule_representation_uses_data_id_when_name_missing( + parser, +): + mol = Chem.MolFromSmiles("CCO") + + setup = SimulationSetup( + simulation_name="demo", + temperature=[300.0], + density=[1.0], + force_field="PCFF", + monomers=[ + MonomerEntry( + id=1, + data_id="data_1", + name=None, + smiles="CCO", + count={"sim": 1}, + ratio=None, + rdkit_mol=mol, + ), + ], + ) + + _, legends = ( + parser.molecule_representation_of_initial_molecules( + setup + ) + ) + + assert legends == [ + "data_1" + ] + + +def test_initial_molecules_image_grid_uses_expected_draw_settings( + parser, + monkeypatch, +): + mol_1 = Chem.MolFromSmiles("CCO") + mol_2 = Chem.MolFromSmiles("CCN") + + setup = SimulationSetup( + simulation_name="demo", + temperature=[300.0], + density=[1.0], + force_field="PCFF", + monomers=[ + MonomerEntry( + id=1, + data_id="data_1", + name="ethanol", + smiles="CCO", + count={"sim": 1}, + ratio=None, + rdkit_mol=mol_1, + ), + MonomerEntry( + id=2, + data_id="data_2", + name="ethylamine", + smiles="CCN", + count={"sim": 1}, + ratio=None, + rdkit_mol=mol_2, + ), + ], + ) + + session = SimpleNamespace( + inputs=setup + ) + + captured = {} + + fake_image = object() + + def fake_grid( + molecules, + *, + molsPerRow, + subImgSize, + legends, + ): + captured["molecules"] = molecules + captured["molsPerRow"] = molsPerRow + captured["subImgSize"] = subImgSize + captured["legends"] = legends + + return fake_image + + monkeypatch.setattr( + "AutoREACTER.input_parser.Draw.MolsToGridImage", + fake_grid, + ) + + result = parser.initial_molecules_image_grid( + session + ) + + assert result is fake_image + + assert captured["molecules"] == [ + mol_1, + mol_2, + ] + + assert captured["molsPerRow"] == 3 + + assert captured["subImgSize"] == ( + 400, + 400, + ) + + assert captured["legends"] == [ + "ethanol", + "ethylamine", + ] \ No newline at end of file diff --git a/tests/unit/test_public_api.py b/tests/unit/test_public_api.py new file mode 100644 index 00000000..8873bce7 --- /dev/null +++ b/tests/unit/test_public_api.py @@ -0,0 +1,1014 @@ +from pathlib import Path +from types import SimpleNamespace +from packaging.version import Version +import pytest + +import AutoREACTER as arx + + +# ============================================================================= +# Fixtures / helpers +# ============================================================================= + + +@pytest.fixture(autouse=True) +def reset_active_workflow(monkeypatch): + """ + Every test starts without an active global workflow. + + AutoREACTER intentionally keeps one package-level active workflow, so + isolating this state prevents tests from affecting one another. + """ + monkeypatch.setattr( + arx, + "_active_workflow", + None, + ) + + +class FakeWorkflow: + """ + Lightweight ARXCLI-like object used to verify public API delegation. + """ + + def __init__(self): + self.session = object() + self.calls = [] + + def show_molecules(self): + self.calls.append( + ("show_molecules",) + ) + return "molecules-image" + + def show_functional_groups(self): + self.calls.append( + ("show_functional_groups",) + ) + return "functional-groups-image" + + def show_reactions(self): + self.calls.append( + ("show_reactions",) + ) + return "reactions-image" + + def select_reactions(self): + self.calls.append( + ("select_reactions",) + ) + return None + + def show_non_reactants(self): + self.calls.append( + ("show_non_reactants",) + ) + return "non-reactants-image" + + def select_non_reactants(self): + self.calls.append( + ("select_non_reactants",) + ) + return None + + def prepare_reactions(self): + self.calls.append( + ("prepare_reactions",) + ) + return None + + def show_reaction_templates( + self, + highlight_type="template", + ): + self.calls.append( + ( + "show_reaction_templates", + highlight_type, + ) + ) + + return ( + f"templates-{highlight_type}" + ) + + def process(self): + self.calls.append( + ("process",) + ) + return None + + +def install_fake_workflow( + monkeypatch, +): + workflow = FakeWorkflow() + + monkeypatch.setattr( + arx, + "_active_workflow", + workflow, + ) + + return workflow + + +# ============================================================================= +# Package metadata +# ============================================================================= + + +def test_package_title(): + assert arx.__title__ == "AutoREACTER" + + + +def test_package_version(): + assert Version(arx.__version__) >= Version("1.0.0") + + +def test_package_release_matches_version(): + assert ( + arx.__release__ + == arx.__version__ + ) + + +def test_package_license(): + assert arx.__license__ == "MIT" + + +def test_package_authors(): + assert arx.__authors__ == [ + "Janitha Mahanthe", + "Jacob Gissinger", + ] + + +def test_package_author_string_matches_authors(): + assert ( + arx.__author__ + == ", ".join(arx.__authors__) + ) + + +# ============================================================================= +# _ensure_workflow +# ============================================================================= + + +def test_ensure_workflow_raises_without_active_session(): + with pytest.raises( + RuntimeError, + match="No active session", + ): + arx._ensure_workflow() + + +def test_ensure_workflow_error_tells_user_to_call_run(): + with pytest.raises(RuntimeError) as exc_info: + arx._ensure_workflow() + + message = str(exc_info.value) + + assert "arx.run" in message + assert "your_file.json" in message + + +def test_ensure_workflow_returns_active_workflow( + monkeypatch, +): + workflow = object() + + monkeypatch.setattr( + arx, + "_active_workflow", + workflow, + ) + + assert ( + arx._ensure_workflow() + is workflow + ) + + +# ============================================================================= +# session() +# ============================================================================= + + +def test_session_requires_active_workflow(): + with pytest.raises( + RuntimeError, + match="No active session", + ): + arx.session() + + +def test_session_returns_active_workflow_session( + monkeypatch, +): + workflow = FakeWorkflow() + + monkeypatch.setattr( + arx, + "_active_workflow", + workflow, + ) + + assert ( + arx.session() + is workflow.session + ) + + +# ============================================================================= +# run() +# ============================================================================= + + +def test_run_rejects_missing_input_file( + tmp_path, + monkeypatch, +): + constructed = [] + + class FakeARXCLI: + def __init__( + self, + input_path, + ): + constructed.append( + input_path + ) + + monkeypatch.setattr( + arx, + "ARXCLI", + FakeARXCLI, + ) + + missing = ( + tmp_path + / "missing.json" + ) + + with pytest.raises( + FileNotFoundError, + match="Input file not found", + ): + arx.run(missing) + + assert constructed == [] + + assert ( + arx._active_workflow + is None + ) + + +def test_run_constructs_arxcli_with_resolved_path( + tmp_path, + monkeypatch, +): + input_file = ( + tmp_path / "input.json" + ) + + input_file.write_text( + "{}", + encoding="utf-8", + ) + + received = [] + + class FakeARXCLI: + def __init__( + self, + input_path, + ): + received.append( + input_path + ) + + monkeypatch.setattr( + arx, + "ARXCLI", + FakeARXCLI, + ) + + result = arx.run( + input_file + ) + + assert received == [ + input_file.resolve() + ] + + assert ( + result + is arx._active_workflow + ) + + +def test_run_accepts_string_path( + tmp_path, + monkeypatch, +): + input_file = ( + tmp_path / "input.json" + ) + + input_file.write_text( + "{}", + encoding="utf-8", + ) + + received = [] + + class FakeARXCLI: + def __init__( + self, + input_path, + ): + received.append( + input_path + ) + + monkeypatch.setattr( + arx, + "ARXCLI", + FakeARXCLI, + ) + + arx.run( + str(input_file) + ) + + assert received == [ + input_file.resolve() + ] + + assert isinstance( + received[0], + Path, + ) + + +def test_run_resolves_relative_path( + tmp_path, + monkeypatch, +): + input_file = ( + tmp_path / "input.json" + ) + + input_file.write_text( + "{}", + encoding="utf-8", + ) + + monkeypatch.chdir( + tmp_path + ) + + received = [] + + class FakeARXCLI: + def __init__( + self, + input_path, + ): + received.append( + input_path + ) + + monkeypatch.setattr( + arx, + "ARXCLI", + FakeARXCLI, + ) + + arx.run( + "input.json" + ) + + assert received == [ + input_file.resolve() + ] + + +def test_run_returns_created_workflow( + tmp_path, + monkeypatch, +): + input_file = ( + tmp_path / "input.json" + ) + + input_file.write_text( + "{}", + encoding="utf-8", + ) + + created = object() + + monkeypatch.setattr( + arx, + "ARXCLI", + lambda path: created, + ) + + result = arx.run( + input_file + ) + + assert result is created + + assert ( + arx._active_workflow + is created + ) + + +def test_run_replaces_existing_workflow( + tmp_path, + monkeypatch, +): + first_input = ( + tmp_path / "first.json" + ) + + second_input = ( + tmp_path / "second.json" + ) + + first_input.write_text( + "{}", + encoding="utf-8", + ) + + second_input.write_text( + "{}", + encoding="utf-8", + ) + + created = [] + + class FakeARXCLI: + def __init__( + self, + input_path, + ): + self.input_path = ( + input_path + ) + + created.append(self) + + monkeypatch.setattr( + arx, + "ARXCLI", + FakeARXCLI, + ) + + first = arx.run( + first_input + ) + + second = arx.run( + second_input + ) + + assert first is created[0] + assert second is created[1] + + assert ( + arx._active_workflow + is second + ) + + assert ( + first is not second + ) + + +def test_run_does_not_replace_existing_workflow_if_new_construction_fails( + tmp_path, + monkeypatch, +): + input_file = ( + tmp_path / "input.json" + ) + + input_file.write_text( + "{}", + encoding="utf-8", + ) + + existing = object() + + monkeypatch.setattr( + arx, + "_active_workflow", + existing, + ) + + class FailingARXCLI: + def __init__( + self, + input_path, + ): + raise RuntimeError( + "construction failed" + ) + + monkeypatch.setattr( + arx, + "ARXCLI", + FailingARXCLI, + ) + + with pytest.raises( + RuntimeError, + match="construction failed", + ): + arx.run(input_file) + + # Assignment happens only after successful ARXCLI construction. + assert ( + arx._active_workflow + is existing + ) + + +# ============================================================================= +# Delegation before run() +# ============================================================================= + + +@pytest.mark.parametrize( + "api_call", + [ + lambda: arx.show_molecules(), + lambda: arx.show_functional_groups(), + lambda: arx.show_reactions(), + lambda: arx.select_reactions(), + lambda: arx.show_non_reactants(), + lambda: arx.select_non_reactants(), + lambda: arx.prepare_reactions(), + lambda: arx.show_reaction_templates(), + lambda: arx.process(), + ], +) +def test_public_api_requires_run_first( + api_call, +): + with pytest.raises( + RuntimeError, + match="No active session", + ): + api_call() + + +# ============================================================================= +# Public API delegation +# ============================================================================= + + +def test_show_molecules_delegates( + monkeypatch, +): + workflow = install_fake_workflow( + monkeypatch + ) + + result = arx.show_molecules() + + assert ( + result + == "molecules-image" + ) + + assert workflow.calls == [ + ("show_molecules",) + ] + + +def test_show_functional_groups_delegates( + monkeypatch, +): + workflow = install_fake_workflow( + monkeypatch + ) + + result = ( + arx.show_functional_groups() + ) + + assert ( + result + == "functional-groups-image" + ) + + assert workflow.calls == [ + ("show_functional_groups",) + ] + + +def test_show_reactions_delegates( + monkeypatch, +): + workflow = install_fake_workflow( + monkeypatch + ) + + result = arx.show_reactions() + + assert ( + result + == "reactions-image" + ) + + assert workflow.calls == [ + ("show_reactions",) + ] + + +def test_select_reactions_delegates( + monkeypatch, +): + workflow = install_fake_workflow( + monkeypatch + ) + + result = arx.select_reactions() + + assert result is None + + assert workflow.calls == [ + ("select_reactions",) + ] + + +def test_show_non_reactants_delegates( + monkeypatch, +): + workflow = install_fake_workflow( + monkeypatch + ) + + result = ( + arx.show_non_reactants() + ) + + assert ( + result + == "non-reactants-image" + ) + + assert workflow.calls == [ + ("show_non_reactants",) + ] + + +def test_show_non_reactants_propagates_none( + monkeypatch, +): + workflow = FakeWorkflow() + + def return_none(): + workflow.calls.append( + ("show_non_reactants",) + ) + + return None + + workflow.show_non_reactants = ( + return_none + ) + + monkeypatch.setattr( + arx, + "_active_workflow", + workflow, + ) + + assert ( + arx.show_non_reactants() + is None + ) + + +def test_select_non_reactants_delegates( + monkeypatch, +): + workflow = install_fake_workflow( + monkeypatch + ) + + result = ( + arx.select_non_reactants() + ) + + assert result is None + + assert workflow.calls == [ + ("select_non_reactants",) + ] + + +def test_prepare_reactions_delegates( + monkeypatch, +): + workflow = install_fake_workflow( + monkeypatch + ) + + result = ( + arx.prepare_reactions() + ) + + assert result is None + + assert workflow.calls == [ + ("prepare_reactions",) + ] + + +def test_process_delegates( + monkeypatch, +): + workflow = install_fake_workflow( + monkeypatch + ) + + result = arx.process() + + assert result is None + + assert workflow.calls == [ + ("process",) + ] + + +# ============================================================================= +# show_reaction_templates() +# ============================================================================= + + +@pytest.mark.parametrize( + "highlight_type", + [ + "template", + "edge", + "initiators", + "delete", + ], +) +def test_show_reaction_templates_accepts_supported_types( + monkeypatch, + highlight_type, +): + workflow = install_fake_workflow( + monkeypatch + ) + + result = ( + arx.show_reaction_templates( + highlight_type + ) + ) + + assert result == ( + f"templates-{highlight_type}" + ) + + assert workflow.calls == [ + ( + "show_reaction_templates", + highlight_type, + ) + ] + + +@pytest.mark.parametrize( + "highlight_type, expected", + [ + ("TEMPLATE", "template"), + ("Edge", "edge"), + ("INITIATORS", "initiators"), + ("Delete", "delete"), + ], +) +def test_show_reaction_templates_is_case_insensitive( + monkeypatch, + highlight_type, + expected, +): + workflow = install_fake_workflow( + monkeypatch + ) + + result = ( + arx.show_reaction_templates( + highlight_type + ) + ) + + assert result == ( + f"templates-{expected}" + ) + + assert workflow.calls == [ + ( + "show_reaction_templates", + expected, + ) + ] + + +def test_show_reaction_templates_defaults_to_template( + monkeypatch, +): + workflow = install_fake_workflow( + monkeypatch + ) + + result = ( + arx.show_reaction_templates() + ) + + assert ( + result + == "templates-template" + ) + + assert workflow.calls == [ + ( + "show_reaction_templates", + "template", + ) + ] + + +def test_show_reaction_templates_none_defaults_to_template( + monkeypatch, +): + workflow = install_fake_workflow( + monkeypatch + ) + + result = ( + arx.show_reaction_templates( + None + ) + ) + + assert ( + result + == "templates-template" + ) + + assert workflow.calls == [ + ( + "show_reaction_templates", + "template", + ) + ] + + +@pytest.mark.parametrize( + "highlight_type", + [ + "", + "wrong", + "reaction", + "atoms", + "templates", + ], +) +def test_show_reaction_templates_rejects_invalid_type( + monkeypatch, + highlight_type, +): + workflow = install_fake_workflow( + monkeypatch + ) + + if highlight_type == "": + # Current API intentionally treats false-like/empty input + # as the default "template". + result = ( + arx.show_reaction_templates( + highlight_type + ) + ) + + assert ( + result + == "templates-template" + ) + + return + + with pytest.raises( + ValueError, + match="Invalid highlight_type", + ): + arx.show_reaction_templates( + highlight_type + ) + + # Validation occurs before delegation. + assert workflow.calls == [] + + +def test_show_reaction_templates_error_lists_allowed_values( + monkeypatch, +): + install_fake_workflow( + monkeypatch + ) + + with pytest.raises( + ValueError, + ) as exc_info: + arx.show_reaction_templates( + "bad" + ) + + message = str( + exc_info.value + ) + + assert "template" in message + assert "edge" in message + assert "initiators" in message + assert "delete" in message + + +# ============================================================================= +# __all__ / public export contract +# ============================================================================= + + +def test_public_all_contains_package_metadata(): + expected = { + "__title__", + "__version__", + "__release__", + "__authors__", + "__license__", + } + + assert expected.issubset( + set(arx.__all__) + ) + + +def test_public_all_contains_user_workflow_commands(): + expected = { + "run", + "show_molecules", + "show_functional_groups", + "show_reactions", + "select_reactions", + "show_non_reactants", + "select_non_reactants", + "prepare_reactions", + "show_reaction_templates", + "process", + } + + assert expected.issubset( + set(arx.__all__) + ) + + +def test_session_is_part_of_public_api(): + """ + session() is used directly by the documented/user-facing workflow: + + session = arx.session() + + Therefore it should be exported alongside the other public API helpers. + """ + assert "session" in arx.__all__ + + +def test_public_all_has_no_duplicates(): + assert len(arx.__all__) == len( + set(arx.__all__) + ) + + +def test_every_name_in_public_all_exists(): + for name in arx.__all__: + assert hasattr( + arx, + name, + ), ( + f"{name!r} appears in " + "__all__ but does not exist" + ) \ No newline at end of file diff --git a/tests/unit/test_session.py b/tests/unit/test_session.py new file mode 100644 index 00000000..29ddba47 --- /dev/null +++ b/tests/unit/test_session.py @@ -0,0 +1,1038 @@ +import importlib +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +session_module = importlib.import_module("AutoREACTER.session") +cache_module = importlib.import_module("AutoREACTER.cache") + +Session = session_module.Session + + +# ============================================================================= +# Helpers +# ============================================================================= + + +def make_validated_inputs(simulation_name="test_simulation"): + """ + Minimal object representing InputParser.validate_inputs() output. + + Session/read_input only requires simulation_name directly during this + stage, so a lightweight namespace keeps these tests isolated from + InputParser itself. + """ + return SimpleNamespace( + simulation_name=simulation_name + ) + + +def write_json(path: Path, data: dict) -> Path: + path.write_text( + json.dumps(data), + encoding="utf-8", + ) + return path + + +def configure_read_input_dependencies( + monkeypatch, + tmp_path, + *, + validated_inputs=None, + validation_error=None, +): + """ + Replace Initialization, InputParser, and GetCacheDir so read_input() + can be tested without touching the real AutoREACTER environment. + """ + if validated_inputs is None: + validated_inputs = make_validated_inputs() + + calls = { + "initialization": 0, + "parser_instances": 0, + "validate_calls": 0, + "validated_data": None, + "clear_staging": None, + "cache_calls": 0, + } + + staging_dir = tmp_path / "staging" + + def fake_initialization(): + calls["initialization"] += 1 + + class FakeInputParser: + def __init__(self): + calls["parser_instances"] += 1 + + def validate_inputs(self, data): + calls["validate_calls"] += 1 + calls["validated_data"] = data + + if validation_error is not None: + raise validation_error + + return validated_inputs + + def fake_get_cache_dir(clear_staging=True): + calls["cache_calls"] += 1 + calls["clear_staging"] = clear_staging + + staging_dir.mkdir( + parents=True, + exist_ok=True, + ) + + return SimpleNamespace( + staging_dir=staging_dir + ) + + monkeypatch.setattr( + session_module, + "Initialization", + fake_initialization, + ) + + monkeypatch.setattr( + session_module, + "InputParser", + FakeInputParser, + ) + + monkeypatch.setattr( + cache_module, + "GetCacheDir", + fake_get_cache_dir, + ) + + return calls, staging_dir, validated_inputs + + +# ============================================================================= +# Session dataclass +# ============================================================================= + + +def test_session_stores_required_core_fields(tmp_path): + inputs = make_validated_inputs() + + session = Session( + inputs=inputs, + staging_dir=tmp_path / "staging", + output_dir=tmp_path / "output", + images_dir=tmp_path / "output" / "images", + ) + + assert session.inputs is inputs + assert session.staging_dir == tmp_path / "staging" + assert session.output_dir == tmp_path / "output" + + assert ( + session.images_dir + == tmp_path / "output" / "images" + ) + + +def test_session_pipeline_lists_default_to_empty(tmp_path): + session = Session( + inputs=make_validated_inputs(), + staging_dir=tmp_path / "staging", + output_dir=tmp_path / "output", + images_dir=tmp_path / "images", + ) + + assert session.monomer_roles == [] + assert session.reaction_instances == [] + assert session.non_reactants == [] + assert session.reaction_metadata == [] + + +def test_session_mutable_defaults_are_independent(tmp_path): + session_1 = Session( + inputs=make_validated_inputs("one"), + staging_dir=tmp_path / "staging1", + output_dir=tmp_path / "output1", + images_dir=tmp_path / "images1", + ) + + session_2 = Session( + inputs=make_validated_inputs("two"), + staging_dir=tmp_path / "staging2", + output_dir=tmp_path / "output2", + images_dir=tmp_path / "images2", + ) + + session_1.monomer_roles.append("role") + session_1.reaction_instances.append("reaction") + session_1.non_reactants.append("nonreactant") + session_1.reaction_metadata.append("metadata") + + assert session_2.monomer_roles == [] + assert session_2.reaction_instances == [] + assert session_2.non_reactants == [] + assert session_2.reaction_metadata == [] + + +def test_session_file_bundles_default_to_none(tmp_path): + session = Session( + inputs=make_validated_inputs(), + staging_dir=tmp_path / "staging", + output_dir=tmp_path / "output", + images_dir=tmp_path / "images", + ) + + assert session.ff_files is None + assert session.reacter_files is None + + +def test_session_runtime_defaults(tmp_path): + inputs = make_validated_inputs() + + session = Session( + inputs=inputs, + staging_dir=tmp_path / "staging", + output_dir=tmp_path / "output", + images_dir=tmp_path / "images", + ) + + assert session.inputs is inputs + + assert session.monomer_roles == [] + assert session.reaction_instances == [] + assert session.non_reactants == [] + assert session.reaction_metadata == [] + + assert session.ff_files is None + assert session.reacter_files is None + + assert session.reaction_id_counter == 0 + + assert ( + session.reaction_progression_session + is None + ) + + +def test_session_uses_slots(tmp_path): + session = Session( + inputs=make_validated_inputs(), + staging_dir=tmp_path / "staging", + output_dir=tmp_path / "output", + images_dir=tmp_path / "images", + ) + + with pytest.raises(AttributeError): + session.random_new_attribute = 123 + + +# ============================================================================= +# _resolve_input_path +# ============================================================================= + + +def test_resolve_input_path_returns_absolute_json_path( + tmp_path, +): + input_file = tmp_path / "input.json" + input_file.write_text( + "{}", + encoding="utf-8", + ) + + result = session_module._resolve_input_path( + input_file + ) + + assert result == input_file.resolve() + assert result.is_absolute() + + +def test_resolve_input_path_accepts_uppercase_json_suffix( + tmp_path, +): + input_file = tmp_path / "input.JSON" + input_file.write_text( + "{}", + encoding="utf-8", + ) + + result = session_module._resolve_input_path( + input_file + ) + + assert result == input_file.resolve() + + +def test_resolve_input_path_rejects_missing_file( + tmp_path, +): + missing = tmp_path / "missing.json" + + with pytest.raises( + FileNotFoundError, + match="Input file not found", + ): + session_module._resolve_input_path( + missing + ) + + +def test_resolve_input_path_rejects_non_json_file( + tmp_path, +): + input_file = tmp_path / "input.txt" + input_file.write_text( + "{}", + encoding="utf-8", + ) + + with pytest.raises( + ValueError, + match="Input file must be a JSON file", + ): + session_module._resolve_input_path( + input_file + ) + + +def test_resolve_input_path_rejects_directory_named_json( + tmp_path, +): + directory = tmp_path / "fake.json" + directory.mkdir() + + with pytest.raises( + ValueError, + match="Input file must be a JSON file", + ): + session_module._resolve_input_path( + directory + ) + + +# ============================================================================= +# _clear_directory +# ============================================================================= + + +def test_clear_directory_removes_files_but_preserves_root( + tmp_path, +): + directory = tmp_path / "output" + directory.mkdir() + + (directory / "one.txt").write_text("one") + (directory / "two.txt").write_text("two") + + session_module._clear_directory( + directory + ) + + assert directory.exists() + assert directory.is_dir() + assert list(directory.iterdir()) == [] + + +def test_clear_directory_removes_nested_directories( + tmp_path, +): + directory = tmp_path / "output" + nested = directory / "nested" / "deeper" + + nested.mkdir(parents=True) + + (nested / "data.txt").write_text( + "content", + encoding="utf-8", + ) + + session_module._clear_directory( + directory + ) + + assert directory.exists() + assert list(directory.iterdir()) == [] + + +def test_clear_directory_nonexistent_path_is_noop( + tmp_path, +): + path = tmp_path / "does_not_exist" + + session_module._clear_directory(path) + + assert not path.exists() + + +def test_clear_directory_file_path_is_noop( + tmp_path, +): + path = tmp_path / "file.txt" + path.write_text( + "keep me", + encoding="utf-8", + ) + + session_module._clear_directory(path) + + assert path.exists() + + assert ( + path.read_text(encoding="utf-8") + == "keep me" + ) + + +# ============================================================================= +# _resolve_output_dir +# ============================================================================= + + +@pytest.mark.parametrize( + "raw_output_dir", + [ + None, + "", + " ", + ], +) +def test_resolve_output_dir_uses_default_when_missing( + tmp_path, + raw_output_dir, +): + input_path = tmp_path / "input.json" + + result = session_module._resolve_output_dir( + raw_output_dir=raw_output_dir, + input_path=input_path, + simulation_name="my_sim", + ) + + expected = ( + tmp_path + / "AutoREACTER_outputs" + / "my_sim" + ).resolve() + + assert result == expected + + +def test_resolve_output_dir_relative_path_is_relative_to_input( + tmp_path, +): + input_path = tmp_path / "input.json" + + result = session_module._resolve_output_dir( + raw_output_dir="custom/output", + input_path=input_path, + simulation_name="ignored_here", + ) + + assert result == ( + tmp_path / "custom" / "output" + ).resolve() + + +def test_resolve_output_dir_absolute_path_is_preserved( + tmp_path, +): + input_path = tmp_path / "input.json" + + absolute_output = ( + tmp_path + / "absolute_output" + ).resolve() + + result = session_module._resolve_output_dir( + raw_output_dir=str(absolute_output), + input_path=input_path, + simulation_name="sim", + ) + + assert result == absolute_output + + +def test_resolve_output_dir_windows_forward_slash_path(): + input_path = Path("/tmp/input.json") + + result = session_module._resolve_output_dir( + raw_output_dir=( + "C:/Users/Janitha/Documents/ARX" + ), + input_path=input_path, + simulation_name="sim", + ) + + assert result == Path( + "/mnt/c/Users/Janitha/Documents/ARX" + ).resolve() + + +def test_resolve_output_dir_windows_backslash_path(): + input_path = Path("/tmp/input.json") + + result = session_module._resolve_output_dir( + raw_output_dir=( + r"D:\Projects\AutoREACTER\outputs" + ), + input_path=input_path, + simulation_name="sim", + ) + + assert result == Path( + "/mnt/d/Projects/AutoREACTER/outputs" + ).resolve() + + +def test_resolve_output_dir_windows_drive_is_lowercased(): + result = session_module._resolve_output_dir( + raw_output_dir=r"E:\Research\run", + input_path=Path("/tmp/input.json"), + simulation_name="sim", + ) + + assert result == Path( + "/mnt/e/Research/run" + ).resolve() + + +def test_resolve_output_dir_expands_user_home( + tmp_path, + monkeypatch, +): + fake_home = tmp_path / "home" + fake_home.mkdir() + + monkeypatch.setenv( + "HOME", + str(fake_home), + ) + + result = session_module._resolve_output_dir( + raw_output_dir="~/arx_output", + input_path=tmp_path / "input.json", + simulation_name="sim", + ) + + assert result == ( + fake_home / "arx_output" + ).resolve() + + +# ============================================================================= +# read_input +# ============================================================================= + + +def test_read_input_calls_initialization_once( + tmp_path, + monkeypatch, +): + calls, _, _ = configure_read_input_dependencies( + monkeypatch, + tmp_path, + ) + + input_file = write_json( + tmp_path / "input.json", + { + "simulation_name": "test_simulation", + }, + ) + + session_module.read_input(input_file) + + assert calls["initialization"] == 1 + + +def test_read_input_constructs_input_parser_once( + tmp_path, + monkeypatch, +): + calls, _, _ = configure_read_input_dependencies( + monkeypatch, + tmp_path, + ) + + input_file = write_json( + tmp_path / "input.json", + { + "simulation_name": "test_simulation", + }, + ) + + session_module.read_input(input_file) + + assert calls["parser_instances"] == 1 + assert calls["validate_calls"] == 1 + + +def test_read_input_passes_json_data_to_validator( + tmp_path, + monkeypatch, +): + calls, _, _ = configure_read_input_dependencies( + monkeypatch, + tmp_path, + ) + + data = { + "simulation_name": "demo", + "deep_search": False, + "wildcards": True, + } + + input_file = write_json( + tmp_path / "input.json", + data, + ) + + session_module.read_input(input_file) + + assert calls["validated_data"] == data + + +def test_read_input_propagates_clear_staging_true( + tmp_path, + monkeypatch, +): + calls, _, _ = configure_read_input_dependencies( + monkeypatch, + tmp_path, + ) + + input_file = write_json( + tmp_path / "input.json", + { + "simulation_name": "demo", + }, + ) + + session_module.read_input( + input_file, + clear_staging=True, + ) + + assert calls["clear_staging"] is True + + +def test_read_input_propagates_clear_staging_false( + tmp_path, + monkeypatch, +): + calls, _, _ = configure_read_input_dependencies( + monkeypatch, + tmp_path, + ) + + input_file = write_json( + tmp_path / "input.json", + { + "simulation_name": "demo", + }, + ) + + session_module.read_input( + input_file, + clear_staging=False, + ) + + assert calls["clear_staging"] is False + + +def test_read_input_returns_session( + tmp_path, + monkeypatch, +): + _, staging_dir, validated = ( + configure_read_input_dependencies( + monkeypatch, + tmp_path, + ) + ) + + input_file = write_json( + tmp_path / "input.json", + { + "simulation_name": "test_simulation", + }, + ) + + result = session_module.read_input( + input_file + ) + + assert isinstance(result, Session) + assert result.inputs is validated + assert result.staging_dir == staging_dir + + +def test_read_input_creates_default_output_directory( + tmp_path, + monkeypatch, +): + configure_read_input_dependencies( + monkeypatch, + tmp_path, + validated_inputs=make_validated_inputs( + "my_simulation" + ), + ) + + input_file = write_json( + tmp_path / "input.json", + { + "simulation_name": "my_simulation", + }, + ) + + result = session_module.read_input( + input_file + ) + + expected = ( + tmp_path + / "AutoREACTER_outputs" + / "my_simulation" + ).resolve() + + assert result.output_dir == expected + assert expected.is_dir() + + +def test_read_input_creates_images_directory( + tmp_path, + monkeypatch, +): + configure_read_input_dependencies( + monkeypatch, + tmp_path, + ) + + input_file = write_json( + tmp_path / "input.json", + { + "simulation_name": "test_simulation", + }, + ) + + result = session_module.read_input( + input_file + ) + + assert ( + result.images_dir + == result.output_dir / "images" + ) + + assert result.images_dir.is_dir() + + +def test_read_input_uses_relative_custom_output_dir( + tmp_path, + monkeypatch, +): + configure_read_input_dependencies( + monkeypatch, + tmp_path, + validated_inputs=make_validated_inputs( + "demo" + ), + ) + + input_file = write_json( + tmp_path / "input.json", + { + "simulation_name": "demo", + "output_dir": "custom_outputs", + }, + ) + + result = session_module.read_input( + input_file + ) + + assert result.output_dir == ( + tmp_path / "custom_outputs" + ).resolve() + + +def test_read_input_uses_absolute_custom_output_dir( + tmp_path, + monkeypatch, +): + configure_read_input_dependencies( + monkeypatch, + tmp_path, + validated_inputs=make_validated_inputs( + "demo" + ), + ) + + custom_output = ( + tmp_path / "absolute_custom" + ).resolve() + + input_file = write_json( + tmp_path / "input.json", + { + "simulation_name": "demo", + "output_dir": str(custom_output), + }, + ) + + result = session_module.read_input( + input_file + ) + + assert result.output_dir == custom_output + assert custom_output.is_dir() + + +def test_read_input_clears_existing_output_directory( + tmp_path, + monkeypatch, +): + configure_read_input_dependencies( + monkeypatch, + tmp_path, + validated_inputs=make_validated_inputs( + "demo" + ), + ) + + output_dir = tmp_path / "existing_output" + output_dir.mkdir() + + old_file = output_dir / "old.txt" + old_file.write_text( + "old data", + encoding="utf-8", + ) + + nested = output_dir / "old_folder" + nested.mkdir() + + (nested / "old_nested.txt").write_text( + "old nested data", + encoding="utf-8", + ) + + input_file = write_json( + tmp_path / "input.json", + { + "simulation_name": "demo", + "output_dir": str(output_dir), + }, + ) + + result = session_module.read_input( + input_file + ) + + assert result.output_dir == output_dir.resolve() + + assert not old_file.exists() + assert not nested.exists() + + assert ( + output_dir / "images" + ).is_dir() + + +def test_read_input_rejects_output_path_that_is_file( + tmp_path, + monkeypatch, +): + configure_read_input_dependencies( + monkeypatch, + tmp_path, + validated_inputs=make_validated_inputs( + "demo" + ), + ) + + output_file = tmp_path / "not_a_directory" + output_file.write_text( + "existing file", + encoding="utf-8", + ) + + input_file = write_json( + tmp_path / "input.json", + { + "simulation_name": "demo", + "output_dir": str(output_file), + }, + ) + + with pytest.raises( + ValueError, + match=( + "Resolved output_dir exists " + "but is not a directory" + ), + ): + session_module.read_input( + input_file + ) + + +@pytest.mark.parametrize( + "simulation_name", + [ + "../escape", + "folder/simulation", + ".", + "..", + ], +) +def test_read_input_rejects_unsafe_simulation_name( + tmp_path, + monkeypatch, + simulation_name, +): + configure_read_input_dependencies( + monkeypatch, + tmp_path, + validated_inputs=make_validated_inputs( + simulation_name + ), + ) + + input_file = write_json( + tmp_path / "input.json", + { + "simulation_name": simulation_name, + }, + ) + + with pytest.raises( + ValueError, + match=( + "Invalid simulation_name " + "for output directory" + ), + ): + session_module.read_input( + input_file + ) + + +def test_read_input_propagates_validation_error( + tmp_path, + monkeypatch, +): + configure_read_input_dependencies( + monkeypatch, + tmp_path, + validation_error=ValueError( + "bad inputs" + ), + ) + + input_file = write_json( + tmp_path / "input.json", + { + "simulation_name": "demo", + }, + ) + + with pytest.raises( + ValueError, + match="bad inputs", + ): + session_module.read_input( + input_file + ) + + +def test_read_input_invalid_json_propagates_json_error( + tmp_path, + monkeypatch, +): + configure_read_input_dependencies( + monkeypatch, + tmp_path, + ) + + input_file = tmp_path / "input.json" + + input_file.write_text( + "{ definitely not valid json", + encoding="utf-8", + ) + + with pytest.raises( + json.JSONDecodeError + ): + session_module.read_input( + input_file + ) + + +def test_read_input_missing_file_fails_before_initialization( + tmp_path, + monkeypatch, +): + calls, _, _ = configure_read_input_dependencies( + monkeypatch, + tmp_path, + ) + + missing = tmp_path / "missing.json" + + with pytest.raises(FileNotFoundError): + session_module.read_input( + missing + ) + + assert calls["initialization"] == 0 + assert calls["cache_calls"] == 0 + assert calls["parser_instances"] == 0 + + +def test_read_input_prints_session_information( + tmp_path, + monkeypatch, + capsys, +): + configure_read_input_dependencies( + monkeypatch, + tmp_path, + validated_inputs=make_validated_inputs( + "print_test" + ), + ) + + input_file = write_json( + tmp_path / "input.json", + { + "simulation_name": "print_test", + }, + ) + + result = session_module.read_input( + input_file + ) + + output = capsys.readouterr().out + + assert ( + "[INFO] Initialized AutoREACTER Session" + in output + ) + + assert ( + "[INFO] Simulation Name: print_test" + in output + ) + + assert str(input_file.resolve()) in output + assert str(result.staging_dir) in output + assert str(result.output_dir) in output \ No newline at end of file