diff --git a/src/atomate2/cp2k/drones.py b/src/atomate2/cp2k/drones.py index abb192988..d894498ab 100644 --- a/src/atomate2/cp2k/drones.py +++ b/src/atomate2/cp2k/drones.py @@ -40,8 +40,7 @@ def assimilate(self, path: str | Path | None = None) -> TaskDocument: TaskDocument A CP2K task document. """ - if path is None: - path = Path.cwd() + path = path or Path.cwd() try: doc = TaskDocument.from_directory(path, **self.task_document_kwargs) diff --git a/src/atomate2/qchem/__init__.py b/src/atomate2/qchem/__init__.py new file mode 100644 index 000000000..d9f4ad895 --- /dev/null +++ b/src/atomate2/qchem/__init__.py @@ -0,0 +1 @@ +"""Module for QChem workflows.""" diff --git a/src/atomate2/qchem/drones.py b/src/atomate2/qchem/drones.py new file mode 100644 index 000000000..c6b201195 --- /dev/null +++ b/src/atomate2/qchem/drones.py @@ -0,0 +1,84 @@ +"""Drones for parsing VASP calculations and realtd outputs.""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path + +from emmet.core.qc_tasks import TaskDoc +from pymatgen.apps.borg.hive import AbstractDrone + +logger = logging.getLogger(__name__) + + +class QChemDrone(AbstractDrone): + """ + A QChem drone to parse QChem outputs. + + Parameters + ---------- + **task_document_kwargs + Additional keyword args passed to :obj: `.TaskDoc.from_directory`. + """ + + def __init__(self, **task_document_kwargs) -> None: + self.task_document_kwargs = task_document_kwargs + + def assimilate(self, path: str | Path | None = None) -> TaskDoc: + """ + Parse QChem output files and return the output document. + + Parameters + ---------- + path : str pr Path or None + Path to the directory containing mol.qout and other output files. + + + Returns + ------- + TaskDocument + A QChem task document + """ + path = path or Path.cwd() + try: + doc = TaskDoc.from_directory(path, **self.task_document_kwargs) + except Exception: + import traceback + + logger.exception( + f"Error in {Path(path).absolute()}\n{traceback.format_exc()}" + ) + raise + return doc + + def get_valid_paths(self, path: tuple[str, list[str], list[str]]) -> list[str]: + """ + Get valid paths to assimilate. + + Parameters + ---------- + path : tuple of (str, list of str, list of str) + Input path as a tuple generated from ``os.walk``, i.e., (parent, subdirs, + files). + + Returns + ------- + list of str + A list of paths to assimilate. + """ + parent, subdirs, _ = path + task_names = ["mol.qout.*"] + combined_paths = [parent + os.sep + sdir for sdir in subdirs] + rpath = [] + for cpath in combined_paths: + fnames = os.listdir(cpath) + if any(name.startswith("mol.qout.") for name in fnames): + rpath.append(parent) + + if ( + not any(parent.endswith(os.sep + r) for r in task_names) + and len(list(Path(parent).glob("mol.qout*"))) > 0 + ): + rpath.append(parent) + return rpath diff --git a/src/atomate2/qchem/files.py b/src/atomate2/qchem/files.py new file mode 100644 index 000000000..2509c1a7b --- /dev/null +++ b/src/atomate2/qchem/files.py @@ -0,0 +1,126 @@ +"""Functions for manipulating QChem files.""" + +from __future__ import annotations + +import logging +import re +from pathlib import Path +from typing import TYPE_CHECKING + +from atomate2.common.files import copy_files, get_zfile, gunzip_files, rename_files +from atomate2.utils.file_client import FileClient, auto_fileclient +from atomate2.utils.path import strip_hostname + +if TYPE_CHECKING: + from collections.abc import Sequence + + +logger = logging.getLogger(__name__) + + +@auto_fileclient +def copy_qchem_outputs( + src_dir: Path | str, + src_host: str | None = None, + additional_qchem_files: Sequence[str] = (), + file_client: FileClient | None = None, +) -> None: + """ + Copy QChem output files to the current directory. + + For folders containing multiple calculations (e.g., suffixed with opt_1, opt_2, + etc), this function will only copy the files with the highest numbered suffix + and the suffix will be removed. Additional qchem files will be also be copied + with the same suffix applied. + Lastly, this function will gunzip any gzipped files. + + Parameters + ---------- + src_dir : str or Path + The source directory. + src_host : str or None + The source hostname used to specify a remote filesystem. Can be given as + either "username@remote_host" or just "remote_host" in which case the username + will be inferred from the current user. If ``None``, the local filesystem will + be used as the source. + additional_qchem_files : list of str + Additional files to copy. + file_client : .FileClient + A file client to use for performing file operations. + """ + src_dir = strip_hostname(src_dir) # TODO: Handle hostnames properly. + + logger.info(f"Copying QChem inputs from {src_dir}") + opt_ext = get_largest_opt_extension(src_dir, src_host, file_client=file_client) + directory_listing = file_client.listdir(src_dir, host=src_host) + + # find required files + files = ("mol.qin", "mol.qout", *tuple(additional_qchem_files)) + required_files = [get_zfile(directory_listing, r + opt_ext) for r in files] + + copy_files( + src_dir, + src_host=src_host, + include_files=required_files, + file_client=file_client, + ) + + gunzip_files( + include_files=required_files, + allow_missing=True, + file_client=file_client, + ) + + # rename files to remove opt extension + if opt_ext: + all_files = required_files + files_to_rename = { + k.name.replace(".gz", ""): k.name.replace(opt_ext, "").replace(".gz", "") + for k in all_files + } + rename_files(files_to_rename, allow_missing=True, file_client=file_client) + + logger.info("Finished copying inputs") + + +@auto_fileclient +def get_largest_opt_extension( + directory: Path | str, + host: str | None = None, + file_client: FileClient | None = None, +) -> str: + """ + Get the largest numbered opt extension of files in a directory. + + For example, if listdir gives ["mol.qout.opt_0.gz", "mol.qout.opt_1.gz"], + this function will return ".opt_1". + + Parameters + ---------- + directory : str or Path + A directory to search. + host : str or None + The hostname used to specify a remote filesystem. Can be given as either + "username@remote_host" or just "remote_host" in which case the username will be + inferred from the current user. If ``None``, the local filesystem will be used. + file_client : .FileClient + A file client to use for performing file operations. + + Returns + ------- + str + The opt extension or an empty string if there were not multiple relaxations. + """ + opt_files = file_client.glob(Path(directory) / "*.opt*", host=host) + if len(opt_files) == 0: + return "" + numbers = [] + for file in opt_files: + match = re.search(r"\.opt_(\d+)", file.name) + if match: + numbers.append(match.group(1)) + + if not numbers: + return "" # No matches found + max_relax = max(numbers, key=int) + return f".opt_{max_relax}" diff --git a/src/atomate2/qchem/jobs/__init__.py b/src/atomate2/qchem/jobs/__init__.py new file mode 100644 index 000000000..5d086b001 --- /dev/null +++ b/src/atomate2/qchem/jobs/__init__.py @@ -0,0 +1 @@ +"""Jobs for running QChem calculations.""" diff --git a/src/atomate2/qchem/jobs/base.py b/src/atomate2/qchem/jobs/base.py new file mode 100644 index 000000000..0c48b6629 --- /dev/null +++ b/src/atomate2/qchem/jobs/base.py @@ -0,0 +1,145 @@ +"""Definition of a base QChem Maker.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import TYPE_CHECKING, Callable + +from emmet.core.qc_tasks import TaskDoc +from jobflow import Maker, Response, job +from monty.serialization import dumpfn +from monty.shutil import gzip_dir +from pymatgen.io.qchem.inputs import QCInput + +from atomate2.qchem.files import copy_qchem_outputs +from atomate2.qchem.run import run_qchem, should_stop_children +from atomate2.qchem.sets.base import QCInputGenerator + +if TYPE_CHECKING: + from pymatgen.core.structure import Molecule + + +def qchem_job(method: Callable) -> job: + """ + Decorate the ``make`` method of QChem job makers. + + This is a thin wrapper around :obj:`~jobflow.core.job.Job` that configures common + settings for all QChem jobs. It also configures the output schema to be a QChem + :obj:`.TaskDoc`. + + Any makers that return QChem jobs (not flows) should decorate the ``make`` method + with @qchem_job. For example: + + .. code-block:: python + + class MyQChemMaker(BaseQChemMaker): + @qchem_job + def make(molecule): + # code to run QChem job. + pass + + Parameters + ---------- + method : callable + A BaseQChemMaker.make method. This should not be specified directly and is + implied by the decorator. + + Returns + ------- + callable + A decorated version of the make function that will generate QChem jobs. + """ + return job(method, data=QCInput, output_schema=TaskDoc) + + +@dataclass +class BaseQCMaker(Maker): + """ + Base QChem job maker. + + Parameters + ---------- + name : str + The job name. + input_set_generator : .QChemInputGenerator + A generator used to make the input set. + write_input_set_kwargs : dict + Keyword arguments that will get passed to :obj:`.write_qchem_input_set`. + copy_qchem_kwargs : dict + Keyword arguments that will get passed to :obj:`.copy_qchem_outputs`. + run_qchem_kwargs : dict + Keyword arguments that will get passed to :obj:`.run_qchem`. + task_document_kwargs : dict + Keyword arguments that will get passed to :obj:`.TaskDoc.from_directory`. + stop_children_kwargs : dict + Keyword arguments that will get passed to :obj:`.should_stop_children`. + write_additional_data : dict + Additional data to write to the current directory. Given as a dict of + {filename: data}. Note that if using FireWorks, dictionary keys cannot contain + the "." character which is typically used to denote file extensions. To avoid + this, use the ":" character, which will automatically be converted to ".". E.g. + ``{"my_file:txt": "contents of the file"}``. + """ + + name: str = "base qchem job" + input_set_generator: QCInputGenerator = field( + default_factory=lambda: QCInputGenerator( + job_type="sp", scf_algorithm="diis", basis_set="def2-qzvppd" + ) + ) + write_input_set_kwargs: dict = field(default_factory=dict) + copy_qchem_kwargs: dict = field(default_factory=dict) + run_qchem_kwargs: dict = field(default_factory=dict) + task_document_kwargs: dict = field(default_factory=dict) + stop_children_kwargs: dict = field(default_factory=dict) + write_additional_data: dict = field(default_factory=dict) + + @qchem_job + def make( + self, molecule: Molecule, prev_qchem_dir: str | Path | None = None + ) -> Response: + """ + Run a QChem calculation. + + Parameters + ---------- + molecule : Molecule + A pymatgen molecule object. + prev_qchem_dir : str or Path or None + A previous QChem calculation directory to copy output files from. + """ + # copy previous inputs + from_prev = prev_qchem_dir is not None + if prev_qchem_dir is not None: + copy_qchem_outputs(prev_qchem_dir, **self.copy_qchem_kwargs) + + self.write_input_set_kwargs.setdefault("from_prev", from_prev) + + # write qchem input files + # self.input_set_generator.get_input_set(molecule).write_inputs() + self.input_set_generator.get_input_set(molecule) + + # write any additional data + for filename, data in self.write_additional_data.items(): + dumpfn(data, filename.replace(":", ".")) + + # run qchem + run_qchem(**self.run_qchem_kwargs) + + # parse qchem outputs + task_doc = TaskDoc.from_directory(Path.cwd(), **self.task_document_kwargs) + # task_doc.task_label = self.name + task_doc.task_type = self.name + + # decide whether child jobs should proceed + stop_children = should_stop_children(task_doc, **self.stop_children_kwargs) + + # gzip folder + gzip_dir(".") + + return Response( + stop_children=stop_children, + stored_data={"custodian": task_doc.custodian}, + output=task_doc, + ) diff --git a/src/atomate2/qchem/jobs/core.py b/src/atomate2/qchem/jobs/core.py new file mode 100644 index 000000000..b0d7987d1 --- /dev/null +++ b/src/atomate2/qchem/jobs/core.py @@ -0,0 +1,236 @@ +"""Core jobs for running QChem calculations.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from atomate2.qchem.sets.base import QCInputGenerator + +from atomate2.qchem.jobs.base import BaseQCMaker +from atomate2.qchem.sets.core import ( + ForceSetGenerator, + FreqSetGenerator, + OptSetGenerator, + PESScanSetGenerator, + SinglePointSetGenerator, + TransitionStateSetGenerator, +) + +# from custodian.qchem.handlers import ( +# QChemErrorHandler, +# ) + + +logger = logging.getLogger(__name__) + + +@dataclass +class SinglePointMaker(BaseQCMaker): + """ + Maker to create QChem single point calculation jobs. + + Parameters + ---------- + name : str + The job name. + input_set_generator : .QChemInputGenerator + A generator used to make the input set. + write_input_set_kwargs : dict + Keyword arguments that will get passed to :obj:`.write_qchem_input_set`. + copy_qchem_kwargs : dict + Keyword arguments that will get passed to :obj:`.copy_qchem_outputs`. + run_qchem_kwargs : dict + Keyword arguments that will get passed to :obj:`.run_qchem`. + task_document_kwargs : dict + Keyword arguments that will get passed to :obj:`.TaskDocument.from_directory`. + stop_children_kwargs : dict + Keyword arguments that will get passed to :obj:`.should_stop_children`. + write_additional_data : dict + Additional data to write to the current directory. Given as a dict of + {filename: data}. Note that if using FireWorks, dictionary keys cannot contain + the "." character which is typically used to denote file extensions. To avoid + this, use the ":" character, which will automatically be converted to ".". E.g. + ``{"my_file:txt": "contents of the file"}``. + """ + + name: str = "single point" + input_set_generator: QCInputGenerator = field( + default_factory=SinglePointSetGenerator + ) + + +@dataclass +class OptMaker(BaseQCMaker): + """ + Maker to create QChem optimization jobs. + + Parameters + ---------- + name : str + The job name. + input_set_generator : .QChemInputGenerator + A generator used to make the input set. + write_input_set_kwargs : dict + Keyword arguments that will get passed to :obj:`.write_qchem_input_set`. + copy_qchem_kwargs : dict + Keyword arguments that will get passed to :obj:`.copy_qchem_outputs`. + run_qchem_kwargs : dict + Keyword arguments that will get passed to :obj:`.run_qchem`. + task_document_kwargs : dict + Keyword arguments that will get passed to :obj:`.TaskDocument.from_directory`. + stop_children_kwargs : dict + Keyword arguments that will get passed to :obj:`.should_stop_children`. + write_additional_data : dict + Additional data to write to the current directory. Given as a dict of + {filename: data}. Note that if using FireWorks, dictionary keys cannot contain + the "." character which is typically used to denote file extensions. To avoid + this, use the ":" character, which will automatically be converted to ".". E.g. + ``{"my_file:txt": "contents of the file"}``. + """ + + name: str = "optimization" + input_set_generator: QCInputGenerator = field(default_factory=OptSetGenerator) + + +@dataclass +class ForceMaker(BaseQCMaker): + """ + QChem Maker for a Force Job. + + Maker to create QChem job to converge electron density + and calculate the gradient and atomic forces. + + Parameters + ---------- + name : str + The job name. + input_set_generator : .QChemInputGenerator + A generator used to make the input set. + write_input_set_kwargs : dict + Keyword arguments that will get passed to :obj:`.write_qchem_input_set`. + copy_qchem_kwargs : dict + Keyword arguments that will get passed to :obj:`.copy_qchem_outputs`. + run_qchem_kwargs : dict + Keyword arguments that will get passed to :obj:`.run_qchem`. + task_document_kwargs : dict + Keyword arguments that will get passed to :obj:`.TaskDocument.from_directory`. + stop_children_kwargs : dict + Keyword arguments that will get passed to :obj:`.should_stop_children`. + write_additional_data : dict + Additional data to write to the current directory. Given as a dict of + {filename: data}. Note that if using FireWorks, dictionary keys cannot contain + the "." character which is typically used to denote file extensions. To avoid + this, use the ":" character, which will automatically be converted to ".". E.g. + ``{"my_file:txt": "contents of the file"}``. + """ + + name: str = "force" + input_set_generator: QCInputGenerator = field(default_factory=ForceSetGenerator) + + +@dataclass +class TransitionStateMaker(BaseQCMaker): + """ + Maker to create QChem transition state structure optimization jobs. + + Parameters + ---------- + name : str + The job name. + input_set_generator : .QChemInputGenerator + A generator used to make the input set. + write_input_set_kwargs : dict + Keyword arguments that will get passed to :obj:`.write_qchem_input_set`. + copy_qchem_kwargs : dict + Keyword arguments that will get passed to :obj:`.copy_qchem_outputs`. + run_qchem_kwargs : dict + Keyword arguments that will get passed to :obj:`.run_qchem`. + task_document_kwargs : dict + Keyword arguments that will get passed to :obj:`.TaskDocument.from_directory`. + stop_children_kwargs : dict + Keyword arguments that will get passed to :obj:`.should_stop_children`. + write_additional_data : dict + Additional data to write to the current directory. Given as a dict of + {filename: data}. Note that if using FireWorks, dictionary keys cannot contain + the "." character which is typically used to denote file extensions. To avoid + this, use the ":" character, which will automatically be converted to ".". E.g. + ``{"my_file:txt": "contents of the file"}``. + """ + + name: str = "transition state" + input_set_generator: QCInputGenerator = field( + default_factory=TransitionStateSetGenerator + ) + + +@dataclass +class FreqMaker(BaseQCMaker): + """ + Maker to create QChem job for frequency calculations. + + Parameters + ---------- + name : str + The job name. + input_set_generator : .QChemInputGenerator + A generator used to make the input set. + write_input_set_kwargs : dict + Keyword arguments that will get passed to :obj:`.write_qchem_input_set`. + copy_qchem_kwargs : dict + Keyword arguments that will get passed to :obj:`.copy_qchem_outputs`. + run_qchem_kwargs : dict + Keyword arguments that will get passed to :obj:`.run_qchem`. + task_document_kwargs : dict + Keyword arguments that will get passed to :obj:`.TaskDocument.from_directory`. + stop_children_kwargs : dict + Keyword arguments that will get passed to :obj:`.should_stop_children`. + write_additional_data : dict + Additional data to write to the current directory. Given as a dict of + {filename: data}. Note that if using FireWorks, dictionary keys cannot contain + the "." character which is typically used to denote file extensions. To avoid + this, use the ":" character, which will automatically be converted to ".". E.g. + ``{"my_file:txt": "contents of the file"}``. + """ + + name: str = "frequency" + input_set_generator: QCInputGenerator = field(default_factory=FreqSetGenerator) + + +@dataclass +class PESScanMaker(BaseQCMaker): + """ + Maker for a PES Scan job. + + Maker to create a QChem job which perform a potential energy surface + scan by varying bond lengths, angles, + and/or dihedral angles in a molecule. + + Parameters + ---------- + name : str + The job name. + input_set_generator : .QChemInputGenerator + A generator used to make the input set. + write_input_set_kwargs : dict + Keyword arguments that will get passed to :obj:`.write_qchem_input_set`. + copy_qchem_kwargs : dict + Keyword arguments that will get passed to :obj:`.copy_qchem_outputs`. + run_qchem_kwargs : dict + Keyword arguments that will get passed to :obj:`.run_qchem`. + task_document_kwargs : dict + Keyword arguments that will get passed to :obj:`.TaskDocument.from_directory`. + stop_children_kwargs : dict + Keyword arguments that will get passed to :obj:`.should_stop_children`. + write_additional_data : dict + Additional data to write to the current directory. Given as a dict of + {filename: data}. Note that if using FireWorks, dictionary keys cannot contain + the "." character which is typically used to denote file extensions. To avoid + this, use the ":" character, which will automatically be converted to ".". E.g. + ``{"my_file:txt": "contents of the file"}``. + """ + + name: str = "PES scan" + input_set_generator: QCInputGenerator = field(default_factory=PESScanSetGenerator) diff --git a/src/atomate2/qchem/run.py b/src/atomate2/qchem/run.py new file mode 100644 index 000000000..9010dc7b6 --- /dev/null +++ b/src/atomate2/qchem/run.py @@ -0,0 +1,143 @@ +"""Functions to run QChem in atomate 2.""" + +from __future__ import annotations + +import logging +import shlex +from os.path import expandvars +from typing import TYPE_CHECKING, Any + +from custodian import Custodian +from custodian.qchem.handlers import QChemErrorHandler +from custodian.qchem.jobs import QCJob +from jobflow.utils import ValueEnum + +from atomate2 import SETTINGS + +if TYPE_CHECKING: + from collections.abc import Sequence + + from custodian.custodian import ErrorHandler + from emmet.core.qc_tasks import TaskDoc + + +_DEFAULT_HANDLERS = (QChemErrorHandler,) + +logger = logging.getLogger(__name__) + + +class JobType(ValueEnum): + """ + Type of QChem job. + + - ``DIRECT``: Run QChem without using custodian. + - ``NORMAL``: Normal custodian :obj:`.QCJob`. + """ + + DIRECT = "direct" + NORMAL = "normal" + + +def run_qchem( + job_type: JobType | str = JobType.NORMAL, + qchem_cmd: str = SETTINGS.QCHEM_CMD, + max_errors: int = SETTINGS.QCHEM_CUSTODIAN_MAX_ERRORS, + scratch_dir: str = SETTINGS.CUSTODIAN_SCRATCH_DIR, + handlers: Sequence[ErrorHandler] = _DEFAULT_HANDLERS, + # wall_time: int | None = None, + qchem_job_kwargs: dict[str, Any] = None, + custodian_kwargs: dict[str, Any] = None, +) -> None: + """ + Run QChem. + + Supports running QChem with or without custodian (see :obj:`JobType`). + + Parameters + ---------- + job_type : str or .JobType + The job type. + qchem_cmd : str + The command used to run the standard version of QChem. + max_errors : int + The maximum number of errors allowed by custodian. + scratch_dir : str + The scratch directory used by custodian. + handlers : list of .ErrorHandler + The error handlers used by custodian. + wall_time : int + The maximum wall time. If set, a WallTimeHandler will be added to the list + of handlers. + qchem_job_kwargs : dict + Keyword arguments that are passed to :obj:`.QCJob`. + custodian_kwargs : dict + Keyword arguments that are passed to :obj:`.Custodian`. + """ + qchem_job_kwargs = {} if qchem_job_kwargs is None else qchem_job_kwargs + custodian_kwargs = {} if custodian_kwargs is None else custodian_kwargs + + qchem_cmd = expandvars(qchem_cmd) + split_qchem_cmd = shlex.split(qchem_cmd) + + if job_type == JobType.NORMAL: + jobs = [ + QCJob( + split_qchem_cmd, max_cores=SETTINGS.QCHEM_MAX_CORES, **qchem_job_kwargs + ) + ] + else: + raise ValueError(f"Unsupported job type: {job_type}") + + c = Custodian( + handlers, + jobs, + max_errors=max_errors, + scratch_dir=scratch_dir, + **custodian_kwargs, + ) + + logger.info("Running QChem using custodian.") + c.run() + + +def should_stop_children( + task_document: TaskDoc, + handle_unsuccessful: bool | str = SETTINGS.QCHEM_HANDLE_UNSUCCESSFUL, +) -> bool: + """ + Parse QChem outputs and decide whether child jobs should continue. + + Parameters + ---------- + task_document : .TaskDocument + A QChem task document. + handle_unsuccessful : bool or str + This is a three-way toggle on what to do if your job looks OK, but is actually + unconverged (either electronic or ionic): + + - `True`: Mark job as completed, but stop children. + - `False`: Do nothing, continue with workflow as normal. + - `"error"`: Throw an error. + + Returns + ------- + bool + Whether to stop child jobs. + """ + if task_document.state == "successful": + if isinstance(handle_unsuccessful, bool): + return handle_unsuccessful + + if handle_unsuccessful == "error": + raise RuntimeError( + "Job was successful but children jobs need to be stopped!" + ) + return False + + if task_document.state == "unsuccessful": + raise RuntimeError( + "Job was not successful (perhaps your job did not converge within the " + "limit of electronic/ionic iterations)!" + ) + + raise RuntimeError(f"Unknown option for defuse_unsuccessful: {handle_unsuccessful}") diff --git a/src/atomate2/qchem/sets/__init__.py b/src/atomate2/qchem/sets/__init__.py new file mode 100644 index 000000000..1ad67fe89 --- /dev/null +++ b/src/atomate2/qchem/sets/__init__.py @@ -0,0 +1 @@ +"""Module defining QChem input sets used in atomate2.""" diff --git a/src/atomate2/qchem/sets/base.py b/src/atomate2/qchem/sets/base.py new file mode 100644 index 000000000..9574c5f68 --- /dev/null +++ b/src/atomate2/qchem/sets/base.py @@ -0,0 +1,430 @@ +"""Module defining base QChem input set and generator.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import TYPE_CHECKING, Any, Literal + +from monty.io import zopen +from pymatgen.io.core import InputGenerator, InputSet +from pymatgen.io.qchem.inputs import QCInput +from pymatgen.io.qchem.utils import lower_and_check_unique + +if TYPE_CHECKING: + from pymatgen.core.structure import Molecule + +# from pymatgen.io.qchem.sets import QChemDictSet + +__author__ = "Alex Ganose, Ryan Kingsbury, Rishabh D Guha" +__copyright__ = "Copyright 2018-2022, The Materials Project" +__version__ = "0.1" + +# _BASE_QCHEM_SET = +# loadfn(resource_filename("atomate2.qchem.sets", "BaseQchemSet.yaml")) + + +class QCInputSet(InputSet): + """ + A class to represent a QChem input file as a QChem InputSet. + + Parameters + ---------- + qcinput + A QCInput object + optional_files + Any other optional input files supplied as a dict of ``{filename: object}``. + The objects should follow standard pymatgen conventions in + implementing an ``as_dict()`` and ``from_dict()`` method. + """ + + def __init__( + self, + qcinput: QCInput, + optional_files: dict | None = None, + ) -> None: + self.qcinput = qcinput + self.optional_files = {} if optional_files is None else optional_files + + def write_input( + self, + directory: str | Path, + overwrite: bool = True, + ) -> None: + """ + Write QChem input file to directory. + + Parameters + ---------- + directory + Directory to write input files to. + overwrite + Whether to overwrite an input file if it already exists. + """ + directory = Path(directory) + os.makedirs(directory, exist_ok=True) + + inputs = {"Input_Dict": self.qcinput} + inputs.update(self.optional_files) + + for k, v in inputs.items(): + if v is not None and (overwrite or not (directory / k).exists()): + with zopen(directory / k, "wt") as f: + f.write(v.__str__()) + elif not overwrite and (directory / k).exists(): + raise FileExistsError(f"{directory / k} already exists.") + + @staticmethod + def from_directory( + directory: str | Path, optional_files: dict = None + ) -> QCInputSet: + """ + Load a set of QChem inputs from a directory. + + Parameters + ---------- + directory + Directory to read QChem inputs from. + optional_files + Optional files to read in as well as a dict of {filename: Object class}. + Object class must have a static/class method from_file + """ + directory = Path(directory) + objs = {"Input_Dict": QCInput} + + inputs = {} + for name, obj in objs.items(): + if (directory / name).exists(): + inputs[name.lower()] = obj.from_file(directory / name) + + optional_inputs = {} + if optional_files is not None: + for name, obj in optional_files.items(): + optional_inputs[name] = obj.from_file(directory / name) + + return QCInputSet(inputs["input_dict"], optional_files=optional_inputs) + + # Todo + # Implement is_valid property + + +@dataclass +class QCInputGenerator(InputGenerator): + """ + A dataclass to generate QChem input set. + + Parameters + ---------- + job_type : str + QChem job type to run. Valid options are "opt" for optimization, + "sp" for single point, "freq" for frequency calculation, or "force" for + force evaluation. + + basis_set : str + Basis set to use. For example, "def2-tzvpd". + + scf_algorithm : str + Algorithm to use for converging the SCF. Recommended choices are + "DIIS", "GDM", and "DIIS_GDM". Other algorithms supported by Qchem's + GEN_SCFMAN module will also likely perform well. + Refer to the QChem manual for further details. + + dft_rung : int + Select the DFT functional among 5 recommended levels of theory, + in order of increasing accuracy/cost. 1 = SPW92, 2 = B97-D3(BJ), 3 = B97M-V, + 4 = ωB97M-V, 5 = ωB97M-(2). (Default: 4) + To set a functional not given by one of the above, set the overwrite_inputs + argument to {"method":""} + **Note that the "rungs" in this argument do NOT correspond to rungs on "Jacob's + Ladder of Density Functional Approximations"** + + pcm_dielectric : float + Dielectric constant to use for PCM implicit solvation model. (Default: None) + + smd_solvent : str + Solvent to use for SMD implicit solvation model. (Default: None) + Examples include "water", "ethanol", "methanol", and "acetonitrile". + Refer to the QChem manual for a complete list of solvents available. + To define a custom solvent, set this argument to "custom" and + populate custom_smd with the necessary parameters. + + **Note that only one of smd_solvent and pcm_dielectric may be set.** + + custom_smd : str + List of parameters to define a custom solvent in SMD. (Default: None) + Must be given as a string of seven comma separated values + in the following order: + "dielectric, refractive index, acidity, basicity, + surface tension, aromaticity, electronegative halogenicity" + Refer to the QChem manual for further details. + + opt_dict : dict + A dictionary of opt sections, where each opt section is a key + and the corresponding values are a list of strings. Strings must be formatted + as instructed by the QChem manual. + The different opt sections are: CONSTRAINT, FIXED, DUMMY, and CONNECT. + Ex. + opt = + {"CONSTRAINT": ["tors 2 3 4 5 25.0", "tors 2 5 7 9 80.0"], "FIXED": ["2 XY"]} + + scan_dict : dict + A dictionary of scan variables. Because two constraints of the + same type are allowed (for instance, two torsions or two bond stretches), + each TYPE of variable (stre, bend, tors) should be its own key in the dict, + rather than each variable. Note that the total number of variable + (sum of lengths of all lists) CANNOT be more than two. + Ex. scan_variables = + {"stre": ["3 6 1.5 1.9 0.1"], "tors": ["1 2 3 4 -180 180 15"]} + + max_scf_cycles : int + Maximum number of SCF iterations. (Default: 100) + + geom_opt_max_cycles : int + Maximum number of geometry optimization iterations. (Default: 200) + + plot_cubes : bool + Whether to write CUBE files of the electron density. (Default: False) + + nbo_params : dict + A dict containing the desired NBO params. Note that a key:value pair of + "version":7 will trigger NBO7 analysis. + Otherwise, NBO5 analysis will be performed, + including if an empty dict is passed. + Besides a key of "version", all other key:value pairs + will be written into the $nbo section of the QChem input file. (Default: False) + + new_geom_opt : dict + A dict containing parameters for the $geom_opt section of the QChem + input file, which control the new geometry optimizer + available starting in version 5.4.2. + Further note that even passing an empty dictionary + will trigger the new optimizer. + (Default: False) + + overwrite_inputs : dict + Dictionary of QChem input sections to add or overwrite variables. + The currently available sections (keys) are rem, pcm, solvent, smx, opt, + scan, van_der_waals, and plots. The value of each key is a dictionary + of key value pairs relevant to that section. + For example, to add a new variable to the rem section that sets + symmetry to false, use + overwrite_inputs = {"rem": {"symmetry": "false"}} + **Note that if something like basis is added to the rem dict it will overwrite + the default basis.** + **Note that supplying a van_der_waals section here will automatically modify + the PCM "radii" setting to "read".** + **Note that all keys must be given as strings, even when they are numbers!** + + vdw_mode : str + Method of specifying custom van der Waals radii. + Either "atomic" (default) or "sequential". + In "atomic" mode, dict keys represent the atomic number + associated with each radius (e.g., 12 = carbon). + In "sequential" mode, dict keys represent the sequential position + of a single specific atom in the input structure. + + """ + + job_type: str = field(default=None) + basis_set: str = field(default=None) + scf_algorithm: str = field(default=None) + dft_rung: int = field(default=4) + pcm_dielectric: float = field(default=None) + smd_solvent: str = field(default=None) + custom_smd: str = field(default=None) + opt_dict: dict[str, Any] = field(default_factory=dict) + scan_dict: dict[str, Any] = field(default_factory=dict) + max_scf_cycles: int = field(default=100) + geom_opt_max_cycles: int = field(default=200) + plot_cubes: bool = field(default=False) + nbo_params: dict[str, Any] = field(default_factory=dict) + new_geom_opt: dict[str, Any] = field(default_factory=dict) + overwrite_inputs: dict[str, str] = field(default_factory=dict) + vdw_mode: Literal["atomic", "sequential"] = field(default="atomic") + rem_dict: dict[str, Any] = field(default_factory=dict) + vdw_dict: dict[str, float] = field(default_factory=dict) + pcm_dict: dict[str, Any] = field(default_factory=dict) + solv_dict: dict[str, Any] = field(default_factory=dict) + smx_dict: dict[str, Any] = field(default_factory=dict) + plots_dict: dict[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + """Post init formatting of arguments.""" + self.rem_dict = { + "job_type": self.job_type, + "basis": self.basis_set, + "max_scf_cycles": str(self.max_scf_cycles), + "gen_scfman": "true", + "xc_grid": "3", + "thresh": "14", + "s2thresh": "16", + "scf_algorithm": self.scf_algorithm, + "resp_charges": "true", + "symmetry": "false", + "sym_ignore": "true", + } + + rung_2_func = ["spw92", "b97d3", "b97mv", "wb97mv", "wb97m(2)"] + qc_method = {i + 1: e for i, e in enumerate(rung_2_func)} + + if qc_method.get(self.dft_rung): + self.rem_dict["method"] = qc_method.get(self.dft_rung) + else: + raise ValueError("Provided DFT rung should be between 1 and 5!") + + if self.dft_rung == 2: + self.rem_dict["dft_D"] = "D3_BJ" + + if self.job_type.lower() in ["opt", "ts", "pes_scan"]: + self.rem_dict["geom_opt_max_cycles"] = str(self.geom_opt_max_cycles) + + if self.pcm_dielectric and self.smd_solvent: + raise ValueError( + "Only one of pcm or smd may be used as an implicit solvent. Not both!" + ) + + if self.pcm_dielectric: + pcm_defaults = { + "heavypoints": "194", + "hpoints": "194", + "radii": "uff", + "theory": "cpcm", + "vdwscale": "1.1", + } + + self.pcm_dict = pcm_defaults + self.solv_dict["dielectric"] = self.pcm_dielectric + self.rem_dict["solvent_method"] = "pcm" + + if self.smd_solvent: + if self.smd_solvent == "custom": + self.smx_dict["solvent"] = "other" + else: + self.smx_dict["solvent"] = self.smd_solvent + self.rem_dict["solvent_method"] = "smd" + self.rem_dict["ideriv"] = "1" + if self.smd_solvent in ("custom", "other") and self.custom_smd is None: + raise ValueError( + "A user-defined SMD requires passing custom_smd," + "a string of seven comma separated values in the following order: " + "dielectric, refractive index, acidity, basicity, surface tension," + "aromaticity, electronegative halogenicity" + ) + + if self.plot_cubes: + plots_defaults = {"grid_spacing": "0.05", "total_density": "0"} + self.plots_dict = plots_defaults + self.rem_dict["plots"] = "true" + self.rem_dict["make_cube_files"] = "true" + + if self.nbo_params: + self.rem_dict["nbo"] = "true" + nbo_params_copy = self.nbo_params.copy() + if "version" in nbo_params_copy: + if nbo_params_copy["version"] == 7: + self.rem_dict["nbo_external"] = "true" + else: + raise RuntimeError( + "nbo params version should only be set to 7! Exiting..." + ) + for key in nbo_params_copy: + if key == "version": + self.nbo_params.pop(key) + + if self.new_geom_opt: + self.rem_dict["geom_opt2"] = "3" + + if "maxiter" in self.new_geom_opt and self.new_geom_opt["maxiter"] != str( + self.geom_opt_max_cycles + ): + raise RuntimeError( + "Max # of optimization cycles must be the same! Exiting..." + ) + + def get_input_set(self, molecule: Molecule = None) -> QCInputSet: + """ + Return a QChem InputSet for a molecule. + + Parameters + ---------- + molecule: Molecule + Pymatgen representation of a molecule for which the QCInputSet + will be generated + + Returns + ------- + QchemInputSet + A QChem input set + """ + if self.overwrite_inputs: + for sub, sub_dict in self.overwrite_inputs.items(): + if sub == "rem": + temp_rem = lower_and_check_unique(sub_dict) + for k, v in temp_rem.items(): + self.rem_dict[k] = v + if sub == "pcm": + temp_pcm = lower_and_check_unique(sub_dict) + for k, v in temp_pcm.items(): + self.pcm_dict[k] = v + if sub == "solvent": + temp_solvent = lower_and_check_unique(sub_dict) + for k, v in temp_solvent.items(): + self.solv_dict[k] = v + if sub == "smx": + temp_smx = lower_and_check_unique(sub_dict) + for k, v in temp_smx.items(): + self.smx_dict[k] = v + if sub == "scan": + temp_scan = lower_and_check_unique(sub_dict) + for k, v in temp_scan.items(): + self.scan_dict[k] = v + if sub == "van_der_waals": + temp_vdw = lower_and_check_unique(sub_dict) + for k, v in temp_vdw.items(): + self.vdw_dict[k] = v + # set the PCM section to read custom radii + self.pcm_dict["radii"] = "read" + if sub == "plots": + temp_plots = lower_and_check_unique(sub_dict) + for k, v in temp_plots.items(): + self.plots_dict[k] = v + if sub == "nbo": + if self.nbo_dict is None: + raise RuntimeError( + "Can't overwrite nbo params when NBO" + "is not being run! Exiting..." + ) + temp_nbo = lower_and_check_unique(sub_dict) + for k, v in temp_nbo.items(): + self.nbo_dict[k] = v + if sub == "geom_opt": + if self.geom_opt_dict is None: + raise RuntimeError( + "Can't overwrite geom_opt params when" + "not using the new optimizer! Exiting..." + ) + temp_geomopt = lower_and_check_unique(sub_dict) + for k, v in temp_geomopt.items(): + self.geom_opt_dict[k] = v + if sub == "opt": + temp_opts = lower_and_check_unique(sub_dict) + for k, v in temp_opts.items(): + self.opt_dict[k] = v + + return QCInputSet( + qcinput=QCInput( + molecule, + rem=self.rem_dict, + opt=self.opt_dict, + pcm=self.pcm_dict, + solvent=self.solv_dict, + smx=self.smx_dict, + scan=self.scan_dict, + van_der_waals=self.vdw_dict, + vdw_mode=self.vdw_mode, + plots=self.plots_dict, + nbo=self.nbo_params, + geom_opt=self.new_geom_opt, + ) + ) diff --git a/src/atomate2/qchem/sets/core.py b/src/atomate2/qchem/sets/core.py new file mode 100644 index 000000000..df92554f4 --- /dev/null +++ b/src/atomate2/qchem/sets/core.py @@ -0,0 +1,64 @@ +"""Module defining core QChem input set generators.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass + +from atomate2.qchem.sets.base import QCInputGenerator + +logger = logging.getLogger(__name__) + + +@dataclass +class SinglePointSetGenerator(QCInputGenerator): + """Generate QChem Single Point input sets.""" + + job_type: str = "sp" + scf_algorithm: str = "diis" + basis_set: str = "def2-tzvppd" + + +@dataclass +class OptSetGenerator(QCInputGenerator): + """Generate QChem Optimization input sets.""" + + job_type: str = "opt" + scf_algorithm: str = "diis" + basis_set: str = "def2-tzvppd" + + +@dataclass +class TransitionStateSetGenerator(QCInputGenerator): + """Generate QChem Transition State calculation input sets.""" + + job_type: str = "ts" + scf_algorithm: str = "diis" + basis_set: str = "def2-tzvppd" + + +@dataclass +class ForceSetGenerator(QCInputGenerator): + """Generate QChem force input sets.""" + + job_type: str = "force" + scf_algorithm: str = "diis" + basis_set: str = "def2-tzvppd" + + +@dataclass +class FreqSetGenerator(QCInputGenerator): + """Generate QChem frequency calculation input sets.""" + + job_type: str = "freq" + scf_algorithm: str = "diis" + basis_set: str = "def2-tzvppd" + + +@dataclass +class PESScanSetGenerator(QCInputGenerator): + """Generate QChem PES scan input sets.""" + + job_type: str = "pes_scan" + scf_algorithm: str = "diis" + basis_set: str = "def2-tzvppd" diff --git a/src/atomate2/settings.py b/src/atomate2/settings.py index 77cea1991..890ebf42a 100644 --- a/src/atomate2/settings.py +++ b/src/atomate2/settings.py @@ -193,6 +193,32 @@ class Atomate2Settings(BaseSettings): model_config = SettingsConfigDict(env_prefix=_ENV_PREFIX) + # QChem specific settings + + QCHEM_CMD: str = Field( + "qchem_std", description="Command to run standard version of qchem." + ) + + QCHEM_CUSTODIAN_MAX_ERRORS: int = Field( + 5, description="Maximum number of errors to correct before custodian gives up" + ) + + QCHEM_MAX_CORES: int = Field(4, description="Maximum number of cores for QCJob") + + QCHEM_HANDLE_UNSUCCESSFUL: Union[str, bool] = Field( + "fizzle", + description="Three-way toggle on what to do if the job looks OK but is actually" + " unconverged (either electronic or ionic). - True: mark job as COMPLETED, but " + "stop children. - False: do nothing, continue with workflow as normal. 'error':" + " throw an error", + ) + + QCHEM_STORE_ADDITIONAL_JSON: bool = Field( + default=True, + description="Ingest any additional JSON data present into database when " + "parsing QChem directories useful for storing duplicate of FW.json", + ) + @model_validator(mode="before") @classmethod def load_default_settings(cls, values: dict[str, Any]) -> dict[str, Any]: diff --git a/src/atomate2/vasp/drones.py b/src/atomate2/vasp/drones.py index dfe92f4b7..ca3f92189 100644 --- a/src/atomate2/vasp/drones.py +++ b/src/atomate2/vasp/drones.py @@ -39,8 +39,7 @@ def assimilate(self, path: str | Path | None = None) -> TaskDoc: TaskDoc A VASP task document. """ - if path is None: - path = Path.cwd() + path = path or Path.cwd() try: doc = TaskDoc.from_directory(path, **self.task_document_kwargs) diff --git a/tests/qchem/conftest.py b/tests/qchem/conftest.py new file mode 100644 index 000000000..229680b73 --- /dev/null +++ b/tests/qchem/conftest.py @@ -0,0 +1,200 @@ +from __future__ import annotations + +import logging +import shutil +from pathlib import Path +from typing import TYPE_CHECKING, Any, Callable, Final, Literal + +import pytest +from jobflow import CURRENT_JOB +from pymatgen.io.qchem.inputs import QCInput +from pytest import MonkeyPatch + +import atomate2.qchem.jobs.base +import atomate2.qchem.jobs.core +import atomate2.qchem.run +from atomate2.qchem.sets.base import QCInputGenerator + +if TYPE_CHECKING: + from collections.abc import Generator, Sequence + + +logger = logging.getLogger("atomate2") + +_QFILES: Final = "mol.qin.gz" +_REF_PATHS: dict[str, str | Path] = {} +_FAKE_RUN_QCHEM_KWARGS: dict[str, dict] = {} + + +@pytest.fixture(scope="session") +def qchem_test_dir(test_dir): + return test_dir / "qchem" + + +@pytest.fixture() +def mock_qchem( + monkeypatch: MonkeyPatch, qchem_test_dir: Path +) -> Generator[Callable[[Any, Any], Any], None, None]: + """ + This fixture allows one to mock (fake) running qchem. + + It works by monkeypatching (replacing) calls to run_qchem and + QCInputSet.write_inputs with versions that will work when the + Qchem executables are not present. + + The primary idea is that instead of running QChem to generate the output files, + reference files will be copied into the directory instead. As we do not want to + test whether QChem is giving the correct output rather that the calculation inputs + are generated correctly and that the outputs are parsed properly, this should be + sufficient for our needs. + + To use the fixture successfully, the following steps must be followed: + 1. "mock_qchem" should be included as an argument to any test that would like to use + its functionally. + 2. For each job in your workflow, you should prepare a reference directory + containing two folders "inputs" (containing the reference input files expected + to be produced by write_qchem_input_set) and "outputs" (containing the expected + output files to be produced by run_qchem). These files should reside in a + subdirectory of "tests/test_data/qchem". + 3. Create a dictionary mapping each job name to its reference directory. Note that + you should supply the reference directory relative to the "tests/test_data/qchem" + folder. For example, if your calculation has one job named "single_point" + and the reference files are present in + "tests/test_data/qchem/single_point", the dictionary + would look like: ``{"single_point": "single_point"}``. + 4. Optional: create a dictionary mapping each job name to + custom keyword arguments that will be supplied to fake_run_qchem. + This way you can configure which rem settings are expected for each job. + For example, if your calculation has one job named "single_point" and + you wish to validate that "BASIS" is set correctly in the qin, + your dictionary would look like ``{"single_point": {"rem": {"BASIS": "6-31G"}}``. + 5. Inside the test function, call `mock_qchem(ref_paths, fake_qchem_kwargs)`, where + ref_paths is the dictionary created in step 3 and fake_qchem_kwargs is the + dictionary created in step 4. + 6. Run your qchem job after calling `mock_qchem`. + + For examples, see the tests in tests/qchem/makers/core.py. + """ + + # print(f"qchem_test directory is {qchem_test_dir}") + def mock_run_qchem(*args, **kwargs): + name = CURRENT_JOB.job.name + try: + ref_path = qchem_test_dir / _REF_PATHS[name] + except KeyError: + raise ValueError( + f"no reference directory found for job {name!r}; " + f"reference paths received={_REF_PATHS}" + ) from None + + fake_run_qchem(ref_path, **_FAKE_RUN_QCHEM_KWARGS.get(name, {})) + + get_input_set_orig = QCInputGenerator.get_input_set + + def mock_get_input_set(self, *args, **kwargs): + return get_input_set_orig(self, *args, **kwargs) + + monkeypatch.setattr(atomate2.qchem.run, "run_qchem", mock_run_qchem) + monkeypatch.setattr(atomate2.qchem.jobs.base, "run_qchem", mock_run_qchem) + monkeypatch.setattr(QCInputGenerator, "get_input_set", mock_get_input_set) + # monkeypatch.setattr(QCInputGenerator, "get_nelect", mock_get_nelect) + + def _run(ref_paths, fake_run_qchem_kwargs=None): + _REF_PATHS.update(ref_paths) + _FAKE_RUN_QCHEM_KWARGS.update(fake_run_qchem_kwargs or {}) + + yield _run + + monkeypatch.undo() + _REF_PATHS.clear() + _FAKE_RUN_QCHEM_KWARGS.clear() + + +def fake_run_qchem( + ref_path: Path, + input_settings: Sequence[str] = None, + input_exclude: Sequence[str] = None, + check_inputs: Sequence[Literal["qin"]] = _QFILES, + clear_inputs: bool = True, +): + """ + Emulate running QChem and validate QChem input files. + + Parameters + ---------- + ref_path + Path to reference directory with QChem input files in the folder named 'inputs' + and output files in the folder named 'outputs'. + input_settings + A list of input settings to check. Defaults to None which checks all settings. + Empty list or tuple means no settings will be checked. + input_exclude + A list of input settings to exclude from checking. Defaults to None, meaning + no settings will be excluded. + check_inputs + A list of qchem input files to check. In case of qchem, it is "qin". + clear_inputs + Whether to clear input files before copying in the reference QChem outputs. + """ + logger.info("Running fake QChem.") + + if "mol.qin.gz" in check_inputs: + check_qin(ref_path, input_settings, input_exclude) + + # This is useful to check if the WAVECAR has been copied + logger.info("Verified inputs successfully") + + if clear_inputs: + clear_qchem_inputs() + + copy_qchem_outputs(ref_path) + + # pretend to run VASP by copying pre-generated outputs from reference dir + logger.info("Generated fake qchem outputs") + + +def check_qin( + ref_path: Path, qin_settings: Sequence[str], qin_exclude: Sequence[str] +) -> None: + # user_qin = QCInput.from_file("mol.qin.gz") + ref_qin_path = ref_path / "inputs" / "mol.qin.gz" + ref_qin = QCInput.from_file(ref_qin_path) + script_directory = Path(__file__).resolve().parent + # print(f"The job name is {job_name}") + # defaults = {"sym_ignore": True, "symmetry": False, "xc_grid": 3} + job_name = ref_path.stem + if job_name == "water_single_point": + user_qin_path = script_directory / "sp.qin.gz" + elif job_name == "water_optimization": + user_qin_path = script_directory / "opt.qin.gz" + elif job_name == "water_frequency": + user_qin_path = script_directory / "freq.qin.gz" + user_qin = QCInput.from_file(user_qin_path) + + keys_to_check = ( + set(user_qin.as_dict()) if qin_settings is None else set(qin_settings) + ) - set(qin_exclude or []) + user_dict = user_qin.as_dict() + ref_dict = ref_qin.as_dict() + for key in keys_to_check: + user_val = user_dict[key] + ref_val = ref_dict[key] + if user_val != ref_val: + raise ValueError( + f"\n\nQCInput value of {key} is inconsistent: expected {ref_val}, " + f"got {user_val} \nin ref file {ref_qin_path}" + ) + + +def clear_qchem_inputs(): + for qchem_file in ("mol.qin.gz", "mol.qin.orig.gz"): + if Path(qchem_file).exists(): + Path(qchem_file).unlink() + logger.info("Cleared qchem inputs") + + +def copy_qchem_outputs(ref_path: Path): + output_path = ref_path / "outputs" + for output_file in output_path.iterdir(): + if output_file.is_file(): + shutil.copy(output_file, ".") diff --git a/tests/qchem/freq.qin.gz b/tests/qchem/freq.qin.gz new file mode 100644 index 000000000..e56db71ea Binary files /dev/null and b/tests/qchem/freq.qin.gz differ diff --git a/tests/qchem/jobs/H2O.xyz b/tests/qchem/jobs/H2O.xyz new file mode 100644 index 000000000..212ca2ea8 --- /dev/null +++ b/tests/qchem/jobs/H2O.xyz @@ -0,0 +1,5 @@ +3 + +O 0.00000 0.00000 0.12124 +H -0.78304 -0.00000 -0.48495 +H 0.78304 0.00000 -0.48495 diff --git a/tests/qchem/jobs/test_core.py b/tests/qchem/jobs/test_core.py new file mode 100644 index 000000000..75b5907cf --- /dev/null +++ b/tests/qchem/jobs/test_core.py @@ -0,0 +1,97 @@ +from pathlib import Path + +from emmet.core.qc_tasks import TaskDoc +from jobflow import run_locally +from pymatgen.core.structure import Molecule +from pytest import approx + +from atomate2.qchem.jobs.core import FreqMaker, OptMaker, SinglePointMaker + +current_directory = Path(__file__).resolve().parent +file_name = current_directory / "H2O.xyz" + +H2O = Molecule.from_file(file_name) + + +def test_single_point_maker(mock_qchem, clean_dir): + # mapping from job name to directory containing test files + ref_paths = {"single point": "water_single_point"} + + # settings passed to fake_run_vasp; adjust these to check for certain INCAR settings + # fake_run_qchem_kwargs = {"single_point": {"qin_settings": None}} + fake_run_qchem_kwargs = {} + + # automatically use fake qchem during the test + mock_qchem(ref_paths, fake_run_qchem_kwargs) + + # generate job + job = SinglePointMaker().make(H2O) + + # run the flow or job and ensure that it finished running successfully + responses = run_locally(job, create_folders=True, ensure_success=True) + + # validate job outputs + output1 = responses[job.uuid][1].output + assert isinstance(output1, TaskDoc) + assert output1.output.final_energy == approx(-76.4451488262) + + +def test_opt_maker(mock_qchem, clean_dir): + ref_paths = {"optimization": "water_optimization"} + fake_run_qchem_kwargs = {} + mock_qchem(ref_paths, fake_run_qchem_kwargs) + + job = OptMaker().make(H2O) + + responses = run_locally(job, create_folders=True, ensure_success=True) + opt_geometry = { + "@module": "pymatgen.core.structure", + "@class": "Molecule", + "charge": 0, + "spin_multiplicity": 1, + "sites": [ + { + "name": "O", + "species": [{"element": "O", "occu": 1}], + "xyz": [-0.8001136722, 2.2241304324, -0.0128020517], + "properties": {}, + "label": "O", + }, + { + "name": "H", + "species": [{"element": "H", "occu": 1}], + "xyz": [0.1605037895, 2.195300528, 0.0211059581], + "properties": {}, + "label": "H", + }, + { + "name": "H", + "species": [{"element": "H", "occu": 1}], + "xyz": [-1.0782701173, 1.6278690395, 0.6883760935], + "properties": {}, + "label": "H", + }, + ], + "properties": {}, + } + + output1 = responses[job.uuid][1].output + assert isinstance(output1, TaskDoc) + assert sorted(opt_geometry.items()) == sorted( + output1.output.optimized_molecule.as_dict().items() + ) + assert output1.output.final_energy == approx(-76.450849061819) + + +def test_freq(mock_qchem, clean_dir): + ref_paths = {"frequency": "water_frequency"} + fake_run_qchem_kwargs = {} + mock_qchem(ref_paths, fake_run_qchem_kwargs) + + job = FreqMaker().make(H2O) + + responses = run_locally(job, create_folders=True, ensure_success=True) + ref_freqs = [1643.03, 3446.82, 3524.32] + output1 = responses[job.uuid][1].output + assert output1.calcs_reversed[0].output.frequencies == ref_freqs + assert output1.output.final_energy == approx(-76.449405011) diff --git a/tests/qchem/opt.qin.gz b/tests/qchem/opt.qin.gz new file mode 100644 index 000000000..bad1fee84 Binary files /dev/null and b/tests/qchem/opt.qin.gz differ diff --git a/tests/qchem/sets/test_core.py b/tests/qchem/sets/test_core.py new file mode 100644 index 000000000..2d02f797c --- /dev/null +++ b/tests/qchem/sets/test_core.py @@ -0,0 +1,160 @@ +import pytest + +from atomate2.qchem.sets.base import QCInputGenerator +from atomate2.qchem.sets.core import ( + ForceSetGenerator, + FreqSetGenerator, + OptSetGenerator, + PESScanSetGenerator, + SinglePointSetGenerator, + TransitionStateSetGenerator, +) + + +@pytest.mark.parametrize( + "set_generator, expected_job_type", + [ + (SinglePointSetGenerator, "sp"), + (OptSetGenerator, "opt"), + (TransitionStateSetGenerator, "ts"), + (ForceSetGenerator, "force"), + (FreqSetGenerator, "freq"), + (PESScanSetGenerator, "pes_scan"), + ], +) +def test_qc_sets(set_generator: QCInputGenerator, expected_job_type: str) -> None: + qc_set: QCInputGenerator = set_generator() + assert {*qc_set.__dict__} >= { + "job_type", + "basis_set", + "scf_algorithm", + "dft_rung", + "pcm_dielectric", + "smd_solvent", + "custom_smd", + "opt_dict", + "scan_dict", + "max_scf_cycles", + "geom_opt_max_cycles", + "plot_cubes", + "nbo_params", + "new_geom_opt", + "overwrite_inputs", + "vdw_mode", + "rem_dict", + "pcm_dict", + "solv_dict", + "smx_dict", + "vdw_dict", + "plots_dict", + } + assert qc_set.scf_algorithm == "diis" + assert qc_set.job_type == expected_job_type + assert qc_set.basis_set == "def2-tzvppd" + assert isinstance(qc_set.rem_dict, dict) + + +@pytest.mark.parametrize( + "set_generator_pcm_d3, expected_job_type", + [ + (SinglePointSetGenerator, "sp"), + (OptSetGenerator, "opt"), + (TransitionStateSetGenerator, "ts"), + (ForceSetGenerator, "force"), + (FreqSetGenerator, "freq"), + (PESScanSetGenerator, "pes_scan"), + ], +) +def test_extra_params_pcm( + set_generator_pcm_d3: QCInputGenerator, expected_job_type: str +) -> None: + qc_set: QCInputGenerator = set_generator_pcm_d3(dft_rung=2, pcm_dielectric=78.39) + assert qc_set.rem_dict["dft_D"] == "D3_BJ" + assert qc_set.rem_dict["solvent_method"] == "pcm" + + pcm_defaults = { + "heavypoints": "194", + "hpoints": "194", + "radii": "uff", + "theory": "cpcm", + "vdwscale": "1.1", + } + + assert qc_set.pcm_dict == pcm_defaults + assert qc_set.solv_dict["dielectric"] == qc_set.pcm_dielectric + assert qc_set.scf_algorithm == "diis" + assert qc_set.job_type == expected_job_type + assert qc_set.basis_set == "def2-tzvppd" + assert isinstance(qc_set.rem_dict, dict) + + +@pytest.mark.parametrize( + "set_generator_smd, expected_job_type", + [ + (SinglePointSetGenerator, "sp"), + (OptSetGenerator, "opt"), + (TransitionStateSetGenerator, "ts"), + (ForceSetGenerator, "force"), + (FreqSetGenerator, "freq"), + (PESScanSetGenerator, "pes_scan"), + ], +) +def test_extra_params_smd( + set_generator_smd: QCInputGenerator, expected_job_type: str +) -> None: + qc_set: QCInputGenerator = set_generator_smd(smd_solvent="water") + assert qc_set.rem_dict["solvent_method"] == "smd" + assert qc_set.rem_dict["ideriv"] == "1" + assert qc_set.smx_dict["solvent"] == "water" + assert qc_set.scf_algorithm == "diis" + assert qc_set.job_type == expected_job_type + assert qc_set.basis_set == "def2-tzvppd" + assert isinstance(qc_set.rem_dict, dict) + + +@pytest.mark.parametrize( + "set_generator_plots, expected_job_type", + [ + (SinglePointSetGenerator, "sp"), + (OptSetGenerator, "opt"), + (TransitionStateSetGenerator, "ts"), + (ForceSetGenerator, "force"), + (FreqSetGenerator, "freq"), + (PESScanSetGenerator, "pes_scan"), + ], +) +def test_extra_params_plots( + set_generator_plots: QCInputGenerator, expected_job_type: str +) -> None: + qc_set: QCInputGenerator = set_generator_plots(plot_cubes=True) + assert qc_set.plots_dict == {"grid_spacing": "0.05", "total_density": "0"} + assert qc_set.rem_dict["plots"] == "true" + assert qc_set.rem_dict["make_cube_files"] == "true" + assert qc_set.scf_algorithm == "diis" + assert qc_set.job_type == expected_job_type + assert qc_set.basis_set == "def2-tzvppd" + + +@pytest.mark.parametrize( + "set_generator_nbo, expected_job_type", + [ + (SinglePointSetGenerator, "sp"), + (OptSetGenerator, "opt"), + (TransitionStateSetGenerator, "ts"), + (ForceSetGenerator, "force"), + (FreqSetGenerator, "freq"), + (PESScanSetGenerator, "pes_scan"), + ], +) +def test_extra_params_nbo( + set_generator_nbo: QCInputGenerator, expected_job_type: str +) -> None: + qc_set: QCInputGenerator = set_generator_nbo( + nbo_params={"version": 7, "plots": "PLOT"} + ) + + assert qc_set.rem_dict["nbo"] == "true" + assert qc_set.rem_dict["nbo_external"] == "true" + assert qc_set.scf_algorithm == "diis" + assert qc_set.job_type == expected_job_type + assert qc_set.basis_set == "def2-tzvppd" diff --git a/tests/qchem/sp.qin.gz b/tests/qchem/sp.qin.gz new file mode 100644 index 000000000..bb34b4336 Binary files /dev/null and b/tests/qchem/sp.qin.gz differ diff --git a/tests/qchem/test_drones.py b/tests/qchem/test_drones.py new file mode 100644 index 000000000..31dcffb1a --- /dev/null +++ b/tests/qchem/test_drones.py @@ -0,0 +1,19 @@ +from atomate2.qchem.drones import QChemDrone + + +def test_structure_optimization(qchem_test_dir): + drone = QChemDrone() + doc = drone.assimilate(qchem_test_dir / "water_single_point" / "outputs") + assert doc + + +def test_valid_paths(qchem_test_dir): + drone = QChemDrone() + valid_paths = drone.get_valid_paths( + [ + str(qchem_test_dir) + "/water_frequency", + ["inputs/", "outputs/"], + ["mol.in", "mol.out"], + ] + ) + assert valid_paths diff --git a/tests/qchem/test_files.py b/tests/qchem/test_files.py new file mode 100644 index 000000000..d504b6175 --- /dev/null +++ b/tests/qchem/test_files.py @@ -0,0 +1,39 @@ +from pathlib import Path + +import pytest + +from atomate2.qchem.files import copy_qchem_outputs, get_largest_opt_extension + + +@pytest.mark.parametrize( + "files", + [("custodian.json.gz", "FW.json.gz")], +) +def test_copy_qchem_outputs_sp(qchem_test_dir, tmp_dir, files): + path = qchem_test_dir / "water_single_point" / "outputs" + copy_qchem_outputs(src_dir=path, additional_qchem_files=files) + + for file in files: + assert Path(path / file).exists() + + +@pytest.mark.parametrize( + "files", + [("custodian.json.gz", "FW.json.gz")], +) +def test_copy_qchem_outputs_freq(qchem_test_dir, tmp_dir, files): + path = qchem_test_dir / "water_frequency" / "outputs" + copy_qchem_outputs(src_dir=path, additional_qchem_files=files) + + for file in files: + assert Path(path / file).exists() + + +def test_get_largest_opt_extension(qchem_test_dir): + path = qchem_test_dir / "double_opt_test" / "outputs" + extension = get_largest_opt_extension(directory=path) + assert extension == ".opt_2" + + path = qchem_test_dir / "water_single_point" / "static" / "outputs" + extension = get_largest_opt_extension(directory=path) + assert extension == "" diff --git a/tests/qchem/test_run.py b/tests/qchem/test_run.py new file mode 100644 index 000000000..325b244b4 --- /dev/null +++ b/tests/qchem/test_run.py @@ -0,0 +1,36 @@ +import pytest + +from atomate2.qchem.drones import QChemDrone +from atomate2.qchem.run import should_stop_children + + +def test_stop_children_val_td(qchem_test_dir): + drone = QChemDrone() + task_doc = drone.assimilate(qchem_test_dir / "water_single_point" / "outputs") + chk_stop_children = should_stop_children( + task_document=task_doc, handle_unsuccessful=False + ) + + assert isinstance(chk_stop_children, bool) + with pytest.raises(RuntimeError) as exc_info: + should_stop_children(task_document=task_doc, handle_unsuccessful="error") + + error_message = "Job was successful but children jobs need to be stopped!" + + assert str(exc_info.value) == error_message + + +def test_stop_children_inval_td(qchem_test_dir): + drone = QChemDrone() + task_doc = drone.assimilate(qchem_test_dir / "failed_qchem_task_dir" / "outputs") + + with pytest.raises(RuntimeError) as exc_info: + should_stop_children(task_document=task_doc, handle_unsuccessful="error") + + error_message = ( + "Job was not successful " + "(perhaps your job did not converge within the " + "limit of electronic/ionic iterations)!" + ) + + assert str(exc_info.value) == error_message diff --git a/tests/qchem/test_sets.py b/tests/qchem/test_sets.py new file mode 100644 index 000000000..8aa2d21d3 --- /dev/null +++ b/tests/qchem/test_sets.py @@ -0,0 +1,106 @@ +import os + +import pytest +from pymatgen.core.structure import Molecule +from pymatgen.io.qchem.inputs import QCInput + +from atomate2.qchem.sets.base import QCInputSet +from atomate2.qchem.sets.core import SinglePointSetGenerator + + +@pytest.fixture(scope="module") +def water_mol() -> Molecule: + """Dummy molecular structure for water as a test molecule.""" + return Molecule( + ["O", "H", "H"], + [[0.0, 0.0, 0.121], [-0.783, 0.0, -0.485], [0.783, 0.0, -0.485]], + ) + + +@pytest.mark.parametrize( + "molecule,overwrite_inputs", + [ + ("water_mol", {"rem": {"ideriv": 1, "method": "B97-D3", "dft_d": "D3_BJ"}}), + ("water_mol", {"opt": {"CONSTRAINT": ["stre 1 2 0.96"]}}), + ( + "water_mol", + { + "rem": {"solvent_method": "pcm"}, + "pcm": {"theory": "cpcm", "hpoints": "194"}, + "solvent": {"dielectric": "78.39"}, + }, + ), + ("water_mol", {"rem": {"solvent_method": "smd"}, "smx": {"solvent": "water"}}), + ("water_mol", {"scan": {"stre": ["1 2 0.95 1.35 0.1"]}}), + ], +) +def test_overwrite(molecule, overwrite_inputs, request) -> None: + """ + Test for ensuring whether overwrite_inputs correctly + changes the default input_set parameters. + + Here, we use the StaticSetGenerator as an example, + but any input generator that has a passed overwrite_inputs + dict as an input argument could be used. + """ + molecule = request.getfixturevalue(molecule) + + input_gen = SinglePointSetGenerator() + input_gen.overwrite_inputs = overwrite_inputs + in_set = input_gen.get_input_set(molecule) + in_set_rem = {} + in_set_opt = {} + in_set_pcm = {} + in_set_smx = {} + in_set_solvent = {} + in_set_scan = {} + if overwrite_inputs.keys() == "rem": + in_set_rem = in_set.qcinput.as_dict()["rem"] + elif overwrite_inputs.keys() == "opt": + in_set_opt = in_set.qcinput.as_dict()["opt"] + elif overwrite_inputs.keys() == ["rem", "pcm", "solvent"]: + in_set_rem = in_set.qcinput.as_dict()["rem"] + in_set_pcm = in_set.qcinput.as_dict()["pcm"] + in_set_solvent = in_set.qcinput.as_dict()["solvent"] + elif overwrite_inputs.keys() == ["rem", "smx"]: + in_set_rem = in_set.qcinput.as_dict()["rem"] + in_set_smx = in_set.qcinput.as_dict()["smx"] + + if in_set_rem: # case 1 + assert in_set_rem["method"] == "b97-d3" + assert in_set_rem["dft_d"] == "d3_bj" + elif in_set_opt: # case 2 + assert in_set_opt["constraint"] == ["stre 1 2 0.96"] + elif in_set_rem and in_set_pcm and in_set_solvent: + assert in_set_rem["solvent_method"] == "pcm" + assert in_set_pcm["theory"] == "cpcm" + assert in_set_pcm["hpoints"] == "194" + assert in_set_solvent["dielectric"] == "78.39" + elif in_set_rem and in_set_smx: + assert in_set_rem["solvent_method"] == "smd" + assert in_set_smx["solvent"] == "water" + elif in_set_scan: + assert in_set_scan["stre"] == ["1 2 0.95 1.35 0.1"] + + +@pytest.mark.parametrize( + "molecule", + [("water_mol")], +) +def test_write_set(molecule, clean_dir, request) -> None: + """ + Test for ensuring whether overwrite_inputs correctly + changes the default input_set parameters. + + Here, we use the StaticSetGenerator as an example, + but any input generator that has a passed overwrite_inputs + dict as an input argument could be used. + """ + molecule = request.getfixturevalue(molecule) + + input_gen = SinglePointSetGenerator() + in_set = input_gen.get_input_set(molecule) + in_set.write_input(directory="./inset_write", overwrite=True) + chk_input_set = QCInputSet.from_directory(directory="./inset_write") + assert os.path.isdir("./inset_write") + assert isinstance(chk_input_set.qcinput, QCInput) diff --git a/tests/test_data/qchem/double_opt_test/inputs/mol.qin.freq_0.gz b/tests/test_data/qchem/double_opt_test/inputs/mol.qin.freq_0.gz new file mode 100644 index 000000000..78714aa86 Binary files /dev/null and b/tests/test_data/qchem/double_opt_test/inputs/mol.qin.freq_0.gz differ diff --git a/tests/test_data/qchem/double_opt_test/inputs/mol.qin.freq_1.gz b/tests/test_data/qchem/double_opt_test/inputs/mol.qin.freq_1.gz new file mode 100644 index 000000000..022bca3b0 Binary files /dev/null and b/tests/test_data/qchem/double_opt_test/inputs/mol.qin.freq_1.gz differ diff --git a/tests/test_data/qchem/double_opt_test/inputs/mol.qin.freq_2.gz b/tests/test_data/qchem/double_opt_test/inputs/mol.qin.freq_2.gz new file mode 100644 index 000000000..18cbb7e25 Binary files /dev/null and b/tests/test_data/qchem/double_opt_test/inputs/mol.qin.freq_2.gz differ diff --git a/tests/test_data/qchem/double_opt_test/inputs/mol.qin.opt_0.gz b/tests/test_data/qchem/double_opt_test/inputs/mol.qin.opt_0.gz new file mode 100644 index 000000000..990085ad9 Binary files /dev/null and b/tests/test_data/qchem/double_opt_test/inputs/mol.qin.opt_0.gz differ diff --git a/tests/test_data/qchem/double_opt_test/inputs/mol.qin.opt_1.gz b/tests/test_data/qchem/double_opt_test/inputs/mol.qin.opt_1.gz new file mode 100644 index 000000000..d08d6077b Binary files /dev/null and b/tests/test_data/qchem/double_opt_test/inputs/mol.qin.opt_1.gz differ diff --git a/tests/test_data/qchem/double_opt_test/inputs/mol.qin.opt_2.gz b/tests/test_data/qchem/double_opt_test/inputs/mol.qin.opt_2.gz new file mode 100644 index 000000000..625c1d848 Binary files /dev/null and b/tests/test_data/qchem/double_opt_test/inputs/mol.qin.opt_2.gz differ diff --git a/tests/test_data/qchem/double_opt_test/inputs/mol.qin.orig.gz b/tests/test_data/qchem/double_opt_test/inputs/mol.qin.orig.gz new file mode 100644 index 000000000..b7282b802 Binary files /dev/null and b/tests/test_data/qchem/double_opt_test/inputs/mol.qin.orig.gz differ diff --git a/tests/test_data/qchem/double_opt_test/outputs/FW.json.gz b/tests/test_data/qchem/double_opt_test/outputs/FW.json.gz new file mode 100644 index 000000000..d68cad5f5 Binary files /dev/null and b/tests/test_data/qchem/double_opt_test/outputs/FW.json.gz differ diff --git a/tests/test_data/qchem/double_opt_test/outputs/FW_submit.script.gz b/tests/test_data/qchem/double_opt_test/outputs/FW_submit.script.gz new file mode 100644 index 000000000..75e6a8a73 Binary files /dev/null and b/tests/test_data/qchem/double_opt_test/outputs/FW_submit.script.gz differ diff --git a/tests/test_data/qchem/double_opt_test/outputs/GRAD.freq_0.gz b/tests/test_data/qchem/double_opt_test/outputs/GRAD.freq_0.gz new file mode 100644 index 000000000..1c6fff216 Binary files /dev/null and b/tests/test_data/qchem/double_opt_test/outputs/GRAD.freq_0.gz differ diff --git a/tests/test_data/qchem/double_opt_test/outputs/GRAD.freq_1.gz b/tests/test_data/qchem/double_opt_test/outputs/GRAD.freq_1.gz new file mode 100644 index 000000000..c16190642 Binary files /dev/null and b/tests/test_data/qchem/double_opt_test/outputs/GRAD.freq_1.gz differ diff --git a/tests/test_data/qchem/double_opt_test/outputs/GRAD.freq_2.gz b/tests/test_data/qchem/double_opt_test/outputs/GRAD.freq_2.gz new file mode 100644 index 000000000..6f287d740 Binary files /dev/null and b/tests/test_data/qchem/double_opt_test/outputs/GRAD.freq_2.gz differ diff --git a/tests/test_data/qchem/double_opt_test/outputs/GRAD.opt_0.gz b/tests/test_data/qchem/double_opt_test/outputs/GRAD.opt_0.gz new file mode 100644 index 000000000..43c33779e Binary files /dev/null and b/tests/test_data/qchem/double_opt_test/outputs/GRAD.opt_0.gz differ diff --git a/tests/test_data/qchem/double_opt_test/outputs/GRAD.opt_1.gz b/tests/test_data/qchem/double_opt_test/outputs/GRAD.opt_1.gz new file mode 100644 index 000000000..cc0f9688d Binary files /dev/null and b/tests/test_data/qchem/double_opt_test/outputs/GRAD.opt_1.gz differ diff --git a/tests/test_data/qchem/double_opt_test/outputs/GRAD.opt_2.gz b/tests/test_data/qchem/double_opt_test/outputs/GRAD.opt_2.gz new file mode 100644 index 000000000..4b452fef6 Binary files /dev/null and b/tests/test_data/qchem/double_opt_test/outputs/GRAD.opt_2.gz differ diff --git a/tests/test_data/qchem/double_opt_test/outputs/HESS.freq_0.gz b/tests/test_data/qchem/double_opt_test/outputs/HESS.freq_0.gz new file mode 100644 index 000000000..d164ee5d1 Binary files /dev/null and b/tests/test_data/qchem/double_opt_test/outputs/HESS.freq_0.gz differ diff --git a/tests/test_data/qchem/double_opt_test/outputs/HESS.freq_1.gz b/tests/test_data/qchem/double_opt_test/outputs/HESS.freq_1.gz new file mode 100644 index 000000000..0c5e0bf06 Binary files /dev/null and b/tests/test_data/qchem/double_opt_test/outputs/HESS.freq_1.gz differ diff --git a/tests/test_data/qchem/double_opt_test/outputs/HESS.freq_2.gz b/tests/test_data/qchem/double_opt_test/outputs/HESS.freq_2.gz new file mode 100644 index 000000000..bc63e92df Binary files /dev/null and b/tests/test_data/qchem/double_opt_test/outputs/HESS.freq_2.gz differ diff --git a/tests/test_data/qchem/double_opt_test/outputs/HESS.opt_0.gz b/tests/test_data/qchem/double_opt_test/outputs/HESS.opt_0.gz new file mode 100644 index 000000000..9524c5a2e Binary files /dev/null and b/tests/test_data/qchem/double_opt_test/outputs/HESS.opt_0.gz differ diff --git a/tests/test_data/qchem/double_opt_test/outputs/HESS.opt_1.gz b/tests/test_data/qchem/double_opt_test/outputs/HESS.opt_1.gz new file mode 100644 index 000000000..a4402c52b Binary files /dev/null and b/tests/test_data/qchem/double_opt_test/outputs/HESS.opt_1.gz differ diff --git a/tests/test_data/qchem/double_opt_test/outputs/HESS.opt_2.gz b/tests/test_data/qchem/double_opt_test/outputs/HESS.opt_2.gz new file mode 100644 index 000000000..bb5197864 Binary files /dev/null and b/tests/test_data/qchem/double_opt_test/outputs/HESS.opt_2.gz differ diff --git a/tests/test_data/qchem/double_opt_test/outputs/Reduced_Mg_OTF_2_G2_-15344258.error.gz b/tests/test_data/qchem/double_opt_test/outputs/Reduced_Mg_OTF_2_G2_-15344258.error.gz new file mode 100644 index 000000000..1046f0a15 Binary files /dev/null and b/tests/test_data/qchem/double_opt_test/outputs/Reduced_Mg_OTF_2_G2_-15344258.error.gz differ diff --git a/tests/test_data/qchem/double_opt_test/outputs/Reduced_Mg_OTF_2_G2_-15344258.out.gz b/tests/test_data/qchem/double_opt_test/outputs/Reduced_Mg_OTF_2_G2_-15344258.out.gz new file mode 100644 index 000000000..ffb16e256 Binary files /dev/null and b/tests/test_data/qchem/double_opt_test/outputs/Reduced_Mg_OTF_2_G2_-15344258.out.gz differ diff --git a/tests/test_data/qchem/double_opt_test/outputs/custodian.json.gz b/tests/test_data/qchem/double_opt_test/outputs/custodian.json.gz new file mode 100644 index 000000000..8c232013c Binary files /dev/null and b/tests/test_data/qchem/double_opt_test/outputs/custodian.json.gz differ diff --git a/tests/test_data/qchem/double_opt_test/outputs/mol.qclog.freq_0.gz b/tests/test_data/qchem/double_opt_test/outputs/mol.qclog.freq_0.gz new file mode 100644 index 000000000..8ad4d42aa Binary files /dev/null and b/tests/test_data/qchem/double_opt_test/outputs/mol.qclog.freq_0.gz differ diff --git a/tests/test_data/qchem/double_opt_test/outputs/mol.qclog.freq_1.gz b/tests/test_data/qchem/double_opt_test/outputs/mol.qclog.freq_1.gz new file mode 100644 index 000000000..d9a484659 Binary files /dev/null and b/tests/test_data/qchem/double_opt_test/outputs/mol.qclog.freq_1.gz differ diff --git a/tests/test_data/qchem/double_opt_test/outputs/mol.qclog.freq_2.gz b/tests/test_data/qchem/double_opt_test/outputs/mol.qclog.freq_2.gz new file mode 100644 index 000000000..0ab9d5bb3 Binary files /dev/null and b/tests/test_data/qchem/double_opt_test/outputs/mol.qclog.freq_2.gz differ diff --git a/tests/test_data/qchem/double_opt_test/outputs/mol.qclog.opt_0.gz b/tests/test_data/qchem/double_opt_test/outputs/mol.qclog.opt_0.gz new file mode 100644 index 000000000..d9dbededc Binary files /dev/null and b/tests/test_data/qchem/double_opt_test/outputs/mol.qclog.opt_0.gz differ diff --git a/tests/test_data/qchem/double_opt_test/outputs/mol.qclog.opt_1.gz b/tests/test_data/qchem/double_opt_test/outputs/mol.qclog.opt_1.gz new file mode 100644 index 000000000..1aa82718a Binary files /dev/null and b/tests/test_data/qchem/double_opt_test/outputs/mol.qclog.opt_1.gz differ diff --git a/tests/test_data/qchem/double_opt_test/outputs/mol.qclog.opt_2.gz b/tests/test_data/qchem/double_opt_test/outputs/mol.qclog.opt_2.gz new file mode 100644 index 000000000..874ec4546 Binary files /dev/null and b/tests/test_data/qchem/double_opt_test/outputs/mol.qclog.opt_2.gz differ diff --git a/tests/test_data/qchem/double_opt_test/outputs/mol.qout.freq_0.gz b/tests/test_data/qchem/double_opt_test/outputs/mol.qout.freq_0.gz new file mode 100644 index 000000000..683bc875d Binary files /dev/null and b/tests/test_data/qchem/double_opt_test/outputs/mol.qout.freq_0.gz differ diff --git a/tests/test_data/qchem/double_opt_test/outputs/mol.qout.freq_1.gz b/tests/test_data/qchem/double_opt_test/outputs/mol.qout.freq_1.gz new file mode 100644 index 000000000..bac143f80 Binary files /dev/null and b/tests/test_data/qchem/double_opt_test/outputs/mol.qout.freq_1.gz differ diff --git a/tests/test_data/qchem/double_opt_test/outputs/mol.qout.freq_2.gz b/tests/test_data/qchem/double_opt_test/outputs/mol.qout.freq_2.gz new file mode 100644 index 000000000..d59956cc0 Binary files /dev/null and b/tests/test_data/qchem/double_opt_test/outputs/mol.qout.freq_2.gz differ diff --git a/tests/test_data/qchem/double_opt_test/outputs/mol.qout.opt_0.gz b/tests/test_data/qchem/double_opt_test/outputs/mol.qout.opt_0.gz new file mode 100644 index 000000000..12de617b1 Binary files /dev/null and b/tests/test_data/qchem/double_opt_test/outputs/mol.qout.opt_0.gz differ diff --git a/tests/test_data/qchem/double_opt_test/outputs/mol.qout.opt_1.gz b/tests/test_data/qchem/double_opt_test/outputs/mol.qout.opt_1.gz new file mode 100644 index 000000000..6b1a356db Binary files /dev/null and b/tests/test_data/qchem/double_opt_test/outputs/mol.qout.opt_1.gz differ diff --git a/tests/test_data/qchem/double_opt_test/outputs/mol.qout.opt_2.gz b/tests/test_data/qchem/double_opt_test/outputs/mol.qout.opt_2.gz new file mode 100644 index 000000000..a1a400b54 Binary files /dev/null and b/tests/test_data/qchem/double_opt_test/outputs/mol.qout.opt_2.gz differ diff --git a/tests/test_data/qchem/failed_qchem_task_dir/inputs/mol.qin.gz b/tests/test_data/qchem/failed_qchem_task_dir/inputs/mol.qin.gz new file mode 100644 index 000000000..2a682b47c Binary files /dev/null and b/tests/test_data/qchem/failed_qchem_task_dir/inputs/mol.qin.gz differ diff --git a/tests/test_data/qchem/failed_qchem_task_dir/inputs/mol.qin.last.gz b/tests/test_data/qchem/failed_qchem_task_dir/inputs/mol.qin.last.gz new file mode 100644 index 000000000..81c152756 Binary files /dev/null and b/tests/test_data/qchem/failed_qchem_task_dir/inputs/mol.qin.last.gz differ diff --git a/tests/test_data/qchem/failed_qchem_task_dir/inputs/mol.qin.orig.gz b/tests/test_data/qchem/failed_qchem_task_dir/inputs/mol.qin.orig.gz new file mode 100644 index 000000000..22cd4389c Binary files /dev/null and b/tests/test_data/qchem/failed_qchem_task_dir/inputs/mol.qin.orig.gz differ diff --git a/tests/test_data/qchem/failed_qchem_task_dir/outputs/FW.json.gz b/tests/test_data/qchem/failed_qchem_task_dir/outputs/FW.json.gz new file mode 100644 index 000000000..2faa1f29b Binary files /dev/null and b/tests/test_data/qchem/failed_qchem_task_dir/outputs/FW.json.gz differ diff --git a/tests/test_data/qchem/failed_qchem_task_dir/outputs/FW_submit.script.gz b/tests/test_data/qchem/failed_qchem_task_dir/outputs/FW_submit.script.gz new file mode 100644 index 000000000..ce7723582 Binary files /dev/null and b/tests/test_data/qchem/failed_qchem_task_dir/outputs/FW_submit.script.gz differ diff --git a/tests/test_data/qchem/failed_qchem_task_dir/outputs/custodian.json.gz b/tests/test_data/qchem/failed_qchem_task_dir/outputs/custodian.json.gz new file mode 100644 index 000000000..0df36087b Binary files /dev/null and b/tests/test_data/qchem/failed_qchem_task_dir/outputs/custodian.json.gz differ diff --git a/tests/test_data/qchem/failed_qchem_task_dir/outputs/mol.qclog.gz b/tests/test_data/qchem/failed_qchem_task_dir/outputs/mol.qclog.gz new file mode 100644 index 000000000..89668103d Binary files /dev/null and b/tests/test_data/qchem/failed_qchem_task_dir/outputs/mol.qclog.gz differ diff --git a/tests/test_data/qchem/failed_qchem_task_dir/outputs/mol.qin.gz b/tests/test_data/qchem/failed_qchem_task_dir/outputs/mol.qin.gz new file mode 100644 index 000000000..2a682b47c Binary files /dev/null and b/tests/test_data/qchem/failed_qchem_task_dir/outputs/mol.qin.gz differ diff --git a/tests/test_data/qchem/failed_qchem_task_dir/outputs/mol.qin.last.gz b/tests/test_data/qchem/failed_qchem_task_dir/outputs/mol.qin.last.gz new file mode 100644 index 000000000..81c152756 Binary files /dev/null and b/tests/test_data/qchem/failed_qchem_task_dir/outputs/mol.qin.last.gz differ diff --git a/tests/test_data/qchem/failed_qchem_task_dir/outputs/mol.qin.orig.gz b/tests/test_data/qchem/failed_qchem_task_dir/outputs/mol.qin.orig.gz new file mode 100644 index 000000000..22cd4389c Binary files /dev/null and b/tests/test_data/qchem/failed_qchem_task_dir/outputs/mol.qin.orig.gz differ diff --git a/tests/test_data/qchem/failed_qchem_task_dir/outputs/mol.qout.gz b/tests/test_data/qchem/failed_qchem_task_dir/outputs/mol.qout.gz new file mode 100644 index 000000000..6555f37f4 Binary files /dev/null and b/tests/test_data/qchem/failed_qchem_task_dir/outputs/mol.qout.gz differ diff --git a/tests/test_data/qchem/single_point/inputs/mol.qin.gz b/tests/test_data/qchem/single_point/inputs/mol.qin.gz new file mode 100644 index 000000000..bb34b4336 Binary files /dev/null and b/tests/test_data/qchem/single_point/inputs/mol.qin.gz differ diff --git a/tests/test_data/qchem/single_point/inputs/mol.qin.orig.gz b/tests/test_data/qchem/single_point/inputs/mol.qin.orig.gz new file mode 100644 index 000000000..43b8eee33 Binary files /dev/null and b/tests/test_data/qchem/single_point/inputs/mol.qin.orig.gz differ diff --git a/tests/test_data/qchem/single_point/outputs/FW.json.gz b/tests/test_data/qchem/single_point/outputs/FW.json.gz new file mode 100644 index 000000000..c2b3f6a5e Binary files /dev/null and b/tests/test_data/qchem/single_point/outputs/FW.json.gz differ diff --git a/tests/test_data/qchem/single_point/outputs/FW_submit.script.gz b/tests/test_data/qchem/single_point/outputs/FW_submit.script.gz new file mode 100644 index 000000000..e712cfabd Binary files /dev/null and b/tests/test_data/qchem/single_point/outputs/FW_submit.script.gz differ diff --git a/tests/test_data/qchem/single_point/outputs/custodian.json.gz b/tests/test_data/qchem/single_point/outputs/custodian.json.gz new file mode 100644 index 000000000..edaabaf7e Binary files /dev/null and b/tests/test_data/qchem/single_point/outputs/custodian.json.gz differ diff --git a/tests/test_data/qchem/single_point/outputs/ffopt_formed_water.x-15578601.error.gz b/tests/test_data/qchem/single_point/outputs/ffopt_formed_water.x-15578601.error.gz new file mode 100644 index 000000000..6541dd339 Binary files /dev/null and b/tests/test_data/qchem/single_point/outputs/ffopt_formed_water.x-15578601.error.gz differ diff --git a/tests/test_data/qchem/single_point/outputs/ffopt_formed_water.x-15578601.out.gz b/tests/test_data/qchem/single_point/outputs/ffopt_formed_water.x-15578601.out.gz new file mode 100644 index 000000000..dc14d603a Binary files /dev/null and b/tests/test_data/qchem/single_point/outputs/ffopt_formed_water.x-15578601.out.gz differ diff --git a/tests/test_data/qchem/single_point/outputs/mol.qclog.gz b/tests/test_data/qchem/single_point/outputs/mol.qclog.gz new file mode 100644 index 000000000..4bdd47aa2 Binary files /dev/null and b/tests/test_data/qchem/single_point/outputs/mol.qclog.gz differ diff --git a/tests/test_data/qchem/single_point/outputs/mol.qin.gz b/tests/test_data/qchem/single_point/outputs/mol.qin.gz new file mode 100644 index 000000000..bb34b4336 Binary files /dev/null and b/tests/test_data/qchem/single_point/outputs/mol.qin.gz differ diff --git a/tests/test_data/qchem/single_point/outputs/mol.qin.orig.gz b/tests/test_data/qchem/single_point/outputs/mol.qin.orig.gz new file mode 100644 index 000000000..43b8eee33 Binary files /dev/null and b/tests/test_data/qchem/single_point/outputs/mol.qin.orig.gz differ diff --git a/tests/test_data/qchem/single_point/outputs/mol.qout.gz b/tests/test_data/qchem/single_point/outputs/mol.qout.gz new file mode 100644 index 000000000..edc4ea953 Binary files /dev/null and b/tests/test_data/qchem/single_point/outputs/mol.qout.gz differ diff --git a/tests/test_data/qchem/water_frequency/inputs/mol.qin.gz b/tests/test_data/qchem/water_frequency/inputs/mol.qin.gz new file mode 100644 index 000000000..e56db71ea Binary files /dev/null and b/tests/test_data/qchem/water_frequency/inputs/mol.qin.gz differ diff --git a/tests/test_data/qchem/water_frequency/inputs/mol.qin.orig.gz b/tests/test_data/qchem/water_frequency/inputs/mol.qin.orig.gz new file mode 100644 index 000000000..a921d6eb2 Binary files /dev/null and b/tests/test_data/qchem/water_frequency/inputs/mol.qin.orig.gz differ diff --git a/tests/test_data/qchem/water_frequency/outputs/FW.json.gz b/tests/test_data/qchem/water_frequency/outputs/FW.json.gz new file mode 100644 index 000000000..859115bfc Binary files /dev/null and b/tests/test_data/qchem/water_frequency/outputs/FW.json.gz differ diff --git a/tests/test_data/qchem/water_frequency/outputs/FW_submit.script.gz b/tests/test_data/qchem/water_frequency/outputs/FW_submit.script.gz new file mode 100644 index 000000000..a35b3c008 Binary files /dev/null and b/tests/test_data/qchem/water_frequency/outputs/FW_submit.script.gz differ diff --git a/tests/test_data/qchem/water_frequency/outputs/custodian.json.gz b/tests/test_data/qchem/water_frequency/outputs/custodian.json.gz new file mode 100644 index 000000000..d19bef7a7 Binary files /dev/null and b/tests/test_data/qchem/water_frequency/outputs/custodian.json.gz differ diff --git a/tests/test_data/qchem/water_frequency/outputs/mol.qclog.gz b/tests/test_data/qchem/water_frequency/outputs/mol.qclog.gz new file mode 100644 index 000000000..81bf683c3 Binary files /dev/null and b/tests/test_data/qchem/water_frequency/outputs/mol.qclog.gz differ diff --git a/tests/test_data/qchem/water_frequency/outputs/mol.qin.gz b/tests/test_data/qchem/water_frequency/outputs/mol.qin.gz new file mode 100644 index 000000000..e56db71ea Binary files /dev/null and b/tests/test_data/qchem/water_frequency/outputs/mol.qin.gz differ diff --git a/tests/test_data/qchem/water_frequency/outputs/mol.qin.orig.gz b/tests/test_data/qchem/water_frequency/outputs/mol.qin.orig.gz new file mode 100644 index 000000000..a921d6eb2 Binary files /dev/null and b/tests/test_data/qchem/water_frequency/outputs/mol.qin.orig.gz differ diff --git a/tests/test_data/qchem/water_frequency/outputs/mol.qout.gz b/tests/test_data/qchem/water_frequency/outputs/mol.qout.gz new file mode 100644 index 000000000..a28328b05 Binary files /dev/null and b/tests/test_data/qchem/water_frequency/outputs/mol.qout.gz differ diff --git a/tests/test_data/qchem/water_optimization/inputs/mol.qin.gz b/tests/test_data/qchem/water_optimization/inputs/mol.qin.gz new file mode 100644 index 000000000..bad1fee84 Binary files /dev/null and b/tests/test_data/qchem/water_optimization/inputs/mol.qin.gz differ diff --git a/tests/test_data/qchem/water_optimization/inputs/mol.qin.orig.gz b/tests/test_data/qchem/water_optimization/inputs/mol.qin.orig.gz new file mode 100644 index 000000000..c09004b1b Binary files /dev/null and b/tests/test_data/qchem/water_optimization/inputs/mol.qin.orig.gz differ diff --git a/tests/test_data/qchem/water_optimization/outputs/FW.json.gz b/tests/test_data/qchem/water_optimization/outputs/FW.json.gz new file mode 100644 index 000000000..b93ba9ca8 Binary files /dev/null and b/tests/test_data/qchem/water_optimization/outputs/FW.json.gz differ diff --git a/tests/test_data/qchem/water_optimization/outputs/FW_submit.script.gz b/tests/test_data/qchem/water_optimization/outputs/FW_submit.script.gz new file mode 100644 index 000000000..3b4e3e134 Binary files /dev/null and b/tests/test_data/qchem/water_optimization/outputs/FW_submit.script.gz differ diff --git a/tests/test_data/qchem/water_optimization/outputs/custodian.json.gz b/tests/test_data/qchem/water_optimization/outputs/custodian.json.gz new file mode 100644 index 000000000..8dee8b607 Binary files /dev/null and b/tests/test_data/qchem/water_optimization/outputs/custodian.json.gz differ diff --git a/tests/test_data/qchem/water_optimization/outputs/mol.qclog.gz b/tests/test_data/qchem/water_optimization/outputs/mol.qclog.gz new file mode 100644 index 000000000..ec2c65200 Binary files /dev/null and b/tests/test_data/qchem/water_optimization/outputs/mol.qclog.gz differ diff --git a/tests/test_data/qchem/water_optimization/outputs/mol.qin.gz b/tests/test_data/qchem/water_optimization/outputs/mol.qin.gz new file mode 100644 index 000000000..bad1fee84 Binary files /dev/null and b/tests/test_data/qchem/water_optimization/outputs/mol.qin.gz differ diff --git a/tests/test_data/qchem/water_optimization/outputs/mol.qin.orig.gz b/tests/test_data/qchem/water_optimization/outputs/mol.qin.orig.gz new file mode 100644 index 000000000..c09004b1b Binary files /dev/null and b/tests/test_data/qchem/water_optimization/outputs/mol.qin.orig.gz differ diff --git a/tests/test_data/qchem/water_optimization/outputs/mol.qout.gz b/tests/test_data/qchem/water_optimization/outputs/mol.qout.gz new file mode 100644 index 000000000..e26682b89 Binary files /dev/null and b/tests/test_data/qchem/water_optimization/outputs/mol.qout.gz differ diff --git a/tests/test_data/qchem/water_single_point/inputs/mol.qin.gz b/tests/test_data/qchem/water_single_point/inputs/mol.qin.gz new file mode 100644 index 000000000..bb34b4336 Binary files /dev/null and b/tests/test_data/qchem/water_single_point/inputs/mol.qin.gz differ diff --git a/tests/test_data/qchem/water_single_point/inputs/mol.qin.orig.gz b/tests/test_data/qchem/water_single_point/inputs/mol.qin.orig.gz new file mode 100644 index 000000000..43b8eee33 Binary files /dev/null and b/tests/test_data/qchem/water_single_point/inputs/mol.qin.orig.gz differ diff --git a/tests/test_data/qchem/water_single_point/outputs/FW.json.gz b/tests/test_data/qchem/water_single_point/outputs/FW.json.gz new file mode 100644 index 000000000..c2b3f6a5e Binary files /dev/null and b/tests/test_data/qchem/water_single_point/outputs/FW.json.gz differ diff --git a/tests/test_data/qchem/water_single_point/outputs/FW_submit.script.gz b/tests/test_data/qchem/water_single_point/outputs/FW_submit.script.gz new file mode 100644 index 000000000..e712cfabd Binary files /dev/null and b/tests/test_data/qchem/water_single_point/outputs/FW_submit.script.gz differ diff --git a/tests/test_data/qchem/water_single_point/outputs/custodian.json.gz b/tests/test_data/qchem/water_single_point/outputs/custodian.json.gz new file mode 100644 index 000000000..edaabaf7e Binary files /dev/null and b/tests/test_data/qchem/water_single_point/outputs/custodian.json.gz differ diff --git a/tests/test_data/qchem/water_single_point/outputs/mol.qclog.gz b/tests/test_data/qchem/water_single_point/outputs/mol.qclog.gz new file mode 100644 index 000000000..4bdd47aa2 Binary files /dev/null and b/tests/test_data/qchem/water_single_point/outputs/mol.qclog.gz differ diff --git a/tests/test_data/qchem/water_single_point/outputs/mol.qin.gz b/tests/test_data/qchem/water_single_point/outputs/mol.qin.gz new file mode 100644 index 000000000..bb34b4336 Binary files /dev/null and b/tests/test_data/qchem/water_single_point/outputs/mol.qin.gz differ diff --git a/tests/test_data/qchem/water_single_point/outputs/mol.qin.orig.gz b/tests/test_data/qchem/water_single_point/outputs/mol.qin.orig.gz new file mode 100644 index 000000000..43b8eee33 Binary files /dev/null and b/tests/test_data/qchem/water_single_point/outputs/mol.qin.orig.gz differ diff --git a/tests/test_data/qchem/water_single_point/outputs/mol.qout.gz b/tests/test_data/qchem/water_single_point/outputs/mol.qout.gz new file mode 100644 index 000000000..edc4ea953 Binary files /dev/null and b/tests/test_data/qchem/water_single_point/outputs/mol.qout.gz differ