diff --git a/censo b/censo index 4164be76..7746a9cc 100755 --- a/censo +++ b/censo @@ -2,8 +2,8 @@ import sys import os -sys.path.insert(0, f"{os.path.join(os.path.split(__file__)[0], 'src')}") - from censo.cli.interface import entry_point -sys.exit(entry_point()) +if __name__ == "__main__": + sys.path.insert(0, f"{os.path.join(os.path.split(__file__)[0], 'src')}") + sys.exit(entry_point()) diff --git a/src/censo/assets/dfa.json b/src/censo/assets/dfa.json index fec3868d..30748ec3 100644 --- a/src/censo/assets/dfa.json +++ b/src/censo/assets/dfa.json @@ -2,18 +2,21 @@ "pbeh-3c": { "tm": "pbeh-3c", "orca": "pbeh-3c", + "psi4": "pbeh-3c", "disp": "composite", "type": "composite_hybrid" }, "b97-3c": { "tm": "b97-3c", "orca": "b97-3c", + "psi4": "b97-3c", "disp": "composite", "type": "composite_gga" }, "r2scan-3c": { "tm": "r2scan-3c", "orca": "r2scan-3c", + "psi4": "r2scan-3c", "disp": "composite", "type": "composite_mgga" }, @@ -38,30 +41,70 @@ "r2scan-d4": { "tm": "r2scan", "orca": "r2scan", + "psi4": "r2scan-d4", "disp": "d4", "type": "mgga" }, "pbe-novdw": { "tm": "pbe", "orca": "pbe", "disp": "novdw", "type": "gga" }, - "pbe-d3": { "tm": "pbe", "orca": "pbe", "disp": "d3bj", "type": "gga" }, + "pbe-d3": { + "tm": "pbe", + "orca": "pbe", + "psi4": "pbe-d3", + "disp": "d3bj", + "type": "gga" + }, "pbe-d3(0)": { "tm": "pbe", "orca": "pbe", "disp": "d3(0)", "type": "gga" }, - "pbe-d4": { "tm": "pbe", "orca": "pbe", "disp": "d4", "type": "gga" }, - "pbe-nl": { "tm": "pbe", "disp": "nl", "type": "gga" }, - "bp86": { "tm": "b-p", "disp": "novdw", "type": "gga" }, + "pbe-d4": { + "tm": "pbe", + "orca": "pbe", + "psi4": "pbe-d4", + "disp": "d4", + "type": "gga" + }, + "pbe-nl": { + "tm": "pbe", + "psi4": "pbe-nl", + "disp": "nl", + "type": "gga" + }, + "bp86": { + "tm": "b-p", + "psi4": "bp86", + "disp": "novdw", + "type": "gga" + }, "tpss-novdw": { "tm": "tpss", "orca": "tpss", "disp": "novdw", "type": "mgga" }, - "tpss-d3": { "tm": "tpss", "orca": "tpss", "disp": "d3bj", "type": "mgga" }, + "tpss-d3": { + "tm": "tpss", + "orca": "tpss", + "psi4": "tpss-d3", + "disp": "d3bj", + "type": "mgga" + }, "tpss-d3(0)": { "tm": "tpss", "orca": "tpss", "disp": "d3(0)", "type": "mgga" }, - "tpss-d4": { "tm": "tpss", "orca": "tpss", "disp": "d4", "type": "mgga" }, - "tpss-nl": { "tm": "tpss", "disp": "nl", "type": "mgga" }, + "tpss-d4": { + "tm": "tpss", + "orca": "tpss", + "psi4": "tpss-d4", + "disp": "d4", + "type": "mgga" + }, + "tpss-nl": { + "tm": "tpss", + "psi4": "tpss-nl", + "disp": "nl", + "type": "mgga" + }, "revtpss-novdw": { "tm": "revtpss", "orca": "revTPSS", @@ -73,13 +116,23 @@ "disp": "novdw", "type": "global_hybrid" }, - "tpssh-d3": { "orca": "tpssh", "disp": "d3", "type": "global_hybrid" }, + "tpssh-d3": { + "orca": "tpssh", + "psi4": "tpssh-d3", + "disp": "d3", + "type": "global_hybrid" + }, "tpssh-d3(0)": { "orca": "tpssh", "disp": "d3(0)", "type": "global_hybrid" }, - "tpssh-d4": { "orca": "tpssh", "disp": "d4", "type": "global_hybrid" }, + "tpssh-d4": { + "orca": "tpssh", + "psi4": "tpssh-d4", + "disp": "d4", + "type": "global_hybrid" + }, "b97-d3": { "tm": "b97-d", "orca": "b97-d3", @@ -98,6 +151,7 @@ "pbe0-d3": { "tm": "pbe0", "orca": "pbe0", + "psi4": "pbe0-d3", "disp": "d3bj", "type": "global_hybrid" }, @@ -110,10 +164,16 @@ "pbe0-d4": { "tm": "pbe0", "orca": "pbe0", + "psi4": "pbe0-d4", "disp": "d4", "type": "global_hybrid" }, - "pbe0-nl": { "tm": "pbe0", "disp": "nl", "type": "global_hybrid" }, + "pbe0-nl": { + "tm": "pbe0", + "psi4": "pbe0-nl", + "disp": "nl", + "type": "global_hybrid" + }, "pw6b95-novdw": { "tm": "pw6b95", "orca": "pw6b95", @@ -123,6 +183,7 @@ "pw6b95-d3": { "tm": "pw6b95", "orca": "pw6b95", + "psi4": "pw6b95-d3", "disp": "d3bj", "type": "global_hybrid" }, @@ -135,6 +196,7 @@ "pw6b95-d4": { "tm": "pw6b95", "orca": "pw6b95", + "psi4": "pw6b95-d4", "disp": "d4", "type": "global_hybrid" }, @@ -147,6 +209,7 @@ "b3lyp-d3": { "tm": "b3-lyp", "orca": "b3lyp", + "psi4": "b3lyp-d3", "disp": "d3bj", "type": "global_hybrid" }, @@ -159,39 +222,46 @@ "b3lyp-d4": { "tm": "b3-lyp", "orca": "b3lyp", + "psi4": "b3lyp-d4", "disp": "d4", "type": "global_hybrid" }, "b3lyp-nl": { "tm": "b3-lyp", "orca": "b3lyp", + "psi4": "b3lyp-nl", "disp": "nl", "type": "global_hybrid" }, "wb97x-v": { "tm": "wb97x-v", "orca": "wb97x-v", + "psi4": "wb97x-v", "disp": "included", "type": "rs_hybrid" }, "wb97x-d3": { "orca": "wb97x-d3", + "psi4": "wb97x-d3", "disp": "included", "type": "rs_hybrid" }, "wb97x-d3bj": { "orca": "wb97x-d3bj", + "psi4": "wb97x-d3bj", "disp": "included", "type": "rs_hybrid" }, "wb97x-d4": { "orca": "wb97x-d4", + "psi4": "wb97x-d4", "disp": "included", "type": "rs_hybrid" }, "wb97m-v": { "orca": "wb97m-v", "tm": "wb97m-v", + "psi4": "wb97m-v", "disp": "included", "type": "rs_hybrid" }, @@ -219,5 +289,10 @@ "orca": "dsd-pbep86", "disp": "d3bj", "type": "double" + }, + "generic": { + "generic": "generic", + "disp": "generic", + "type": "generic" } } diff --git a/src/censo/config/parts/rot.py b/src/censo/config/parts/rot.py index 72ecd050..eec10371 100644 --- a/src/censo/config/parts/rot.py +++ b/src/censo/config/parts/rot.py @@ -10,7 +10,7 @@ class RotConfig(BasePartConfig): """Config class for optical rotation calculations (gas-phase only).""" - prog: Literal[QmProg.TM] = QmProg.TM + prog: Literal[QmProg.TM] | Literal[QmProg.GENERIC] = QmProg.TM """Program that should be used for calculations.""" func: str = "pbe-d4" diff --git a/src/censo/config/parts/uvvis.py b/src/censo/config/parts/uvvis.py index 7bc538b4..0afc3c81 100644 --- a/src/censo/config/parts/uvvis.py +++ b/src/censo/config/parts/uvvis.py @@ -9,7 +9,7 @@ class UVVisConfig(BasePartConfig): """Config class for UVVis""" - prog: Literal[QmProg.ORCA] = QmProg.ORCA + prog: Literal[QmProg.ORCA] | Literal[QmProg.GENERIC] = QmProg.ORCA """Program that should be used for calculations.""" func: str = "wb97x-d4" diff --git a/src/censo/config/paths.py b/src/censo/config/paths.py index 4ea7df51..19041b30 100644 --- a/src/censo/config/paths.py +++ b/src/censo/config/paths.py @@ -32,12 +32,18 @@ class PathsConfig(BaseModel): _orcaversion: str = PrivateAttr("") """ORCA version string. Should be extracted from somewhere.""" + psi4: str = Field("") + """Absolute path to the psi4 binary directory.""" + tm: str = Field("") """Absolute path to the turbomole binary directory.""" xtb: str = Field("") """Absolute path to the xtb binary.""" + generic: str = Field("") + """Absolute path to the generic binary.""" + cosmotherm: str = Field("") """Absolute path to the cosmotherm binary.""" @@ -76,6 +82,18 @@ def validate_orca(cls, value: str): raise ValueError(f"orca executable not found at {value}.") return value + @field_validator("psi4") + def validate_psi4(cls, value: str): + """ + Validate the psi4 executable path. + + :param value: The path to validate. + :return: The validated path. + """ + if not Path(value).is_file(): + raise ValueError(f"psi4 executable not found at {value}.") + return value + @field_validator("tm") def validate_turbomole(cls, value: str): """ diff --git a/src/censo/parallel.py b/src/censo/parallel.py index 60eedb43..e35f9b53 100644 --- a/src/censo/parallel.py +++ b/src/censo/parallel.py @@ -303,7 +303,7 @@ def execute[ ) else: results[meta.conf_name] = result - if prog in QmProg: + if (prog in QmProg) and (prog is not QmProg.PSI4): # TODO: implement mo for psi4 conf.mo_paths[prog].append(result.mo_path) except CancelledError: logger.debug("Future cancelled.") diff --git a/src/censo/params.py b/src/censo/params.py index a8daa82b..6cd9cc67 100644 --- a/src/censo/params.py +++ b/src/censo/params.py @@ -99,6 +99,7 @@ class Prog(str, Enum): """ ORCA = "orca" + PSI4 = "psi4" TM = "tm" XTB = "xtb" GENERIC = "generic" @@ -110,7 +111,9 @@ class QmProg(str, Enum): """ ORCA = Prog.ORCA.value + PSI4 = Prog.PSI4.value TM = Prog.TM.value + GENERIC = Prog.GENERIC.value class GridLevel(str, Enum): diff --git a/src/censo/processing/__init__.py b/src/censo/processing/__init__.py index 8db26452..609bee53 100644 --- a/src/censo/processing/__init__.py +++ b/src/censo/processing/__init__.py @@ -1,8 +1,9 @@ from .processor import GenericProc from .qm_processor import QmProc from .orca_processor import OrcaProc +from .psi4_processor import Psi4Proc from .tm_processor import TmProc from .xtb_processor import XtbProc from .job import JobContext -__all__ = ["GenericProc", "XtbProc", "QmProc", "OrcaProc", "TmProc", "JobContext"] +__all__ = ["GenericProc", "XtbProc", "QmProc", "OrcaProc", "Psi4Proc", "TmProc", "JobContext"] diff --git a/src/censo/processing/psi4_processor.py b/src/censo/processing/psi4_processor.py new file mode 100644 index 00000000..03a7f20d --- /dev/null +++ b/src/censo/processing/psi4_processor.py @@ -0,0 +1,248 @@ +""" +Contains Psi4Proc class for calculating psi4 related properties of conformers. +""" + +import os +import pathlib +import typing + +from ..config.job_config import ( + OptJobConfig, + SPJobConfig, +) +from .job import ( + JobContext, +) +from .results import ( + OptResult, + SPResult, + MetaData, +) +from ..utilities import Factory +from ..logging import setup_logger +from ..params import ( + Prog, +) +from ..assets import FUNCTIONALS +from .qm_processor import QmProc + +logger = setup_logger(__name__) + + +@typing.final +class Psi4Proc(QmProc): + + def __prep( + self, + job: JobContext, + config: SPJobConfig, + jobtype: str, + no_solv: bool = False, + xyzfile: str | None = None, + ) -> list[str]: + """ + Prepares an list of str to be written an input file for jobtype 'jobtype' + (e.g. sp). + + TODO: use a template file from user assets folder. + + :param job: JobContext object containing the job information + :type job: JobContext + :param config: Configuration for the job + :type config: SPJobConfig + :param jobtype: jobtype to prepare the input for + :type jobtype: str + :param no_solv: if True, no solvent model is used + :type no_solv: bool + :param xyzfile: if not None, the geometry is read from this file instead of the job object + :type xyzfile: str | None + :returns: List of strings for the input file + :rtype: list[str] + + NOTE: the xyzfile has to already exist, this function just bluntly writes the name into the input. + """ + + inp: list[str] = [] + + template = "template" in config.model_fields and bool(config.template) + if template: + logger.warn("template not implemented for psi4") + + # basis set + inp.append(f"set basis {config.basis}") + inp.append('') + + # molecule + inp.append("molecule {") + for line in job.conf.toxyz()[2:]: + inp.append(f"\t{line.strip()}") + inp.append("}") + inp.append('') + + # functional + match jobtype: + case "sp": + inp.append(f'print("%f" % energy("{FUNCTIONALS[config.func][Prog.PSI4.value]}"))') + case "opt": + inp.append(f'print("%f" % optimize("{FUNCTIONALS[config.func][Prog.PSI4.value]}"))') + case unknown: + logger.warn(f"{unknown} is not implemented for psi4") + inp.append('') + + logger.warn("Grid is not implemnented for psi4") + + return inp + + @typing.final + def __sp( + self, + job: JobContext, + config: SPJobConfig, + jobdir: str | pathlib.Path | None = None, + filename: str = "sp", + no_solv: bool = False, + prep: bool = True, + ) -> tuple[SPResult, MetaData]: + """ + PSI4 single-point calculation. + Unwrapped function to call from other methods. + + :param job: JobContext object containing the job information, metadata is stored in job.meta + :type job: JobContext + :param config: Configuration for the job + :type config: SPJobConfig + :param jobdir: path to the job directory + :type jobdir: str | Path | None + :param filename: name of the input file + :type filename: str + :param no_solv: if True, no solvent model is used + :type no_solv: bool + :param prep: if True, a new input file is generated + :type prep: bool + :returns: Tuple of (SPResult, MetaData) + :rtype: tuple[SPResult, MetaData] + """ + if jobdir is None: + jobdir = self._setup(job, "sp") + + # set results + result = SPResult() + meta = MetaData(job.conf.name) + + # set in/out path + inputpath = os.path.join(jobdir, f"{filename}.inp") + outputpath = os.path.join(jobdir, f"{filename}.out") + + # check for unsupported config + solvation = not (config.gas_phase or no_solv) + if solvation: + logger.warning("Solvation is not implemented for psi4") + if job.omp > 1: + logger.warning("omp is not implemented for psi4") + if config.copy_mo: + logger.warning("copy_mo is not implemented for psi4") + + # prepare input + if prep: + inp = self.__prep(job, config, "sp", solvation) + + # write input into file "{filename}.inp" in a subdir + # created for the conformer + with open(inputpath, "w") as f: + f.write('\n'.join(inp)) + + # call psi4 + call = [config.paths.psi4, f"{filename}.inp"] + returncode, _ = self._make_call(call, outputpath, jobdir) + meta.success = returncode == 0 + + # read output + with open(outputpath) as out: + lines = out.readlines() + + # Get final energy + try: + result.energy = next( + ( + float(line.split()[3]) + for line in lines + if "Total Energy = " in line + ), + ) + except StopIteration: + meta.success = False + meta.error = "Could not parse final energy" + + return result, meta + + @typing.override + def sp(self, *args, **kwargs): + """ + Perform single-point calculation. + + :param args: Arguments. + :param kwargs: Keyword arguments. + :return: Tuple of (SP result, metadata). + """ + + logger.warn("sp is not fully implemented for psi4") + + return self.__sp(*args, **kwargs) + + @typing.override + def opt( + self, + job: JobContext, + config: OptJobConfig, + ) -> tuple[OptResult, MetaData]: + """ + Geometry optimization using psi4 optimizer. + Note that solvation in handled here always implicitly. + + :param job: JobContext object containing the job information, metadata is stored in job.meta + :param config: Optimization configuration + :return: Tuple of (OptResult, MetaData) + """ + + logger.warn("opt is not fully implemented for psi4") + + # prepare result + result = OptResult() + meta = MetaData(job.conf.name) + + jobdir = self._setup(job, "opt") + filename = "opt" + + # set orca input/output paths + inputpath = os.path.join(jobdir, f"{filename}.inp") + outputpath = os.path.join(jobdir, f"{filename}.out") + + # prepare input dict + inp = self.__prep(job, config, "opt", no_solv=config.gas_phase) + + # write input into file "{filename}.inp" in a subdir + # created for the conformer + with open(inputpath, "w") as f: + f.write('\n'.join(inp)) + + # read output + with open(outputpath) as out: + lines = out.readlines() + + # Get final energy + try: + result.energy = next( + ( + float(line.split()[3]) + for line in lines + if "Total Energy = " in line + ), + ) + except StopIteration: + meta.success = False + meta.error = "Could not parse final energy" + + return result, meta + + +Factory.register_builder(Prog.PSI4, Psi4Proc)