diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e459cf..4e23df0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ 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 the qm program turbomole. ### Breaking Changes - Removal of the `dist_threshold` flag and in the `-toml` file. diff --git a/mindlessgen.toml b/mindlessgen.toml index 8151b53..4b6c9c4 100644 --- a/mindlessgen.toml +++ b/mindlessgen.toml @@ -60,7 +60,7 @@ hlgap = 0.5 debug = false [postprocess] -# > Engine for the post-processing part. Options: 'xtb', 'orca' +# > Engine for the post-processing part. Options: 'xtb', 'orca', 'turbomole' engine = "orca" # > Optimize geometry in the post-processing part. If `false`, only a single-point is conducted. Options: optimize = true @@ -88,3 +88,13 @@ basis = "def2-SVP" gridsize = 1 # > Maximum number of SCF cycles: Options: scf_cycles = 100 + +[turbomole] +# > Path to the turbomole executable. The names `ridft` and `jobex` are automatically searched for. Options: +turbomole_path = "/path/to/turbomole" +# > Functional/Method: Options: +functional = "PBE" +# > Basis set: Options: +basis = "def2-SVP" +# > Maximum number of SCF cycles: Options: +scf_cycles = 100 diff --git a/src/mindlessgen/generator/main.py b/src/mindlessgen/generator/main.py index e723ce9..f0078a7 100644 --- a/src/mindlessgen/generator/main.py +++ b/src/mindlessgen/generator/main.py @@ -10,7 +10,16 @@ import warnings from ..molecules import generate_random_molecule, Molecule -from ..qm import XTB, get_xtb_path, QMMethod, ORCA, get_orca_path, GP3, get_gp3_path +from ..qm import ( + XTB, + get_xtb_path, + QMMethod, + ORCA, + get_orca_path, + GP3, + Turbomole, + get_turbomole_path, +) from ..molecules import iterative_optimization, postprocess_mol from ..prog import ConfigManager @@ -46,11 +55,16 @@ def generator(config: ConfigManager) -> tuple[list[Molecule] | None, int]: config, get_xtb_path, get_orca_path, # GP3 cannot be used anyway + get_turbomole_path, ) if config.general.postprocess: postprocess_engine: QMMethod | None = setup_engines( - config.postprocess.engine, config, get_xtb_path, get_orca_path, get_gp3_path + config.postprocess.engine, + config, + get_xtb_path, + get_orca_path, + get_turbomole_path, ) else: postprocess_engine = None @@ -266,6 +280,7 @@ def setup_engines( cfg: ConfigManager, xtb_path_func: Callable, orca_path_func: Callable, + turbomole_path_func: Callable, gp3_path_func: Callable | None = None, ): """ @@ -287,6 +302,14 @@ def setup_engines( except ImportError as e: raise ImportError("orca not found.") from e return ORCA(path, cfg.orca) + elif engine_type == "turbomole": + try: + path = turbomole_path_func(cfg.turbomole.turbomole_path) + if not path: + raise ImportError("turbomole not found.") + except ImportError as e: + raise ImportError("turbomole not found.") from e + return Turbomole(path, cfg.turbomole) elif engine_type == "gp3": if gp3_path_func is None: raise ImportError("No callable function for determining the gp3 path.") diff --git a/src/mindlessgen/prog/__init__.py b/src/mindlessgen/prog/__init__.py index 8dee820..0f0810c 100644 --- a/src/mindlessgen/prog/__init__.py +++ b/src/mindlessgen/prog/__init__.py @@ -7,6 +7,7 @@ GeneralConfig, XTBConfig, ORCAConfig, + TURBOMOLEConfig, GenerateConfig, RefineConfig, PostProcessConfig, @@ -17,6 +18,7 @@ "GeneralConfig", "XTBConfig", "ORCAConfig", + "TURBOMOLEConfig", "GenerateConfig", "RefineConfig", "PostProcessConfig", diff --git a/src/mindlessgen/prog/config.py b/src/mindlessgen/prog/config.py index cb1e8bc..df61c74 100644 --- a/src/mindlessgen/prog/config.py +++ b/src/mindlessgen/prog/config.py @@ -656,8 +656,8 @@ def engine(self, engine: str): """ if not isinstance(engine, str): raise TypeError("Refinement engine should be a string.") - if engine not in ["xtb", "orca"]: - raise ValueError("Refinement engine can only be xtb or orca.") + if engine not in ["xtb", "orca", "turbomole"]: + raise ValueError("Refinement engine can only be xtb, orca or turbomole.") self._engine = engine @property @@ -723,8 +723,8 @@ def engine(self, engine: str): """ if not isinstance(engine, str): raise TypeError("Postprocess engine should be a string.") - if engine not in ["xtb", "orca", "gp3"]: - raise ValueError("Postprocess engine can only be xtb or orca.") + if engine not in ["xtb", "orca", "gp3", "turbomole"]: + raise ValueError("Postprocess engine can only be xtb, orca or turbomole.") self._engine = engine @property @@ -925,6 +925,87 @@ def scf_cycles(self, max_scf_cycles: int): self._scf_cycles = max_scf_cycles +class TURBOMOLEConfig(BaseConfig): + """ + Configuration class for TURBOMOLE. + """ + + def __init__(self: TURBOMOLEConfig) -> None: + self._turbomole_path: str | Path = "turbomole" + self._functional: str = "pbe" + self._basis: str = "def2-SVP" + self._scf_cycles: int = 100 + + def get_identifier(self) -> str: + return "turbomole" + + @property + def turbomole_path(self): + """ + Get the turbomole path. + """ + return self._turbomole_path + + @turbomole_path.setter + def turbomole_path(self, turbomole_path: str | Path): + """ + Set the turbomole path. + """ + if not isinstance(turbomole_path, str | Path): + raise TypeError("turbomole_path should be a string or Path.") + self._turbomole_path = turbomole_path + + @property + def functional(self): + """ + Get the TURBOMOLE functional/method. + """ + return self._functional + + @functional.setter + def functional(self, functional: str): + """ + Set the TURBOMOLE functional/method. + """ + if not isinstance(functional, str): + raise TypeError("Functional should be a string.") + self._functional = functional + + @property + def basis(self): + """ + Get the TURBOMOLE basis set. + """ + return self._basis + + @basis.setter + def basis(self, basis: str): + """ + Set the TURBOMOLE basis set. + """ + if not isinstance(basis, str): + raise TypeError("Basis should be a string.") + self._basis = basis + + @property + def scf_cycles(self): + """ + Get the maximum number of SCF cycles. + """ + return self._scf_cycles + + @scf_cycles.setter + def scf_cycles(self, max_scf_cycles: int): + """ + Set the maximum number of SCF cycles. + """ + if not isinstance(max_scf_cycles, int): + raise TypeError("Max SCF cycles should be an integer.") + if max_scf_cycles < 1: + raise ValueError("Max SCF cycles should be greater than 0.") + self._scf_cycles = max_scf_cycles + + class ConfigManager: """ Overall configuration manager for the program. @@ -937,6 +1018,7 @@ def __init__(self, config_file: str | Path | None = None): self.general = GeneralConfig() self.xtb = XTBConfig() self.orca = ORCAConfig() + self.turbomole = TURBOMOLEConfig() self.refine = RefineConfig() self.postprocess = PostProcessConfig() self.generate = GenerateConfig() @@ -1026,6 +1108,9 @@ def load_from_toml(self, config_file: str | Path) -> None: [orca] orca_option = "opt" + [turbomole] + turbomole_option = "opt" + Arguments: config_file (str): Path to the configuration file @@ -1048,6 +1133,9 @@ def load_from_dict(self, config_dict: dict) -> None: }, "orca": { "orca_option": "opt" + }, + "turbomole": { + "turbomole_option": "opt" } } diff --git a/src/mindlessgen/qm/__init__.py b/src/mindlessgen/qm/__init__.py index a5e8e3a..e21ef44 100644 --- a/src/mindlessgen/qm/__init__.py +++ b/src/mindlessgen/qm/__init__.py @@ -6,6 +6,7 @@ from .xtb import XTB, get_xtb_path from .orca import ORCA, get_orca_path from .gp3 import GP3, get_gp3_path +from .tm import Turbomole, get_turbomole_path __all__ = [ "XTB", @@ -15,4 +16,6 @@ "get_orca_path", "GP3", "get_gp3_path", + "Turbomole", + "get_turbomole_path", ] diff --git a/src/mindlessgen/qm/tm.py b/src/mindlessgen/qm/tm.py new file mode 100644 index 0000000..71148d8 --- /dev/null +++ b/src/mindlessgen/qm/tm.py @@ -0,0 +1,250 @@ +""" +This module handles all Turbomole-related functionality. +""" + +from pathlib import Path +import shutil +import subprocess as sp +from tempfile import TemporaryDirectory + +from ..molecules import Molecule +from ..prog import TURBOMOLEConfig as turbomoleConfig +from .base import QMMethod + + +class Turbomole(QMMethod): + """ + This class handles all interaction with the turbomole external dependency. + """ + + def __init__(self, path: str | Path, turbomolecfg: turbomoleConfig) -> None: + """ + Initialize the turbomole class. + """ + if isinstance(path, str): + self.path: Path = Path(path).resolve() + elif isinstance(path, Path): + self.path = path + else: + raise TypeError("turbomole_path should be a string or a Path object.") + self.cfg = turbomolecfg + + def optimize( + self, + molecule: Molecule, + max_cycles: int | None = None, + verbosity: int = 1, + ) -> Molecule: + """ + Optimize a molecule using ORCA. + """ + + # Create a unique temporary directory using TemporaryDirectory context manager + with TemporaryDirectory(prefix="turbomole_") as temp_dir: + temp_path = Path(temp_dir).resolve() + # write the molecule to a temporary file + molfile = "molecule.xyz" + molecule.write_xyz_to_file(temp_path / molfile) + + # convert molfile to coord file (tm format) + command = f"x2t {temp_path / molfile} > {temp_path / 'coord'}" + + try: + # run the command in a shell + sp.run(command, shell=True, check=True) + except sp.CalledProcessError as e: + print(f"The xyz file could not be converted to a coord file: {e}") + + if verbosity > 2: + with open(temp_path / "coord", encoding="utf8") as f: + tm_coordinates = f.read() + print(tm_coordinates) + + # write the input file + inputname = "control" + tm_input = self._gen_input(molecule) + if verbosity > 1: + print("Turbomole input file:\n##################") + print(tm_input) + print("##################") + with open(temp_path / inputname, "w", encoding="utf8") as f: + f.write(tm_input) + + # Setup the turbomole optimization command including the max number of optimization cycles + arguments = [f"PARNODES=1 jobex -ri -c {max_cycles} > jobex.out"] + + if verbosity > 2: + print(f"Running command: {' '.join(arguments)}") + + tm_log_out, tm_log_err, return_code = self._run( + temp_path=temp_path, arguments=arguments + ) + if verbosity > 2: + print(tm_log_out) + if return_code != 0: + raise RuntimeError( + f"Turbomole failed with return code {return_code}:\n{tm_log_err}" + ) + + # revert the coord file to xyz file + revert_command = f"t2x {temp_path / 'coord'} > {temp_path / 'molecule.xyz'}" + try: + sp.run(revert_command, shell=True, check=True) + except sp.CalledProcessError as e: + print(f"The coord file could not be converted to a xyz file: {e}") + # read the optimized molecule from the output file + xyzfile = Path(temp_path / "molecule.xyz").resolve().with_suffix(".xyz") + optimized_molecule = molecule.copy() + optimized_molecule.read_xyz_from_file(xyzfile) + return optimized_molecule + + def singlepoint(self, molecule: Molecule, verbosity: int = 1) -> str: + """ + Perform a single point calculation using Turbomole. + """ + # Create a unique temporary directory using TemporaryDirectory context manager + with TemporaryDirectory(prefix="turbomole_") as temp_dir: + temp_path = Path(temp_dir).resolve() + # write the molecule to a temporary file + molfile = "mol.xyz" + molecule.write_xyz_to_file(temp_path / molfile) + print(temp_path / molfile) + + # convert molfile to coord file + command = f"x2t {temp_path / molfile} > {temp_path / 'coord'}" + + try: + # run the command in a shell + sp.run(command, shell=True, check=True) + except sp.CalledProcessError as e: + print(f"The xyz file could not be converted to a coord file: {e}") + with open(temp_path / "coord", encoding="utf8") as f: + content = f.read() + print(content) + + # write the input file + inputname = "control" + tm_input = self._gen_input(molecule) + if verbosity > 1: + print("Turbomole input file:\n##################") + print(self._gen_input(molecule)) + print("##################") + with open(temp_path / inputname, "w", encoding="utf8") as f: + f.write(tm_input) + + # set up the turbomole single point calculation command + run_tm = ["PARNODES=1 ridft > ridft.out"] + + tm_log_out, tm_log_err, return_code = self._run( + temp_path=temp_path, arguments=run_tm + ) + if verbosity > 2: + print(tm_log_out) + if return_code != 0: + raise RuntimeError( + f"Turbomole failed with return code {return_code}:\n{tm_log_err}" + ) + + return tm_log_out + + def check_gap( + self, molecule: Molecule, threshold: float, verbosity: int = 1 + ) -> bool: + """ + Check if the HL gap is larger than a given threshold. + """ + raise NotImplementedError("check_gap not implemented for turbomole.") + + def _run(self, temp_path: Path, arguments: list[str]) -> tuple[str, str, int]: + """ + Run turbomole with the given arguments. + + Arguments: + arguments (list[str]): The arguments to pass to turbomole. + + Returns: + tuple[str, str, int]: The output of the turbomole calculation (stdout and stderr) + and the return code + """ + try: + sp.run( + arguments, + cwd=temp_path, + capture_output=True, + check=True, + shell=True, + ) + if "PARNODES=1 ridft > ridft.out" in arguments[0]: + with open(temp_path / "ridft.out", encoding="utf-8") as file: + ridft_file = file.read() + turbomole_log_out = ridft_file + turbomole_log_err = "" + else: + # Read the job-last file to get the output of the calculation + output_file = temp_path / "job.last" + if output_file.exists(): + with open(output_file, encoding="utf-8") as file: + file_content = file.read() + turbomole_log_out = file_content + turbomole_log_err = "" + else: + raise FileNotFoundError(f"Output file {output_file} not found.") + + if "ridft : all done" not in turbomole_log_out: + raise sp.CalledProcessError( + 1, + str(output_file), + turbomole_log_out.encode("utf8"), + turbomole_log_err.encode("utf8"), + ) + return turbomole_log_out, turbomole_log_err, 0 + except sp.CalledProcessError as e: + turbomole_log_out = e.stdout.decode("utf8", errors="replace") + turbomole_log_err = e.stderr.decode("utf8", errors="replace") + return turbomole_log_out, turbomole_log_err, e.returncode + + def _gen_input( + self, + molecule: Molecule, + ) -> str: + """ + Generate a default input file for Turbomole. + """ + tm_input = "$coord file=coord\n" + tm_input += f"$charge={molecule.charge} unpaired={molecule.uhf}\n" + tm_input += "$symmetry c1\n" + tm_input += "$atoms\n" + tm_input += f" basis={self.cfg.basis}\n" + tm_input += "$dft\n" + tm_input += f" functional {self.cfg.functional}\n" + tm_input += "$rij\n" + tm_input += f"$scfiterlimit {self.cfg.scf_cycles}\n" + tm_input += "$energy file=energy\n" + tm_input += "$grad file=gradient\n" + tm_input += "$end" + return tm_input + + +# TODO: 1. Convert this to a @staticmethod of Class turbomole +# 2. Rename to `get_method` or similar to enable an abstract interface +# 3. Add the renamed method to the ABC `QMMethod` +# 4. In `main.py`: Remove the passing of the path finder functions as arguments +# and remove the boiler plate code to make it more general. +def get_turbomole_path(binary_name: str | Path | None = None) -> Path: + """ + Get the path to the turbomole binary based on different possible names + that are searched for in the PATH. + """ + default_turbomole_names: list[str | Path] = ["ridft", "jobex"] + # put binary name at the beginning of the lixt to prioritize it + if binary_name is not None: + binary_names = [binary_name] + default_turbomole_names + else: + binary_names = default_turbomole_names + # Get turbomole path from 'which ridft' command + for binpath in binary_names: + which_ridft = shutil.which(binpath) + if which_ridft: + ridft_path = Path(which_ridft).resolve() + return ridft_path + raise ImportError("'turbomole' binary could not be found.")