diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 1489c07..dbc6337 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,12 +1,12 @@ # These owners will be the default owners for everything in # the repo. Unless a later match takes precedence, -# @marcelmbn and @thfroitzheim will be requested for +# the names mentioned after the '*' will be requested for # review when someone opens a pull request. * @marcelmbn @jonathan-schoeps # These parts are specifically owned by some people /src/mindlessgen/cli @marcelmbn -/src/mindlessgen/generator @marcelmbn +/src/mindlessgen/generator @marcelmbn @lmseidler /src/mindlessgen/molecules @marcelmbn @jonathan-schoeps -/src/mindlessgen/prog @marcelmbn @jonathan-schoeps -/src/mindlessgen/qm @marcelmbn +/src/mindlessgen/prog @marcelmbn @jonathan-schoeps @lmseidler +/src/mindlessgen/qm @marcelmbn @jonathan-schoeps diff --git a/CHANGELOG.md b/CHANGELOG.md index 8be9cb1..269ca9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - to set the elemental composition it is now possible to use dicts with not only int but also the element symbols (str) - dict keys for elemental compositions will now always be checked for validity - Renamed GP3-xTB to g-xTB +- Nothing will be printed while multiple molecules are generated in parallel, tqdm-based progress bar instead +- Some debugging statements from generate had to be removed (esp. w.r.t. early stopping) ### Added - `GXTBConfig` class for the g-xTB method, supporting SCF cycles check +- support for TURBOMOLE as QM engine. +- updated the parallelization to work over the number of molecules ### Fixed - version string is now correctly formatted and printed @@ -45,7 +49,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - support for `python-3.13` - option to set a fixed molecular charge, while ensuring `uhf = 0` - `element_composition` and `forbidden_elements` can now be directly set to a `dict` or `list`, respectively, via API access -- support for TURBOMOLE as QM engine. ### Breaking Changes - Removal of the `dist_threshold` flag and in the `-toml` file. diff --git a/environment.yml b/environment.yml index be9d9ec..130200e 100644 --- a/environment.yml +++ b/environment.yml @@ -9,5 +9,6 @@ dependencies: - pre-commit - pytest - tox + - tqdm - pip: - covdefaults diff --git a/mindlessgen.toml b/mindlessgen.toml index 722aa5f..ac720ae 100644 --- a/mindlessgen.toml +++ b/mindlessgen.toml @@ -58,6 +58,8 @@ hlgap = 0.5 # > Debug this step. Leads to more verbose output as soon as the refinement part is reached. Options: # > If `debug` is true, the process is terminated after the first (successful or not) refinement step. debug = false +# > Number of cores to be used for geometry optimizations. Single-points will continue to use one core each. +ncores = 4 [postprocess] # > Engine for the post-processing part. Options: 'xtb', 'orca', 'turbomole' @@ -70,6 +72,8 @@ opt_cycles = 5 # > If `debug` is true, the process is terminated after the first (successful or not) post-processing step. # > Note: This option is only relevant if the 'postprocess' option in the 'general' section is set to 'true'. debug = false +# > Number of cores to be used for both single-point calculations and geometry optimizations. +ncores = 4 [xtb] # > Path to the xtb executable. The names `xtb` and `xtb_dev` are automatically searched for. Options: diff --git a/pyproject.toml b/pyproject.toml index fc0b69d..72feb57 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ classifiers = [ "Topic :: Scientific/Engineering", "Typing :: Typed", ] -dependencies = ["numpy", "networkx", "toml"] +dependencies = ["numpy", "networkx", "toml", "tqdm"] dynamic = ["version"] [project.urls] @@ -41,6 +41,7 @@ dev = [ "tox", "setuptools_scm>=8", "types-toml", + "types-tqdm", ] [project.scripts] diff --git a/src/mindlessgen/generator/main.py b/src/mindlessgen/generator/main.py index 1854d38..25bc358 100644 --- a/src/mindlessgen/generator/main.py +++ b/src/mindlessgen/generator/main.py @@ -4,11 +4,20 @@ from __future__ import annotations +# Python standard library from collections.abc import Callable +from concurrent.futures import Future, as_completed from pathlib import Path import multiprocessing as mp +from threading import Event import warnings +from time import perf_counter +from datetime import timedelta +# External packages +from tqdm import tqdm + +# Internal modules from ..molecules import generate_random_molecule, Molecule from ..qm import ( XTB, @@ -23,14 +32,13 @@ get_gxtb_path, ) from ..molecules import iterative_optimization, postprocess_mol -from ..prog import ConfigManager - +from ..prog import ConfigManager, setup_managers, ResourceMonitor, setup_blocks from ..__version__ import __version__ MINDLESS_MOLECULES_FILE = "mindless.molecules" -def generator(config: ConfigManager) -> tuple[list[Molecule] | None, int]: +def generator(config: ConfigManager) -> tuple[list[Molecule], int]: """ Generate a molecule. """ @@ -44,12 +52,14 @@ def generator(config: ConfigManager) -> tuple[list[Molecule] | None, int]: # __/ | # |___/ + start = perf_counter() + if config.general.verbosity > 0: print(header(str(__version__))) if config.general.print_config: print(config) - return None, 0 + return [], 0 # Import and set up required engines refine_engine: QMMethod = setup_engines( @@ -83,95 +93,174 @@ def generator(config: ConfigManager) -> tuple[list[Molecule] | None, int]: if config.general.verbosity > 0: print(f"Running with {num_cores} cores.") - # Check if the file "mindless.molecules" exists. If yes, append to it. + # Check if the file {MINDLESS_MOLECULES_FILE} exists. If yes, append to it. if Path(MINDLESS_MOLECULES_FILE).is_file(): if config.general.verbosity > 0: print(f"\n--- Appending to existing file '{MINDLESS_MOLECULES_FILE}'. ---") + exitcode = 0 optimized_molecules: list[Molecule] = [] - for molcount in range(config.general.num_molecules): - # print a decent header for each molecule iteration - if config.general.verbosity > 0: - print(f"\n{'=' * 80}") - print( - f"{'=' * 22} Generating molecule {molcount + 1:<4} of " - + f"{config.general.num_molecules:<4} {'=' * 24}" - ) - print(f"{'=' * 80}") - manager = mp.Manager() - stop_event = manager.Event() - cycles = range(config.general.max_cycles) - backup_verbosity: int | None = None - if num_cores > 1 and config.general.verbosity > 0: - backup_verbosity = ( - config.general.verbosity - ) # Save verbosity level for later - config.general.verbosity = 0 # Disable verbosity if parallel - - if config.general.verbosity == 0: - print("Cycle... ", end="", flush=True) - with mp.Pool(processes=num_cores) as pool: - results = pool.starmap( - single_molecule_generator, - [ - (config, refine_engine, postprocess_engine, cycle, stop_event) - for cycle in cycles - ], - ) - if config.general.verbosity == 0: - print("") - - # Restore verbosity level if it was changed - if backup_verbosity is not None: - config.general.verbosity = backup_verbosity - # Filter out None values and return the first successful molecule - optimized_molecule: Molecule | None = None - for i, result in enumerate(results): + # Initialize parallel blocks here + blocks = setup_blocks( + num_cores, + config.general.num_molecules, + min(config.refine.ncores, config.postprocess.ncores), + ) + blocks.sort(key=lambda x: x.ncores) + + backup_verbosity: int | None = None + if len(blocks) > 1 and config.general.verbosity > 0: + backup_verbosity = config.general.verbosity # Save verbosity level for later + config.general.verbosity = 0 # Disable verbosity if parallel + # NOTE: basically no messages will be printed if generation is run in parallel + + # Set up parallel blocks environment + with setup_managers(num_cores // blocks[0].ncores, num_cores) as ( + executor, + _, + resources, + ): + # The following creates a queue of futures which occupy a certain number of cores each + # as defined by each block + # Each future represents the generation of one molecule + # NOTE: proceeding this way assures that each molecule gets a static number of cores + # a dynamic setting would also be thinkable and straightforward to implement + tasks: list[Future[Molecule | None]] = [] + for block in blocks: + for _ in range(block.num_molecules): + tasks.append( + executor.submit( + single_molecule_generator, + len(tasks), + config, + resources, + refine_engine, + postprocess_engine, + block.ncores, + ) + ) + + # Collect results of all tries to create a molecule + for future in tqdm( + as_completed(tasks), + total=len(tasks), + desc="Generating Molecules ...", + ): + result: Molecule | None = future.result() if result is not None: - cycles_needed = i + 1 - optimized_molecule = result - break - - if optimized_molecule is None: - warnings.warn( - "Molecule generation including optimization (and postprocessing) " - + f"failed for all cycles for molecule {molcount + 1}." - ) - exitcode = 1 - continue - if config.general.verbosity > 0: - print(f"Optimized mindless molecule found in {cycles_needed} cycles.") - print(optimized_molecule) - if config.general.write_xyz: - optimized_molecule.write_xyz_to_file() - if config.general.verbosity > 0: - print(f"Written molecule file 'mlm_{optimized_molecule.name}.xyz'.\n") - with open("mindless.molecules", "a", encoding="utf8") as f: - f.write(f"mlm_{optimized_molecule.name}\n") - optimized_molecules.append(optimized_molecule) + optimized_molecules.append(result) + else: + exitcode = 1 + + # Restore verbosity level if it was changed + if backup_verbosity is not None: + config.general.verbosity = backup_verbosity + + end = perf_counter() + runtime = end - start + + print(f"\nSuccessfully generated {len(optimized_molecules)} molecules:") + for optimized_molecule in optimized_molecules: + print(optimized_molecule.name) + + time = timedelta(seconds=int(runtime)) + hours, r = divmod(time.seconds, 3600) + minutes, seconds = divmod(r, 60) + if time.days: + hours += time.days * 24 + + print(f"\nRan MindlessGen in {hours:02d}:{minutes:02d}:{seconds:02d} (HH:MM:SS)") return optimized_molecules, exitcode def single_molecule_generator( + molcount: int, config: ConfigManager, + resources: ResourceMonitor, refine_engine: QMMethod, postprocess_engine: QMMethod | None, - cycle: int, - stop_event, + ncores: int, ) -> Molecule | None: """ - Generate a single molecule. + Generate a single molecule (from start to finish). """ + + # Wait for enough cores (cores freed automatically upon leaving managed context) + with resources.occupy_cores(ncores): + # print a decent header for each molecule iteration + if config.general.verbosity > 0: + print(f"\n{'='*80}") + print( + f"{'='*22} Generating molecule {molcount + 1:<4} of " + + f"{config.general.num_molecules:<4} {'='*24}" + ) + print(f"{'='*80}") + + with setup_managers(ncores, ncores) as (executor, manager, resources_local): + stop_event = manager.Event() + # Launch worker processes to find molecule + cycles = range(config.general.max_cycles) + tasks: list[Future[Molecule | None]] = [] + for cycle in cycles: + tasks.append( + executor.submit( + single_molecule_step, + config, + resources_local, + refine_engine, + postprocess_engine, + cycle, + stop_event, + ) + ) + + results = [task.result() for task in as_completed(tasks)] + + optimized_molecule: Molecule | None = None + for i, result in enumerate(results): + if result is not None: + cycles_needed = i + 1 + optimized_molecule = result + if config.general.verbosity > 0: + print(f"Optimized mindless molecule found in {cycles_needed} cycles.") + print(optimized_molecule) + break + + # Write out molecule if requested + if optimized_molecule is not None and config.general.write_xyz: + optimized_molecule.write_xyz_to_file() + with open(MINDLESS_MOLECULES_FILE, "a", encoding="utf8") as f: + f.write(f"mlm_{optimized_molecule.name}\n") + if config.general.verbosity > 0: + print(f"Written molecule file 'mlm_{optimized_molecule.name}.xyz'.\n") + elif optimized_molecule is None: + # TODO: will this conflict with progress bar? + warnings.warn( + "Molecule generation including optimization (and postprocessing) " + + f"failed for all cycles for molecule {molcount + 1}." + ) + + return optimized_molecule + + +def single_molecule_step( + config: ConfigManager, + resources_local: ResourceMonitor, + refine_engine: QMMethod, + postprocess_engine: QMMethod | None, + cycle: int, + stop_event: Event, +) -> Molecule | None: + """Execute one step in a single molecule generation""" + if stop_event.is_set(): return None # Exit early if a molecule has already been found - if config.general.verbosity == 0: - # print the cycle in one line, not starting a new line - print("✔", end="", flush=True) - elif config.general.verbosity > 0: - print(f"Cycle {cycle + 1}:") + if config.general.verbosity > 0: + print(f"Starting cycle {cycle + 1:<3}...") + # _____ _ # / ____| | | # | | __ ___ _ __ ___ _ __ __ _| |_ ___ _ __ @@ -209,10 +298,11 @@ def single_molecule_generator( # | | # |_| optimized_molecule = iterative_optimization( - mol=mol, - engine=refine_engine, - config_generate=config.generate, - config_refine=config.refine, + mol, + refine_engine, + config.generate, + config.refine, + resources_local, verbosity=config.general.verbosity, ) except RuntimeError as e: @@ -231,7 +321,8 @@ def single_molecule_generator( optimized_molecule, postprocess_engine, # type: ignore config.postprocess, - config.general.verbosity, + resources_local, + verbosity=config.general.verbosity, ) except RuntimeError as e: if config.general.verbosity > 0: diff --git a/src/mindlessgen/molecules/postprocess.py b/src/mindlessgen/molecules/postprocess.py index 4189cc9..8e0e767 100644 --- a/src/mindlessgen/molecules/postprocess.py +++ b/src/mindlessgen/molecules/postprocess.py @@ -4,11 +4,15 @@ from .molecule import Molecule from ..qm import QMMethod -from ..prog import PostProcessConfig +from ..prog import PostProcessConfig, ResourceMonitor def postprocess_mol( - mol: Molecule, engine: QMMethod, config: PostProcessConfig, verbosity: int = 1 + mol: Molecule, + engine: QMMethod, + config: PostProcessConfig, + resources_local: ResourceMonitor, + verbosity: int = 1, ) -> Molecule: """ Postprocess the generated molecule. @@ -26,14 +30,19 @@ def postprocess_mol( print("Postprocessing molecule...") if config.optimize: try: - postprocmol = engine.optimize( - mol, max_cycles=config.opt_cycles, verbosity=verbosity - ) + with resources_local.occupy_cores(config.ncores): + postprocmol = engine.optimize( + mol, + max_cycles=config.opt_cycles, + ncores=config.ncores, + verbosity=verbosity, + ) except RuntimeError as e: raise RuntimeError("Optimization in postprocessing failed.") from e else: try: - engine.singlepoint(mol, verbosity=verbosity) + with resources_local.occupy_cores(config.ncores): + engine.singlepoint(mol, config.ncores, verbosity=verbosity) postprocmol = mol except RuntimeError as e: raise RuntimeError( diff --git a/src/mindlessgen/molecules/refinement.py b/src/mindlessgen/molecules/refinement.py index 6bd415a..5abe35c 100644 --- a/src/mindlessgen/molecules/refinement.py +++ b/src/mindlessgen/molecules/refinement.py @@ -7,8 +7,9 @@ from pathlib import Path import networkx as nx # type: ignore import numpy as np + from ..qm.base import QMMethod -from ..prog import GenerateConfig, RefineConfig +from ..prog import GenerateConfig, RefineConfig, ResourceMonitor from .molecule import Molecule from .miscellaneous import ( set_random_charge, @@ -31,6 +32,7 @@ def iterative_optimization( engine: QMMethod, config_generate: GenerateConfig, config_refine: RefineConfig, + resources_local: ResourceMonitor, verbosity: int = 1, ) -> Molecule: """ @@ -43,9 +45,21 @@ def iterative_optimization( verbosity = 3 for cycle in range(config_refine.max_frag_cycles): + # Run single points first, start optimization if scf converges + try: + with resources_local.occupy_cores(1): + _ = engine.singlepoint(rev_mol, 1, verbosity) + except RuntimeError as e: + raise RuntimeError( + f"Single-point calculation failed at fragmentation cycle {cycle}: {e}" + ) from e + # Optimize the current molecule try: - rev_mol = engine.optimize(rev_mol, None, verbosity) + with resources_local.occupy_cores(config_refine.ncores): + rev_mol = engine.optimize( + rev_mol, config_refine.ncores, None, verbosity + ) except RuntimeError as e: raise RuntimeError( f"Optimization failed at fragmentation cycle {cycle}: {e}" @@ -149,9 +163,13 @@ def iterative_optimization( ) try: - gap_sufficient = engine.check_gap( - molecule=rev_mol, threshold=config_refine.hlgap, verbosity=verbosity - ) + with resources_local.occupy_cores(1): + gap_sufficient = engine.check_gap( + molecule=rev_mol, + threshold=config_refine.hlgap, + ncores=1, + verbosity=verbosity, + ) except NotImplementedError: warnings.warn("HOMO-LUMO gap check not implemented with this engine.") except RuntimeError as e: diff --git a/src/mindlessgen/prog/__init__.py b/src/mindlessgen/prog/__init__.py index e86adc0..57dd822 100644 --- a/src/mindlessgen/prog/__init__.py +++ b/src/mindlessgen/prog/__init__.py @@ -1,5 +1,6 @@ """ -This module contains the classes and functions for all configuration-related tasks. +This module contains the classes and functions for all configuration-related tasks, +as well as utilities concerned with parallelization. """ from .config import ( @@ -13,6 +14,7 @@ RefineConfig, PostProcessConfig, ) +from .parallel import setup_managers, ResourceMonitor, setup_blocks __all__ = [ "ConfigManager", @@ -24,4 +26,7 @@ "GenerateConfig", "RefineConfig", "PostProcessConfig", + "setup_managers", + "ResourceMonitor", + "setup_blocks", ] diff --git a/src/mindlessgen/prog/config.py b/src/mindlessgen/prog/config.py index 37af6c8..3c9cbb2 100644 --- a/src/mindlessgen/prog/config.py +++ b/src/mindlessgen/prog/config.py @@ -620,6 +620,7 @@ def __init__(self: RefineConfig) -> None: self._engine: str = "xtb" self._hlgap: float = 0.5 self._debug: bool = False + self._ncores: int = 4 def get_identifier(self) -> str: return "refine" @@ -694,6 +695,22 @@ def debug(self, debug: bool): raise TypeError("Debug should be a boolean.") self._debug = debug + @property + def ncores(self): + """ + Get the number of cores to be used for geometry optimizations in refinement. + """ + return self._ncores + + @ncores.setter + def ncores(self, ncores: int): + """ + Set the number of cores to be used for geometry optimizations in refinement. + """ + if not isinstance(ncores, int): + raise TypeError("Number of cores should be an integer.") + self._ncores = ncores + class PostProcessConfig(BaseConfig): """ @@ -705,6 +722,7 @@ def __init__(self: PostProcessConfig) -> None: self._opt_cycles: int | None = 5 self._optimize: bool = True self._debug: bool = False + self._ncores: int = 4 def get_identifier(self) -> str: return "postprocess" @@ -777,6 +795,22 @@ def debug(self, debug: bool): raise TypeError("Debug should be a boolean.") self._debug = debug + @property + def ncores(self): + """ + Get the number of cores to be used in post-processing. + """ + return self._ncores + + @ncores.setter + def ncores(self, ncores: int): + """ + Set the number of cores to be used in post-processing. + """ + if not isinstance(ncores, int): + raise TypeError("Number of cores should be an integer.") + self._ncores = ncores + class XTBConfig(BaseConfig): """ @@ -1112,6 +1146,17 @@ def check_config(self, verbosity: int = 1) -> None: + "with possibly similar errors in parallel mode. " + "Don't be confused!" ) + + # Assert that parallel configuration is valid + if num_cores < self.refine.ncores: + raise RuntimeError( + f"Number of cores ({num_cores}) is too low to run refinement using {self.refine.ncores}." + ) + if self.general.postprocess and num_cores < self.postprocess.ncores: + raise RuntimeError( + f"Number of cores ({num_cores}) is too low to run post-processing using {self.postprocess.ncores}." + ) + if self.refine.engine == "xtb": # Check for f-block elements in forbidden elements if self.generate.forbidden_elements: diff --git a/src/mindlessgen/prog/parallel.py b/src/mindlessgen/prog/parallel.py new file mode 100644 index 0000000..d156335 --- /dev/null +++ b/src/mindlessgen/prog/parallel.py @@ -0,0 +1,82 @@ +from concurrent.futures import ProcessPoolExecutor +from multiprocessing.managers import SyncManager +from multiprocessing import Manager +from contextlib import contextmanager +from dataclasses import dataclass + + +@contextmanager +def setup_managers(max_workers: int, ncores: int): + executor: ProcessPoolExecutor = ProcessPoolExecutor(max_workers=max_workers) + manager: SyncManager = Manager() + resource_manager: ResourceMonitor = ResourceMonitor(manager, ncores) + try: + yield executor, manager, resource_manager + finally: + executor.shutdown(False, cancel_futures=True) + manager.shutdown() + + +class ResourceMonitor: + def __init__(self, manager: SyncManager, ncores: int): + self.__free_cores = manager.Value(int, ncores) + self.__enough_cores = manager.Condition() + + @contextmanager + def occupy_cores(self, ncores: int): + try: + with self.__enough_cores: + self.__enough_cores.wait_for(lambda: self.__free_cores.value >= ncores) + self.__free_cores.value -= ncores + yield + finally: + with self.__enough_cores: + self.__free_cores.value += ncores + self.__enough_cores.notify() # TODO: try this with notify_all instead + + +@dataclass +class Block: + num_molecules: int + ncores: int + + +def setup_blocks(ncores: int, num_molecules: int, mincores: int) -> list[Block]: + blocks: list[Block] = [] + + # Maximum and minimum number of parallel processes possible + maxcores = ncores + maxprocs = max(1, ncores // mincores) + minprocs = max(1, ncores // maxcores) + + # Distribute number of molecules among blocks + # First (if possible) create the maximum number of parallel blocks (maxprocs) and distribute as many molecules as possible + molecules_left = num_molecules + if molecules_left >= maxprocs: + p = maxprocs + molecules_per_block = molecules_left // p + for _ in range(p): + blocks.append(Block(molecules_per_block, ncores // p)) + molecules_left -= molecules_per_block * p + + # While there are more than minprocs (1) molecules left find the optimal number of parallel blocks + # Again distribute as many molecules per block as possible + while molecules_left >= minprocs: + p = max( + [ + j + for j in range(minprocs, maxprocs) + if ncores % j == 0 and j <= molecules_left + ] + ) + molecules_per_block = molecules_left // p + for _ in range(p): + blocks.append(Block(molecules_per_block, ncores // p)) + molecules_left -= molecules_per_block * p + + # NOTE: using minprocs = 1 this is probably never true + if molecules_left > 0: + blocks.append(Block(molecules_left, maxcores)) + molecules_left -= molecules_left + + return blocks diff --git a/src/mindlessgen/qm/base.py b/src/mindlessgen/qm/base.py index 524f612..3a5069b 100644 --- a/src/mindlessgen/qm/base.py +++ b/src/mindlessgen/qm/base.py @@ -21,36 +21,43 @@ def __init__(self, path: str | Path, verbosity: int = 1): @abstractmethod def optimize( - self, molecule: Molecule, max_cycles: int | None = None, verbosity: int = 1 + self, + molecule: Molecule, + ncores: int, + max_cycles: int | None = None, + verbosity: int = 1, ) -> Molecule: """ Define the optimization process. Arguments: molecule (Molecule): Molecule to optimize + ncores (int): Number of cores to use Returns: Molecule: Optimized molecule """ @abstractmethod - def singlepoint(self, molecule: Molecule, verbosity: int = 1) -> str: + def singlepoint(self, molecule: Molecule, ncores: int, verbosity: int = 1) -> str: """ Define the single point calculation process. Arguments: molecule (Molecule): Molecule to calculate + ncores (int): Number of cores to use """ @abstractmethod def check_gap( - self, molecule: Molecule, threshold: float, verbosity: int = 1 + self, molecule: Molecule, ncores: int, threshold: float, verbosity: int = 1 ) -> bool: """ Check if the HL gap is larger than a given threshold. Arguments: molecule (Molecule): Molecule to check + ncores (int): Number of cores to use threshold (float): Threshold for the gap """ diff --git a/src/mindlessgen/qm/gxtb.py b/src/mindlessgen/qm/gxtb.py index 970c6ca..ff2da9e 100644 --- a/src/mindlessgen/qm/gxtb.py +++ b/src/mindlessgen/qm/gxtb.py @@ -30,7 +30,7 @@ def __init__(self, path: str | Path, gxtbcfg: GXTBConfig) -> None: raise TypeError("gxtb_path should be a string or a Path object.") self.cfg = gxtbcfg - def singlepoint(self, molecule: Molecule, verbosity: int = 1) -> str: + def singlepoint(self, molecule: Molecule, ncores: int, verbosity: int = 1) -> str: """ Perform a single-point calculation using g-xTB. """ @@ -88,7 +88,11 @@ def singlepoint(self, molecule: Molecule, verbosity: int = 1) -> str: return gxtb_log_out def check_gap( - self, molecule: Molecule, threshold: float = 0.5, verbosity: int = 1 + self, + molecule: Molecule, + ncores: int, + threshold: float = 0.5, + verbosity: int = 1, ) -> bool: """ Check if the HL gap is larger than a given threshold. @@ -103,7 +107,7 @@ def check_gap( # Perform a single point calculation try: - gxtb_out = self.singlepoint(molecule) + gxtb_out = self.singlepoint(molecule, ncores) except RuntimeError as e: raise RuntimeError("Single point calculation failed.") from e @@ -130,7 +134,11 @@ def check_gap( return hlgap > threshold def optimize( - self, molecule: Molecule, max_cycles: int | None = None, verbosity: int = 1 + self, + molecule: Molecule, + ncores: int, + max_cycles: int | None = None, + verbosity: int = 1, ) -> Molecule: """ Optimize a molecule using g-xTB. diff --git a/src/mindlessgen/qm/orca.py b/src/mindlessgen/qm/orca.py index 3225335..31f2120 100644 --- a/src/mindlessgen/qm/orca.py +++ b/src/mindlessgen/qm/orca.py @@ -30,7 +30,11 @@ def __init__(self, path: str | Path, orcacfg: ORCAConfig) -> None: self.cfg = orcacfg def optimize( - self, molecule: Molecule, max_cycles: int | None = None, verbosity: int = 1 + self, + molecule: Molecule, + ncores: int, + max_cycles: int | None = None, + verbosity: int = 1, ) -> Molecule: """ Optimize a molecule using ORCA. @@ -43,7 +47,9 @@ def optimize( molecule.write_xyz_to_file(temp_path / "molecule.xyz") inputname = "orca_opt.inp" - orca_input = self._gen_input(molecule, "molecule.xyz", True, max_cycles) + orca_input = self._gen_input( + molecule, "molecule.xyz", ncores, True, max_cycles + ) if verbosity > 1: print("ORCA input file:\n##################") print(orca_input) @@ -72,7 +78,7 @@ def optimize( optimized_molecule.read_xyz_from_file(xyzfile) return optimized_molecule - def singlepoint(self, molecule: Molecule, verbosity: int = 1) -> str: + def singlepoint(self, molecule: Molecule, ncores: int, verbosity: int = 1) -> str: """ Perform a single point calculation using ORCA. """ @@ -85,10 +91,10 @@ def singlepoint(self, molecule: Molecule, verbosity: int = 1) -> str: # write the input file inputname = "orca.inp" - orca_input = self._gen_input(molecule, molfile) + orca_input = self._gen_input(molecule, molfile, ncores) if verbosity > 1: print("ORCA input file:\n##################") - print(self._gen_input(molecule, molfile)) + print(self._gen_input(molecule, molfile, ncores)) print("##################") with open(temp_path / inputname, "w", encoding="utf8") as f: f.write(orca_input) @@ -110,7 +116,7 @@ def singlepoint(self, molecule: Molecule, verbosity: int = 1) -> str: return orca_log_out def check_gap( - self, molecule: Molecule, threshold: float, verbosity: int = 1 + self, molecule: Molecule, ncores: int, threshold: float, verbosity: int = 1 ) -> bool: """ Check if the HL gap is larger than a given threshold. @@ -156,6 +162,7 @@ def _gen_input( self, molecule: Molecule, xyzfile: str, + ncores: int, optimization: bool = False, opt_cycles: int | None = None, ) -> str: @@ -175,7 +182,7 @@ def _gen_input( orca_input += ( f"%scf\n\tMaxIter {self.cfg.scf_cycles}\n\tConvergence Medium\nend\n" ) - orca_input += "%pal nprocs 1 end\n\n" + orca_input += f"%pal nprocs {ncores} end\n\n" orca_input += f"* xyzfile {molecule.charge} {molecule.uhf + 1} {xyzfile}\n" return orca_input diff --git a/src/mindlessgen/qm/tm.py b/src/mindlessgen/qm/tm.py index c75806e..9bedaa4 100644 --- a/src/mindlessgen/qm/tm.py +++ b/src/mindlessgen/qm/tm.py @@ -44,6 +44,7 @@ def __init__( def optimize( self, molecule: Molecule, + ncores: int, max_cycles: int | None = None, verbosity: int = 1, ) -> Molecule: @@ -67,7 +68,7 @@ def optimize( f.write(tm_input) # Setup the turbomole optimization command including the max number of optimization cycles - arguments = [f"PARNODES=1 {self.jobex_path} -c {max_cycles}"] + arguments = [f"PARNODES={ncores} {self.jobex_path} -c {max_cycles}"] if verbosity > 2: print(f"Running command: {' '.join(arguments)}") @@ -87,7 +88,7 @@ def optimize( optimized_molecule.read_xyz_from_coord(coordfile) return optimized_molecule - def singlepoint(self, molecule: Molecule, verbosity: int = 1) -> str: + def singlepoint(self, molecule: Molecule, ncores: int, verbosity: int = 1) -> str: """ Perform a single point calculation using Turbomole. """ @@ -109,7 +110,7 @@ def singlepoint(self, molecule: Molecule, verbosity: int = 1) -> str: f.write(tm_input) # set up the turbomole single point calculation command - run_tm = [f"PARNODES=1 {self.ridft_path}"] + run_tm = [f"PARNODES={ncores} {self.ridft_path}"] if verbosity > 2: print(f"Running command: {' '.join(run_tm)}") @@ -126,7 +127,7 @@ def singlepoint(self, molecule: Molecule, verbosity: int = 1) -> str: return tm_log_out def check_gap( - self, molecule: Molecule, threshold: float, verbosity: int = 1 + self, molecule: Molecule, ncores: int, threshold: float, verbosity: int = 1 ) -> bool: """ Check if the HL gap is larger than a given threshold. diff --git a/src/mindlessgen/qm/xtb.py b/src/mindlessgen/qm/xtb.py index 3fe4435..b35f757 100644 --- a/src/mindlessgen/qm/xtb.py +++ b/src/mindlessgen/qm/xtb.py @@ -34,7 +34,11 @@ def __init__(self, path: str | Path, xtb_config: XTBConfig) -> None: self.cfg = xtb_config def optimize( - self, molecule: Molecule, max_cycles: int | None = None, verbosity: int = 1 + self, + molecule: Molecule, + ncores: int, + max_cycles: int | None = None, + verbosity: int = 1, ) -> Molecule: """ Optimize a molecule using xtb. @@ -64,6 +68,8 @@ def optimize( "--opt", "--gfn", f"{self.cfg.level}", + "-P", + f"{ncores}", ] if molecule.charge != 0: arguments += ["--chrg", str(molecule.charge)] @@ -99,7 +105,7 @@ def optimize( optimized_molecule.atlist = molecule.atlist return optimized_molecule - def singlepoint(self, molecule: Molecule, verbosity: int = 1) -> str: + def singlepoint(self, molecule: Molecule, ncores: int, verbosity: int = 1) -> str: """ Perform a single-point calculation using xtb. """ @@ -128,6 +134,8 @@ def singlepoint(self, molecule: Molecule, verbosity: int = 1) -> str: "molecule.xyz", "--gfn", f"{self.cfg.level}", + "-P", + f"{ncores}", ] if molecule.charge != 0: arguments += ["--chrg", str(molecule.charge)] @@ -157,13 +165,18 @@ def singlepoint(self, molecule: Molecule, verbosity: int = 1) -> str: return xtb_log_out def check_gap( - self, molecule: Molecule, threshold: float = 0.5, verbosity: int = 1 + self, + molecule: Molecule, + ncores: int, + threshold: float = 0.5, + verbosity: int = 1, ) -> bool: """ Check if the HL gap is larger than a given threshold. Arguments: molecule (Molecule): Molecule to check + ncores (int): Number of cores to use threshold (float): Threshold for the gap Returns: @@ -172,7 +185,7 @@ def check_gap( # Perform a single point calculation try: - xtb_out = self.singlepoint(molecule) + xtb_out = self.singlepoint(molecule, ncores) except RuntimeError as e: raise RuntimeError("Single point calculation failed.") from e @@ -204,8 +217,6 @@ def _run(self, temp_path: Path, arguments: list[str]) -> tuple[str, str, int]: tuple[str, str, int]: The output of the xtb calculation (stdout and stderr) and the return code """ - non_parallel = ["-P", "1"] - arguments += non_parallel try: xtb_out = sp.run( [str(self.path)] + arguments, diff --git a/test/test_molecules/test_refinement.py b/test/test_molecules/test_refinement.py index 4011e3c..583ef03 100644 --- a/test/test_molecules/test_refinement.py +++ b/test/test_molecules/test_refinement.py @@ -11,6 +11,7 @@ from mindlessgen.molecules import detect_fragments # type: ignore from mindlessgen.molecules import Molecule # type: ignore from mindlessgen.molecules import iterative_optimization # type: ignore +from mindlessgen.prog.parallel import setup_managers from mindlessgen.qm import XTB, get_xtb_path # type: ignore TESTSDIR = Path(__file__).resolve().parents[1] @@ -131,6 +132,7 @@ def test_iterative_optimization(mol_C13H14: Molecule, mol_C7H8: Molecule) -> Non # fragment charge is not completely random anymore. Currently, that's the # reason for a virtually switched off HL gap check (fragment can be -2, 0, 2) config.refine.max_frag_cycles = 1 + config.refine.ncores = 1 if config.refine.engine == "xtb": try: xtb_path = get_xtb_path() @@ -142,13 +144,15 @@ def test_iterative_optimization(mol_C13H14: Molecule, mol_C7H8: Molecule) -> Non else: raise NotImplementedError("Engine not implemented.") mol = mol_C13H14 - mol_opt = iterative_optimization( - mol, - engine=engine, - config_generate=config.generate, - config_refine=config.refine, - verbosity=2, - ) + with setup_managers(1, 1) as (_, _, resources): + mol_opt = iterative_optimization( + mol, + engine, + config.generate, + config.refine, + resources, + verbosity=2, + ) mol_ref = mol_C7H8 # assert number of atoms in mol_opt is equal to number of atoms in mol_ref diff --git a/test/test_qm/test_xtb.py b/test/test_qm/test_xtb.py index 860661d..43d74f5 100644 --- a/test/test_qm/test_xtb.py +++ b/test/test_qm/test_xtb.py @@ -31,7 +31,7 @@ def test_xtb_optimize_xtb(coordinates_ethanol: np.ndarray) -> None: mol.uhf = 0 mol.num_atoms = 9 - optimized_molecule = xtb.optimize(mol) + optimized_molecule = xtb.optimize(mol, 1) print(optimized_molecule) @@ -126,9 +126,9 @@ def test_check_gap_low_gap(mol_C2H4N1O1Au1: Molecule, mol_H3B4Pd1Rn1: Molecule): raise ImportError("xtb not found.") from e engine = XTB(xtb_path, cfg) # Test for molecule with low gap - result_low_gap = engine.check_gap(mol_H3B4Pd1Rn1, threshold=0.5) + result_low_gap = engine.check_gap(mol_H3B4Pd1Rn1, 1, threshold=0.5) assert result_low_gap is False # Test for molecule with high gap - result_high_gap = engine.check_gap(mol_C2H4N1O1Au1, threshold=0.5) + result_high_gap = engine.check_gap(mol_C2H4N1O1Au1, 1, threshold=0.5) assert result_high_gap is True