diff --git a/examples/README.md b/examples/README.md index 4385bee0..2deea2af 100644 --- a/examples/README.md +++ b/examples/README.md @@ -39,7 +39,7 @@ python3 job.py - exmp016_autoci: Use AUTO-CI to run CISD/def2-SVP single-point - exmp017_roci: Perform a ROCIS calculation - exmp018_cipsi: Perform a ICE-CI calculation -- exmp019_engrad: Perfrom an energy & gradient calculation with r²SCAN-3c +- exmp019_engrad: Perform an energy & gradient calculation with r²SCAN-3c - exmp020_smd: Run a r²SCAN-3c+SMD(Water) calculation - exmp021_basis: Run a BP86/def2-SVP energy calculation with additional diffuse function for oxygen - exmp022_scf_block: Run a PBE0/def2-SVP energy calculation and rotate the initial SCF guess @@ -59,7 +59,7 @@ python3 job.py - exmp036_solvator: Run ORCAs autosolvation workflow (solvator) - exmp037_s2: Get the S² expectation value from an unrestricted calculation - exmp038_integrals: Get integrals from a calculation -- exmp039_neb: Perfrom a transition state search with NEB +- exmp039_neb: Perform a transition state search with NEB - exmp040_xzyfraglib: Assign fragments with external fragment library (frag_lib.xyz) - exmp041_graph: How to print overview of the available results in output - exmp042_element: Access cardinal numbers of a structure @@ -73,5 +73,9 @@ python3 job.py - exmp050_docker: Use DOCKER to dock water with water - exmp051_libxc: Shows modification of DFT LibXC parameters via the method block - exmp052_densities: Perform UHF calculation and obtain density and spin-density in AO-basis -- exmp053_opencosmors: Run OpenCOSMO-RS task - +- exmp053_opencosmors: Run OpenCOSMO-RS task +- exmp057_singlepoint_simpletask: B3LYP/def2-SVP single-point with CPCM(water) using the `SinglePointTask` functionality +- exmp058_engrad_simpletask: r²SCAN-3c energy & gradient calculation using the `EngradTask` functionality +- exmp059_opt_simpletask: B3LYP/def2-SVP geometry optimization with CPCM(water) using the `OptTask` functionality +- exmp060_freq_simpletask: TPSS/def2-SVP frequency calculation using the `FreqTask` functionality +- exmp061_goat_simpletask: GFN2-xTB GOAT conformer search using the `GoatTask` functionality diff --git a/examples/exmp057_singlepoint_simpletask/inp.xyz b/examples/exmp057_singlepoint_simpletask/inp.xyz new file mode 100644 index 00000000..8a2e30ef --- /dev/null +++ b/examples/exmp057_singlepoint_simpletask/inp.xyz @@ -0,0 +1,5 @@ +3 + +O -3.56626 1.77639 0.00000 +H -2.59626 1.77639 0.00000 +H -3.88959 1.36040 -0.81444 \ No newline at end of file diff --git a/examples/exmp057_singlepoint_simpletask/job.py b/examples/exmp057_singlepoint_simpletask/job.py new file mode 100644 index 00000000..6593eb3b --- /dev/null +++ b/examples/exmp057_singlepoint_simpletask/job.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +import sys +from pathlib import Path + +from opi.input.simple_keywords import AtomicCharge +from opi.input.structures import Structure +from opi.simple_tasks import SinglePointResults, SinglePointTask + + +def run_exmp057( + structure: Structure | None = None, working_dir: Path = Path("RUN") +) -> SinglePointResults: + # > if no structure is given read structure from inp.xyz + if structure is None: + structure = Structure.from_xyz("inp.xyz") + + # > set up the task + simple_task = SinglePointTask( + method="b3lyp", basis_set="def2-svp", solvation_model="cpcm", solvent="water" + ) + # > there are task and method-specific settings, these can be set through kwargs + + # > It is possible to modify the input object associated with the task object for more specific settings + simple_task.input.add_simple_keywords(AtomicCharge.HIRSHFELD) + + # > run the calculation with given data + singlepoint_result = simple_task.run("job", structure, working_dir=working_dir) + + # > check if the ORCA calculation terminated normally + if not singlepoint_result.status: + print("SinglePoint task failed") + sys.exit(1) + + # > extract primary property from the `TaskResults` object. For a single point calculation, it is the final energy. + final_energy = singlepoint_result.final_energy + + print(f"Final single point energy: {final_energy: 10f} Eh") + + return singlepoint_result + + +if __name__ == "__main__": + run_exmp057() diff --git a/examples/exmp058_engrad_simpletask/inp.xyz b/examples/exmp058_engrad_simpletask/inp.xyz new file mode 100644 index 00000000..8a2e30ef --- /dev/null +++ b/examples/exmp058_engrad_simpletask/inp.xyz @@ -0,0 +1,5 @@ +3 + +O -3.56626 1.77639 0.00000 +H -2.59626 1.77639 0.00000 +H -3.88959 1.36040 -0.81444 \ No newline at end of file diff --git a/examples/exmp058_engrad_simpletask/job.py b/examples/exmp058_engrad_simpletask/job.py new file mode 100644 index 00000000..db427ce8 --- /dev/null +++ b/examples/exmp058_engrad_simpletask/job.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +import sys +from pathlib import Path + +from opi.input.structures import Structure +from opi.simple_tasks import EngradResults, EngradTask + + +def run_exmp058( + structure: Structure | None = None, working_dir: Path = Path("RUN") +) -> EngradResults: + # > if no structure is given read structure from inp.xyz + if structure is None: + structure = Structure.from_xyz("inp.xyz") + + engrad_task = EngradTask(method="r2scan-3c") + engrad_result = engrad_task.run("job", structure, working_dir=working_dir) + + if not engrad_result.status: + print(f"Engrad task failed, see output file: {engrad_result.output.get_outfile()}") + sys.exit(1) + + print("FINAL SINGLE POINT ENERGY") + print(engrad_result.final_energy) + print("SCF ENERGY") + print(engrad_result.output.get_energies()["SCF"]) + + return engrad_result + + +if __name__ == "__main__": + run_exmp058() diff --git a/examples/exmp059_opt_simpletask/inp.xyz b/examples/exmp059_opt_simpletask/inp.xyz new file mode 100644 index 00000000..420f0f9e --- /dev/null +++ b/examples/exmp059_opt_simpletask/inp.xyz @@ -0,0 +1,10 @@ +8 +Coordinates from ORCA-job job E -79.174339553991 + C -0.07696488620844 0.00264225043565 0.01503449747601 + C 1.42297403167085 0.00026730705255 0.00170779972564 + H -0.52956432516482 0.16759451146808 0.99517342002418 + H -0.47206799721260 0.78060586812661 -0.64348212080303 + H -0.47202276565405 -0.94827280862626 -0.35199393102178 + H 1.83370076026311 0.95190889480824 0.34680814012902 + H 1.83362978930886 -0.78232192510389 0.64382744884855 + H 1.80356539299709 -0.17242409816099 -1.00707525437860 diff --git a/examples/exmp059_opt_simpletask/job.py b/examples/exmp059_opt_simpletask/job.py new file mode 100644 index 00000000..04152569 --- /dev/null +++ b/examples/exmp059_opt_simpletask/job.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +import sys +from pathlib import Path + +from opi.input.structures import Structure +from opi.simple_tasks import OptResults, OptSettings, OptTask +from opi.simple_tasks.method_settings import DftSettings + + +def run_exmp059(structure: Structure | None = None, working_dir: Path = Path("RUN")) -> OptResults: + # > if no structure is given read structure from inp.xyz + if structure is None: + structure = Structure.from_xyz("../exmp057_singlepoint_simpletask/inp.xyz") + + # > set up the task + simple_task = OptTask( + method="b3lyp", + basis_set="def2-svp", + solvation_model="cpcm", + solvent="water", + method_settings=DftSettings(scf_maxiter=150), + task_settings=OptSettings(opt_threshold="tight"), + ) + # > there are task and method-specific settings, these can be set through kwargs + + # > run the calculation with given data + opt_result = simple_task.run("job", structure, working_dir=working_dir) + + # > check if the ORCA calculation terminated normally + if not opt_result.status: + print("Opt task failed") + sys.exit(1) + + # > extract optimized structure from the `OptResults` object + structure = opt_result.structure + + print("Optimized Structure:") + print(structure.format_orca()) + + return opt_result + + +if __name__ == "__main__": + run_exmp059() diff --git a/examples/exmp060_freq_simpletask/inp.xyz b/examples/exmp060_freq_simpletask/inp.xyz new file mode 100644 index 00000000..ff1fa22c --- /dev/null +++ b/examples/exmp060_freq_simpletask/inp.xyz @@ -0,0 +1,5 @@ +3 + +O -3.56626 1.77639 0.00000 +H -2.59626 1.77639 0.00000 +H -3.88959 1.36040 -0.81444 diff --git a/examples/exmp060_freq_simpletask/job.py b/examples/exmp060_freq_simpletask/job.py new file mode 100644 index 00000000..a1bb9302 --- /dev/null +++ b/examples/exmp060_freq_simpletask/job.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +import sys +from pathlib import Path + +from opi.input.structures import Structure +from opi.output.ir_mode import IrMode +from opi.simple_tasks import FreqResults, FreqTask + + +def run_exmp060(structure: Structure | None = None, working_dir: Path = Path("RUN")) -> FreqResults: + # > if no structure is given read structure from inp.xyz + if structure is None: + structure = Structure.from_xyz("inp.xyz") + + freq_task = FreqTask(method="tpss", basis_set="def2-svp") + freq_result = freq_task.run("job", structure, working_dir=working_dir) + + if not freq_result.status: + print(f"Freq task failed, see output file: {freq_result.output.get_outfile()}") + sys.exit(1) + + output = freq_result.output + + ir_dict = output.get_ir() + print(IrMode.header()) + for ir_mode in ir_dict.values(): + print(ir_mode) + + ngeoms = len(output.results_properties.geometries) + print("N GEOMETRIES") + print(ngeoms) + print("FINAL SINGLE POINT ENERGY") + print(output.get_final_energy()) + print("Temperature [K]") + print(output.results_properties.geometries[0].thermochemistry_energies[0].temperature) + print("Final Gibbs free energy") + print(output.get_free_energy()) + print("Zero-point energy") + print(output.get_zpe()) + print("Final enthalpy H") + print(output.get_enthalpy()) + print("Final entropy S") + print(output.get_entropy()) + print("G-E(el)") + print(freq_result.free_energy_delta) + + return freq_result + + +if __name__ == "__main__": + run_exmp060() diff --git a/examples/exmp061_goat_simpletask/inp.xyz b/examples/exmp061_goat_simpletask/inp.xyz new file mode 100644 index 00000000..420f0f9e --- /dev/null +++ b/examples/exmp061_goat_simpletask/inp.xyz @@ -0,0 +1,10 @@ +8 +Coordinates from ORCA-job job E -79.174339553991 + C -0.07696488620844 0.00264225043565 0.01503449747601 + C 1.42297403167085 0.00026730705255 0.00170779972564 + H -0.52956432516482 0.16759451146808 0.99517342002418 + H -0.47206799721260 0.78060586812661 -0.64348212080303 + H -0.47202276565405 -0.94827280862626 -0.35199393102178 + H 1.83370076026311 0.95190889480824 0.34680814012902 + H 1.83362978930886 -0.78232192510389 0.64382744884855 + H 1.80356539299709 -0.17242409816099 -1.00707525437860 diff --git a/examples/exmp061_goat_simpletask/job.py b/examples/exmp061_goat_simpletask/job.py new file mode 100644 index 00000000..201a4785 --- /dev/null +++ b/examples/exmp061_goat_simpletask/job.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +#!/usr/bin/env python3 +import sys +from pathlib import Path + +from opi.input.structures import Properties, Structure +from opi.simple_tasks import GoatSettings, GoatTask + + +def run_exmp061( + structure: Structure | None = None, working_dir: Path = Path("RUN") +) -> tuple[list[Structure], list[Properties]]: + + # > if no structure is given read structure from inp.xyz + if structure is None: + structure = Structure.from_xyz("inp.xyz") + + goat_settings = GoatSettings(goat_maxiter=128, goat_explore=True) + goat_task = GoatTask("gfn2-xtb", task_settings=goat_settings) + + goat_result = goat_task.run("job", structure, working_dir=working_dir, ncores=4) + + # > check if the ORCA calculation terminated normally + if not goat_result.status: + print("GOAT task failed") + sys.exit(1) + + structures, properties_list = goat_result.primary_property + + # > Print structures that were read + for structure, properties in zip(structures, properties_list): + print(f"FINAL ENERGY: {properties.energy_total}") + print(structure.to_xyz_block()) + + return structures, properties_list + + +if __name__ == "__main__": + run_exmp061() diff --git a/pyproject.toml b/pyproject.toml index 824d607b..1ce97030 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -128,6 +128,7 @@ markers = [ "input: input side unit tests", "unit: unit tests", "json_files: Export JSON files with --update-json-files", + "simpletasks: simpletasks unit tests", ] diff --git a/src/opi/input/blocks/base.py b/src/opi/input/blocks/base.py index 3e2267e3..1842c276 100644 --- a/src/opi/input/blocks/base.py +++ b/src/opi/input/blocks/base.py @@ -169,6 +169,25 @@ def format_orca(self) -> str: return s + def __or__(self, other: "Block") -> "Block": + """ + Merges two instances of `Block`. If a common attribute exists in both `self` and `other`, the value in `other` will be given precedence. + Parameters + ---------- + other: Block + Instance of `Block` to be merged into `self` + + Returns + ------- + Block + New instance of `Block` with attributes of `self` and `other`. + + """ + new_block = self.__class__.model_validate( + {**self.model_dump(exclude_none=True), **other.model_dump(exclude_none=True)} + ) + return new_block + @property def name(self) -> str: return self._name @@ -189,3 +208,44 @@ def init_inputpath(cls, inp: Any) -> Any: return InputFilePath(file=inp) else: return inp + + @classmethod + def get_subclass_by_name(cls, name: str) -> type["Block"]: + """ + Look up a ``Block`` subclass by its class name or ORCA block name. + + Matching is case-insensitive and checks both the Python class name + (e.g. ``"BlockScf"``) and the ORCA block name returned by the + subclass's ``name`` property (e.g. ``"scf"``). + + Parameters + ---------- + name : str + Class name or ORCA block name of the desired subclass. + + Returns + ------- + type[Block] + The matching subclass. + + Raises + ------ + ValueError + If no subclass matches ``name``. + """ + # Search for `Block` class by OPI name + opi_block_name_matches = {sub.__name__.lower(): sub for sub in cls.__subclasses__()} + # Search for `Block` class by ORCA block name + orca_block_name_matches = {sub().name.lower(): sub for sub in cls.__subclasses__()} + # Collect matches across both criteria + all_matches = opi_block_name_matches.get(name.lower()) or orca_block_name_matches.get( + name.lower() + ) + + # If no match is found, raise ValueError + if all_matches is None: + raise ValueError( + f"No Block subclass found with name {name!r}. Available: {list(opi_block_name_matches.keys())}" + ) + + return all_matches diff --git a/src/opi/input/blocks/block_ice.py b/src/opi/input/blocks/block_ice.py index 8a119d0d..8c81f75d 100644 --- a/src/opi/input/blocks/block_ice.py +++ b/src/opi/input/blocks/block_ice.py @@ -21,4 +21,4 @@ class BlockIce(Block): etol: float | None = None icetype: Literal["CFGs", "CSFs", "DETs"] | None = None # > algorithm details - integrals: Literal["exact", "ri"] | None + integrals: Literal["exact", "ri"] | None = None diff --git a/src/opi/input/simple_keywords/base.py b/src/opi/input/simple_keywords/base.py index 85b70efe..7c164359 100644 --- a/src/opi/input/simple_keywords/base.py +++ b/src/opi/input/simple_keywords/base.py @@ -1,28 +1,109 @@ -__all__ = ("SimpleKeyword",) +__all__ = ("SimpleKeyword", "SimpleKeywordBox") class SimpleKeywordBox: - pass + """ + Base class for groups of related ``SimpleKeyword`` constants. + + Each concrete subclass (e.g. ``Task``, ``Dft``, ``BasisSet``) declares its + members as class-level ``SimpleKeyword`` attributes. + """ + + @classmethod + def from_string(cls, s: str) -> "SimpleKeyword": + """ + Look up a ``SimpleKeyword`` by string across all attributes. + + Matching is case-insensitive and checks the keyword value, the + attribute name, and the optional alias — in that order. + + Raises + ------ + ValueError + If no matching keyword is found in the registry. + """ + norm = s.lower() + + # Here the function will loop over all attributes both existing and inherited by the current class. + # The simple keywords are structured such that, for example, the larger `Scf` grouping is a child class of the semantically smaller groupings, + # such as `ScfConvergence`, `ScfThreshold` and so on. From these attributes the simple keyword are selected and checked as to + # whether they match the input string. + for attr_name in dir(cls): + # Get the value associated with attribute + candidate_attr = getattr(cls, attr_name) + if attr_name.startswith("_") or not isinstance( + candidate_attr, SimpleKeyword + ): # Skip private/magic attributes or non-Simple Keyword attributes + continue + # Case 1: if the user searches for keyword through how it would appear in ORCA input + if candidate_attr.keyword.lower() == norm: + return candidate_attr + # Case 2: if the user searches for keyword through how it is stored in OPI + if attr_name.lower() == norm: + return candidate_attr + # Case 3: if the user searches for keyword through a known alias + if candidate_attr.alias and any(a.lower() == norm for a in candidate_attr.alias): + return candidate_attr + + # In the case that no matches are found, raise ValueError + raise ValueError(f"Keyword {s} not found in class {cls.__name__}") + + @classmethod + def find_keyword(cls, inp: "SimpleKeyword | str") -> "SimpleKeyword": + """ + Resolve a string or ``SimpleKeyword`` to a valid ``SimpleKeyword``. + + Accepts a bare string or an existing ``SimpleKeyword`` (whose ``.keyword`` + string is used for the lookup). + + Raises + ------ + ValueError + If ``inp`` is not a str or SimpleKeyword, or if the string is not + found. + """ + # If input is SimpleKeyword, convert to string representation + if isinstance(inp, SimpleKeyword): + inp = inp.keyword + + if not isinstance(inp, str): + raise ValueError( + f"{cls.__name__} expects a str or SimpleKeyword, got {type(inp).__name__}: {inp!r}" + ) + + return cls.from_string(inp) class SimpleKeyword: """ - Class to represent simple keywords used in ORCA input files - Simple keywords are keywords that have a public name and a keyword - The public name is the name of the keyword that is used in the input file - The keyword is the name of the keyword that is used in the ORCA input file + A single ORCA simple keyword (e.g. ``SP``, ``PBE``, ``def2-SVP``). + + Instances are used as typed constants inside ``SimpleKeywordBox`` subclasses + and can also be constructed directly from a raw string for ad-hoc keywords. Attributes ---------- - keyword: str - simple keyword as it will appear in the ORCA .inp file + keyword : str + The keyword string as it will appear in the ORCA ``.inp`` file. + alias : list[str] | None + Optional alternative name(s) accepted by ``SimpleKeywordBox.from_string``. """ - def __init__(self, keyword: str) -> None: - self._keyword: str = "" + _keyword: str + alias: list[str] | None = None + + def __init__(self, keyword: str, alias: str | list[str] | None = None) -> None: + """ + Parameters + ---------- + keyword : str + Keyword string for the ORCA input line. Leading/trailing whitespace + is stripped; an empty string raises ``ValueError``. + alias : str, optional + Alternative lookup name for ``SimpleKeywordBox.from_string``. + """ self.keyword = keyword - self._name: str = "" - # self.name = name + self.alias = [alias] if isinstance(alias, str) else alias @property def keyword(self) -> str: @@ -45,27 +126,6 @@ def keyword(self, value: str) -> None: ) self._keyword = value - @property - def name(self) -> str: - return self._name - - @name.setter - def name(self, value: str) -> None: - """ - Parameters - ---------- - value : str - """ - if not isinstance(value, str): - raise TypeError(f"{self.__class__.__name__}.name: must be of type str!") - # > Stripping trailing whitespaces - value = value.rstrip() - if not value: - raise ValueError( - f"{self.__class__.__name__}.name: must contain more than just whitespaces!" - ) - self._name = value - def format_orca(self) -> str: """ Function to format simple keyword for ORCA input file diff --git a/src/opi/input/simple_keywords/dlpno.py b/src/opi/input/simple_keywords/dlpno.py index 115a58a6..5b018149 100644 --- a/src/opi/input/simple_keywords/dlpno.py +++ b/src/opi/input/simple_keywords/dlpno.py @@ -6,19 +6,22 @@ __all__ = ("Dlpno",) -class Dlpno(SimpleKeywordBox): - """Enum to store all simple keywords of type Dlpno.""" - - HFLD = SimpleKeyword("hfld") - """SimpleKeyword: HF + Dispersion energy.""" - LED = SimpleKeyword("led") - """SimpleKeyword: Energy decomposition for DLPNO-CC methods..""" +class PNOThresh(SimpleKeywordBox): LOOSEPNO = SimpleKeyword("loosepno") """SimpleKeyword: Select loose PNO settings..""" NORMALPNO = SimpleKeyword("normalpno") """SimpleKeyword: Select normal PNO settings..""" TIGHTPNO = SimpleKeyword("tightpno") """SimpleKeyword: Select Tight PNO settings..""" + + +class Dlpno(PNOThresh): + """Enum to store all simple keywords of type Dlpno.""" + + HFLD = SimpleKeyword("hfld") + """SimpleKeyword: HF + Dispersion energy.""" + LED = SimpleKeyword("led") + """SimpleKeyword: Energy decomposition for DLPNO-CC methods..""" ADLD = SimpleKeyword("adld") """SimpleKeyword: Atomic decomposition of the London Dispersion energy..""" ADEX = SimpleKeyword("adex") diff --git a/src/opi/input/simple_keywords/grid.py b/src/opi/input/simple_keywords/grid.py index bcb10704..7383c969 100644 --- a/src/opi/input/simple_keywords/grid.py +++ b/src/opi/input/simple_keywords/grid.py @@ -9,11 +9,11 @@ class Grid(SimpleKeywordBox): """Enum to store all simple keywords of type Grid.""" - DEFGRID1 = SimpleKeyword("defgrid1") + DEFGRID1 = SimpleKeyword("defgrid1", alias="1") """SimpleKeyword: small grid.""" - DEFGRID2 = SimpleKeyword("defgrid2") + DEFGRID2 = SimpleKeyword("defgrid2", alias="2") """SimpleKeyword: medium grid.""" - DEFGRID3 = SimpleKeyword("defgrid3") + DEFGRID3 = SimpleKeyword("defgrid3", alias="3") """SimpleKeyword: large grid.""" REFGRID = SimpleKeyword("refgrid") """SimpleKeyword: reference grid.""" diff --git a/src/opi/input/simple_keywords/opt.py b/src/opi/input/simple_keywords/opt.py index f9f20161..90278607 100644 --- a/src/opi/input/simple_keywords/opt.py +++ b/src/opi/input/simple_keywords/opt.py @@ -6,24 +6,27 @@ __all__ = ("Opt",) -class Opt(SimpleKeywordBox): - """Enum to store all simple keywords of type Opt.""" - - OPT = SimpleKeyword("opt") - """SimpleKeyword: Perform a geometry optimization.""" - CRUDEOPT = SimpleKeyword("crudeopt") +class OptThreshold(SimpleKeywordBox): + CRUDEOPT = SimpleKeyword("crudeopt", alias="crude") """SimpleKeyword: Geometry optimization with thresholds.""" - INTERPOPT = SimpleKeyword("interpopt") + LOOSEOPT = SimpleKeyword("looseopt", alias="loose") """SimpleKeyword: Geometry optimization with thresholds.""" - LOOSEOPT = SimpleKeyword("looseopt") + NORMALOPT = SimpleKeyword("normalopt", alias="normal") """SimpleKeyword: Geometry optimization with thresholds.""" - NORMALOPT = SimpleKeyword("normalopt") + SLOPPYOPT = SimpleKeyword("sloppyopt", alias="sloppy") """SimpleKeyword: Geometry optimization with thresholds.""" - SLOPPYOPT = SimpleKeyword("sloppyopt") + TIGHTOPT = SimpleKeyword("tightopt", alias="tight") """SimpleKeyword: Geometry optimization with thresholds.""" - TIGHTOPT = SimpleKeyword("tightopt") + VERYTIGHTOPT = SimpleKeyword("verytightopt", alias="verytight") """SimpleKeyword: Geometry optimization with thresholds.""" - VERYTIGHTOPT = SimpleKeyword("verytightopt") + + +class Opt(SimpleKeywordBox): + """Enum to store all simple keywords of type Opt.""" + + OPT = SimpleKeyword("opt") + """SimpleKeyword: Perform a geometry optimization.""" + INTERPOPT = SimpleKeyword("interpopt") """SimpleKeyword: Geometry optimization with thresholds.""" OPTH = SimpleKeyword("opth") """SimpleKeyword: Optimize only hydrogen atoms.""" diff --git a/src/opi/input/simple_keywords/scf.py b/src/opi/input/simple_keywords/scf.py index 0bd72599..201bdcf6 100644 --- a/src/opi/input/simple_keywords/scf.py +++ b/src/opi/input/simple_keywords/scf.py @@ -6,7 +6,67 @@ __all__ = ("Scf",) -class Scf(SimpleKeywordBox): +class ScfThreshold(SimpleKeywordBox): + SLOPPYSCF = SimpleKeyword("sloppyscf", alias="sloppy") + """SimpleKeyword: SCF convergence threshold settings.""" + LOOSESCF = SimpleKeyword("loosescf", alias="loose") + """SimpleKeyword: SCF convergence threshold settings.""" + NORMALSCF = SimpleKeyword("normalscf", alias="normal") + """SimpleKeyword: SCF convergence threshold settings.""" + STRONGSCF = SimpleKeyword("strongscf", alias="strong") + """SimpleKeyword: SCF convergence threshold settings.""" + TIGHTSCF = SimpleKeyword("tightscf", alias="tight") + """SimpleKeyword: SCF convergence threshold settings.""" + VERYTIGHTSCF = SimpleKeyword("verytightscf", alias="verytight") + """SimpleKeyword: SCF convergence threshold settings.""" + EXTREMESCF = SimpleKeyword("extremescf", alias="extreme") + """SimpleKeyword: SCF convergence threshold settings.""" + + +class ScfGuess(SimpleKeywordBox): + MOREAD = SimpleKeyword("moread") + """SimpleKeyword: SCF initial guess read orbitals from gbw file.""" + EHTANO = SimpleKeyword("ehtano") + """SimpleKeyword: SCF initial guess.""" + HCORE = SimpleKeyword("hcore") + """SimpleKeyword: SCF initial guess.""" + PATOM = SimpleKeyword("patom") + """SimpleKeyword: SCF initial guess.""" + PMODEL = SimpleKeyword("pmodel") + """SimpleKeyword: SCF initial guess.""" + PMODELX = SimpleKeyword("pmodelx") + """SimpleKeyword: SCF initial guess.""" + PMODELXAV = SimpleKeyword("pmodelxav") + """SimpleKeyword: SCF initial guess.""" + PMODELXPM = SimpleKeyword("pmodelxpm") + """SimpleKeyword: SCF initial guess.""" + SYMBREAKGUESS = SimpleKeyword("symbreakguess") + """SimpleKeyword: SCF initial guess.""" + + +class ScfConvergence(SimpleKeywordBox): + EASYCONV = SimpleKeyword("easyconv", alias="easy") + """SimpleKeyword: SCF convergence strategy.""" + NORMALCONV = SimpleKeyword("normalconv", alias="normal") + """SimpleKeyword: SCF convergence strategy.""" + SLOWCONV = SimpleKeyword("slowconv", alias="slow") + """SimpleKeyword: SCF convergence strategy.""" + VERYSLOWCONV = SimpleKeyword("veryslowconv", alias="veryslow") + """SimpleKeyword: SCF convergence strategy.""" + + +class ScfSolver(SimpleKeywordBox): + DIIS = SimpleKeyword("diis") + """SimpleKeyword: SCF solver.""" + KDIIS = SimpleKeyword("kdiis") + """SimpleKeyword: SCF solver.""" + SOSCF = SimpleKeyword("soscf") + """SimpleKeyword: SCF solver.""" + TRAH = SimpleKeyword("trah") + """SimpleKeyword: SCF solver.""" + + +class Scf(ScfThreshold, ScfConvergence, ScfGuess, ScfSolver): """Enum to store all simple keywords of type Scf.""" G3CONV = SimpleKeyword("3conv") @@ -17,50 +77,29 @@ class Scf(SimpleKeywordBox): """SimpleKeyword: SCF solver combination.""" KDIISTRAH = SimpleKeyword("kdiistrah") """SimpleKeyword: SCF solver combination.""" - DIIS = SimpleKeyword("diis") - """SimpleKeyword: SCF solver.""" + NODIIS = SimpleKeyword("nodiis") """SimpleKeyword: SCF solver.""" AODIIS = SimpleKeyword("aodiis") """SimpleKeyword: SCF solver.""" NOAODIIS = SimpleKeyword("noaodiis") """SimpleKeyword: SCF solver.""" - KDIIS = SimpleKeyword("kdiis") - """SimpleKeyword: SCF solver.""" + NOKDIIS = SimpleKeyword("nokdiis") """SimpleKeyword: SCF solver.""" - SOSCF = SimpleKeyword("soscf") - """SimpleKeyword: SCF solver.""" + NOSOSCF = SimpleKeyword("nososcf") """SimpleKeyword: SCF solver.""" - TRAH = SimpleKeyword("trah") - """SimpleKeyword: SCF solver.""" + NOTRAH = SimpleKeyword("notrah") """SimpleKeyword: SCF solver.""" AUTOSTART = SimpleKeyword("autostart") """SimpleKeyword: SCF initial guess start SCF from a gbw file with the same basename (default).""" NOAUTOSTART = SimpleKeyword("noautostart") """SimpleKeyword: SCF initial guess do not start SCF from a gbw file with the same basename.""" - MOREAD = SimpleKeyword("moread") - """SimpleKeyword: SCF initial guess read orbitals from gbw file.""" - EHTANO = SimpleKeyword("ehtano") - """SimpleKeyword: SCF initial guess.""" - HCORE = SimpleKeyword("hcore") - """SimpleKeyword: SCF initial guess.""" HUECKEL = SimpleKeyword("hueckel") """SimpleKeyword: SCF initial guess.""" - PATOM = SimpleKeyword("patom") - """SimpleKeyword: SCF initial guess.""" - PMODEL = SimpleKeyword("pmodel") - """SimpleKeyword: SCF initial guess.""" - PMODELX = SimpleKeyword("pmodelx") - """SimpleKeyword: SCF initial guess.""" - PMODELXAV = SimpleKeyword("pmodelxav") - """SimpleKeyword: SCF initial guess.""" - PMODELXPM = SimpleKeyword("pmodelxpm") - """SimpleKeyword: SCF initial guess.""" - SYMBREAKGUESS = SimpleKeyword("symbreakguess") - """SimpleKeyword: SCF initial guess.""" + UNITMATRIXGUESS = SimpleKeyword("unitmatrixguess") """SimpleKeyword: SCF initial guess.""" USEGRAMSCHMIDT = SimpleKeyword("usegramschmidt") @@ -91,20 +130,6 @@ class Scf(SimpleKeywordBox): """SimpleKeyword: enable fractional occupations.""" SCFCONVFORCED = SimpleKeyword("scfconvforced") """SimpleKeyword: Force SCF convergence for subsequent operations.""" - SLOPPYSCF = SimpleKeyword("sloppyscf") - """SimpleKeyword: SCF convergence threshold settings.""" - LOOSESCF = SimpleKeyword("loosescf") - """SimpleKeyword: SCF convergence threshold settings.""" - NORMALSCF = SimpleKeyword("normalscf") - """SimpleKeyword: SCF convergence threshold settings.""" - STRONGSCF = SimpleKeyword("strongscf") - """SimpleKeyword: SCF convergence threshold settings.""" - TIGHTSCF = SimpleKeyword("tightscf") - """SimpleKeyword: SCF convergence threshold settings.""" - VERYTIGHTSCF = SimpleKeyword("verytightscf") - """SimpleKeyword: SCF convergence threshold settings.""" - EXTREMESCF = SimpleKeyword("extremescf") - """SimpleKeyword: SCF convergence threshold settings.""" SLOPPYSCFCHECK = SimpleKeyword("sloppyscfcheck") """SimpleKeyword: SCF convergence threshold settings.""" NOSLOPPYSCFCHECK = SimpleKeyword("nosloppyscfcheck") @@ -125,14 +150,6 @@ class Scf(SimpleKeywordBox): """SimpleKeyword: SCF convergence threshold settings.""" SCFCONV12 = SimpleKeyword("scfconv12") """SimpleKeyword: SCF convergence threshold settings.""" - EASYCONV = SimpleKeyword("easyconv") - """SimpleKeyword: SCF convergence strategy.""" - NORMALCONV = SimpleKeyword("normalconv") - """SimpleKeyword: SCF convergence strategy.""" - SLOWCONV = SimpleKeyword("slowconv") - """SimpleKeyword: SCF convergence strategy.""" - VERYSLOWCONV = SimpleKeyword("veryslowconv") - """SimpleKeyword: SCF convergence strategy.""" DAMP = SimpleKeyword("damp") """SimpleKeyword: SCF settings.""" NODAMP = SimpleKeyword("nodamp") diff --git a/src/opi/input/simple_keywords/solvation_model.py b/src/opi/input/simple_keywords/solvation_model.py index dd22568c..0568f22f 100644 --- a/src/opi/input/simple_keywords/solvation_model.py +++ b/src/opi/input/simple_keywords/solvation_model.py @@ -12,7 +12,7 @@ def __init__(self, model: str) -> None: super().__init__(model) def __call__(self, solvent: Solvent, /) -> SimpleKeyword: - if not isinstance(solvent, Solvent): + if not isinstance(solvent, Solvent | str): raise TypeError(f"Solvent '{solvent}' is not of Solvent type!") return SimpleKeyword(f"{self.keyword}({solvent})") diff --git a/src/opi/input/simple_keywords/solvent.py b/src/opi/input/simple_keywords/solvent.py index 9ae95c30..e71f0cb5 100644 --- a/src/opi/input/simple_keywords/solvent.py +++ b/src/opi/input/simple_keywords/solvent.py @@ -242,3 +242,11 @@ class Solvent(StrEnum): WOCTANOL = "WOCTANOL" WETOCTANOL = "WETOCTANOL" CONDUCTOR = "CONDUCTOR" + + @classmethod + def find_keyword(cls, key: str) -> str: + norm = key.lower() + for member in cls: + if member.value.lower() == norm or member.name.lower() == norm: + return str(member.value) + raise ValueError(f"Key {key} not found in {cls.__name__}") diff --git a/src/opi/input/simple_keywords/wft.py b/src/opi/input/simple_keywords/wft.py index e383e97c..f4c73d20 100644 --- a/src/opi/input/simple_keywords/wft.py +++ b/src/opi/input/simple_keywords/wft.py @@ -6,7 +6,14 @@ __all__ = ("Wft",) -class Wft(SimpleKeywordBox): +class DLPNOcc(SimpleKeywordBox): + DLPNO_CCSD = SimpleKeyword("dlpno-ccsd") + """SimpleKeyword: WFT Methods.""" + DLPNO_CCSD_T = SimpleKeyword("dlpno-ccsd(t)") + """SimpleKeyword: WFT Methods.""" + + +class Wft(DLPNOcc): """Enum to store all simple keywords of type Wft.""" HF = SimpleKeyword("hf") @@ -41,8 +48,7 @@ class Wft(SimpleKeywordBox): """SimpleKeyword: WFT Methods.""" CCSD_F12 = SimpleKeyword("ccsd-f12") """SimpleKeyword: WFT Methods.""" - DLPNO_CCSD = SimpleKeyword("dlpno-ccsd") - """SimpleKeyword: WFT Methods.""" + DLPNO_CCSD_F12 = SimpleKeyword("dlpno-ccsd-f12") """SimpleKeyword: WFT Methods.""" DLPNO_CCSD_F12D = SimpleKeyword("dlpno-ccsd-f12d") @@ -51,8 +57,6 @@ class Wft(SimpleKeywordBox): """SimpleKeyword: WFT Methods.""" CCSD_T_F12 = SimpleKeyword("ccsd(t)-f12") """SimpleKeyword: WFT Methods.""" - DLPNO_CCSD_T = SimpleKeyword("dlpno-ccsd(t)") - """SimpleKeyword: WFT Methods.""" DLPNO_CCSD_T1 = SimpleKeyword("dlpno-ccsd(t1)") """SimpleKeyword: WFT Methods.""" DLPNO_CCSD_T_F12 = SimpleKeyword("dlpno-ccsd(t)-f12") diff --git a/src/opi/simple_tasks/__init__.py b/src/opi/simple_tasks/__init__.py new file mode 100644 index 00000000..f3a9160b --- /dev/null +++ b/src/opi/simple_tasks/__init__.py @@ -0,0 +1,55 @@ +from opi.simple_tasks.method_settings import ( + DftSettings, + DlpnoCcSettings, + ForceFieldSettings, + HFSettings, + MethodSettings, + SqmSettings, + WftSettings, +) +from opi.simple_tasks.simple_task import SimpleTask, TaskResults, TaskSettings +from opi.simple_tasks.tasks import ( + EngradResults, + EngradSettings, + EngradTask, + FreqResults, + FreqSettings, + FreqTask, + GoatResults, + GoatSettings, + GoatTask, + OptResults, + OptSettings, + OptTask, + SinglePointResults, + SinglePointSettings, + SinglePointTask, +) + +__all__ = [ + "DftSettings", + "DlpnoCcSettings", + "EngradResults", + "EngradSettings", + "EngradTask", + "ForceFieldSettings", + "FreqResults", + "FreqSettings", + "FreqTask", + "GoatResults", + "GoatSettings", + "GoatTask", + "HFSettings", + "MethodSettings", + "OptResults", + "OptSettings", + "OptTask", + "SimpleTask", + "SinglePointResults", + "SinglePointSettings", + "SinglePointTask", + "SqmSettings", + "TaskResults", + "TaskSettings", + "WftSettings", +] diff --git a/src/opi/simple_tasks/method_settings.py b/src/opi/simple_tasks/method_settings.py new file mode 100644 index 00000000..ccf76d0f --- /dev/null +++ b/src/opi/simple_tasks/method_settings.py @@ -0,0 +1,401 @@ +import typing +import warnings +from typing import Any + +from pydantic import ConfigDict, field_validator, model_validator +from pydantic_core.core_schema import ValidationInfo + +from opi.input import Input +from opi.input.simple_keywords import ( + AuxBasisSet, + BasisSet, + Dft, + DispersionCorrection, + ForceField, + Grid, + Method, + SimpleKeyword, + SolvationModel, + Solvent, + Sqm, + Wft, +) +from opi.input.simple_keywords.dlpno import Dlpno, PNOThresh +from opi.input.simple_keywords.scf import Scf, ScfConvergence, ScfSolver, ScfThreshold +from opi.input.simple_keywords.wft import DLPNOcc +from opi.simple_tasks.settings import Settings + + +class MethodSettings(Settings): + """ + Base settings class for all computational methods. + + Instantiating ``MethodSettings`` directly dispatches to the appropriate + subclass (``DFTSettings``, ``SQMSettings``, etc.) based on the ``method`` + keyword via ``__new__``. Subclasses set ``extra="forbid"`` so that + unknown fields raise an error at construction time. + """ + + model_config = ConfigDict(arbitrary_types_allowed=True, validate_assignment=True, extra="allow") + + method: typing.Annotated[SimpleKeyword, Method] | None = None + basis_set: typing.Annotated[SimpleKeyword, BasisSet] | None = None + solvation_model: typing.Annotated[SimpleKeyword, SolvationModel] | None = None + solvent: typing.Annotated[str, Solvent] | None = None + + def __new__(cls, /, **data: Any) -> "MethodSettings": + """ + Dispatch to the correct ``MethodSettings`` subclass. + + When called as ``MethodSettings(method=..., ...)``, inspects ``method`` + and returns an instance of the matching subclass so callers never need + to import the concrete type. + """ + if cls is MethodSettings: + method = data.get("method") + if method is not None: + resolved_cls = cls.resolve_method_settings_type(method) + return super().__new__(resolved_cls) + return super().__new__(cls) + + @model_validator(mode="before") + @classmethod + def _check_valid_fields(cls, data: Any) -> Any: + """ + Reject unknown fields before Pydantic processes them. + + Subclasses use ``extra="forbid"``, but this pre-validator runs first + and provides a clearer error message listing both the invalid fields + and the valid ones. + """ + if not isinstance(data, dict) or cls is MethodSettings: + return data + invalid_fields = set(data) - set(cls.model_fields) + if invalid_fields: + field_word = "field" if len(invalid_fields) == 1 else "fields" + raise ValueError( + f"Invalid {field_word} for {cls.__name__}: {', '.join(sorted(invalid_fields))}. " + f"Valid fields: {', '.join(sorted(cls.model_fields))}" + ) + return data + + @classmethod + def resolve_method_settings_type( + cls, method: str | SimpleKeyword + ) -> typing.Type["MethodSettings"]: + """ + Return the ``MethodSettings`` subclass that handles ``method``. + + Iterates over the known enum-to-settings mapping and returns the first + match. + + Parameters + ---------- + method : str or SimpleKeyword + Method keyword to look up (e.g. ``"PBE"``, ``"XTB2"``). + + Returns + ------- + type[MethodSettings] + Concrete subclass responsible for the given method family. + + Raises + ------ + ValueError + If ``method`` is not found in any registered enum. + """ + + enum_to_settings = { + Dft: DftSettings, + Sqm: SqmSettings, + Wft: WftSettings, + Method: HFSettings, + ForceField: ForceFieldSettings, + } + for enum_class, settings_type in enum_to_settings.items(): + try: + if enum_class.find_keyword(method): + return typing.cast(typing.Type[MethodSettings], settings_type) + except ValueError: + pass + + raise ValueError(f"Keyword {method} not found in any of the valid groupings") + + +class DftSettings(MethodSettings): + """ + Method settings for DFT calculations. + + Accepts DFT functionals with an optional ``-`` separated dispersion + correction (e.g. ``"PBE-D3BJ"``). Composite "3c" methods silently drop + ``basis_set`` because they carry their own basis internally. + """ + + model_config = ConfigDict( + arbitrary_types_allowed=True, validate_assignment=True, extra="forbid" + ) + _name: str = "dft" + method: typing.Annotated[SimpleKeyword, Dft] | None = None + grid: typing.Annotated[SimpleKeyword, Grid] | None = None + scf_maxiter: typing.Annotated[int, "BlockScf", "maxiter"] | None = None + scf_threshold: typing.Annotated[SimpleKeyword, ScfThreshold] | None = None + scf_solver: typing.Annotated[SimpleKeyword, ScfSolver] | None = None + scf_stab: bool = False + scf_conv: typing.Annotated[SimpleKeyword, ScfConvergence] | None = None + + @field_validator("*", mode="before") + @classmethod + def validate_fields(cls, value: Any, info: ValidationInfo) -> Any: + """ + Pre-validate all fields, with special handling for ``method``. + + For the ``method`` field, tries a plain ``Dft`` lookup first; if that + fails, falls back to ``_find_dft_disp_keyword`` to support + ``"Functional-Dispersion"`` compound strings. All other fields + delegate to the base ``Settings`` validator. + """ + if info.field_name == "method": + try: + new_keyword = Dft.find_keyword(value) + except ValueError: + new_keyword = cls._find_dft_disp_keyword(value) + return new_keyword + else: + return super().validate_fields(value, info) + + @model_validator(mode="after") + def cross_validate(self) -> "DftSettings": + """ + Cross-validation for `DftSettings`. + If the method keyword contains '3c', the `basis_set` attribute will be set to `None`. + + The `DftSettings` object is then returned. + Parameters + ---------- + data: DFTSettings + `DFTSettings` object given as input. + + Returns + ------- + DFTSettings + Cross-validated `DFTSettings` object. + + """ + if (self.method and "3c" in self.method.keyword) and self.basis_set: + warnings.warn("Basis Set will be ignored due to selection of Method", UserWarning) + self.basis_set = None + + return self + + def map_to_input(self, input_object: Input) -> Input: + """ + Extend the base mapping with SCF stability analysis. + + Calls the parent ``map_to_input`` for all annotated fields, then + appends ``SCFSTAB`` when ``scf_stab=True``. + + Parameters + ---------- + input_object : Input + ``Input`` object to populate. + + Returns + ------- + Input + Modified ``Input`` object. + """ + input_object = super().map_to_input(input_object) + + if self.scf_stab: + input_object.add_simple_keywords(Scf.SCFSTAB) + + return input_object + + @classmethod + def _find_dft_disp_keyword(cls, value: str | SimpleKeyword) -> SimpleKeyword: + """ + Function to search for a `Dft` keyword along with `DispersionCorrection`. + In the case that an - is present in keyword, the keyword is split along the - and it is verified whether the + given keyword is a valid combination of `Dft` and `DispersionCorrection` keyword. + + If it is , a `SimpleKeyword` object is created and returned. If not , a `ValueError` is raised. + Parameters + ---------- + value: str | SimpleKeyword + The value to search for. + + Returns + ------- + SimpleKeyword + The created `SimpleKeyword` object. + + Raises + ------ + ValueError + If given value is invalid + + """ + if isinstance(value, SimpleKeyword): + value = value.keyword + + if "-" in value: + try: + keywords = value.split("-") + Dft.find_keyword(keywords[0]) + DispersionCorrection.find_keyword(keywords[1]) + + return SimpleKeyword(value) + except ValueError: + raise ValueError(f"Invalid Dft keyword or dispersion correction given: {value}") + else: + raise ValueError(f"Invalid Dft keyword '{value}'") + + +class SqmSettings(MethodSettings): + """ + Method settings for semi-empirical (SQM) calculations. + + Supports the same SCF control knobs as ``DFTSettings`` (``scf_maxiter``, + ``scf_threshold``, ``scf_solver``, ``scf_stab``, ``scf_conv``). + No basis set field — SQM methods carry their own parametrisation. + """ + + model_config = ConfigDict( + arbitrary_types_allowed=True, validate_assignment=True, extra="forbid" + ) + _name: str = "sqm" + method: typing.Annotated[SimpleKeyword, Sqm] + scf_maxiter: typing.Annotated[int, "BlockScf", "maxiter"] | None = None + scf_threshold: typing.Annotated[SimpleKeyword, ScfThreshold] | None = None + scf_solver: typing.Annotated[SimpleKeyword, ScfSolver] | None = None + scf_stab: bool = False + scf_conv: typing.Annotated[SimpleKeyword, ScfConvergence] | None = None + + def map_to_input(self, input_object: Input) -> Input: + """ + Extend the base mapping with SCF stability analysis. + + Parameters + ---------- + input_object : Input + ``Input`` object to populate. + + Returns + ------- + Input + Modified ``Input`` object. + """ + input_object = super().map_to_input(input_object) + + if self.scf_stab: + input_object.add_simple_keywords(Scf.SCFSTAB) + + return input_object + + +class WftSettings(MethodSettings): + """ + Method settings for wave-function theory (WFT) calculations. + + Covers correlated methods such as MP2, CCSD, CCSD(T), etc. Basis set + and solvation are inherited from ``MethodSettings``. + """ + + model_config = ConfigDict( + arbitrary_types_allowed=True, validate_assignment=True, extra="forbid" + ) + _name: str = "wft" + method: typing.Annotated[SimpleKeyword, Wft] + + +class HFSettings(MethodSettings): + """ + Method settings for Hartree-Fock calculations. + + Basis set and solvation are inherited from ``MethodSettings``. + """ + + model_config = ConfigDict( + arbitrary_types_allowed=True, validate_assignment=True, extra="forbid" + ) + _name: str = "hf" + method: typing.Annotated[SimpleKeyword, Method] + + +class ForceFieldSettings(MethodSettings): + """ + Method settings for force-field calculations. + + ``basis_set`` is silently dropped because force fields do not use a + quantum-mechanical basis. + """ + + model_config = ConfigDict( + arbitrary_types_allowed=True, validate_assignment=True, extra="forbid" + ) + _name: str = "forcefield" + method: typing.Annotated[SimpleKeyword, ForceField] + + @model_validator(mode="after") + def cross_validate(self) -> "ForceFieldSettings": + if self.basis_set: + warnings.warn( + "Basis Set will be ignored due to selection of Force Field method", UserWarning + ) + self.basis_set = None + + return self + + +class DlpnoCcSettings(MethodSettings): + """ + Method settings for DLPNO coupled-cluster calculations. + + Exposes DLPNO-specific thresholds (``pno_thresh``, ``dlpno_t_cut_do``, + ``dlono_t_cut_pno``) and enables LED analysis via ``dlpno_led=True``. + SCF control knobs are also available. + """ + + model_config = ConfigDict( + arbitrary_types_allowed=True, validate_assignment=True, extra="forbid" + ) + _name: str = "dlpnocc" + method: typing.Annotated[SimpleKeyword, DLPNOcc] | None = None + aux_basis: typing.Annotated[SimpleKeyword, AuxBasisSet] | None = None + pno_thresh: typing.Annotated[SimpleKeyword, PNOThresh] | None = None + dlpno_led: bool | None = None + dlpno_t_cut_do: typing.Annotated[float, "BlockMdci", "tcutdo"] | None = None + dlono_t_cut_pno: typing.Annotated[float, "BlockMdci", "tcutpno"] | None = None + scf_maxiter: typing.Annotated[int, "BlockScf", "maxiter"] | None = None + scf_threshold: typing.Annotated[SimpleKeyword, ScfThreshold] | None = None + scf_solver: typing.Annotated[SimpleKeyword, ScfSolver] | None = None + scf_stab: bool = False + scf_conv: typing.Annotated[SimpleKeyword, ScfConvergence] | None = None + + def map_to_input(self, input_object: Input) -> Input: + """ + Extend the base mapping with DLPNO-specific keywords. + + Appends ``SCFSTAB`` when ``scf_stab=True`` and ``LED`` when + ``dlpno_led=True``, in addition to the standard field-annotation + mapping performed by the parent. + + Parameters + ---------- + input_object : Input + ``Input`` object to populate. + + Returns + ------- + Input + Modified ``Input`` object. + """ + input_object = super().map_to_input(input_object) + + if self.scf_stab: + input_object.add_simple_keywords(Scf.SCFSTAB) + + if self.dlpno_led: + input_object.add_simple_keywords(Dlpno.LED) + + return input_object diff --git a/src/opi/simple_tasks/settings.py b/src/opi/simple_tasks/settings.py new file mode 100644 index 00000000..1b503514 --- /dev/null +++ b/src/opi/simple_tasks/settings.py @@ -0,0 +1,330 @@ +import types as builtin_types +import typing +from typing import Any + +from pydantic import BaseModel, ConfigDict, field_validator, model_validator +from pydantic_core.core_schema import ValidationInfo + +from opi.input import Input +from opi.input.blocks import Block +from opi.input.simple_keywords import SimpleKeyword, SimpleKeywordBox, SolvationModel, Solvent +from opi.input.simple_keywords.solvation_model import SolvationModelAndSolvent + + +class Settings(BaseModel): + """ + Base Pydantic model for all OPI task and method settings. + + Subclasses declare typed fields whose annotations encode how each value + maps to an ORCA input: a single-element metadata tuple means a simple + keyword, a two-element tuple ``(block_name, attr)`` means a block option. + ``map_to_input`` reads those annotations at runtime to populate an + ``Input`` object automatically. + """ + + model_config = ConfigDict(arbitrary_types_allowed=True, validate_assignment=True) + _name: str + + def __or__(self, other: "Settings") -> "Settings": + """ + Merge two ``Settings`` objects of the same type. + + Fields explicitly set on ``other`` take precedence; fields that were + not set on ``other`` keep their value from ``self``. + + Parameters + ---------- + other : Settings + Settings object whose set fields override those of ``self``. + + Returns + ------- + Settings + New instance of the same type containing the merged fields. + """ + if type(self) is not type(other): + return NotImplemented + + combined_data = {**self.model_dump(), **other.model_dump(exclude_unset=True)} + return self.__class__(**combined_data) + + def map_to_input(self, input_object: Input) -> Input: + """ + Function to map all information held in `Settings` class to an `Input` class object. The function receives an + `Input` object , which may or may not be already populated, after which the function uses the type hints of + every field defined in the class to either fetch a `SimpleKeyword` from the appropriate Enum, and adds it to + `Input.simple_keywords`, or initializes the appropriate block with the attribute set to user defined value, and + adds it to `Input.blocks`. + + The modified `Input` object is then returned. + + Parameters + ---------- + input_object: Input + `Input` object to be modified + + Returns + ------- + Input + Modified `Input` object. + """ + # include_extras=True preserves Annotated metadata, which encodes how each field maps to ORCA input + hints = typing.get_type_hints(self.__class__, include_extras=True) + + for field_name, field_type in hints.items(): + value = getattr(self, field_name) + if value is None: + continue + + metadata = self._get_field_metadata(field_type) + + match metadata: + case (validator,): + # Single-element metadata: field maps to a simple keyword + if issubclass(validator, Solvent): + # Solvent is handled indirectly via SolvationModel; skip standalone + continue + + new_keyword = self._get_simple_keyword(validator, value) + if new_keyword: + input_object.add_simple_keywords(new_keyword) + + case (validator, key): + # Two-element metadata: field maps to an attribute on a block + block_type = Block.get_subclass_by_name(validator) + block_instant = block_type(**{key: value}) + + block_exists, *_ = input_object.has_blocks(block_type) # type:ignore + if not block_exists: + input_object.add_blocks(block_instant) + else: + # Merge with any existing block of this type so no other attributes are lost + existing_block = input_object.get_blocks(block_type)[block_type] + new_block = block_instant | existing_block + input_object.add_blocks(new_block, overwrite=True) + + return input_object + + def __str__(self) -> str: + """ + String representation of `Settings`. Mostly for debugging purposes. + + Returns + ------- + str + String representation of `Settings`. + + """ + lines = [f"{self._name.title()} Settings:"] + for field_name, value in self.model_dump().items(): + lines.append(f" {field_name}: {value}") + return "\n".join(lines) + + @staticmethod + def _get_field_metadata(hint: Any) -> tuple[Any, ...]: + """ + Function to return the metadata of the type annotation of a field. + The type hints are first retrieved after which the metadata is extracted and then returned. + + Parameters + ---------- + hint: Type hint / annotation of field + + Returns + ------- + tuple + Tuple of metadata about the field. + + """ + origin = typing.get_origin(hint) + args = typing.get_args(hint) + # Optional[X] and X | None both produce a Union; unwrap to reach the inner type + is_union = origin is typing.Union or isinstance(hint, builtin_types.UnionType) + if is_union: + non_none_args = [arg for arg in args if arg is not type(None)] + if non_none_args: + return Settings._get_field_metadata(non_none_args[0]) + # For Annotated[T, m1, m2, ...], get_args returns (T, m1, m2, ...); skip T at index 0 + return args[1:] if len(args) > 1 else () + + @staticmethod + def _resolve_field_value( + value: Any, metadata: tuple[type["SimpleKeywordBox"]] | tuple[str, str] + ) -> Any: + """ + Function to translate user input into OPI compatible types. This is done using the metadata of the type + annotation associated with the field. There are two cases: + + Case 1 + ------ + Metadata has only one value. In this case the field is a simple keyword. Here the class associated with the input + option (eg: `Dft`) , will be checked to see if the user-given input option exists , and if it does , returns + the enum member associated with the input option. + + Example + ======= + simple_keyword_attribute: typing.Annotated[SimpleKeyword, Dft] + + In this case the metadata is the class Dft, which signifies that the expected simple keyword must be from the + Dft enum. + + Case 2 + ------ + Metadata has two values, validator class and key. In this case the field is a block option. The associated block + class is first fetched, and then the attribute of the block is set to the user-given input option. This is then + validated by the block itself using Pydantic features. The validation function of the block translates user input + to OPI compatible types, which is then returned. + + Example + ======= + block_option: typing.Annotated[int, BlockScf, "maxiter"] + + In this case the metadata is (BlockScf, "maxiter"). The first value indicates which OPI Block class the value belongs to, + while the second value indicates which field from the Block class correlates to this block option. + + + Parameters + ---------- + value: Any + User input value. + metadata: tuple + Tuple of metadata about the field. + + Returns + ------- + Any + User input value translated to OPI compatible types. + + """ + match metadata: + case (validator,): + # Case 1: Simple keyword — look up value as a member of the validator enum + # Solvent is not resolved here; it is paired with SolvationModel later in _get_simple_keyword + if isinstance(validator, type) and issubclass(validator, Solvent): + return value + if not isinstance(validator, type) or not issubclass(validator, SimpleKeywordBox): + raise TypeError(f"Expected SimpleKeywordBox subclass, got {validator}") + # Find simple keyword using the validator given + return validator.find_keyword(value) + + case (validator, key): + # Case 2: Block option — run value through Pydantic block validation to get the translated type + if not isinstance(key, str): + raise TypeError(f"Expected str key, got {type(key)}") + + block_cls = Block.get_subclass_by_name(str(validator)) + instance = block_cls.model_validate({key: value}) + return getattr(instance, str(key)) + + case _: + # No metadata: field needs no translation + return value + + def _get_simple_keyword( + self, + validator: type[SimpleKeywordBox], + value: str | SimpleKeyword | SolvationModelAndSolvent, + ) -> SimpleKeyword: + """ + Resolve a field value to a ``SimpleKeyword``. + + For ``SolvationModel`` fields the current ``solvent`` attribute is + combined with the model to produce a ``SolvationModelAndSolvent`` + keyword; all other fields are looked up directly in their enum. + + Parameters + ---------- + validator : type[SimpleKeywordBox] + The enum class that owns the keyword (e.g. ``Dft``, ``BasisSet``). + value : str or SimpleKeyword or SolvationModelAndSolvent + Raw user-supplied value. + + Returns + ------- + SimpleKeyword + Resolved keyword ready to pass to ``Input.add_simple_keywords``. + + Raises + ------ + ValueError + If ``validator`` is ``SolvationModel`` but ``solvent`` is not set. + TypeError + If ``value`` is not a ``SolvationModelAndSolvent`` when required. + """ + if validator == SolvationModel: + # SolvationModel needs a solvent to form a compound keyword; read it from the Solvent field + solvent_value = getattr(self, "solvent", None) + if solvent_value is None: + raise ValueError("A solvation model was requested without a solvent.") + solvent = Solvent(Solvent.find_keyword(str(solvent_value))) + if not isinstance(value, SolvationModelAndSolvent): + raise TypeError("Wrong type for solvent or solvation model") + + # value is a SolvationModelAndSolvent callable; calling it with solvent produces the keyword + new_keyword = value(solvent) + else: + # All other validators: look up the string value directly in the enum + new_keyword = validator.find_keyword(value) + + return new_keyword + + @field_validator("*", mode="before") + @classmethod + def validate_fields(cls, value: Any, info: ValidationInfo) -> Any: + """ + This field validator handles validation upon reassignment of values of class attributes. + + This validator is applied to all fields and is executed prior to Pydantics internal validation, which is useful + for handling reassignment of values or custom preprocessing logic. + + The method does the following: + 1. Retrieves type hints, including metadata and checks whether current field has corresponding type hint. + 2. Extracts field specific metadata from type hint. + 3. Resolves incoming value using metadata. + + Parameters + ---------- + value: Any + User input value. + info: ValidationInfo + Object containing contextual information about field being validated. + + Returns + ------- + Any + User input value processed and converted to OPI compatible types. + """ + if value is None: + return value + + hints = typing.get_type_hints(cls, include_extras=True) + + if info.field_name not in hints: + return value + + hint = hints[info.field_name] + metadata = cls._get_field_metadata(hint) + return cls._resolve_field_value(value, metadata) + + @model_validator(mode="before") + @classmethod + def cross_validate(cls, data: dict[str, Any]) -> dict[str, Any]: + """ + Function to process and validate user input, this validator handles validation upon model initialization. Since + `self.validate_field()` already exists, this function will be reserved only for cross validation. + + Parameters + ---------- + data: dict + User input data. + + Returns + ------- + dict + Cross-validated user input data + + """ + if not isinstance(data, dict): + return data + + return data diff --git a/src/opi/simple_tasks/simple_task.py b/src/opi/simple_tasks/simple_task.py new file mode 100644 index 00000000..0678a028 --- /dev/null +++ b/src/opi/simple_tasks/simple_task.py @@ -0,0 +1,668 @@ +import os +import shutil +import typing +import warnings +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Any, get_type_hints + +from pydantic import ConfigDict + +from opi.core import Calculator +from opi.input import Input +from opi.input.simple_keywords import ( + SimpleKeyword, + SimpleKeywordBox, + Solvent, +) +from opi.input.structures import BaseStructureFile, Structure +from opi.output.core import Output +from opi.simple_tasks.method_settings import MethodSettings +from opi.simple_tasks.settings import Settings + + +class TaskSettings(Settings): + """ + Base settings class for task-level keywords (SP, OPT, FREQ, …). + + Subclasses set ``task_keyword`` to the default ``Task`` enum member for + the calculation type. Additional task-specific options (convergence + thresholds, flags, block parameters) are declared as typed fields in the + subclass. + """ + + model_config = ConfigDict( + arbitrary_types_allowed=True, validate_assignment=True, extra="forbid" + ) + task_keyword: typing.Annotated[SimpleKeyword, SimpleKeywordBox] | None = None + + +_RT = typing.TypeVar("_RT", bound="TaskResults") + + +class SimpleTask(ABC, typing.Generic[_RT]): + """ + Abstract base class for all OPI simple tasks. + + Combines a ``TaskSettings`` (what kind of calculation to run) with a + ``MethodSettings`` (which method/basis/solvent to use) and exposes a + ``run()`` method that writes the ORCA input, executes the calculation, + and returns a ``TaskResults`` object. + + Concrete subclasses (``SinglePointTask``, ``OptTask``, …) bind the + specific settings and results types through ``_task_settings_type`` and + ``_results_type``. Each subclass is parameterised with its concrete + results type (e.g. ``SimpleTask[FreqResults]``) so that ``run``, + ``restart``, and ``from_string`` all return the exact subtype without + casts. + + Method family switching via the ``method`` setter automatically migrates + compatible fields (``basis_set``, ``solvation_model``, ``solvent``) and + warns about any settings that cannot be transferred. + """ + + _results_type: type[_RT] + _task_settings: TaskSettings | None = None + _method_settings: MethodSettings | None = None + _input_object: Input + + def __init__( + self, + method: str | SimpleKeyword | None = None, + basis_set: str | SimpleKeyword | None = None, + solvation_model: str | SimpleKeyword | None = None, + solvent: str | Solvent | None = None, + task_settings: "TaskSettings | dict[str, typing.Any] | None" = None, + method_settings: "MethodSettings | dict[str, typing.Any] | None" = None, + input: Input | str | None = None, + ): + """ + The various input parameters, both task and method related are set using this function. The most widely used arguments, + like method and basis set are set using arguments that are defined in the init. If the user wants to add additional settings, + such as block options like scf maxiter, they can make use of the `task_settings` and `method-settings` arguments, where any additional + settings will be parsed, validated and then added to the respective `Settings` class. + + There is an additional optional argument `input`, if the user chooses to directly pass the input to the init, the `SimpleTask` class + will forego any validation of input and simply execute the task using the user-given input data. This is recommended for more experienced + users that want to add certain simple keywords or block options that are not provided by default. + + Parameters + ---------- + method : str or SimpleKeyword, optional + Shorthand for setting ``method_settings.method``. Mutually + exclusive with passing a fully configured ``method_settings`` + object that already has a method. + basis_set : str or SimpleKeyword, optional + Shorthand for ``method_settings.basis_set``. + solvation_model : str or SimpleKeyword, optional + Shorthand for ``method_settings.solvation_model``. + solvent : str or Solvent, optional + Shorthand for ``method_settings.solvent``. + task_settings : TaskSettings or dict, optional + Overrides the default task settings. A plain ``dict`` is + validated against the concrete ``TaskSettings`` subclass. + method_settings : MethodSettings or dict, optional + Pre-built method settings object. When ``method`` is also given, + values from the shorthand parameters take precedence over fields + already present in ``method_settings``. + + Raises + ------ + ValueError + If neither ``method`` nor a ``method_settings`` object with a + method is provided. + """ + if input: + # Raw input bypasses the typed settings system entirely — no validation. + if not isinstance(input, Input): + self._input_object: Input = Input() + self._input_object.add_arbitrary_string(input, pos="top") + else: + self._input_object: Input = input + + self._task_settings = None + self._method_settings = None + + else: + # Get the intended TaskSettings type from type hints + task_settings_type = self._get_task_settings_type() + if isinstance(task_settings, dict): + self._task_settings = task_settings_type.model_validate(task_settings) + else: + # None falls back to a default instance so there's always a settings object. + # In the case that a TaskSettings object is passed, it will be merged with the default initializaition + # of the TaskSettings object. This is simply to ensure there is always a TaskSettings object. + self._task_settings = task_settings or task_settings_type() + + resolved_method_settings: MethodSettings | None = ( + MethodSettings(**method_settings) + if isinstance(method_settings, dict) + else method_settings + ) + + if method is not None: + # Here the type of MethodSettings class will be resolved depending on which method was selected. + resolved_type = MethodSettings.resolve_method_settings_type(method) + # MethodSettings object is created with the basic essential input parameters. + base_data: dict[str, typing.Any] = { + k: v + for k, v in { + "method": method, + "basis_set": basis_set, + "solvation_model": solvation_model, + "solvent": solvent, + }.items() + if v is not None + } + if resolved_method_settings is not None: + # If there are other options defined over method_settings argument, they will be merged with the existing + # MethodSettings object here. + # model_extra captures plugin-defined fields absent from model_fields. + extra: dict[str, typing.Any] = { + **resolved_method_settings.model_dump(exclude_unset=True), + **(resolved_method_settings.model_extra or {}), + } + # base_data last so shorthand args override method_settings fields. + self._method_settings = resolved_type(**{**extra, **base_data}) + else: + # if method_settings argument is None, simple the methodSettings class with the base input parameters will be defined. + self._method_settings = resolved_type(**base_data) + else: + # raise error if no method is given + if resolved_method_settings is None: + raise ValueError( + "Either 'method' or a 'method_settings' object with a method must be provided" + ) + self._method_settings = resolved_method_settings + + self._input_object: Input = Input() + + @classmethod + def _get_task_settings_type(cls) -> type[TaskSettings]: + """ Get the TaskSettings subclass type associated with the SimpleTask initialized.""" + hints = get_type_hints(cls) + task_setting_type = hints["_task_settings"] + + return typing.cast(type[TaskSettings], task_setting_type) + + @property + def task_settings(self) -> TaskSettings | None: + """Task-level settings (keyword, thresholds, flags).""" + return self._task_settings if self._task_settings else None + + @property + def method_settings(self) -> MethodSettings | None: + """Method-level settings (functional, basis set, solvent, …).""" + return self._method_settings if self._method_settings else None + + @property + def input(self) -> Input: + """ + Returns the ``Input`` object for this task, with ``task_settings`` and + ``method_settings`` applied on top of any user modifications. + + Settings are re-applied on every access so they always take precedence + over manual edits to the same fields. Additions that settings do not + control (extra keywords, ``ncores``, arbitrary strings, …) are + preserved across accesses because the same ``Input`` instance is reused. + + Returns + ------- + Input + The task's ``Input`` object, ready for inspection or further + user customisation before calling ``run()``. + """ + if self._method_settings and self._task_settings: + self._task_settings.map_to_input(self._input_object) + self._method_settings.map_to_input(self._input_object) + + return self._input_object + + @input.setter + def input(self, value: Input) -> None: + self._input_object = value + + @property + def keyword(self) -> SimpleKeyword | None: + """The primary task keyword (e.g. ``Task.SP``, ``Task.OPT``).""" + return self._task_settings.task_keyword if self._task_settings else None + + @property + def method(self) -> SimpleKeyword | None: + """Active method keyword, or ``None`` if the settings type has no method field.""" + if self._method_settings and hasattr(self._method_settings, "method"): + return self._method_settings.method + return None + + @method.setter + def method(self, new_value: str | SimpleKeyword | None) -> None: + """ + Change the method, migrating to a different settings type when needed. + + If ``new_value`` belongs to the same method family as the current + settings, the field is updated in-place. Otherwise a new settings + object of the correct type is created, carrying over compatible fields + (``basis_set``, ``solvation_model``, ``solvent``) and warning about + any fields that are dropped. + + Raises + ------ + AttributeError + If the current settings type does not have a ``method`` field. + """ + if not self._method_settings: + raise AttributeError("Method settings has not been set.") + if not hasattr(self._method_settings, "method"): + raise AttributeError("method is not defined in method_settings object") + if new_value is None: + # Set the method to None and skip resolving the type. + self._method_settings.method = None + return + resolved_type = MethodSettings.resolve_method_settings_type(new_value) + if isinstance(self._method_settings, resolved_type): + # Add type:ignore since the resolving of string to SimpleKeyword happens in validation of Settings. + self._method_settings.method = new_value # type:ignore + else: + common_fields: dict[str, Any] = {"method": new_value} + for field in ("basis_set", "solvation_model", "solvent"): + if field in resolved_type.model_fields: + val = getattr(self._method_settings, field, None) + if val is not None: + common_fields[field] = val + dropped = [ + f + for f, v in self._method_settings.model_dump(exclude_unset=True).items() + if f not in common_fields and v is not None + ] + if dropped: + warnings.warn( + f"Switching method family dropped settings: {', '.join(dropped)}", + UserWarning, + stacklevel=2, + ) + self._method_settings = resolved_type(**common_fields) + + @property + def basis_set(self) -> SimpleKeyword | None: + """Active basis-set keyword, or ``None`` if the settings type has no basis_set field.""" + if self._method_settings and hasattr(self._method_settings, "basis_set"): + return self._method_settings.basis_set + return None + + @basis_set.setter + def basis_set(self, new_value: str | SimpleKeyword | None) -> None: + """ + Raises + ------ + AttributeError + If the current settings type does not have a ``basis_set`` field. + """ + if not self._method_settings: + raise AttributeError("Method settings has not been set.") + if not hasattr(self._method_settings, "basis_set"): + raise AttributeError("basis_set is not defined in method_settings object") + self._method_settings.basis_set = new_value # type:ignore + + @property + def solvent(self) -> str | None: + """Solvent name, or ``None`` if the settings type has no solvent field.""" + if self._method_settings and hasattr(self._method_settings, "solvent"): + return self._method_settings.solvent + return None + + @solvent.setter + def solvent(self, new_value: str | None) -> None: + """ + Raises + ------ + AttributeError + If the current settings type does not have a ``solvent`` field. + """ + if not self._method_settings: + raise AttributeError("Method settings has not been set.") + if not hasattr(self._method_settings, "solvent"): + raise AttributeError("solvent is not defined in method_settings object") + self._method_settings.solvent = new_value + + @property + def solvation_model(self) -> SimpleKeyword | None: + """Solvation-model keyword, or ``None`` if the settings type has no solvation_model field.""" + if self._method_settings and hasattr(self._method_settings, "solvation_model"): + return self._method_settings.solvation_model + return None + + @solvation_model.setter + def solvation_model(self, new_value: str | SimpleKeyword | None) -> None: + """ + Raises + ------ + AttributeError + If the current settings type does not have a ``solvation_model`` field. + """ + if not self._method_settings: + raise AttributeError("Method settings has not been set.") + if not hasattr(self._method_settings, "solvation_model"): + raise AttributeError("solvation_model is not defined in method_settings object") + self._method_settings.solvation_model = new_value # type:ignore + + @classmethod + def from_string( + cls, + string: str, + basename: str, + structure: Structure | None = None, + working_dir: Path = Path("RUN"), + ncores: int | None = None, + memory: int | None = None, + moinp: Path | None = None, + strict: bool = False, + ) -> _RT: + """ + Run a calculation from a raw ORCA keyword string. + + Bypasses the typed ``TaskSettings``/``MethodSettings`` API and feeds + keywords directly into the input file. + + Parameters + ---------- + string : str + Space-separated ORCA simple keywords (leading ``!`` characters are + stripped automatically). Example: ``"! B3LYP def2-SVP FREQ"``. + basename : str + Base name for the output files. + structure : Structure, optional + Input structure. May be ``None`` if the structure is embedded in + the keyword string or an external file block. + working_dir : Path, optional + Directory in which to run the calculation. Created (or recreated) + unless ``strict=True``. Defaults to ``Path("RUN")``. + ncores : int, optional + Number of CPU cores to use. + memory : int, optional + Memory in MB. + moinp : Path, optional + Path to an MO input file for seeding the SCF guess. + strict : bool, optional + If ``True``, ``working_dir`` must already exist and be empty; + raises ``ValueError`` otherwise. Defaults to ``False``. + + Returns + ------- + TaskResults + Results object of the concrete type bound to this task class. + + Raises + ------ + ValueError + If ``strict=True`` and the working directory does not exist or is + not empty. + """ + if strict: + # Must already exist + if not working_dir.exists(): + raise ValueError( + f"Working directory {working_dir.resolve()} does not exist (strict mode)" + ) + + # Must be empty + if any(working_dir.iterdir()): + raise ValueError( + f"Working directory {working_dir.resolve()} is not empty (strict mode)" + ) + + else: + # Non-strict: recreate directory + if working_dir.exists(): + shutil.rmtree(working_dir) + working_dir.mkdir() + + calc = Calculator(basename, working_dir=working_dir) + calc.structure = structure + inp = calc.input + keywords = string.split(" ") + for keyword in keywords: + keyword = keyword.strip("!") + inp.add_simple_keywords(SimpleKeyword(keyword)) + + if ncores is not None: + inp.ncores = ncores + + if memory is not None: + inp.memory = memory + + if moinp is not None: + inp.moinp = moinp + + calc.write_and_run() + + return cls._results_type(calculator=calc) + + def run( + self, + basename: str, + structure: Structure | BaseStructureFile, + working_dir: Path | str | os.PathLike[str] = Path("RUN"), + ncores: int | None = None, + memory: int | None = None, + moinp: Path | None = None, + strict: bool = False, + ) -> _RT: + """ + Execute the computational task with the given structure and settings. + + This method prepares the working directory, configures the calculation + input parameters, and runs the calculation using an external calculator. + The results are returned as an instance of the configured results type. + + Parameters + ---------- + basename : str + Base name for the calculation. + structure : Structure or BaseStructureFile + The input structure for the calculation. + working_dir : pathlib.Path, optional + Directory in which the calculation will be executed. + If it exists, it will be removed and recreated. Defaults to "RUN". + ncores : int, optional + Number of CPU cores to use for the calculation. Overrides the default + value in the input object if provided. + memory : int, optional + Amount of memory to allocate for the calculation. Overrides the default + value in the input object if provided. + moinp : pathlib.Path, optional + Path to a molecular orbital input file. Overrides the default if provided. + strict : bool, optional + Controls whether working directory will be created/overwritten. Defaults to False. + + Returns + ------- + TaskResults + An instance of the configured results type containing the results + of the calculation. + """ + working_dir = Path(working_dir) + + if strict: + # Must already exist + if not working_dir.exists(): + raise ValueError( + f"Working directory {working_dir.resolve()} does not exist (strict mode)" + ) + + # Must be empty + if any(working_dir.iterdir()): + raise ValueError( + f"Working directory {working_dir.resolve()} is not empty (strict mode)" + ) + + else: + # Non-strict: recreate directory + if working_dir.exists(): + shutil.rmtree(working_dir) + working_dir.mkdir() + + inp = self.input + + if ncores is not None: + inp.ncores = ncores + + if memory is not None: + inp.memory = memory + + if moinp is not None: + inp.moinp = moinp + + calc = Calculator(basename, working_dir=working_dir) + calc.structure = structure + calc.input = inp + + calc.write_and_run() + + return self._results_type(calculator=calc) + + def restart( + self, + previous_results: "TaskResults", + basename: str | None = None, + structure: Structure | BaseStructureFile | None = None, + working_dir: Path | None = None, + ncores: int | None = None, + memory: int | None = None, + moinp: Path | None = None, + use_previous_orbitals: bool = False, + ) -> _RT: + """ + Re-run the task, inheriting settings from a previous calculation. + + All parameters default to the values used in ``previous_results`` and + only need to be supplied when overriding them. The target working + directory is wiped and recreated by the underlying ``run()`` call, so + the previous output files are not preserved unless ``working_dir`` is + changed. + + Parameters + ---------- + previous_results : TaskResults + Results object from the earlier run. Provides fallback values for + ``basename``, ``struct``, ``working_dir``, ``ncores``, and + ``memory``. + basename : str, optional + Job name for the new calculation. Defaults to the basename of the + previous run. + structure : Structure or BaseStructureFile, optional + Input structure. Defaults to the structure used in the previous + run. Raises ``ValueError`` if no structure can be determined. + working_dir : Path, optional + Directory in which to run the new calculation. Defaults to the + working directory of the previous run (which will be overwritten). + ncores : int, optional + Number of CPU cores. Defaults to the value from the previous run. + memory : int, optional + Memory in MB. Defaults to the value from the previous run. + moinp : Path, optional + Explicit path to an MO input file. Ignored when + ``use_previous_orbitals=True``. Defaults to the ``moinp`` of the + previous run. + use_previous_orbitals : bool, optional + If ``True``, passes the ``.gbw`` file from the previous calculation + as ``moinp`` to seed the SCF guess. Raises ``FileNotFoundError`` + if the file does not exist. + + Returns + ------- + TaskResults + Results of the new calculation, of the same concrete type as this + task's ``_results_type``. + + Raises + ------ + ValueError + If no structure is available from either the argument or the + previous results. + FileNotFoundError + If ``use_previous_orbitals=True`` and the ``.gbw`` file from the + previous run is missing. + """ + prev_calc = previous_results.calculator + + basename = basename if basename else prev_calc.basename + struct = structure if structure else prev_calc.structure + if struct is None: + raise ValueError( + "No structure available for restart: previous calculation had no structure set" + ) + working_dir = working_dir if working_dir else prev_calc.working_dir + ncores = ncores if ncores else prev_calc.input.ncores + memory = memory if memory else prev_calc.input.memory + + if use_previous_orbitals: + prev_gbw = prev_calc.working_dir / f"{prev_calc.basename}.gbw" + if not prev_gbw.exists(): + raise FileNotFoundError(f"GBW file not found: {prev_gbw}") + moinp = prev_gbw + else: + moinp = moinp if moinp else prev_calc.input.moinp + + return self.run(basename, struct, working_dir, ncores, memory, moinp) + + +class TaskResults(ABC): + """ + Abstract base class for the results returned by a completed task. + + Wraps the ``Calculator`` that ran the job and provides lazy-evaluated + access to the parsed ``Output``. Subclasses expose task-specific + properties (energies, structures, gradients, …) and must implement + ``primary_property``. + """ + + def __init__(self, calculator: Calculator): + """ + Parameters + ---------- + calculator : Calculator + The calculator that ran the calculation. + """ + self.calculator = calculator + + @property + def output(self) -> Output: + """ + Parsed ORCA output. + + Lazily calls ``get_output()`` and ``parse()`` on the first access so + that result objects can be created without immediately hitting the + filesystem. + """ + if not self.calculator: + raise ValueError("calculator not set") + + out = self.calculator.get_output() + out.parse() + return out + + @property + def status(self) -> bool: + """``True`` if the job terminated normally and SCF converged.""" + return self.output.terminated_normally() + + @property + @abstractmethod + def primary_property(self) -> Any: + """The most important result for this task type (energy, structure, …).""" + pass + + @property + def final_energy(self) -> float: + """The final energy of the calculation. + + Raises + ------ + ValueError + If the energy is not present in the ORCA output.""" + final_energy = self.output.get_final_energy() + + if final_energy is None: + raise ValueError("Could not get final energy from ORCA Output") + + return final_energy diff --git a/src/opi/simple_tasks/tasks/__init__.py b/src/opi/simple_tasks/tasks/__init__.py new file mode 100644 index 00000000..89b916a5 --- /dev/null +++ b/src/opi/simple_tasks/tasks/__init__.py @@ -0,0 +1,27 @@ +from opi.simple_tasks.tasks.engrad_task import EngradResults, EngradSettings, EngradTask +from opi.simple_tasks.tasks.freq_task import FreqResults, FreqSettings, FreqTask +from opi.simple_tasks.tasks.goat_task import GoatResults, GoatSettings, GoatTask +from opi.simple_tasks.tasks.opt_task import OptResults, OptSettings, OptTask +from opi.simple_tasks.tasks.single_point_task import ( + SinglePointResults, + SinglePointSettings, + SinglePointTask, +) + +__all__ = [ + "EngradResults", + "EngradSettings", + "EngradTask", + "FreqResults", + "FreqSettings", + "FreqTask", + "GoatResults", + "GoatSettings", + "GoatTask", + "OptResults", + "OptSettings", + "OptTask", + "SinglePointResults", + "SinglePointSettings", + "SinglePointTask", +] diff --git a/src/opi/simple_tasks/tasks/engrad_task.py b/src/opi/simple_tasks/tasks/engrad_task.py new file mode 100644 index 00000000..4e358162 --- /dev/null +++ b/src/opi/simple_tasks/tasks/engrad_task.py @@ -0,0 +1,49 @@ +import typing + +from opi.input.simple_keywords import SimpleKeyword, Task +from opi.simple_tasks.simple_task import SimpleTask, TaskResults, TaskSettings + + +class EngradSettings(TaskSettings): + """Task settings for energy + gradient calculations (``! ENGRAD``).""" + + _name: str = "engrad" + task_keyword: typing.Annotated[SimpleKeyword, Task] = Task.ENGRAD + + +class EngradResults(TaskResults): + """Results from an energy + gradient calculation.""" + + @property + def gradient(self) -> list[float]: + """ + Cartesian gradient vector in Hartree/Bohr (flattened, atom-major order). + + Raises + ------ + ValueError + If the gradient is not present in the ORCA output. + """ + gradient = self.output.get_gradient() + + if gradient is None: + raise ValueError("Could not get gradient from ORCA Output") + + return gradient + + @property + def primary_property(self) -> tuple[float, list[float]]: + """``(final_energy, gradient)`` tuple.""" + return self.final_energy, self.gradient + + +class EngradTask(SimpleTask[EngradResults]): + """ + Task for single-point energy and gradient calculations. + + Returns an ``EngradResults`` object containing the total energy and + the Cartesian gradient vector. + """ + + _task_settings: EngradSettings + _results_type = EngradResults diff --git a/src/opi/simple_tasks/tasks/freq_task.py b/src/opi/simple_tasks/tasks/freq_task.py new file mode 100644 index 00000000..6338b88c --- /dev/null +++ b/src/opi/simple_tasks/tasks/freq_task.py @@ -0,0 +1,55 @@ +import typing + +from opi.input.simple_keywords import SimpleKeyword, Task +from opi.simple_tasks.simple_task import SimpleTask, TaskResults, TaskSettings + + +class FreqSettings(TaskSettings): + """Task settings for frequency calculations (``! FREQ``).""" + + _name: str = "freq" + task_keyword: typing.Annotated[SimpleKeyword, Task] = Task.FREQ + + +class FreqResults(TaskResults): + """Results from a harmonic frequency calculation.""" + + @property + def status(self) -> bool: + """``True`` if the job terminated normally (SCF convergence not re-checked).""" + return self.output.terminated_normally() + + @property + def free_energy_delta(self) -> float: + """ + Thermal free-energy correction (ΔG) in Hartree. + + Raises + ------ + ValueError + If the free-energy correction is not present in the ORCA output. + """ + free_energy_delta = self.output.get_free_energy_delta() + + if free_energy_delta is None: + raise ValueError("Could not get free energy delta from ORCA output") + + return free_energy_delta + + @property + def primary_property(self) -> float: + """Alias for ``free_energy_delta``.""" + return float(self.free_energy_delta) + + +class FreqTask(SimpleTask[FreqResults]): + """ + Task for harmonic frequency calculations. + + Returns a ``FreqResults`` object. ``status`` is ``True`` when the job + terminated normally (SCF convergence is not checked separately because + frequency jobs always follow an SCF step). + """ + + _task_settings: FreqSettings + _results_type = FreqResults diff --git a/src/opi/simple_tasks/tasks/goat_task.py b/src/opi/simple_tasks/tasks/goat_task.py new file mode 100644 index 00000000..acddb6ff --- /dev/null +++ b/src/opi/simple_tasks/tasks/goat_task.py @@ -0,0 +1,95 @@ +import typing + +from opi.input import Input +from opi.input.simple_keywords import Goat, SimpleKeyword +from opi.input.structures import Properties, Structure +from opi.simple_tasks.simple_task import SimpleTask, TaskResults, TaskSettings + + +class GoatSettings(TaskSettings): + """ + Task settings for GOAT conformer-ensemble explorations (``! GOAT``). + + The boolean flags ``goat_react``, ``goat_diversity``, and + ``goat_explore`` enable the corresponding ORCA keywords; ``goat_maxiter`` + controls the maximum number of GOAT iterations via ``%goat MaxIter``. + """ + + _name: str = "goat" + task_keyword: typing.Annotated[SimpleKeyword, Goat] = Goat.GOAT + goat_maxiter: typing.Annotated[int, "BlockGoat", "maxiter"] | None = None + goat_react: bool | None = None + goat_diversity: bool | None = None + goat_explore: bool | None = None + + def map_to_input(self, input_object: Input) -> Input: + """ + Extend the base mapping with GOAT-specific keywords. + + Appends ``GOAT_REACT``, ``GOAT_DIVERSITY``, or ``GOAT_EXPLORE`` when + the corresponding flag is ``True``. + + Parameters + ---------- + input_object : Input + ``Input`` object to populate. + + Returns + ------- + Input + Modified ``Input`` object. + """ + input_object = super().map_to_input(input_object) + + if self.goat_react: + input_object.add_simple_keywords(Goat.GOAT_REACT) + + if self.goat_diversity: + input_object.add_simple_keywords(Goat.GOAT_DIVERSITY) + + if self.goat_explore: + input_object.add_simple_keywords(Goat.GOAT_EXPLORE) + + return input_object + + +class GoatResults(TaskResults): + """Results from a GOAT conformer-ensemble exploration.""" + + @property + def status(self) -> bool: + """``True`` if the job terminated normally.""" + return self.output.terminated_normally() + + @property + def structures(self) -> list[Structure]: + """All structures in the final conformer ensemble.""" + structures = Structure.from_trj_xyz( + self.output.working_dir / f"{self.output.basename}.finalensemble.xyz" + ) + return structures + + @property + def properties(self) -> list[Properties]: + """Per-conformer properties (energies, …) from the final ensemble file.""" + properties = Properties.from_trj_xyz( + self.output.working_dir / f"{self.output.basename}.finalensemble.xyz", mode="goat" + ) + return properties + + @property + def primary_property(self) -> tuple[list[Structure], list[Properties]]: + """``(structures, properties)`` tuple for the final conformer ensemble.""" + return self.structures, self.properties + + +class GoatTask(SimpleTask[GoatResults]): + """ + High-level task for GOAT conformer-ensemble explorations. + + Returns a ``GoatResults`` object containing the ensemble structures and + their associated properties read from the ``.finalensemble.xyz`` file. + """ + + _task_settings: GoatSettings + _results_type = GoatResults diff --git a/src/opi/simple_tasks/tasks/opt_task.py b/src/opi/simple_tasks/tasks/opt_task.py new file mode 100644 index 00000000..00d67c86 --- /dev/null +++ b/src/opi/simple_tasks/tasks/opt_task.py @@ -0,0 +1,145 @@ +import typing + +from opi.input import Input +from opi.input.simple_keywords import SimpleKeyword, Task +from opi.input.simple_keywords.opt import Opt, OptThreshold +from opi.input.structures import Structure +from opi.simple_tasks.simple_task import SimpleTask, TaskResults, TaskSettings + + +class OptSettings(TaskSettings): + """ + Task settings for geometry optimisations (``! OPT``). + + The ``opt_h``, ``lopt``, and ``optrigid`` flags are translated to the + corresponding ORCA keywords in ``map_to_input``; combinations of + ``opt_h`` and ``lopt`` map to ``OPT_H``, ``L_OPT``, or ``L_OPT_H`` + automatically. + """ + + _name: str = "opt" + task_keyword: typing.Annotated[SimpleKeyword, Task] = Task.OPT + opt_threshold: typing.Annotated[SimpleKeyword, OptThreshold] | None = None + optrigid: bool = False + opt_h: bool = False + lopt: bool = False + opt_maxiter: typing.Annotated[int, "BlockGeom", "maxiter"] | None = None + + def map_to_input(self, input_object: Input) -> Input: + """ + Extend the base mapping with optimisation-mode keywords. + + Appends ``RIGIDBODYOPT`` when ``optrigid=True``, and selects + ``OPT_H`` / ``L_OPT`` / ``L_OPT_H`` based on the ``opt_h`` and + ``lopt`` flags. + + Parameters + ---------- + input_object : Input + ``Input`` object to populate. + + Returns + ------- + Input + Modified ``Input`` object. + """ + input_object = super().map_to_input(input_object) + + if self.optrigid: + input_object.add_simple_keywords(Opt.RIGIDBODYOPT) + + opt_map = { + (True, True): Opt.L_OPTH, + (True, False): Opt.OPTH, + (False, True): Opt.L_OPT, + } + + keyword = opt_map.get((self.opt_h, self.lopt)) + if keyword: + input_object.add_simple_keywords(keyword) + + return input_object + + +class OptResults(TaskResults): + """Results from a geometry optimisation.""" + + @property + def structure(self) -> Structure: + """ + Optimised geometry. + + Raises + ------ + ValueError + If the structure is not present in the ORCA output. + """ + structure = self.output.get_structure() + if structure is None: + raise ValueError("Could not get structure from ORCA Output") + + return structure + + @property + def primary_property(self) -> tuple[float, Structure]: + """``(final_energy, optimised_structure)`` tuple.""" + return self.final_energy, self.structure + + +class OptTask(SimpleTask[OptResults]): + """ + High-level task for geometry optimisations. + + Exposes convenience properties (``opt_threshold``, ``optrigid``, + ``opt_h``, ``lopt``, ``opt_maxiter``) that forward to the underlying + ``OptSettings`` object. Returns ``OptResults`` with the optimised + energy and structure. + """ + + _task_settings: OptSettings + _results_type = OptResults + + @property + def opt_threshold(self) -> SimpleKeyword | None: + """Convergence threshold keyword (e.g. ``OptThreshold.TIGHTOPT``).""" + return self._task_settings.opt_threshold + + @opt_threshold.setter + def opt_threshold(self, new_value: SimpleKeyword | str) -> None: + self._task_settings.opt_threshold = new_value # type:ignore + + @property + def optrigid(self) -> bool: + """When ``True``, adds ``RIGIDBODYOPT`` for rigid-body optimisation.""" + return self._task_settings.optrigid + + @optrigid.setter + def optrigid(self, new_value: bool) -> None: + self._task_settings.optrigid = new_value + + @property + def opt_h(self) -> bool: + """When ``True``, optimises hydrogen positions only (``OPT_H`` / ``L_OPT_H``).""" + return self._task_settings.opt_h + + @opt_h.setter + def opt_h(self, new_value: bool) -> None: + self._task_settings.opt_h = new_value + + @property + def lopt(self) -> bool: + """When ``True``, uses loose optimisation criteria (``L_OPT`` / ``L_OPT_H``).""" + return self._task_settings.lopt + + @lopt.setter + def lopt(self, new_value: bool) -> None: + self._task_settings.lopt = new_value + + @property + def opt_maxiter(self) -> int | None: + """Maximum number of geometry optimisation steps (``%geom MaxIter``).""" + return self._task_settings.opt_maxiter + + @opt_maxiter.setter + def opt_maxiter(self, new_value: int | None) -> None: + self._task_settings.opt_maxiter = new_value diff --git a/src/opi/simple_tasks/tasks/single_point_task.py b/src/opi/simple_tasks/tasks/single_point_task.py new file mode 100644 index 00000000..12ab9899 --- /dev/null +++ b/src/opi/simple_tasks/tasks/single_point_task.py @@ -0,0 +1,33 @@ +import typing + +from opi.input.simple_keywords import SimpleKeyword, Task +from opi.simple_tasks.simple_task import SimpleTask, TaskResults, TaskSettings + + +class SinglePointSettings(TaskSettings): + """Task settings for a single-point energy calculation (``! SP``).""" + + _name: str = "singlepoint" + task_keyword: typing.Annotated[SimpleKeyword, Task] = Task.SP + + +class SinglePointResults(TaskResults): + """Results from a single-point energy calculation.""" + + @property + def primary_property(self) -> float: + """Alias for ``final_energy``.""" + return float(self.final_energy) + + +class SinglePointTask(SimpleTask[SinglePointResults]): + """ + High-level task for single-point energy calculations. + + Configures ORCA with the ``SP`` keyword and returns a + ``SinglePointResults`` object whose ``final_energy`` attribute holds the + total energy in Hartree. + """ + + _task_settings: SinglePointSettings + _results_type = SinglePointResults diff --git a/tests/unit/test_simpletasks_from_string.py b/tests/unit/test_simpletasks_from_string.py new file mode 100644 index 00000000..2042daf5 --- /dev/null +++ b/tests/unit/test_simpletasks_from_string.py @@ -0,0 +1,180 @@ +from pathlib import Path +from unittest.mock import patch + +import pytest + +from opi.input.simple_keywords import SimpleKeyword +from opi.simple_tasks import FreqResults, FreqTask, SinglePointResults, SinglePointTask + +""" +Unit tests for SimpleTask.from_string classmethod: +- Returns correct _results_type for each task subclass +- Keyword string parsing: splits on spaces, strips leading ! characters +- Working directory handling: non-strict creates/recreates, strict validates +- Optional parameters: ncores, memory, moinp forwarded to input +- Structure is assigned to Calculator +""" + +_PATCH_CHECK_VERSION = "opi.core.Calculator.check_version" +_PATCH_WRITE_AND_RUN = "opi.core.Calculator.write_and_run" + + +# --------------------------------------------------------------------------- +# Return type +# --------------------------------------------------------------------------- +@pytest.mark.unit +@pytest.mark.simpletasks +def test_from_string_returns_single_point_results(tmp_path: Path) -> None: + """SinglePointTask.from_string returns a SinglePointResults instance.""" + with patch(_PATCH_CHECK_VERSION), patch(_PATCH_WRITE_AND_RUN, return_value=True): + result = SinglePointTask.from_string( + "B3LYP def2-SVP SP", + basename="test", + working_dir=tmp_path / "RUN", + ) + assert isinstance(result, SinglePointResults) + + +@pytest.mark.unit +@pytest.mark.simpletasks +def test_from_string_returns_freq_results(tmp_path: Path) -> None: + """FreqTask.from_string returns a FreqResults instance.""" + with patch(_PATCH_CHECK_VERSION), patch(_PATCH_WRITE_AND_RUN, return_value=True): + result = FreqTask.from_string( + "B3LYP def2-SVP FREQ", + basename="test", + working_dir=tmp_path / "RUN", + ) + assert isinstance(result, FreqResults) + + +# --------------------------------------------------------------------------- +# Keyword parsing +# --------------------------------------------------------------------------- +@pytest.mark.unit +@pytest.mark.simpletasks +def test_from_string_keywords_without_exclamation(tmp_path: Path) -> None: + """Keywords without ! are parsed and added to the input correctly.""" + with patch(_PATCH_CHECK_VERSION), patch(_PATCH_WRITE_AND_RUN, return_value=True): + result = SinglePointTask.from_string( + "B3LYP def2-SVP", + basename="test", + working_dir=tmp_path / "RUN", + ) + inp = result.calculator.input + assert inp.has_simple_keywords(SimpleKeyword("B3LYP"), SimpleKeyword("def2-SVP")) == ( + True, + True, + ) + + +@pytest.mark.unit +@pytest.mark.simpletasks +def test_from_string_strips_exclamation_prefix(tmp_path: Path) -> None: + """Leading ! attached directly to a keyword is stripped before adding it.""" + with patch(_PATCH_CHECK_VERSION), patch(_PATCH_WRITE_AND_RUN, return_value=True): + result = SinglePointTask.from_string( + "!B3LYP !def2-SVP", + basename="test", + working_dir=tmp_path / "RUN", + ) + inp = result.calculator.input + assert inp.has_simple_keywords(SimpleKeyword("B3LYP"), SimpleKeyword("def2-SVP")) == ( + True, + True, + ) + + +# --------------------------------------------------------------------------- +# Working directory +# --------------------------------------------------------------------------- +@pytest.mark.unit +@pytest.mark.simpletasks +def test_from_string_creates_working_dir(tmp_path: Path) -> None: + """Non-strict mode creates the working directory when it does not exist.""" + run_dir = tmp_path / "RUN" + assert not run_dir.exists() + with patch(_PATCH_CHECK_VERSION), patch(_PATCH_WRITE_AND_RUN, return_value=True): + SinglePointTask.from_string("B3LYP", basename="test", working_dir=run_dir) + assert run_dir.exists() + + +@pytest.mark.unit +@pytest.mark.simpletasks +def test_from_string_strict_raises_if_dir_missing(tmp_path: Path) -> None: + """strict=True raises ValueError when the working directory does not exist.""" + run_dir = tmp_path / "NONEXISTENT" + with pytest.raises(ValueError, match="does not exist"): + SinglePointTask.from_string("B3LYP", basename="test", working_dir=run_dir, strict=True) + + +@pytest.mark.unit +@pytest.mark.simpletasks +def test_from_string_strict_raises_if_dir_not_empty(tmp_path: Path) -> None: + """strict=True raises ValueError when the working directory is not empty.""" + run_dir = tmp_path / "RUN" + run_dir.mkdir() + (run_dir / "existing.txt").write_text("data") + with pytest.raises(ValueError, match="not empty"): + SinglePointTask.from_string("B3LYP", basename="test", working_dir=run_dir, strict=True) + + +# --------------------------------------------------------------------------- +# Optional parameters +# --------------------------------------------------------------------------- +@pytest.mark.unit +@pytest.mark.simpletasks +def test_from_string_sets_ncores(tmp_path: Path) -> None: + """ncores argument is forwarded to the Calculator input.""" + with patch(_PATCH_CHECK_VERSION), patch(_PATCH_WRITE_AND_RUN, return_value=True): + result = SinglePointTask.from_string( + "B3LYP", basename="test", working_dir=tmp_path / "RUN", ncores=4 + ) + assert result.calculator.input.ncores == 4 + + +@pytest.mark.unit +@pytest.mark.simpletasks +def test_from_string_sets_memory(tmp_path: Path) -> None: + """memory argument is forwarded to the Calculator input.""" + with patch(_PATCH_CHECK_VERSION), patch(_PATCH_WRITE_AND_RUN, return_value=True): + result = SinglePointTask.from_string( + "B3LYP", basename="test", working_dir=tmp_path / "RUN", memory=2000 + ) + assert result.calculator.input.memory == 2000 + + +@pytest.mark.unit +@pytest.mark.simpletasks +def test_from_string_sets_moinp(tmp_path: Path) -> None: + """moinp argument is forwarded to the Calculator input.""" + mo_file = tmp_path / "prev.gbw" + mo_file.write_text("") + with patch(_PATCH_CHECK_VERSION), patch(_PATCH_WRITE_AND_RUN, return_value=True): + result = SinglePointTask.from_string( + "B3LYP", + basename="test", + working_dir=tmp_path / "RUN", + moinp=mo_file, + ) + assert result.calculator.input.moinp == mo_file + + +@pytest.mark.unit +@pytest.mark.simpletasks +def test_from_string_assigns_structure(tmp_path: Path) -> None: + """The structure argument is assigned to the Calculator.""" + from opi.input.structures import Structure + + h2 = Structure.from_lists( + symbols=["H", "H"], + coordinates=[(0.0, 0.0, 0.0), (0.0, 0.0, 0.74)], + ) + with patch(_PATCH_CHECK_VERSION), patch(_PATCH_WRITE_AND_RUN, return_value=True): + result = SinglePointTask.from_string( + "B3LYP def2-SVP", + basename="test", + working_dir=tmp_path / "RUN", + structure=h2, + ) + assert result.calculator.structure is h2 diff --git a/tests/unit/test_simpletasks_method_settings.py b/tests/unit/test_simpletasks_method_settings.py new file mode 100644 index 00000000..d2db4f1a --- /dev/null +++ b/tests/unit/test_simpletasks_method_settings.py @@ -0,0 +1,186 @@ +import pytest +from pydantic import ValidationError + +from opi.input import Input +from opi.input.blocks import BlockScf +from opi.input.simple_keywords import BasisSet, Dft +from opi.input.simple_keywords.scf import Scf +from opi.simple_tasks.method_settings import ( + DftSettings, + ForceFieldSettings, + MethodSettings, + SqmSettings, + WftSettings, +) + +""" +Unit tests for MethodSettings and its subclasses: +- Dispatcher (__new__) routing by method keyword +- resolve_method_settings_type classmethod +- DftSettings compound keyword parsing (e.g. "PBE-D3BJ") +- 3c method / ForceField cross-validation dropping basis_set +- scf_stab flag adding SCFSTAB keyword +- Settings merge operator (|) +- Invalid field rejection +""" + + +@pytest.mark.unit +@pytest.mark.simpletasks +@pytest.mark.parametrize( + "method,expected_cls", + [ + ("pbe", DftSettings), + ("gfn2-xtb", SqmSettings), + ("ccsd(t)", WftSettings), + ("hf", WftSettings), + ("gfn-ff", ForceFieldSettings), + ], +) +def test_method_settings_dispatch(method: str, expected_cls: type) -> None: + """MethodSettings(method=X) returns the correct concrete subclass.""" + settings = MethodSettings(method=method) + assert isinstance(settings, expected_cls) + + +@pytest.mark.unit +@pytest.mark.simpletasks +@pytest.mark.parametrize( + "method,expected_cls", + [ + ("pbe", DftSettings), + ("gfn2-xtb", SqmSettings), + ("ccsd(t)", WftSettings), + ("gfn-ff", ForceFieldSettings), + ], +) +def test_resolve_method_settings_type(method: str, expected_cls: type) -> None: + """resolve_method_settings_type returns the correct class for each method family.""" + assert MethodSettings.resolve_method_settings_type(method) is expected_cls + + +@pytest.mark.unit +@pytest.mark.simpletasks +def test_dft_compound_keyword_parsed() -> None: + """DftSettings accepts 'Functional-Dispersion' compound strings like 'PBE-D3BJ'.""" + s = DftSettings(method="PBE-D3BJ") + assert s.method is not None + assert s.method.keyword.lower() == "pbe-d3bj" + + +@pytest.mark.unit +@pytest.mark.simpletasks +def test_dft_invalid_compound_keyword_raises() -> None: + """DftSettings raises ValidationError for unrecognised compound method strings.""" + with pytest.raises(ValidationError): + DftSettings(method="INVALID-NOTADISP") + + +@pytest.mark.unit +@pytest.mark.simpletasks +def test_dft_3c_method_drops_basis_set_with_warning() -> None: + """3c composite methods silently drop basis_set (they carry their own basis internally).""" + with pytest.warns(UserWarning): + s = DftSettings(method="r2scan-3c", basis_set="def2-svp") + assert s.basis_set is None + + +@pytest.mark.unit +@pytest.mark.simpletasks +def test_forcefield_drops_basis_set_with_warning() -> None: + """ForceFieldSettings silently drops basis_set because force fields have no QM basis.""" + with pytest.warns(UserWarning): + s = ForceFieldSettings(method="gfn-ff", basis_set="def2-svp") + assert s.basis_set is None + + +@pytest.mark.unit +@pytest.mark.simpletasks +def test_dft_scf_stab_adds_scfstab_keyword() -> None: + """DftSettings with scf_stab=True adds the SCFSTAB keyword to the input.""" + s = DftSettings(method="pbe", scf_stab=True) + inp = s.map_to_input(Input()) + assert inp.has_simple_keywords(Scf.SCFSTAB) == (True,) + + +@pytest.mark.unit +@pytest.mark.simpletasks +def test_dft_scf_stab_false_no_scfstab_keyword() -> None: + """DftSettings with scf_stab=False (default) does not add SCFSTAB.""" + s = DftSettings(method="pbe") + inp = s.map_to_input(Input()) + assert inp.has_simple_keywords(Scf.SCFSTAB) == (False,) + + +@pytest.mark.unit +@pytest.mark.simpletasks +def test_settings_merge_right_wins_left_preserved() -> None: + """Settings | other: right-hand fields override, unset left-hand fields are preserved.""" + s1 = DftSettings(method="pbe", basis_set="def2-svp") + s2 = DftSettings(method="tpss") + merged_settings = s1 | s2 + assert merged_settings.method == Dft.TPSS + assert merged_settings.basis_set == BasisSet.DEF2_SVP + + +@pytest.mark.unit +@pytest.mark.simpletasks +def test_settings_merge_right_overrides_both() -> None: + """Settings | other: right-hand basis_set overrides left-hand basis_set.""" + s1 = DftSettings(method="pbe", basis_set="def2-svp") + s2 = DftSettings(method="tpss", basis_set="def2-tzvp") + merged = s1 | s2 + assert merged.method == Dft.TPSS + assert merged.basis_set == BasisSet.DEF2_TZVP + + +@pytest.mark.unit +@pytest.mark.simpletasks +def test_dft_invalid_field_raises_validation_error() -> None: + """DftSettings rejects unknown fields with a clear ValidationError.""" + with pytest.raises(ValidationError): + DftSettings(method="pbe", invalid_field="x") # type: ignore[call-arg] + + +@pytest.mark.unit +@pytest.mark.simpletasks +@pytest.mark.parametrize( + "field,text_value,expected_kw", + [ + ("method", "pbe", Dft.PBE), + ("method", "PBE", Dft.PBE), + ("basis_set", "def2-svp", BasisSet.DEF2_SVP), + ("basis_set", "DEF2-SVP", BasisSet.DEF2_SVP), + ], +) +def test_string_fields_converted_to_simple_keyword( + field: str, text_value: str, expected_kw: object +) -> None: + """String field values are validated and converted to the matching SimpleKeyword (case-insensitively).""" + s = DftSettings(**{field: text_value}) # type: ignore[arg-type] + assert getattr(s, field) == expected_kw + + +@pytest.mark.unit +@pytest.mark.simpletasks +@pytest.mark.parametrize( + "settings_cls,settings_kwargs,field,value,block_attr", + [ + (DftSettings, {"method": "pbe", "scf_maxiter": 300}, "scf_maxiter", 300, "maxiter"), + (SqmSettings, {"method": "gfn2-xtb", "scf_maxiter": 500}, "scf_maxiter", 500, "maxiter"), + ], +) +def test_block_option_stored_and_mapped( + settings_cls: type, + settings_kwargs: dict, # type: ignore[type-arg] + field: str, + value: int, + block_attr: str, +) -> None: + """Block-option fields are stored in settings and map to the correct block attribute in the input.""" + s = settings_cls(**settings_kwargs) + assert getattr(s, field) == value + + inp = s.map_to_input(Input()) + assert inp.has_blocks(BlockScf()) == (True,) + assert getattr(inp.blocks[BlockScf], block_attr) == value diff --git a/tests/unit/test_simpletasks_task.py b/tests/unit/test_simpletasks_task.py new file mode 100644 index 00000000..72b92eaf --- /dev/null +++ b/tests/unit/test_simpletasks_task.py @@ -0,0 +1,338 @@ +import pytest + +from opi.input import Input +from opi.input.arbitrary_string import ArbitraryStringPos +from opi.input.blocks import BlockGeom, BlockScf +from opi.input.simple_keywords import BasisSet, Dft, Goat, SimpleKeyword, Task +from opi.input.simple_keywords.opt import Opt +from opi.simple_tasks import ( + EngradTask, + FreqTask, + GoatSettings, + GoatTask, + OptSettings, + OptTask, + SinglePointTask, +) +from opi.simple_tasks.method_settings import DftSettings, SqmSettings + +""" +Unit tests for SimpleTask subclasses and TaskSettings: +- Constructor argument validation (method dispatch, dict args, no-method error) +- input builds correct simple keywords for each task type +- OptSettings flag combinations (opt_h / lopt / optrigid / opt_maxiter) +- GoatSettings boolean flags (goat_react / goat_diversity / goat_explore) +- OptTask convenience property forwarding to task_settings +- Method-family switching (same family and cross-family) +- basis_set getter / setter round-trip +""" + + +# --------------------------------------------------------------------------- +# Construction +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +@pytest.mark.simpletasks +def test_simple_task_no_method_raises() -> None: + """Creating a task without a method or method_settings raises ValueError.""" + with pytest.raises(ValueError): + SinglePointTask() + + +@pytest.mark.unit +@pytest.mark.simpletasks +@pytest.mark.parametrize( + "method,expected_cls", + [ + ("pbe", DftSettings), + ("gfn2-xtb", SqmSettings), + ], +) +def test_simple_task_method_dispatch(method: str, expected_cls: type) -> None: + """Task constructor routes to the correct MethodSettings subclass by method string.""" + task = SinglePointTask(method=method) + assert isinstance(task.method_settings, expected_cls) + + +@pytest.mark.unit +@pytest.mark.simpletasks +def test_simple_task_dict_method_settings() -> None: + """A plain dict passed as method_settings is validated and dispatched correctly.""" + task = SinglePointTask(method_settings={"method": "pbe"}) + assert isinstance(task.method_settings, DftSettings) + + +@pytest.mark.unit +@pytest.mark.simpletasks +def test_simple_task_dict_task_settings() -> None: + """A plain dict passed as task_settings is validated against the task's settings type.""" + task = SinglePointTask(method="pbe", task_settings={}) + assert task.task_settings is not None + + +# --------------------------------------------------------------------------- +# input — task keyword +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +@pytest.mark.simpletasks +@pytest.mark.parametrize( + "task_cls,expected_kw", + [ + (SinglePointTask, Task.SP), + (OptTask, Task.OPT), + (FreqTask, Task.FREQ), + (EngradTask, Task.ENGRAD), + (GoatTask, Goat.GOAT), + ], +) +def test_input_has_task_keyword(task_cls: type, expected_kw: SimpleKeyword) -> None: + """input contains the primary task keyword for each task type.""" + inp = task_cls(method="pbe").input + assert inp.has_simple_keywords(expected_kw) == (True,) + + +@pytest.mark.unit +@pytest.mark.simpletasks +def test_input_has_method_and_basis_set() -> None: + """input contains the task keyword, method, and basis-set keywords.""" + inp = SinglePointTask(method="pbe", basis_set="def2-svp").input + assert inp.has_simple_keywords(Task.SP, Dft.PBE, BasisSet.DEF2_SVP) == (True, True, True) + + +# --------------------------------------------------------------------------- +# OptSettings.map_to_input — flag combinations +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +@pytest.mark.simpletasks +@pytest.mark.parametrize( + "opt_h,lopt,expected_kw", + [ + (True, False, Opt.OPTH), + (False, True, Opt.L_OPT), + (True, True, Opt.L_OPTH), + ], +) +def test_opt_mode_keywords(opt_h: bool, lopt: bool, expected_kw: SimpleKeyword) -> None: + """OptSettings flag combinations map to the correct ORCA optimisation-mode keyword.""" + inp = OptSettings(opt_h=opt_h, lopt=lopt).map_to_input(Input()) + assert inp.has_simple_keywords(expected_kw) == (True,) + + +@pytest.mark.unit +@pytest.mark.simpletasks +def test_opt_no_mode_flags_adds_no_mode_keyword() -> None: + """Default OptSettings (no flags) does not add OPT_H / L_OPT / L_OPT_H.""" + inp = OptSettings().map_to_input(Input()) + for kw in (Opt.OPTH, Opt.L_OPT, Opt.L_OPTH): + assert inp.has_simple_keywords(kw) == (False,) + + +@pytest.mark.unit +@pytest.mark.simpletasks +def test_opt_rigid_body_keyword() -> None: + """OptSettings(optrigid=True) adds the RIGIDBODYOPT keyword.""" + inp = OptSettings(optrigid=True).map_to_input(Input()) + assert inp.has_simple_keywords(Opt.RIGIDBODYOPT) == (True,) + + +@pytest.mark.unit +@pytest.mark.simpletasks +def test_opt_maxiter_creates_geom_block() -> None: + """OptSettings(opt_maxiter=N) creates a %geom block with MaxIter set.""" + inp = OptSettings(opt_maxiter=100).map_to_input(Input()) + assert inp.has_blocks(BlockGeom()) == (True,) + assert inp.blocks[BlockGeom].maxiter == 100 + + +# --------------------------------------------------------------------------- +# GoatSettings.map_to_input — boolean flags +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +@pytest.mark.simpletasks +@pytest.mark.parametrize( + "flag,expected_kw", + [ + ("goat_react", Goat.GOAT_REACT), + ("goat_diversity", Goat.GOAT_DIVERSITY), + ("goat_explore", Goat.GOAT_EXPLORE), + ], +) +def test_goat_flag_keywords(flag: str, expected_kw: SimpleKeyword) -> None: + """GoatSettings boolean flags produce the correct ORCA GOAT-variant keyword.""" + inp = GoatSettings(**{flag: True}).map_to_input(Input()) + assert inp.has_simple_keywords(expected_kw) == (True,) + + +# --------------------------------------------------------------------------- +# OptTask convenience property forwarding +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +@pytest.mark.simpletasks +def test_opt_task_properties_forward_to_task_settings() -> None: + """Setting OptTask properties propagates to the underlying OptSettings object.""" + opt = OptTask(method="pbe") + + opt.optrigid = True + assert opt.task_settings.optrigid is True + + opt.opt_h = True + assert opt.task_settings.opt_h is True + + opt.lopt = True + assert opt.task_settings.lopt is True + + opt.opt_maxiter = 100 + assert opt.task_settings.opt_maxiter == 100 + + +# --------------------------------------------------------------------------- +# Method switching +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +@pytest.mark.simpletasks +def test_method_switch_same_family_updates_in_place() -> None: + """Switching to a method in the same family updates the existing settings object.""" + task = SinglePointTask(method="pbe") + task.method = "tpss" + assert isinstance(task.method_settings, DftSettings) + assert task.method == Dft.TPSS + + +@pytest.mark.unit +@pytest.mark.simpletasks +def test_method_switch_cross_family_warns_about_dropped_fields() -> None: + """Switching method family warns about settings fields that cannot be transferred.""" + task = SinglePointTask(method_settings=DftSettings(method="pbe", scf_maxiter=500)) + with pytest.warns(UserWarning, match="scf_maxiter"): + task.method = "gfn2-xtb" + assert isinstance(task.method_settings, SqmSettings) + + +# --------------------------------------------------------------------------- +# basis_set getter / setter +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +@pytest.mark.simpletasks +def test_basis_set_getter_and_setter() -> None: + """basis_set property reads and writes through to method_settings correctly.""" + task = SinglePointTask(method="pbe", basis_set="def2-svp") + assert task.basis_set == BasisSet.DEF2_SVP + + task.basis_set = "def2-tzvp" + assert task.basis_set == BasisSet.DEF2_TZVP + + +# --------------------------------------------------------------------------- +# input caching +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +@pytest.mark.simpletasks +def test_input_is_cached() -> None: + """Accessing input twice returns the same object instance.""" + task = SinglePointTask(method="pbe") + assert task.input is task.input + + +@pytest.mark.unit +@pytest.mark.simpletasks +def test_user_added_keyword_persists() -> None: + """A keyword added directly to input is visible on every subsequent access.""" + task = SinglePointTask(method="pbe") + kw = SimpleKeyword("NORI") + task.input.add_simple_keywords(kw) + assert task.input.has_simple_keywords(kw) == (True,) + assert task.input.has_simple_keywords(kw) == (True,) + + +@pytest.mark.unit +@pytest.mark.simpletasks +def test_user_added_block_persists() -> None: + """A block added directly to input is visible on every subsequent access.""" + task = SinglePointTask(method="pbe") + block = BlockScf(maxiter=500) + task.input.add_blocks(block) + assert task.input.has_blocks(BlockScf()) == (True,) + assert task.input.blocks[BlockScf].maxiter == 500 + + +@pytest.mark.unit +@pytest.mark.simpletasks +def test_user_modified_block_persists() -> None: + """Mutating an existing block on input is reflected on every subsequent access.""" + task = OptTask(method="pbe", task_settings={"opt_maxiter": 50}) + task.input.blocks[BlockGeom].maxiter = 99 + assert task.input.blocks[BlockGeom].maxiter == 99 + + +@pytest.mark.unit +@pytest.mark.simpletasks +def test_settings_keyword_restored_after_user_removal() -> None: + """A method-controlled keyword removed from input is re-added on the next access.""" + task = SinglePointTask(method="pbe", basis_set="def2-svp") + task.input.remove_simple_keywords(BasisSet.DEF2_SVP) + assert task.input.has_simple_keywords(BasisSet.DEF2_SVP) == (True,) + + +@pytest.mark.unit +@pytest.mark.simpletasks +def test_user_set_ncores_persists() -> None: + """Setting ncores on input is preserved across accesses.""" + task = SinglePointTask(method="pbe") + task.input.ncores = 8 + assert task.input.ncores == 8 + + +@pytest.mark.unit +@pytest.mark.simpletasks +def test_user_set_memory_persists() -> None: + """Setting memory on input is preserved across accesses.""" + task = SinglePointTask(method="pbe") + task.input.memory = 4096 + assert task.input.memory == 4096 + + +@pytest.mark.unit +@pytest.mark.simpletasks +def test_user_added_arbitrary_string_persists() -> None: + """An arbitrary string added to input is preserved across accesses.""" + task = SinglePointTask(method="pbe") + task.input.add_arbitrary_string( + "% some custom block\nend", pos=ArbitraryStringPos.BOTTOM + ) + assert len(task.input.arbitrary_strings) == 1 + assert len(task.input.arbitrary_strings) == 1 + + +@pytest.mark.unit +@pytest.mark.simpletasks +def test_multiple_user_modifications_all_persist() -> None: + """Multiple independent modifications to input all survive repeated accesses.""" + task = SinglePointTask(method="pbe") + kw = SimpleKeyword("RIJCOSX") + block = BlockScf(maxiter=300) + + task.input.add_simple_keywords(kw) + task.input.add_blocks(block) + task.input.ncores = 4 + + inp = task.input + assert inp.has_simple_keywords(kw) == (True,) + assert inp.has_blocks(BlockScf()) == (True,) + assert inp.blocks[BlockScf].maxiter == 300 + assert inp.ncores == 4