From 125f439f77092c2ffc297b0fcced76bfae01acdf Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Wed, 25 Feb 2026 14:07:24 +0100 Subject: [PATCH 01/52] add from_string method for simple keywords --- src/opi/input/simple_keywords/base.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/opi/input/simple_keywords/base.py b/src/opi/input/simple_keywords/base.py index 85b70efe..e262fc45 100644 --- a/src/opi/input/simple_keywords/base.py +++ b/src/opi/input/simple_keywords/base.py @@ -1,7 +1,26 @@ __all__ = ("SimpleKeyword",) +_SIMPLE_KEYWORD_REGISTRY = [] class SimpleKeywordBox: + _registry = [] + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + cls._registry.append(cls) + + @classmethod + def from_string(cls, s): + norm = s.lower() + for c in cls._registry: + for attr, value in vars(c).items(): + if isinstance(value, SimpleKeyword) and value.keyword.lower() == norm: + return value + + raise ValueError(f"Keyword {s} not found.") + + +class Method(SimpleKeywordBox): pass From 665e5d32478c868241025c2533b306916b6f9976 Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Mon, 2 Mar 2026 16:03:12 +0100 Subject: [PATCH 02/52] registry --- src/opi/input/simple_keywords/base.py | 10 +++++++++- src/opi/input/simple_keywords/dft.py | 4 +++- src/opi/input/simple_keywords/function.py | 5 +++++ src/opi/input/simple_keywords/wft.py | 4 +++- 4 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 src/opi/input/simple_keywords/function.py diff --git a/src/opi/input/simple_keywords/base.py b/src/opi/input/simple_keywords/base.py index e262fc45..c5e2575a 100644 --- a/src/opi/input/simple_keywords/base.py +++ b/src/opi/input/simple_keywords/base.py @@ -7,7 +7,15 @@ class SimpleKeywordBox: def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) - cls._registry.append(cls) + cls._registry = [] + + for base in cls.__bases__: + if hasattr(base, "_registry"): + base._registry.append(cls) + + @classmethod + def registry(cls): + return cls._registry @classmethod def from_string(cls, s): diff --git a/src/opi/input/simple_keywords/dft.py b/src/opi/input/simple_keywords/dft.py index f37f969d..fdbda82f 100644 --- a/src/opi/input/simple_keywords/dft.py +++ b/src/opi/input/simple_keywords/dft.py @@ -5,8 +5,10 @@ __all__ = ("Dft",) +from opi.input.simple_keywords.function import Function -class Dft(SimpleKeywordBox): + +class Dft(Function): """Enum to store all simple keywords of type Dft.""" B3LYP3C = SimpleKeyword("b3lyp3c") diff --git a/src/opi/input/simple_keywords/function.py b/src/opi/input/simple_keywords/function.py new file mode 100644 index 00000000..d51115c0 --- /dev/null +++ b/src/opi/input/simple_keywords/function.py @@ -0,0 +1,5 @@ +from opi.input.simple_keywords import SimpleKeywordBox + + +class Function(SimpleKeywordBox): + pass \ No newline at end of file diff --git a/src/opi/input/simple_keywords/wft.py b/src/opi/input/simple_keywords/wft.py index e383e97c..4d946e55 100644 --- a/src/opi/input/simple_keywords/wft.py +++ b/src/opi/input/simple_keywords/wft.py @@ -5,8 +5,10 @@ __all__ = ("Wft",) +from opi.input.simple_keywords.function import Function -class Wft(SimpleKeywordBox): + +class Wft(Function): """Enum to store all simple keywords of type Wft.""" HF = SimpleKeyword("hf") From aa9d68fcdb1a8d108d2bad39029aeacdc65d1390 Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Mon, 16 Mar 2026 17:24:47 +0100 Subject: [PATCH 03/52] task params class, simple keyword lookup --- src/opi/input/blocks/base.py | 11 +++ src/opi/input/simple_keywords/base.py | 19 ++-- src/opi/tasks/task_base.py | 119 ++++++++++++++++++++++++++ 3 files changed, 144 insertions(+), 5 deletions(-) create mode 100644 src/opi/tasks/task_base.py diff --git a/src/opi/input/blocks/base.py b/src/opi/input/blocks/base.py index 3e2267e3..efdb7d05 100644 --- a/src/opi/input/blocks/base.py +++ b/src/opi/input/blocks/base.py @@ -189,3 +189,14 @@ 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"]: + matches = { + sub.__name__.lower(): sub + for sub in cls.__subclasses__() + } + match = matches.get(name.lower()) + if match is None: + raise ValueError(f"No Block subclass found with name {name!r}. Available: {list(matches.keys())}") + return match diff --git a/src/opi/input/simple_keywords/base.py b/src/opi/input/simple_keywords/base.py index c5e2575a..104dbe56 100644 --- a/src/opi/input/simple_keywords/base.py +++ b/src/opi/input/simple_keywords/base.py @@ -1,11 +1,9 @@ __all__ = ("SimpleKeyword",) -_SIMPLE_KEYWORD_REGISTRY = [] - class SimpleKeywordBox: - _registry = [] + _registry: list[type["SimpleKeywordBox"]] = [] - def __init_subclass__(cls, **kwargs): + def __init_subclass__(cls, **kwargs) -> None: super().__init_subclass__(**kwargs) cls._registry = [] @@ -13,8 +11,10 @@ def __init_subclass__(cls, **kwargs): if hasattr(base, "_registry"): base._registry.append(cls) + cls._registry.append(cls) + @classmethod - def registry(cls): + def registry(cls) -> list: return cls._registry @classmethod @@ -28,6 +28,15 @@ def from_string(cls, s): raise ValueError(f"Keyword {s} not found.") + @classmethod + def find_keyword(cls, inp: "SimpleKeyword | str") -> "SimpleKeyword": + if isinstance(inp, SimpleKeyword): + inp = inp.keyword + + return cls.from_string(inp) + + + class Method(SimpleKeywordBox): pass diff --git a/src/opi/tasks/task_base.py b/src/opi/tasks/task_base.py new file mode 100644 index 00000000..e8ac8fd1 --- /dev/null +++ b/src/opi/tasks/task_base.py @@ -0,0 +1,119 @@ +import shutil +import typing +from pathlib import Path +from typing import Any + +from pydantic import model_validator, BaseModel, ConfigDict + +from opi.core import Calculator +from opi.input import Input +from opi.input.blocks import Block +from opi.input.structures import Structure + + + +class TaskParams(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + + + def map_to_input(self, input_object: Input) -> Input: + hints = typing.get_type_hints(self.__class__, include_extras=True) + + for field_name, field_type in hints.items(): + value = getattr(self, field_name) + + args = typing.get_args(field_type) + metadata = args[1:] + + + match metadata: + case (validator, ): + input_object.add_simple_keywords(value) + case (validator, key): + block_type = Block.get_subclass_by_name(validator) + block_class = block_type(**{key: value}) + + block_exists, *_ = input_object.has_blocks(block_type) + if not block_exists: + input_object.add_blocks(block_class) + else: + existing_block = next(iter(input_object.get_blocks(type(block_class)).values())) + new_block = block_type.model_validate({**existing_block.model_dump(), **block_class.model_dump(exclude_unset=True)}) + input_object.add_blocks(new_block, overwrite=True) + + return input_object + + + @model_validator(mode='before') + @classmethod + def validate(cls, data: dict) -> dict: + hints = typing.get_type_hints(cls, include_extras=True) + + for field_name, hint in hints.items(): + value = data.get(field_name) + print(f"{field_name}: {hint}") + if field_name not in data: + continue + + args = typing.get_args(hint) + metadata = args[1:] + + match metadata: + case (validator,): + keyword = validator.find_keyword(data[field_name]) + data[field_name] = keyword + + case (validator, key): + block_cls = Block.get_subclass_by_name(validator) + instance = block_cls.model_validate({key: value}) + data[field_name] = getattr(instance, key) + + + return data + + + +class Task(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + task_parameters: TaskParams + input_object: Input | None = Input() + + def __init__(self, /, **data: Any): + super().__init__(**data) + + + def run(self, basename: str, struct: Structure, working_dir:Path | None = Path("RUN"), ncores:int | None = None, memory:int | None = None, moinp: Path | None = None) -> "TaskResults": + + # > recreate the working dir + shutil.rmtree(working_dir, ignore_errors=True) + working_dir.mkdir() + + if ncores: + self.input_object.ncores = ncores + + if memory: + self.input_object.memory = memory + + if moinp: + self.input_object.moinp = moinp + + + self.input_object = self.task_parameters.map_to_input(input_object=self.input_object) + + calc = Calculator(basename, working_dir=working_dir) + calc.structure = struct + calc.input = self.input_object + + calc.write_and_run() + + return TaskResults() + + +class TaskResults: + pass + + + + + + From 1796784b7fa6593bd03e5d606db58ea1a34e55aa Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Wed, 25 Mar 2026 09:44:38 +0100 Subject: [PATCH 04/52] minimal implementation of Simple Tasks --- src/opi/input/blocks/base.py | 15 +++++- src/opi/input/blocks/block_ice.py | 2 +- src/opi/input/simple_keywords/base.py | 6 ++- .../input/simple_keywords/solvation_model.py | 2 +- src/opi/input/simple_keywords/solvent.py | 8 +++ src/opi/tasks/task_base.py | 49 ++++++++++++++----- 6 files changed, 65 insertions(+), 17 deletions(-) diff --git a/src/opi/input/blocks/base.py b/src/opi/input/blocks/base.py index efdb7d05..a9151518 100644 --- a/src/opi/input/blocks/base.py +++ b/src/opi/input/blocks/base.py @@ -169,6 +169,10 @@ def format_orca(self) -> str: return s + def __add__(self, other: "Block" ) -> "Block": + new_block = self.__class__.model_validate({**self.model_dump(), **other.model_dump(exclude_unset=True)}) + return new_block + @property def name(self) -> str: return self._name @@ -196,7 +200,16 @@ def get_subclass_by_name(cls, name: str) -> type["Block"]: sub.__name__.lower(): sub for sub in cls.__subclasses__() } - match = matches.get(name.lower()) + name_matches = { + sub().name.lower(): sub + for sub in cls.__subclasses__() + } + match = matches.get(name.lower()) or name_matches.get(name.lower()) + if match is None: raise ValueError(f"No Block subclass found with name {name!r}. Available: {list(matches.keys())}") + + return match + + 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 104dbe56..aa974251 100644 --- a/src/opi/input/simple_keywords/base.py +++ b/src/opi/input/simple_keywords/base.py @@ -14,16 +14,18 @@ def __init_subclass__(cls, **kwargs) -> None: cls._registry.append(cls) @classmethod - def registry(cls) -> list: + def registry(cls) -> list[type["SimpleKeywordBox"]]: return cls._registry @classmethod - def from_string(cls, s): + def from_string(cls, s:str) -> "SimpleKeyword": norm = s.lower() for c in cls._registry: for attr, value in vars(c).items(): if isinstance(value, SimpleKeyword) and value.keyword.lower() == norm: return value + elif isinstance(value, SimpleKeyword) and attr.lower() == norm: + return value raise ValueError(f"Keyword {s} not found.") 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/tasks/task_base.py b/src/opi/tasks/task_base.py index e8ac8fd1..70046f93 100644 --- a/src/opi/tasks/task_base.py +++ b/src/opi/tasks/task_base.py @@ -7,9 +7,10 @@ from opi.core import Calculator from opi.input import Input -from opi.input.blocks import Block +from opi.input.blocks import Block, BlockScf +from opi.input.simple_keywords import SimpleKeyword, BasisSet, SolvationModel, Solvent from opi.input.structures import Structure - +from opi.output.core import Output class TaskParams(BaseModel): @@ -28,7 +29,15 @@ def map_to_input(self, input_object: Input) -> Input: match metadata: case (validator, ): - input_object.add_simple_keywords(value) + if validator == SolvationModel: + if not self.solvent: + raise ValueError("Solvent not set") + new_keyword = value(self.solvent) + input_object.add_simple_keywords(new_keyword) + elif validator == Solvent: + continue + else: + input_object.add_simple_keywords(value) case (validator, key): block_type = Block.get_subclass_by_name(validator) block_class = block_type(**{key: value}) @@ -37,8 +46,8 @@ def map_to_input(self, input_object: Input) -> Input: if not block_exists: input_object.add_blocks(block_class) else: - existing_block = next(iter(input_object.get_blocks(type(block_class)).values())) - new_block = block_type.model_validate({**existing_block.model_dump(), **block_class.model_dump(exclude_unset=True)}) + existing_block = next(iter(input_object.get_blocks(block_type).values())) + new_block = existing_block + block_class input_object.add_blocks(new_block, overwrite=True) return input_object @@ -51,7 +60,6 @@ def validate(cls, data: dict) -> dict: for field_name, hint in hints.items(): value = data.get(field_name) - print(f"{field_name}: {hint}") if field_name not in data: continue @@ -75,14 +83,14 @@ def validate(cls, data: dict) -> dict: class Task(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) - task_parameters: TaskParams - input_object: Input | None = Input() + # task_parameters: TaskParams + input_object: Input = Input() def __init__(self, /, **data: Any): super().__init__(**data) - def run(self, basename: str, struct: Structure, working_dir:Path | None = Path("RUN"), ncores:int | None = None, memory:int | None = None, moinp: Path | None = None) -> "TaskResults": + def run(self, basename: str, struct: Structure, working_dir:Path = Path("RUN"), ncores:int | None = None, memory:int | None = None, moinp: Path | None = None) -> "TaskResults": # > recreate the working dir shutil.rmtree(working_dir, ignore_errors=True) @@ -106,11 +114,28 @@ def run(self, basename: str, struct: Structure, working_dir:Path | None = Path(" calc.write_and_run() - return TaskResults() + output = calc.get_output() + + print(output.get_final_energy()) + + return TaskResults(calc_object=calc) + + +class TaskResults(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + calc_object: Calculator | None = None + # + # def __init__(self, calc: Calculator, /, **data: Any): + # super().__init__(**data) + # self.calc_object = calc + + @property + def output(self) -> Output: + if not self.calc_object: + raise ValueError("calc_object not set") + return self.calc_object.get_output() -class TaskResults: - pass From 9fa47b5cd3d230615a879d7e3066a44ac99a75ac Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Wed, 25 Mar 2026 16:50:25 +0100 Subject: [PATCH 05/52] flesh out structure of base Task classes --- src/opi/input/blocks/base.py | 23 ++--- src/opi/input/simple_keywords/base.py | 15 ++- src/opi/input/simple_keywords/dft.py | 4 +- src/opi/input/simple_keywords/function.py | 5 - src/opi/input/simple_keywords/wft.py | 4 +- src/opi/tasks/singlepointtask.py | 71 ++++++++++++++ src/opi/tasks/task_base.py | 108 ++++++++++++---------- 7 files changed, 146 insertions(+), 84 deletions(-) delete mode 100644 src/opi/input/simple_keywords/function.py create mode 100644 src/opi/tasks/singlepointtask.py diff --git a/src/opi/input/blocks/base.py b/src/opi/input/blocks/base.py index a9151518..348e556e 100644 --- a/src/opi/input/blocks/base.py +++ b/src/opi/input/blocks/base.py @@ -169,8 +169,10 @@ def format_orca(self) -> str: return s - def __add__(self, other: "Block" ) -> "Block": - new_block = self.__class__.model_validate({**self.model_dump(), **other.model_dump(exclude_unset=True)}) + def __add__(self, other: "Block") -> "Block": + new_block = self.__class__.model_validate( + {**self.model_dump(), **other.model_dump(exclude_unset=True)} + ) return new_block @property @@ -196,20 +198,13 @@ def init_inputpath(cls, inp: Any) -> Any: @classmethod def get_subclass_by_name(cls, name: str) -> type["Block"]: - matches = { - sub.__name__.lower(): sub - for sub in cls.__subclasses__() - } - name_matches = { - sub().name.lower(): sub - for sub in cls.__subclasses__() - } + matches = {sub.__name__.lower(): sub for sub in cls.__subclasses__()} + name_matches = {sub().name.lower(): sub for sub in cls.__subclasses__()} match = matches.get(name.lower()) or name_matches.get(name.lower()) if match is None: - raise ValueError(f"No Block subclass found with name {name!r}. Available: {list(matches.keys())}") - + raise ValueError( + f"No Block subclass found with name {name!r}. Available: {list(matches.keys())}" + ) return match - - diff --git a/src/opi/input/simple_keywords/base.py b/src/opi/input/simple_keywords/base.py index aa974251..7366859b 100644 --- a/src/opi/input/simple_keywords/base.py +++ b/src/opi/input/simple_keywords/base.py @@ -1,9 +1,12 @@ -__all__ = ("SimpleKeyword",) +__all__ = ("SimpleKeyword", "SimpleKeywordBox") + +from typing import Any + class SimpleKeywordBox: _registry: list[type["SimpleKeywordBox"]] = [] - def __init_subclass__(cls, **kwargs) -> None: + def __init_subclass__(cls, **kwargs: Any) -> None: super().__init_subclass__(**kwargs) cls._registry = [] @@ -18,7 +21,7 @@ def registry(cls) -> list[type["SimpleKeywordBox"]]: return cls._registry @classmethod - def from_string(cls, s:str) -> "SimpleKeyword": + def from_string(cls, s: str) -> "SimpleKeyword": norm = s.lower() for c in cls._registry: for attr, value in vars(c).items(): @@ -29,7 +32,6 @@ def from_string(cls, s:str) -> "SimpleKeyword": raise ValueError(f"Keyword {s} not found.") - @classmethod def find_keyword(cls, inp: "SimpleKeyword | str") -> "SimpleKeyword": if isinstance(inp, SimpleKeyword): @@ -38,11 +40,6 @@ def find_keyword(cls, inp: "SimpleKeyword | str") -> "SimpleKeyword": return cls.from_string(inp) - -class Method(SimpleKeywordBox): - pass - - class SimpleKeyword: """ Class to represent simple keywords used in ORCA input files diff --git a/src/opi/input/simple_keywords/dft.py b/src/opi/input/simple_keywords/dft.py index fdbda82f..f37f969d 100644 --- a/src/opi/input/simple_keywords/dft.py +++ b/src/opi/input/simple_keywords/dft.py @@ -5,10 +5,8 @@ __all__ = ("Dft",) -from opi.input.simple_keywords.function import Function - -class Dft(Function): +class Dft(SimpleKeywordBox): """Enum to store all simple keywords of type Dft.""" B3LYP3C = SimpleKeyword("b3lyp3c") diff --git a/src/opi/input/simple_keywords/function.py b/src/opi/input/simple_keywords/function.py deleted file mode 100644 index d51115c0..00000000 --- a/src/opi/input/simple_keywords/function.py +++ /dev/null @@ -1,5 +0,0 @@ -from opi.input.simple_keywords import SimpleKeywordBox - - -class Function(SimpleKeywordBox): - pass \ No newline at end of file diff --git a/src/opi/input/simple_keywords/wft.py b/src/opi/input/simple_keywords/wft.py index 4d946e55..e383e97c 100644 --- a/src/opi/input/simple_keywords/wft.py +++ b/src/opi/input/simple_keywords/wft.py @@ -5,10 +5,8 @@ __all__ = ("Wft",) -from opi.input.simple_keywords.function import Function - -class Wft(Function): +class Wft(SimpleKeywordBox): """Enum to store all simple keywords of type Wft.""" HF = SimpleKeyword("hf") diff --git a/src/opi/tasks/singlepointtask.py b/src/opi/tasks/singlepointtask.py new file mode 100644 index 00000000..fbd9c906 --- /dev/null +++ b/src/opi/tasks/singlepointtask.py @@ -0,0 +1,71 @@ +import typing +from pathlib import Path + +from opi.input.simple_keywords import BasisSet, Method, SimpleKeyword, SolvationModel, Solvent +from opi.input.structures import Structure +from opi.tasks.task_base import Task, TaskParams, TaskResults + + +class SinglePointParams(TaskParams): + method: typing.Annotated[SimpleKeyword, Method] + basis_set: typing.Annotated[SimpleKeyword, BasisSet] + solvation_model: typing.Annotated[SimpleKeyword, SolvationModel] + solvent: typing.Annotated[str, Solvent] + + +class SinglePointTask(Task): + def __init__( + self, + method: str | SimpleKeyword, + basis_set: str | SimpleKeyword, + solvation_model: str | SolvationModel, + solvent: str | Solvent, + ): + self._task_parameters = SinglePointParams( + method=method, basis_set=basis_set, solvation_model=solvation_model, solvent=solvent + ) + self._results_type = SinglePointResults + + @property + def task_parameters(self) -> SinglePointParams: + return self._task_parameters + + def run( + self, + basename: str, + struct: Structure, + working_dir: Path = Path("RUN"), + ncores: int | None = None, + memory: int | None = None, + moinp: Path | None = None, + ) -> "SinglePointResults": + single_point_result = super().run( + basename=basename, + struct=struct, + working_dir=working_dir, + ncores=ncores, + memory=memory, + moinp=moinp, + ) + + return single_point_result + + +class SinglePointResults(TaskResults): + @property + def status(self) -> bool: + return self.output.terminated_normally() and self.output.scf_converged() + + @property + @TaskResults.output_parse + def final_energy(self) -> float: + 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 + + @property + def primary_property(self) -> float: + return float(self.final_energy) diff --git a/src/opi/tasks/task_base.py b/src/opi/tasks/task_base.py index 70046f93..9dbe9e46 100644 --- a/src/opi/tasks/task_base.py +++ b/src/opi/tasks/task_base.py @@ -1,21 +1,21 @@ import shutil import typing +from abc import ABC, abstractmethod +from functools import cached_property, wraps from pathlib import Path -from typing import Any -from pydantic import model_validator, BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, model_validator from opi.core import Calculator from opi.input import Input -from opi.input.blocks import Block, BlockScf -from opi.input.simple_keywords import SimpleKeyword, BasisSet, SolvationModel, Solvent +from opi.input.blocks import Block +from opi.input.simple_keywords import SolvationModel, Solvent from opi.input.structures import Structure from opi.output.core import Output class TaskParams(BaseModel): - model_config = ConfigDict(arbitrary_types_allowed=True) - + model_config = ConfigDict(arbitrary_types_allowed=True, validate_assignment=True) def map_to_input(self, input_object: Input) -> Input: hints = typing.get_type_hints(self.__class__, include_extras=True) @@ -26,13 +26,13 @@ def map_to_input(self, input_object: Input) -> Input: args = typing.get_args(field_type) metadata = args[1:] - match metadata: - case (validator, ): + case (validator,): if validator == SolvationModel: - if not self.solvent: + solvent = getattr(self, "solvent", None) + if not solvent: raise ValueError("Solvent not set") - new_keyword = value(self.solvent) + new_keyword = value(solvent) input_object.add_simple_keywords(new_keyword) elif validator == Solvent: continue @@ -52,8 +52,7 @@ def map_to_input(self, input_object: Input) -> Input: return input_object - - @model_validator(mode='before') + @model_validator(mode="before") @classmethod def validate(cls, data: dict) -> dict: hints = typing.get_type_hints(cls, include_extras=True) @@ -76,69 +75,78 @@ def validate(cls, data: dict) -> dict: instance = block_cls.model_validate({key: value}) data[field_name] = getattr(instance, key) - return data +class Task(ABC): + _results_type: type["TaskResults"] -class Task(BaseModel): - model_config = ConfigDict(arbitrary_types_allowed=True) - # task_parameters: TaskParams - input_object: Input = Input() - - def __init__(self, /, **data: Any): - super().__init__(**data) - + @property + @abstractmethod + def task_parameters(self) -> TaskParams: + pass - def run(self, basename: str, struct: Structure, working_dir:Path = Path("RUN"), ncores:int | None = None, memory:int | None = None, moinp: Path | None = None) -> "TaskResults": + @property + def input_object(self) -> Input: + inp = Input() + inp = self.task_parameters.map_to_input(input_object=inp) + return inp + + def run( + self, + basename: str, + struct: Structure, + working_dir: Path = Path("RUN"), + ncores: int | None = None, + memory: int | None = None, + moinp: Path | None = None, + ) -> "TaskResults": # > recreate the working dir shutil.rmtree(working_dir, ignore_errors=True) working_dir.mkdir() - if ncores: - self.input_object.ncores = ncores - - if memory: - self.input_object.memory = memory + inp = self.input_object - if moinp: - self.input_object.moinp = moinp + if ncores is not None: + inp.ncores = ncores + if memory is not None: + inp.memory = memory - self.input_object = self.task_parameters.map_to_input(input_object=self.input_object) + if moinp is not None: + inp.moinp = moinp calc = Calculator(basename, working_dir=working_dir) calc.structure = struct - calc.input = self.input_object + calc.input = inp calc.write_and_run() - output = calc.get_output() + return self._results_type(calc_object=calc) - print(output.get_final_energy()) - return TaskResults(calc_object=calc) +class TaskResults(ABC): + def __init__(self, calc_object: Calculator): + self.calc_object = calc_object + self._parsed = False + @staticmethod + def output_parse( + func: typing.Callable[["TaskResults"], typing.Any], + ) -> typing.Callable[["TaskResults"], typing.Any]: + @wraps(func) + def wrapper(self: "TaskResults"): + if not self._parsed: + self.output.parse() + self._parsed = True + return func(self) -class TaskResults(BaseModel): - model_config = ConfigDict(arbitrary_types_allowed=True) - calc_object: Calculator | None = None - # - # def __init__(self, calc: Calculator, /, **data: Any): - # super().__init__(**data) - # self.calc_object = calc + return wrapper - - @property + @cached_property def output(self) -> Output: if not self.calc_object: raise ValueError("calc_object not set") - return self.calc_object.get_output() - - - - - - + return self.calc_object.get_output() From dac75a28dbe7377d7b91226d90c39a00b85286e0 Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Fri, 27 Mar 2026 16:27:36 +0100 Subject: [PATCH 06/52] added field validator for assignment and overloaded getters and setters for easier access to attributes --- src/opi/tasks/singlepointtask.py | 22 +++++++++++++++++++++ src/opi/tasks/task_base.py | 33 ++++++++++++++++++++++++++++++-- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/opi/tasks/singlepointtask.py b/src/opi/tasks/singlepointtask.py index fbd9c906..a5944e11 100644 --- a/src/opi/tasks/singlepointtask.py +++ b/src/opi/tasks/singlepointtask.py @@ -30,6 +30,28 @@ def __init__( def task_parameters(self) -> SinglePointParams: return self._task_parameters + def __getattr__(self, name): + """Delegate attribute access to _task_parameters.""" + if name.startswith('_'): + raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") + + try: + return getattr(self._task_parameters, name) + except AttributeError: + raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") + + def __setattr__(self, name, value): + """Delegate attribute setting to _task_parameters.""" + # Allow setting private attributes and special attributes normally + if name.startswith('_') or name in ('task_parameters', 'run'): + super().__setattr__(name, value) + else: + # Check if _task_parameters exists and has this attribute + if hasattr(self, '_task_parameters') and hasattr(self._task_parameters, name): + setattr(self._task_parameters, name, value) + else: + super().__setattr__(name, value) + def run( self, basename: str, diff --git a/src/opi/tasks/task_base.py b/src/opi/tasks/task_base.py index 9dbe9e46..f086c118 100644 --- a/src/opi/tasks/task_base.py +++ b/src/opi/tasks/task_base.py @@ -4,7 +4,7 @@ from functools import cached_property, wraps from pathlib import Path -from pydantic import BaseModel, ConfigDict, model_validator +from pydantic import BaseModel, ConfigDict, model_validator, field_validator from opi.core import Calculator from opi.input import Input @@ -52,6 +52,31 @@ def map_to_input(self, input_object: Input) -> Input: return input_object + + @field_validator('*', mode='before') + @classmethod + def validate_each_field(cls, value, info): + field_name = info.field_name + hints = typing.get_type_hints(cls, include_extras=True) + + if field_name not in hints: + return value + + hint = hints[field_name] + args = typing.get_args(hint) + metadata = args[1:] if len(args) > 1 else () + + match metadata: + case (validator, ): + return validator.find_keyword(value) + + case (validator, key): + block_cls = Block.get_subclass_by_name(validator) + instance = block_cls.model_validate({key: value}) + return getattr(instance, key) + + return value + @model_validator(mode="before") @classmethod def validate(cls, data: dict) -> dict: @@ -83,7 +108,7 @@ class Task(ABC): @property @abstractmethod - def task_parameters(self) -> TaskParams: + def task_parameters(self) -> TaskParams : pass @property @@ -126,6 +151,10 @@ def run( return self._results_type(calc_object=calc) + def change_parameter(self, param: str, value: typing.Any) -> None: + setattr(self.task_parameters, param, value) + + class TaskResults(ABC): def __init__(self, calc_object: Calculator): self.calc_object = calc_object From 038e98ced47d229d409de3a2d36dbc7fcc0f7ec3 Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Mon, 30 Mar 2026 18:02:04 +0200 Subject: [PATCH 07/52] added restart function, optimized duplicate code --- src/opi/tasks/singlepointtask.py | 31 +-------- src/opi/tasks/task_base.py | 111 +++++++++++++++++++++---------- 2 files changed, 79 insertions(+), 63 deletions(-) diff --git a/src/opi/tasks/singlepointtask.py b/src/opi/tasks/singlepointtask.py index a5944e11..8f6a19fa 100644 --- a/src/opi/tasks/singlepointtask.py +++ b/src/opi/tasks/singlepointtask.py @@ -2,7 +2,7 @@ from pathlib import Path from opi.input.simple_keywords import BasisSet, Method, SimpleKeyword, SolvationModel, Solvent -from opi.input.structures import Structure +from opi.input.structures import Structure, BaseStructureFile from opi.tasks.task_base import Task, TaskParams, TaskResults @@ -26,36 +26,11 @@ def __init__( ) self._results_type = SinglePointResults - @property - def task_parameters(self) -> SinglePointParams: - return self._task_parameters - - def __getattr__(self, name): - """Delegate attribute access to _task_parameters.""" - if name.startswith('_'): - raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") - - try: - return getattr(self._task_parameters, name) - except AttributeError: - raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") - - def __setattr__(self, name, value): - """Delegate attribute setting to _task_parameters.""" - # Allow setting private attributes and special attributes normally - if name.startswith('_') or name in ('task_parameters', 'run'): - super().__setattr__(name, value) - else: - # Check if _task_parameters exists and has this attribute - if hasattr(self, '_task_parameters') and hasattr(self._task_parameters, name): - setattr(self._task_parameters, name, value) - else: - super().__setattr__(name, value) def run( self, basename: str, - struct: Structure, + struct: Structure | BaseStructureFile, working_dir: Path = Path("RUN"), ncores: int | None = None, memory: int | None = None, @@ -70,7 +45,7 @@ def run( moinp=moinp, ) - return single_point_result + return typing.cast(SinglePointResults ,single_point_result) class SinglePointResults(TaskResults): diff --git a/src/opi/tasks/task_base.py b/src/opi/tasks/task_base.py index f086c118..f6e08199 100644 --- a/src/opi/tasks/task_base.py +++ b/src/opi/tasks/task_base.py @@ -10,13 +10,39 @@ from opi.input import Input from opi.input.blocks import Block from opi.input.simple_keywords import SolvationModel, Solvent -from opi.input.structures import Structure +from opi.input.structures import Structure, BaseStructureFile from opi.output.core import Output class TaskParams(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True, validate_assignment=True) + def __str__(self) -> str: + lines = ["Task Parameters:"] + 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: typing.Any) -> tuple: + args = typing.get_args(hint) + return args[1:] if len(args) > 1 else () + + + @staticmethod + def _resolve_field_value(value: typing.Any, metadata: tuple) -> typing.Any: + match metadata: + case (validator,): + return validator.find_keyword(value) + + case (validator, key): + block_cls = Block.get_subclass_by_name(validator) + instance = block_cls.model_validate({key: value}) + return getattr(instance, key) + + case _: + return value + def map_to_input(self, input_object: Input) -> Input: hints = typing.get_type_hints(self.__class__, include_extras=True) @@ -55,27 +81,15 @@ def map_to_input(self, input_object: Input) -> Input: @field_validator('*', mode='before') @classmethod - def validate_each_field(cls, value, info): - field_name = info.field_name + def validate_field(cls, value, info): hints = typing.get_type_hints(cls, include_extras=True) - if field_name not in hints: + if info.field_name not in hints: return value - hint = hints[field_name] - args = typing.get_args(hint) - metadata = args[1:] if len(args) > 1 else () - - match metadata: - case (validator, ): - return validator.find_keyword(value) - - case (validator, key): - block_cls = Block.get_subclass_by_name(validator) - instance = block_cls.model_validate({key: value}) - return getattr(instance, key) - - 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 @@ -83,33 +97,21 @@ def validate(cls, data: dict) -> dict: hints = typing.get_type_hints(cls, include_extras=True) for field_name, hint in hints.items(): - value = data.get(field_name) if field_name not in data: continue - args = typing.get_args(hint) - metadata = args[1:] - - match metadata: - case (validator,): - keyword = validator.find_keyword(data[field_name]) - data[field_name] = keyword - - case (validator, key): - block_cls = Block.get_subclass_by_name(validator) - instance = block_cls.model_validate({key: value}) - data[field_name] = getattr(instance, key) + metadata = cls._get_field_metadata(hint) + data[field_name] = cls._resolve_field_value(data[field_name], metadata) return data - class Task(ABC): _results_type: type["TaskResults"] + _task_parameters: TaskParams @property - @abstractmethod def task_parameters(self) -> TaskParams : - pass + return self._task_parameters @property def input_object(self) -> Input: @@ -117,10 +119,30 @@ def input_object(self) -> Input: inp = self.task_parameters.map_to_input(input_object=inp) return inp + def __getattr__(self, name): + if name.startswith('_'): + raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") + + try: + return getattr(self._task_parameters, name) + except AttributeError: + raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") + + def __setattr__(self, name, value): + # Allow setting private attributes and special attributes normally + if name.startswith('_') or name in ('task_parameters', 'run'): + super().__setattr__(name, value) + else: + # Check if _task_parameters exists and has this attribute + if hasattr(self, '_task_parameters') and hasattr(self._task_parameters, name): + setattr(self._task_parameters, name, value) + else: + super().__setattr__(name, value) + def run( self, basename: str, - struct: Structure, + struct: Structure | BaseStructureFile, working_dir: Path = Path("RUN"), ncores: int | None = None, memory: int | None = None, @@ -155,6 +177,25 @@ def change_parameter(self, param: str, value: typing.Any) -> None: setattr(self.task_parameters, param, value) + def restart(self, previous_results: "TaskResults", + basename: str|None = None, + struct: 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 ) -> "TaskResults": + + basename = basename if basename else previous_results.calc_object.basename + struct = struct if struct else previous_results.calc_object.structure + working_dir = working_dir if working_dir else previous_results.calc_object.working_dir + ncores = ncores if ncores else previous_results.calc_object.input.ncores + memory = memory if memory else previous_results.calc_object.input.memory + moinp = moinp if moinp else previous_results.calc_object.input.moinp + + return self.run(basename, struct, working_dir, ncores, memory, moinp) + + class TaskResults(ABC): def __init__(self, calc_object: Calculator): self.calc_object = calc_object From e1cb6abb9a659ebd4f398f5c90add45a3dbf3ce4 Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Wed, 1 Apr 2026 16:26:20 +0200 Subject: [PATCH 08/52] added docstrings , renamed TaskParams to Settings, added TaskSettings and MethodSettings --- src/opi/tasks/method_settings.py | 9 + src/opi/tasks/singlepointtask.py | 30 ++- src/opi/tasks/task_base.py | 379 ++++++++++++++++++++++++++----- 3 files changed, 347 insertions(+), 71 deletions(-) create mode 100644 src/opi/tasks/method_settings.py diff --git a/src/opi/tasks/method_settings.py b/src/opi/tasks/method_settings.py new file mode 100644 index 00000000..58a2f3ec --- /dev/null +++ b/src/opi/tasks/method_settings.py @@ -0,0 +1,9 @@ +import typing + +from opi.input.simple_keywords import Dft, SimpleKeyword +from opi.tasks.task_base import MethodSettings + + +class DFTSettings(MethodSettings): + method: typing.Annotated[SimpleKeyword, Dft] + _name: str = "dft" diff --git a/src/opi/tasks/singlepointtask.py b/src/opi/tasks/singlepointtask.py index 8f6a19fa..84501b5d 100644 --- a/src/opi/tasks/singlepointtask.py +++ b/src/opi/tasks/singlepointtask.py @@ -1,32 +1,34 @@ import typing from pathlib import Path -from opi.input.simple_keywords import BasisSet, Method, SimpleKeyword, SolvationModel, Solvent -from opi.input.structures import Structure, BaseStructureFile -from opi.tasks.task_base import Task, TaskParams, TaskResults +from opi.input.simple_keywords import SimpleKeyword, SolvationModel, Solvent, Task +from opi.input.structures import BaseStructureFile, Structure +from opi.tasks.method_settings import DFTSettings +from opi.tasks.task_base import SimpleTask, TaskResults, TaskSettings -class SinglePointParams(TaskParams): - method: typing.Annotated[SimpleKeyword, Method] - basis_set: typing.Annotated[SimpleKeyword, BasisSet] - solvation_model: typing.Annotated[SimpleKeyword, SolvationModel] - solvent: typing.Annotated[str, Solvent] +class SinglePointSettings(TaskSettings): + _name: str = "singlepoint" + task_keyword: typing.Annotated[SimpleKeyword, Task] = Task.SP -class SinglePointTask(Task): +class SinglePointTask(SimpleTask): def __init__( self, method: str | SimpleKeyword, basis_set: str | SimpleKeyword, solvation_model: str | SolvationModel, solvent: str | Solvent, + task: str | SimpleKeyword | None = None, ): - self._task_parameters = SinglePointParams( + self._method_settings = DFTSettings( method=method, basis_set=basis_set, solvation_model=solvation_model, solvent=solvent ) + self._task_settings = ( + SinglePointSettings(task_keyword=task) if task else SinglePointSettings() + ) self._results_type = SinglePointResults - def run( self, basename: str, @@ -45,14 +47,10 @@ def run( moinp=moinp, ) - return typing.cast(SinglePointResults ,single_point_result) + return typing.cast(SinglePointResults, single_point_result) class SinglePointResults(TaskResults): - @property - def status(self) -> bool: - return self.output.terminated_normally() and self.output.scf_converged() - @property @TaskResults.output_parse def final_energy(self) -> float: diff --git a/src/opi/tasks/task_base.py b/src/opi/tasks/task_base.py index f6e08199..88f7cadd 100644 --- a/src/opi/tasks/task_base.py +++ b/src/opi/tasks/task_base.py @@ -4,33 +4,96 @@ from functools import cached_property, wraps from pathlib import Path -from pydantic import BaseModel, ConfigDict, model_validator, field_validator +from pydantic import BaseModel, ConfigDict, field_validator, model_validator from opi.core import Calculator from opi.input import Input from opi.input.blocks import Block -from opi.input.simple_keywords import SolvationModel, Solvent -from opi.input.structures import Structure, BaseStructureFile +from opi.input.simple_keywords import ( + BasisSet, + Method, + SimpleKeyword, + SimpleKeywordBox, + SolvationModel, + Solvent, +) +from opi.input.structures import BaseStructureFile, Structure from opi.output.core import Output -class TaskParams(BaseModel): +class Settings(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True, validate_assignment=True) + _name: str def __str__(self) -> str: - lines = ["Task Parameters:"] + """ + 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: typing.Any) -> tuple: + """ + 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. + + """ args = typing.get_args(hint) return args[1:] if len(args) > 1 else () - @staticmethod - def _resolve_field_value(value: typing.Any, metadata: tuple) -> typing.Any: + def _resolve_field_value( + value: typing.Any, metadata: tuple[type["SimpleKeywordBox"]] | tuple[str, str] + ) -> typing.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. + + 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. + + + Parameters + ---------- + value: typing.Any + User input value. + metadata: tuple + Tuple of metadata about the field. + + Returns + ------- + typing.Any + User input value translated to OPI compatible types. + + """ match metadata: case (validator,): return validator.find_keyword(value) @@ -43,7 +106,35 @@ def _resolve_field_value(value: typing.Any, metadata: tuple) -> typing.Any: case _: return value + def _get_simple_keyword(self, validator: type[SimpleKeywordBox], value) -> SimpleKeyword: + if validator == SolvationModel: + solvent = getattr(self, "solvent", None) + new_keyword = value(solvent) + else: + new_keyword = value + + return new_keyword + 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. + """ hints = typing.get_type_hints(self.__class__, include_extras=True) for field_name, field_type in hints.items(): @@ -54,16 +145,12 @@ def map_to_input(self, input_object: Input) -> Input: match metadata: case (validator,): - if validator == SolvationModel: - solvent = getattr(self, "solvent", None) - if not solvent: - raise ValueError("Solvent not set") - new_keyword = value(solvent) - input_object.add_simple_keywords(new_keyword) - elif validator == Solvent: + if validator == Solvent: continue - else: - input_object.add_simple_keywords(value) + + new_keyword = self._get_simple_keyword(validator, value) + input_object.add_simple_keywords(new_keyword) + case (validator, key): block_type = Block.get_subclass_by_name(validator) block_class = block_type(**{key: value}) @@ -78,10 +165,32 @@ def map_to_input(self, input_object: Input) -> Input: return input_object - - @field_validator('*', mode='before') + @field_validator("*", mode="before") @classmethod - def validate_field(cls, value, info): + def validate_fields(cls, value: typing.Any, info): + """ + 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 + Object containing contextual information about field being validated. + + Returns + ------- + Any + User input value processed and converted to OPI compatible types. + """ hints = typing.get_type_hints(cls, include_extras=True) if info.field_name not in hints: @@ -93,49 +202,125 @@ def validate_field(cls, value, info): @model_validator(mode="before") @classmethod - def validate(cls, data: dict) -> dict: - hints = typing.get_type_hints(cls, include_extras=True) + def cross_validate(cls, data: dict[str, typing.Any]) -> dict[str, typing.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. - for field_name, hint in hints.items(): - if field_name not in data: - continue + Returns + ------- + dict + Cross-validated user input data - metadata = cls._get_field_metadata(hint) - data[field_name] = cls._resolve_field_value(data[field_name], metadata) + """ + if not isinstance(data, dict): + return data return data -class Task(ABC): + +class TaskSettings(Settings): + pass + + +class MethodSettings(Settings): + method: typing.Annotated[SimpleKeyword, Method] + basis_set: typing.Annotated[SimpleKeyword, BasisSet] + solvation_model: typing.Annotated[SimpleKeyword, SolvationModel] + solvent: typing.Annotated[str, Solvent] + + +class SimpleTask(ABC): _results_type: type["TaskResults"] - _task_parameters: TaskParams + _task_settings: TaskSettings + _method_settings: MethodSettings @property - def task_parameters(self) -> TaskParams : - return self._task_parameters + def task_settings(self) -> TaskSettings: + return self._task_settings + + @property + def method_settings(self) -> MethodSettings: + return self._method_settings @property def input_object(self) -> Input: + """ + Creates configured `Input` object. First it initializes an empty instance of `Input` , and then passes it as + to corresponding `TaskSettings` and `MethodSettings` objects to be configured by user-defined data stored in those + objects. + + Returns + ------- + `Input` + `Input` object configured by user-defined data. + + """ inp = Input() - inp = self.task_parameters.map_to_input(input_object=inp) + inp = self._task_settings.map_to_input(input_object=inp) + inp = self.method_settings.map_to_input(input_object=inp) return inp - def __getattr__(self, name): - if name.startswith('_'): + def __getattr__(self, name: str) -> typing.Any: + """ + Dynamically resolve attribute access by delegating to internal settings objects. + + This method is called when an attribute is not found on the instance through + the normal lookup process. It attempts to retrieve the attribute from the + internal `_task_settings` and `_method_settings` objects, in that order. + + Parameters + ---------- + name: str + Attribute name. + + Returns + -------- + Any + Attribute value. + """ + if name.startswith("_"): raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") try: - return getattr(self._task_parameters, name) + return getattr(self._task_settings, name) + except AttributeError: + pass + + try: + return getattr(self._method_settings, name) except AttributeError: raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") - def __setattr__(self, name, value): + def __setattr__(self, name: str, value: typing.Any) -> None: + """ + Dynamically assign attributes, delegating to internal settings objects when appropriate. + + This method overrides the default attribute assignment behavior to route + assignments to `_task_settings` or `_method_settings` if the attribute + exists. Otherwise, the attribute is set directly on the instance. + + Parameters + ---------- + name : str + The name of the attribute to assign. + value : Any + The value to assign to the attribute. + """ # Allow setting private attributes and special attributes normally - if name.startswith('_') or name in ('task_parameters', 'run'): + if name.startswith("_") or name in ("task_settings", "run"): super().__setattr__(name, value) else: - # Check if _task_parameters exists and has this attribute - if hasattr(self, '_task_parameters') and hasattr(self._task_parameters, name): - setattr(self._task_parameters, name, value) + # Check if _task_settings exists and has this attribute + if hasattr(self, "_task_settings") and hasattr(self._task_settings, name): + setattr(self._task_settings, name, value) + elif hasattr(self, "_method_settings") and hasattr(self._method_settings, name): + setattr(self._method_settings, name, value) else: super().__setattr__(name, value) @@ -148,6 +333,37 @@ def run( memory: int | None = None, moinp: Path | None = None, ) -> "TaskResults": + """ + 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. + struct : 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. + + Returns + ------- + TaskResults + An instance of the configured results type containing the results + of the calculation. + """ # > recreate the working dir shutil.rmtree(working_dir, ignore_errors=True) @@ -172,26 +388,50 @@ def run( return self._results_type(calc_object=calc) - - def change_parameter(self, param: str, value: typing.Any) -> None: - setattr(self.task_parameters, param, value) - - - def restart(self, previous_results: "TaskResults", - basename: str|None = None, - struct: Structure| BaseStructureFile| None = None, + def _restart( + self, + previous_results: "TaskResults", + basename: str | None = None, + struct: 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 ) -> "TaskResults": - - basename = basename if basename else previous_results.calc_object.basename - struct = struct if struct else previous_results.calc_object.structure - working_dir = working_dir if working_dir else previous_results.calc_object.working_dir - ncores = ncores if ncores else previous_results.calc_object.input.ncores - memory = memory if memory else previous_results.calc_object.input.memory - moinp = moinp if moinp else previous_results.calc_object.input.moinp + use_previous_orbitals: bool = False, + ) -> "TaskResults": + """ + TODO: + - finish restart implementation (low on priority list) + Parameters + ---------- + previous_results + basename + struct + working_dir + ncores + memory + moinp + use_previous_orbitals + + Returns + ------- + + """ + prev_calc = previous_results.calc_object + + basename = basename if basename else prev_calc.basename + struct = struct if struct else prev_calc.structure + 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) @@ -205,6 +445,26 @@ def __init__(self, calc_object: Calculator): def output_parse( func: typing.Callable[["TaskResults"], typing.Any], ) -> typing.Callable[["TaskResults"], typing.Any]: + """ + Decorator to ensure output parsing is performed before accessing results. + + This decorator wraps methods of a `TaskResults` instance and guarantees + that the associated output has been parsed before the method is executed. + Parsing is performed lazily and only once per instance. + + Parameters + ---------- + func : Callable[[TaskResults], Any] + The method to wrap. It must be a method of `TaskResults` that relies + on parsed output data. + + Returns + ------- + Callable[[TaskResults], Any] + A wrapped method that ensures `self.output.parse()` has been called + before delegating to the original function. + """ + @wraps(func) def wrapper(self: "TaskResults"): if not self._parsed: @@ -220,3 +480,12 @@ def output(self) -> Output: raise ValueError("calc_object not set") return self.calc_object.get_output() + + @cached_property + def status(self) -> bool: + return self.output.terminated_normally() and self.output.scf_converged() + + @cached_property + @abstractmethod + def primary_property(self) -> typing.Any: + pass From 0adf343881a44e494b3399a8bb3bbcad520fce77 Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Mon, 13 Apr 2026 16:39:31 +0200 Subject: [PATCH 09/52] changes from todays meeting --- src/opi/input/simple_keywords/base.py | 13 ++- src/opi/input/simple_keywords/grid.py | 6 +- src/opi/input/simple_keywords/scf.py | 114 +++++++++++++++----------- src/opi/tasks/method_settings.py | 101 ++++++++++++++++++++++- src/opi/tasks/singlepointtask.py | 15 ++-- src/opi/tasks/task_base.py | 57 +++++++++++-- 6 files changed, 234 insertions(+), 72 deletions(-) diff --git a/src/opi/input/simple_keywords/base.py b/src/opi/input/simple_keywords/base.py index 7366859b..16a897e4 100644 --- a/src/opi/input/simple_keywords/base.py +++ b/src/opi/input/simple_keywords/base.py @@ -24,13 +24,18 @@ def registry(cls) -> list[type["SimpleKeywordBox"]]: def from_string(cls, s: str) -> "SimpleKeyword": norm = s.lower() for c in cls._registry: - for attr, value in vars(c).items(): + for attr in dir(c): + if attr.startswith('_'): # Skip private/magic attributes + continue + value = getattr(c, attr) if isinstance(value, SimpleKeyword) and value.keyword.lower() == norm: return value elif isinstance(value, SimpleKeyword) and attr.lower() == norm: return value + elif isinstance(value, SimpleKeyword) and value.alias and value.alias.lower() == norm: + return value - raise ValueError(f"Keyword {s} not found.") + raise ValueError(f"Keyword {s} not found in class {cls.__name__}") @classmethod def find_keyword(cls, inp: "SimpleKeyword | str") -> "SimpleKeyword": @@ -52,12 +57,14 @@ class SimpleKeyword: keyword: str simple keyword as it will appear in the ORCA .inp file """ + alias: str | None = None - def __init__(self, keyword: str) -> None: + def __init__(self, keyword: str, alias:str|None=None) -> None: self._keyword: str = "" self.keyword = keyword self._name: str = "" # self.name = name + self.alias = alias @property def keyword(self) -> str: 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/scf.py b/src/opi/input/simple_keywords/scf.py index 0bd72599..f4d53929 100644 --- a/src/opi/input/simple_keywords/scf.py +++ b/src/opi/input/simple_keywords/scf.py @@ -5,8 +5,67 @@ __all__ = ("Scf",) +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(SimpleKeywordBox): + +class Scf(ScfThreshold, ScfConvergence, ScfGuess, ScfSolver): """Enum to store all simple keywords of type Scf.""" G3CONV = SimpleKeyword("3conv") @@ -17,50 +76,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 +129,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 +149,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/tasks/method_settings.py b/src/opi/tasks/method_settings.py index 58a2f3ec..2e0dc603 100644 --- a/src/opi/tasks/method_settings.py +++ b/src/opi/tasks/method_settings.py @@ -1,9 +1,106 @@ import typing +import warnings -from opi.input.simple_keywords import Dft, SimpleKeyword +from pydantic import field_validator, model_validator + +from opi.input.simple_keywords import Dft, SimpleKeyword, Grid, DispersionCorrection +from opi.input.simple_keywords.scf import ScfThreshold, ScfSolver, Scf, ScfConvergence from opi.tasks.task_base import MethodSettings class DFTSettings(MethodSettings): - method: typing.Annotated[SimpleKeyword, Dft] _name: str = "dft" + method: typing.Annotated[SimpleKeyword, Dft] + 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: typing.Annotated[SimpleKeyword, Scf] | None = None + scf_conv: typing.Annotated[SimpleKeyword, ScfConvergence] | None = None + + + @field_validator("*", mode="before") + @classmethod + def validate_fields(cls, value: typing.Any, info): + if info.field_name == "scf_stab": + if value: + return Scf.SCFSTAB + else: + return None + 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") + @classmethod + def cross_validate(cls, data: "DFTSettings") -> "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 "3c" in data.method.keyword and data.basis_set: + warnings.warn("Basis Set will be ignored due to selection of Method", UserWarning) + data.basis_set = None + + return data + + @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}'") + + + diff --git a/src/opi/tasks/singlepointtask.py b/src/opi/tasks/singlepointtask.py index 84501b5d..9d98de5d 100644 --- a/src/opi/tasks/singlepointtask.py +++ b/src/opi/tasks/singlepointtask.py @@ -1,7 +1,7 @@ import typing from pathlib import Path -from opi.input.simple_keywords import SimpleKeyword, SolvationModel, Solvent, Task +from opi.input.simple_keywords import SimpleKeyword, Solvent, Task from opi.input.structures import BaseStructureFile, Structure from opi.tasks.method_settings import DFTSettings from opi.tasks.task_base import SimpleTask, TaskResults, TaskSettings @@ -16,17 +16,18 @@ class SinglePointTask(SimpleTask): def __init__( self, method: str | SimpleKeyword, - basis_set: str | SimpleKeyword, - solvation_model: str | SolvationModel, - solvent: str | Solvent, + basis_set: str | SimpleKeyword | None = None, + solvation_model: str | SimpleKeyword | None = None, + solvent: str | Solvent | None = None, task: str | SimpleKeyword | None = None, ): self._method_settings = DFTSettings( method=method, basis_set=basis_set, solvation_model=solvation_model, solvent=solvent ) self._task_settings = ( - SinglePointSettings(task_keyword=task) if task else SinglePointSettings() - ) + SinglePointSettings(task_keyword=task) + ) if task else SinglePointSettings() + self._results_type = SinglePointResults def run( @@ -37,6 +38,7 @@ def run( ncores: int | None = None, memory: int | None = None, moinp: Path | None = None, + strict: bool = False ) -> "SinglePointResults": single_point_result = super().run( basename=basename, @@ -45,6 +47,7 @@ def run( ncores=ncores, memory=memory, moinp=moinp, + strict=strict ) return typing.cast(SinglePointResults, single_point_result) diff --git a/src/opi/tasks/task_base.py b/src/opi/tasks/task_base.py index 88f7cadd..a3ce0add 100644 --- a/src/opi/tasks/task_base.py +++ b/src/opi/tasks/task_base.py @@ -56,7 +56,12 @@ def _get_field_metadata(hint: typing.Any) -> tuple: Tuple of metadata about the field. """ + origin = typing.get_origin(hint) args = typing.get_args(hint) + if origin is typing.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]) return args[1:] if len(args) > 1 else () @staticmethod @@ -139,9 +144,10 @@ def map_to_input(self, input_object: Input) -> Input: for field_name, field_type in hints.items(): value = getattr(self, field_name) + if value is None: + continue - args = typing.get_args(field_type) - metadata = args[1:] + metadata = self._get_field_metadata(field_type) match metadata: case (validator,): @@ -149,7 +155,8 @@ def map_to_input(self, input_object: Input) -> Input: continue new_keyword = self._get_simple_keyword(validator, value) - input_object.add_simple_keywords(new_keyword) + if new_keyword: + input_object.add_simple_keywords(new_keyword) case (validator, key): block_type = Block.get_subclass_by_name(validator) @@ -191,6 +198,9 @@ def validate_fields(cls, value: typing.Any, info): 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: @@ -230,9 +240,9 @@ class TaskSettings(Settings): class MethodSettings(Settings): method: typing.Annotated[SimpleKeyword, Method] - basis_set: typing.Annotated[SimpleKeyword, BasisSet] - solvation_model: typing.Annotated[SimpleKeyword, SolvationModel] - solvent: typing.Annotated[str, Solvent] + basis_set: typing.Annotated[SimpleKeyword, BasisSet] | None = None + solvation_model: typing.Annotated[SimpleKeyword, SolvationModel] | None = None + solvent: typing.Annotated[str, Solvent] | None = None class SimpleTask(ABC): @@ -332,6 +342,7 @@ def run( ncores: int | None = None, memory: int | None = None, moinp: Path | None = None, + strict: bool = False ) -> "TaskResults": """ Execute the computational task with the given structure and settings. @@ -357,6 +368,8 @@ def run( 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 ------- @@ -364,10 +377,20 @@ def run( An instance of the configured results type containing the results of the calculation. """ + 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)") - # > recreate the working dir - shutil.rmtree(working_dir, ignore_errors=True) - working_dir.mkdir() + else: + # Non-strict: recreate directory + if working_dir.exists(): + shutil.rmtree(working_dir) + working_dir.mkdir() inp = self.input_object @@ -489,3 +512,19 @@ def status(self) -> bool: @abstractmethod def primary_property(self) -> typing.Any: pass + + def __getattr__(self, name): + """ + First tries to get attribute from the object itself. + If not found, tries to get it from self.output. + """ + # Check if 'output' exists to avoid infinite recursion + if name == 'output': + raise AttributeError(f"'{type(self).__name__}' object has no attribute 'output'") + + # Try to get the attribute from self.output + try: + return getattr(self.output, name) + except AttributeError: + raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") + From c009bfcffc4be223f1efdcfd49ce8c65c102a2d3 Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Wed, 15 Apr 2026 10:21:41 +0200 Subject: [PATCH 10/52] flesh out the different tasks --- src/opi/input/simple_keywords/base.py | 4 ++ src/opi/input/simple_keywords/opt.py | 27 +++++---- src/opi/tasks/engrad_task.py | 53 +++++++++++++++++ src/opi/tasks/freq_task.py | 49 ++++++++++++++++ src/opi/tasks/method_settings.py | 16 +++-- src/opi/tasks/opt_task.py | 84 +++++++++++++++++++++++++++ src/opi/tasks/task_base.py | 6 +- 7 files changed, 220 insertions(+), 19 deletions(-) create mode 100644 src/opi/tasks/engrad_task.py create mode 100644 src/opi/tasks/freq_task.py create mode 100644 src/opi/tasks/opt_task.py diff --git a/src/opi/input/simple_keywords/base.py b/src/opi/input/simple_keywords/base.py index 16a897e4..f71e03a7 100644 --- a/src/opi/input/simple_keywords/base.py +++ b/src/opi/input/simple_keywords/base.py @@ -4,6 +4,10 @@ class SimpleKeywordBox: + """ + TODO: + - rework registry to account for latest changes. + """ _registry: list[type["SimpleKeywordBox"]] = [] def __init_subclass__(cls, **kwargs: Any) -> None: 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/tasks/engrad_task.py b/src/opi/tasks/engrad_task.py new file mode 100644 index 00000000..921c4cfc --- /dev/null +++ b/src/opi/tasks/engrad_task.py @@ -0,0 +1,53 @@ +import typing + +from opi.input.simple_keywords import Task, SimpleKeyword, Solvent +from opi.tasks.method_settings import DFTSettings +from opi.tasks.task_base import SimpleTask, TaskSettings, TaskResults + + +class EngradSettings(TaskSettings): + _name: str = "engrad" + task_keyword: typing.Annotated[SimpleKeyword, Task] = Task.ENGRAD + + +class EngradTask(SimpleTask): + def __init__(self, + method: str | SimpleKeyword, + basis_set: str | SimpleKeyword | None = None, + solvation_model: str | SimpleKeyword | None = None, + solvent: str | Solvent | None = None, + task: str | SimpleKeyword | None = None): + self._method_settings = DFTSettings( + method=method, basis_set=basis_set, solvation_model=solvation_model, solvent=solvent + ) + self._task_settings = ( + EngradSettings(task_keyword=task) + ) if task else EngradSettings() + + self._results_type = EngradResults + + +class EngradResults(TaskResults): + @property + @TaskResults.output_parse + def final_energy(self) -> float: + 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 + + @property + @TaskResults.output_parse + def gradient(self) -> list[float]: + 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]]: + return self.final_energy, self.gradient \ No newline at end of file diff --git a/src/opi/tasks/freq_task.py b/src/opi/tasks/freq_task.py new file mode 100644 index 00000000..6a453941 --- /dev/null +++ b/src/opi/tasks/freq_task.py @@ -0,0 +1,49 @@ +import typing +from functools import cached_property + +from opi.input.simple_keywords import Task, SimpleKeyword, Solvent +from opi.tasks.method_settings import DFTSettings +from opi.tasks.task_base import TaskSettings, TaskResults, SimpleTask + + +class FreqSettings(TaskSettings): + _name: str = "freq" + task_keyword: typing.Annotated[SimpleKeyword, Task] = Task.FREQ + + +class FreqTask(SimpleTask): + def __init__(self, + method: str | SimpleKeyword, + basis_set: str | SimpleKeyword | None = None, + solvation_model: str | SimpleKeyword | None = None, + solvent: str | Solvent | None = None, + task: str | SimpleKeyword | None = None): + self._method_settings = DFTSettings( + method=method, basis_set=basis_set, solvation_model=solvation_model, solvent=solvent + ) + self._task_settings = ( + FreqSettings(task_keyword=task) + ) if task else FreqSettings() + + self._results_type = FreqResults + + +class FreqResults(TaskResults): + @cached_property + def status(self) -> bool: + return self.output.terminated_normally() + + @cached_property + @TaskResults.output_parse + def free_energy_delta(self) -> float: + free_energy_delta = self.output.get_free_energy_delta() + + if not free_energy_delta: + raise ValueError("Could not get free energy delta from ORCA output") + + return free_energy_delta + + @property + def primary_property(self) -> float: + return self.free_energy_delta + diff --git a/src/opi/tasks/method_settings.py b/src/opi/tasks/method_settings.py index 2e0dc603..273a4cec 100644 --- a/src/opi/tasks/method_settings.py +++ b/src/opi/tasks/method_settings.py @@ -3,6 +3,7 @@ from pydantic import field_validator, model_validator +from opi.input import Input from opi.input.simple_keywords import Dft, SimpleKeyword, Grid, DispersionCorrection from opi.input.simple_keywords.scf import ScfThreshold, ScfSolver, Scf, ScfConvergence from opi.tasks.task_base import MethodSettings @@ -15,18 +16,13 @@ class DFTSettings(MethodSettings): 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: typing.Annotated[SimpleKeyword, Scf] | None = None + scf_stab: bool = False scf_conv: typing.Annotated[SimpleKeyword, ScfConvergence] | None = None @field_validator("*", mode="before") @classmethod def validate_fields(cls, value: typing.Any, info): - if info.field_name == "scf_stab": - if value: - return Scf.SCFSTAB - else: - return None if info.field_name == "method": try: new_keyword = Dft.find_keyword(value) @@ -63,6 +59,14 @@ def cross_validate(cls, data: "DFTSettings") -> "DFTSettings": return data + def map_to_input(self, input_object: Input) -> Input: + 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: """ diff --git a/src/opi/tasks/opt_task.py b/src/opi/tasks/opt_task.py new file mode 100644 index 00000000..08d3a6d3 --- /dev/null +++ b/src/opi/tasks/opt_task.py @@ -0,0 +1,84 @@ +import typing + +from opi.input import Input +from opi.input.simple_keywords import Task, SimpleKeyword, Solvent +from opi.input.simple_keywords.opt import OptThreshold, Opt +from opi.input.structures import Structure +from opi.tasks.method_settings import DFTSettings +from opi.tasks.task_base import TaskSettings, TaskResults, SimpleTask + + +class OptSettings(TaskSettings): + _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: + 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 OptTask(SimpleTask): + def __init__(self, + method: str | SimpleKeyword, + basis_set: str | SimpleKeyword | None = None, + solvation_model: str | SimpleKeyword | None = None, + solvent: str | Solvent | None = None, + task: str | SimpleKeyword | None = None): + self._method_settings = DFTSettings( + method=method, basis_set=basis_set, solvation_model=solvation_model, solvent=solvent + ) + self._task_settings = ( + OptSettings(task_keyword=task) + ) if task else OptSettings() + + self._results_type = OptResults + + +class OptResults(TaskResults): + @property + @TaskResults.output_parse + def final_energy(self) -> float: + 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 + + + @property + @TaskResults.output_parse + def structure(self) -> Structure: + 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]: + return self.final_energy, self.structure + + + + diff --git a/src/opi/tasks/task_base.py b/src/opi/tasks/task_base.py index a3ce0add..c9af038f 100644 --- a/src/opi/tasks/task_base.py +++ b/src/opi/tasks/task_base.py @@ -22,6 +22,10 @@ class Settings(BaseModel): + """ + TODO: + - add checking for Solvent and SolvationModel now that they are optional. + """ model_config = ConfigDict(arbitrary_types_allowed=True, validate_assignment=True) _name: str @@ -235,7 +239,7 @@ def cross_validate(cls, data: dict[str, typing.Any]) -> dict[str, typing.Any]: class TaskSettings(Settings): - pass + task_keyword: typing.Annotated[SimpleKeyword, SimpleKeywordBox] class MethodSettings(Settings): From 5a8a881f5911a5fd249c82ee2448a43c696687cc Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Wed, 22 Apr 2026 10:07:16 +0200 Subject: [PATCH 11/52] example for simple task --- examples/exmp054_simpletask/inp.xyz | 5 ++++ examples/exmp054_simpletask/job.py | 45 +++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 examples/exmp054_simpletask/inp.xyz create mode 100644 examples/exmp054_simpletask/job.py diff --git a/examples/exmp054_simpletask/inp.xyz b/examples/exmp054_simpletask/inp.xyz new file mode 100644 index 00000000..8a2e30ef --- /dev/null +++ b/examples/exmp054_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/exmp054_simpletask/job.py b/examples/exmp054_simpletask/job.py new file mode 100644 index 00000000..b2a94e53 --- /dev/null +++ b/examples/exmp054_simpletask/job.py @@ -0,0 +1,45 @@ +import shutil +import sys +from pathlib import Path + +from opi.input.structures import Structure +from opi.tasks.singlepointtask import SinglePointTask, SinglePointResults + + +def run_exmp054( + structure: Structure | None = None, working_dir: Path | None = Path("RUN") +) -> SinglePointResults: + # > recreate the working dir + shutil.rmtree(working_dir, ignore_errors=True) + working_dir.mkdir() + + # > 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 + + # > run the calculation with given data + singlepoint_result = simple_task.run("job", structure) + # also possible for user to restart a calculation after changing any input options already given + + # > check if the ORCA calculation terminated normally + if not singlepoint_result.status: + print("SinglePoint task failed") + sys.exit(1) + + # > extract final energy from the `TaskResults` object + final_energy = singlepoint_result.final_energy + + print(f"Final single point energy: {final_energy: 10f} Eh") + + return singlepoint_result + + +if __name__ == "__main__": + run_exmp054() \ No newline at end of file From 525af5fe97c05f934695bfe2dbdac60e96c0f6b6 Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Wed, 22 Apr 2026 16:28:26 +0200 Subject: [PATCH 12/52] api changes --- examples/exmp054_simpletask/job.py | 2 +- src/opi/simpletasks/__init__.py | 0 src/opi/simpletasks/base_task.py | 395 +++++++++++++ src/opi/{tasks => simpletasks}/engrad_task.py | 23 +- src/opi/{tasks => simpletasks}/freq_task.py | 25 +- .../{tasks => simpletasks}/method_settings.py | 45 +- src/opi/{tasks => simpletasks}/opt_task.py | 65 ++- src/opi/simpletasks/settings.py | 252 +++++++++ .../{tasks => simpletasks}/singlepointtask.py | 27 +- src/opi/tasks/task_base.py | 534 ------------------ 10 files changed, 764 insertions(+), 604 deletions(-) create mode 100644 src/opi/simpletasks/__init__.py create mode 100644 src/opi/simpletasks/base_task.py rename src/opi/{tasks => simpletasks}/engrad_task.py (62%) rename src/opi/{tasks => simpletasks}/freq_task.py (56%) rename src/opi/{tasks => simpletasks}/method_settings.py (71%) rename src/opi/{tasks => simpletasks}/opt_task.py (53%) create mode 100644 src/opi/simpletasks/settings.py rename src/opi/{tasks => simpletasks}/singlepointtask.py (65%) delete mode 100644 src/opi/tasks/task_base.py diff --git a/examples/exmp054_simpletask/job.py b/examples/exmp054_simpletask/job.py index b2a94e53..1534cd73 100644 --- a/examples/exmp054_simpletask/job.py +++ b/examples/exmp054_simpletask/job.py @@ -3,7 +3,7 @@ from pathlib import Path from opi.input.structures import Structure -from opi.tasks.singlepointtask import SinglePointTask, SinglePointResults +from opi.simpletasks.singlepointtask import SinglePointTask, SinglePointResults def run_exmp054( diff --git a/src/opi/simpletasks/__init__.py b/src/opi/simpletasks/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/opi/simpletasks/base_task.py b/src/opi/simpletasks/base_task.py new file mode 100644 index 00000000..81a8cc77 --- /dev/null +++ b/src/opi/simpletasks/base_task.py @@ -0,0 +1,395 @@ +import shutil +import typing +from abc import ABC, abstractmethod +from functools import cached_property, wraps +from pathlib import Path + +from opi.core import Calculator +from opi.input import Input +from opi.input.simple_keywords import ( + Method, + SimpleKeyword, + SimpleKeywordBox, + Solvent, ForceField, Dft, Sqm, Wft, +) +from opi.input.structures import BaseStructureFile, Structure +from opi.output.core import Output +from opi.simpletasks.method_settings import MethodSettings +from opi.simpletasks.settings import Settings + + +class TaskSettings(Settings): + task_keyword: typing.Annotated[SimpleKeyword, SimpleKeywordBox] + + +class SimpleTask(ABC): + _results_type: type["TaskResults"] + _task_settings_type: type[TaskSettings] + _task_settings: TaskSettings + _method_settings: MethodSettings + + @classmethod + def resolve_method_settings_type(cls, method: str | SimpleKeyword) -> typing.Type[MethodSettings]: + from opi.simpletasks.method_settings import DFTSettings, SQMSettings, WftSettings, HFSettings, ForceFieldSettings + + 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") + + def __init__(self, method: str | SimpleKeyword, + basis_set: str | SimpleKeyword | None = None, + solvation_model: str | SimpleKeyword | None = None, + solvent: str | Solvent | None = None, + task_settings: TaskSettings | None = None, + method_settings: MethodSettings | None = None): + resolved_methods_settings_type = self.resolve_method_settings_type(method) + user_method_settings = resolved_methods_settings_type(method=method, basis_set=basis_set, solvation_model=solvation_model, solvent=solvent) + self._task_settings = task_settings or self._task_settings_type() + if method_settings: + self._method_settings = method_settings | user_method_settings #type:ignore + else: + self._method_settings = user_method_settings + + + @property + def task_settings(self) -> TaskSettings: + return self._task_settings + + @property + def method_settings(self) -> MethodSettings: + return self._method_settings + + @property + def input_object(self) -> Input: + """ + Creates configured `Input` object. First it initializes an empty instance of `Input` , and then passes it as + to corresponding `TaskSettings` and `MethodSettings` objects to be configured by user-defined data stored in those + objects. + + Returns + ------- + `Input` + `Input` object configured by user-defined data. + + """ + inp = Input() + inp = self._task_settings.map_to_input(input_object=inp) + inp = self.method_settings.map_to_input(input_object=inp) + return inp + + @property + def keyword(self) -> SimpleKeyword: + return self._task_settings.task_keyword + + @property + def method(self) -> SimpleKeyword | None: + if hasattr(self._method_settings, "method"): + return self._method_settings.method + return None + + @method.setter + def method(self, new_value: str | SimpleKeyword | None) -> None: + if not hasattr(self._method_settings, "method"): + raise AttributeError("method is not defined in method_settings object") + self._method_settings.method = new_value #type:ignore + + @property + def basis_set(self) -> SimpleKeyword | None: + if 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: + 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: + if hasattr(self._method_settings, "solvent"): + return self._method_settings.solvent + return None + + @solvent.setter + def solvent(self, new_value: str | None) -> None: + 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: + if 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: + 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 + + @property + def grid(self) -> SimpleKeyword | None: + if hasattr(self._method_settings, "grid"): + return typing.cast(SimpleKeyword | None, self._method_settings.grid) + return None + + @grid.setter + def grid(self, new_value: str | SimpleKeyword | None) -> None: + if not hasattr(self._method_settings, "grid"): + raise AttributeError("grid is not defined in method_settings object") + self._method_settings.grid = new_value + + @property + def scf_maxiter(self) -> int | None: + if hasattr(self._method_settings, "scf_maxiter"): + return typing.cast(int | None, self._method_settings.scf_maxiter) + return None + + @scf_maxiter.setter + def scf_maxiter(self, new_value: int | None) -> None: + if not hasattr(self._method_settings, "scf_maxiter"): + raise AttributeError("scf_maxiter is not defined in method_settings object") + self._method_settings.scf_maxiter = new_value + + @property + def scf_threshold(self) -> SimpleKeyword | None: + if hasattr(self._method_settings, "scf_threshold"): + return typing.cast(SimpleKeyword | None, self._method_settings.scf_threshold) + return None + + @scf_threshold.setter + def scf_threshold(self, new_value: str | SimpleKeyword | None) -> None: + if not hasattr(self._method_settings, "scf_threshold"): + raise AttributeError("scf_threshold is not defined in method_settings object") + self._method_settings.scf_threshold = new_value + + @property + def scf_solver(self) -> SimpleKeyword | None: + if hasattr(self._method_settings, "scf_solver"): + return typing.cast(SimpleKeyword| None, self._method_settings.scf_solver) + return None + + @scf_solver.setter + def scf_solver(self, new_value: str | SimpleKeyword | None) -> None: + if not hasattr(self._method_settings, "scf_solver"): + raise AttributeError("scf_solver is not defined in method_settings object") + self._method_settings.scf_solver = new_value + + @property + def scf_stab(self) -> bool | None: + if hasattr(self._method_settings, "scf_stab"): + return typing.cast(bool | None, self._method_settings.scf_stab) + return None + + @scf_stab.setter + def scf_stab(self, new_value: bool | None) -> None: + if not hasattr(self._method_settings, "scf_stab"): + raise AttributeError("scf_stab is not defined in method_settings object") + self._method_settings.scf_stab = new_value + + @property + def scf_conv(self) -> SimpleKeyword | None: + if hasattr(self._method_settings, "scf_conv"): + return typing.cast(SimpleKeyword | None, self._method_settings.scf_conv) + return None + + @scf_conv.setter + def scf_conv(self, new_value: str | SimpleKeyword | None) -> None: + if not hasattr(self._method_settings, "scf_conv"): + raise AttributeError("scf_conv is not defined in method_settings object") + self._method_settings.scf_conv = new_value + + def run( + self, + basename: str, + struct: Structure | BaseStructureFile, + working_dir: Path = Path("RUN"), + ncores: int | None = None, + memory: int | None = None, + moinp: Path | None = None, + strict: bool = False + ) -> "TaskResults": + """ + 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. + struct : 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. + """ + 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_object + + 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 = struct + calc.input = inp + + calc.write_and_run() + + return self._results_type(calc_object=calc) + + def _restart( + self, + previous_results: "TaskResults", + basename: str | None = None, + struct: 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, + ) -> "TaskResults": + """ + TODO: + - finish restart implementation (low on priority list) + Parameters + ---------- + previous_results + basename + struct + working_dir + ncores + memory + moinp + use_previous_orbitals + + Returns + ------- + + """ + prev_calc = previous_results.calc_object + + basename = basename if basename else prev_calc.basename + struct = struct if struct else prev_calc.structure + 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): + def __init__(self, calc_object: Calculator): + self.calc_object = calc_object + self._parsed = False + + @staticmethod + def output_parse( + func: typing.Callable[[typing.Any], typing.Any], + ) -> typing.Callable[[typing.Any], typing.Any]: + """ + Decorator to ensure output parsing is performed before accessing results. + + This decorator wraps methods of a `TaskResults` instance and guarantees + that the associated output has been parsed before the method is executed. + Parsing is performed lazily and only once per instance. + + Parameters + ---------- + func : Callable[[TaskResults], Any] + The method to wrap. It must be a method of `TaskResults` that relies + on parsed output data. + + Returns + ------- + Callable[[TaskResults], Any] + A wrapped method that ensures `self.output.parse()` has been called + before delegating to the original function. + """ + + @wraps(func) + def wrapper(self: "TaskResults") -> typing.Any: + if not self._parsed: + self.output.parse() + self._parsed = True + return func(self) + + return wrapper + + @cached_property + def output(self) -> Output: + if not self.calc_object: + raise ValueError("calc_object not set") + + return self.calc_object.get_output() + + @cached_property + def status(self) -> bool: + return self.output.terminated_normally() and self.output.scf_converged() + + @cached_property + @abstractmethod + def primary_property(self) -> typing.Any: + pass + + diff --git a/src/opi/tasks/engrad_task.py b/src/opi/simpletasks/engrad_task.py similarity index 62% rename from src/opi/tasks/engrad_task.py rename to src/opi/simpletasks/engrad_task.py index 921c4cfc..7b9ebe64 100644 --- a/src/opi/tasks/engrad_task.py +++ b/src/opi/simpletasks/engrad_task.py @@ -1,8 +1,8 @@ import typing from opi.input.simple_keywords import Task, SimpleKeyword, Solvent -from opi.tasks.method_settings import DFTSettings -from opi.tasks.task_base import SimpleTask, TaskSettings, TaskResults +from opi.simpletasks.base_task import SimpleTask, TaskSettings, TaskResults +from opi.simpletasks.method_settings import MethodSettings class EngradSettings(TaskSettings): @@ -11,18 +11,13 @@ class EngradSettings(TaskSettings): class EngradTask(SimpleTask): - def __init__(self, - method: str | SimpleKeyword, - basis_set: str | SimpleKeyword | None = None, - solvation_model: str | SimpleKeyword | None = None, - solvent: str | Solvent | None = None, - task: str | SimpleKeyword | None = None): - self._method_settings = DFTSettings( - method=method, basis_set=basis_set, solvation_model=solvation_model, solvent=solvent - ) - self._task_settings = ( - EngradSettings(task_keyword=task) - ) if task else EngradSettings() + _task_settings: EngradSettings + + def __init__(self, method: str | SimpleKeyword, basis_set: str | SimpleKeyword | None = None, + solvation_model: str | SimpleKeyword | None = None, solvent: str | Solvent | None = None, + task_settings: EngradSettings | None = None, method_settings: MethodSettings | None = None): + self._task_settings_type = EngradSettings + super().__init__(method, basis_set, solvation_model, solvent, task_settings, method_settings) self._results_type = EngradResults diff --git a/src/opi/tasks/freq_task.py b/src/opi/simpletasks/freq_task.py similarity index 56% rename from src/opi/tasks/freq_task.py rename to src/opi/simpletasks/freq_task.py index 6a453941..a9c4ef46 100644 --- a/src/opi/tasks/freq_task.py +++ b/src/opi/simpletasks/freq_task.py @@ -2,8 +2,8 @@ from functools import cached_property from opi.input.simple_keywords import Task, SimpleKeyword, Solvent -from opi.tasks.method_settings import DFTSettings -from opi.tasks.task_base import TaskSettings, TaskResults, SimpleTask +from opi.simpletasks.base_task import TaskSettings, TaskResults, SimpleTask +from opi.simpletasks.method_settings import MethodSettings class FreqSettings(TaskSettings): @@ -12,18 +12,13 @@ class FreqSettings(TaskSettings): class FreqTask(SimpleTask): - def __init__(self, - method: str | SimpleKeyword, - basis_set: str | SimpleKeyword | None = None, - solvation_model: str | SimpleKeyword | None = None, - solvent: str | Solvent | None = None, - task: str | SimpleKeyword | None = None): - self._method_settings = DFTSettings( - method=method, basis_set=basis_set, solvation_model=solvation_model, solvent=solvent - ) - self._task_settings = ( - FreqSettings(task_keyword=task) - ) if task else FreqSettings() + _task_settings: FreqSettings + + def __init__(self, method: str | SimpleKeyword, basis_set: str | SimpleKeyword | None = None, + solvation_model: str | SimpleKeyword | None = None, solvent: str | Solvent | None = None, + task_settings: FreqSettings | None = None, method_settings: MethodSettings | None = None): + self._task_settings_type = FreqSettings + super().__init__(method, basis_set, solvation_model, solvent, task_settings, method_settings) self._results_type = FreqResults @@ -45,5 +40,5 @@ def free_energy_delta(self) -> float: @property def primary_property(self) -> float: - return self.free_energy_delta + return float(self.free_energy_delta) diff --git a/src/opi/tasks/method_settings.py b/src/opi/simpletasks/method_settings.py similarity index 71% rename from src/opi/tasks/method_settings.py rename to src/opi/simpletasks/method_settings.py index 273a4cec..acad01a5 100644 --- a/src/opi/tasks/method_settings.py +++ b/src/opi/simpletasks/method_settings.py @@ -2,16 +2,26 @@ import warnings from pydantic import field_validator, model_validator +from pydantic_core.core_schema import ValidationInfo from opi.input import Input -from opi.input.simple_keywords import Dft, SimpleKeyword, Grid, DispersionCorrection +from opi.input.simple_keywords import Dft, SimpleKeyword, Grid, DispersionCorrection, Sqm, Wft, Method, ForceField, \ + BasisSet, SolvationModel, Solvent from opi.input.simple_keywords.scf import ScfThreshold, ScfSolver, Scf, ScfConvergence -from opi.tasks.task_base import MethodSettings +from opi.simpletasks.settings import Settings + + + +class MethodSettings(Settings): + 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 class DFTSettings(MethodSettings): _name: str = "dft" - method: typing.Annotated[SimpleKeyword, 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 @@ -22,7 +32,7 @@ class DFTSettings(MethodSettings): @field_validator("*", mode="before") @classmethod - def validate_fields(cls, value: typing.Any, info): + def validate_fields(cls, value: typing.Any, info: ValidationInfo) -> typing.Any: if info.field_name == "method": try: new_keyword = Dft.find_keyword(value) @@ -35,8 +45,7 @@ def validate_fields(cls, value: typing.Any, info): @model_validator(mode="after") - @classmethod - def cross_validate(cls, data: "DFTSettings") -> "DFTSettings": + def cross_validate(self) -> "DFTSettings": """ Cross-validation for `DftSettings`. If the method keyword contains '3c', the `basis_set` attribute will be set to `None`. @@ -53,11 +62,11 @@ def cross_validate(cls, data: "DFTSettings") -> "DFTSettings": Cross-validated `DFTSettings` object. """ - if "3c" in data.method.keyword and data.basis_set: + 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) - data.basis_set = None + self.basis_set = None - return data + return self def map_to_input(self, input_object: Input) -> Input: input_object = super().map_to_input(input_object) @@ -107,4 +116,22 @@ def _find_dft_disp_keyword(cls, value: str | SimpleKeyword) -> SimpleKeyword: raise ValueError(f"Invalid Dft keyword '{value}'") +class SQMSettings(MethodSettings): + _name: str = "sqm" + method: typing.Annotated[SimpleKeyword, Sqm] + + +class WftSettings(MethodSettings): + _name: str = "wft" + method: typing.Annotated[SimpleKeyword, Wft] + +class HFSettings(MethodSettings): + _name: str = "hf" + method: typing.Annotated[SimpleKeyword, Method] + +class ForceFieldSettings(MethodSettings): + _name: str = "forcefield" + method: typing.Annotated[SimpleKeyword, ForceField] + + diff --git a/src/opi/tasks/opt_task.py b/src/opi/simpletasks/opt_task.py similarity index 53% rename from src/opi/tasks/opt_task.py rename to src/opi/simpletasks/opt_task.py index 08d3a6d3..01846f50 100644 --- a/src/opi/tasks/opt_task.py +++ b/src/opi/simpletasks/opt_task.py @@ -4,8 +4,8 @@ from opi.input.simple_keywords import Task, SimpleKeyword, Solvent from opi.input.simple_keywords.opt import OptThreshold, Opt from opi.input.structures import Structure -from opi.tasks.method_settings import DFTSettings -from opi.tasks.task_base import TaskSettings, TaskResults, SimpleTask +from opi.simpletasks.base_task import TaskSettings, TaskResults, SimpleTask +from opi.simpletasks.method_settings import MethodSettings class OptSettings(TaskSettings): @@ -37,22 +37,59 @@ def map_to_input(self, input_object: Input) -> Input: class OptTask(SimpleTask): - def __init__(self, - method: str | SimpleKeyword, - basis_set: str | SimpleKeyword | None = None, - solvation_model: str | SimpleKeyword | None = None, - solvent: str | Solvent | None = None, - task: str | SimpleKeyword | None = None): - self._method_settings = DFTSettings( - method=method, basis_set=basis_set, solvation_model=solvation_model, solvent=solvent - ) - self._task_settings = ( - OptSettings(task_keyword=task) - ) if task else OptSettings() + _task_settings: OptSettings + + def __init__(self, method: str | SimpleKeyword, basis_set: str | SimpleKeyword | None = None, + solvation_model: str | SimpleKeyword | None = None, solvent: str | Solvent | None = None, + task_settings: OptSettings | None = None, method_settings: MethodSettings | None = None): + self._task_settings_type = OptSettings + super().__init__(method, basis_set, solvation_model, solvent, task_settings, method_settings) self._results_type = OptResults + @property + def opt_threshold(self) -> SimpleKeyword | None: + 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: + 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: + 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: + 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: + 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 + + + class OptResults(TaskResults): @property @TaskResults.output_parse diff --git a/src/opi/simpletasks/settings.py b/src/opi/simpletasks/settings.py new file mode 100644 index 00000000..16fc9147 --- /dev/null +++ b/src/opi/simpletasks/settings.py @@ -0,0 +1,252 @@ +import typing + +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 SimpleKeywordBox, SimpleKeyword, SolvationModel, Solvent +from opi.input.simple_keywords.solvation_model import SolvationModelAndSolvent + + +class Settings(BaseModel): + """ + TODO: + - add checking for Solvent and SolvationModel now that they are optional. + """ + model_config = ConfigDict(arbitrary_types_allowed=True, validate_assignment=True) + _name: str + + 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: typing.Any) -> tuple: + """ + 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) + if origin is typing.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]) + return args[1:] if len(args) > 1 else () + + @staticmethod + def _resolve_field_value( + value: typing.Any, metadata: tuple[type["SimpleKeywordBox"]] | tuple[str, str] + ) -> typing.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. + + 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. + + + Parameters + ---------- + value: typing.Any + User input value. + metadata: tuple + Tuple of metadata about the field. + + Returns + ------- + typing.Any + User input value translated to OPI compatible types. + + """ + match metadata: + case (validator,): + # Case 1: Simple keyword + if not isinstance(validator, type) or not issubclass(validator, SimpleKeywordBox): + raise TypeError(f"Expected SimpleKeywordBox subclass, got {validator}") + return validator.find_keyword(value) + + case (validator, key): + # Case 2: Block option + if not isinstance(validator, type) or not issubclass(validator, Block): + raise TypeError(f"Expected Block subclass, got {validator}") + 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 _: + return value + + def _get_simple_keyword(self, validator: type[SimpleKeywordBox], value: str | SimpleKeyword | SolvationModelAndSolvent) -> SimpleKeyword: + if validator == SolvationModel: + solvent_value = getattr(self, "solvent", None) + if solvent_value is None: + raise ValueError("solvent is required for SolvationModel") + solvent = Solvent(str(solvent_value)) + if not isinstance(value, SolvationModelAndSolvent): + raise TypeError("Wrong type for solvent or solvation model") + + new_keyword = value(solvent) + else: + new_keyword = validator.find_keyword(value) + + return new_keyword + + + def __or__(self, other: "Settings") -> "Settings": + if not type(self) == 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. + """ + 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,): + if validator == Solvent: + continue + + new_keyword = self._get_simple_keyword(validator, value) + if new_keyword: + input_object.add_simple_keywords(new_keyword) + + case (validator, key): + block_type = Block.get_subclass_by_name(validator) + block_class = block_type(**{key: value}) + + block_exists, *_ = input_object.has_blocks(block_type) #type:ignore + if not block_exists: + input_object.add_blocks(block_class) + else: + existing_block = next(iter(input_object.get_blocks(block_type).values())) + new_block = existing_block + block_class + input_object.add_blocks(new_block, overwrite=True) + + return input_object + + @field_validator("*", mode="before") + @classmethod + def validate_fields(cls, value: typing.Any, info: ValidationInfo) -> typing.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 + 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, typing.Any]) -> dict[str, typing.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/tasks/singlepointtask.py b/src/opi/simpletasks/singlepointtask.py similarity index 65% rename from src/opi/tasks/singlepointtask.py rename to src/opi/simpletasks/singlepointtask.py index 9d98de5d..f45993b0 100644 --- a/src/opi/tasks/singlepointtask.py +++ b/src/opi/simpletasks/singlepointtask.py @@ -3,8 +3,8 @@ from opi.input.simple_keywords import SimpleKeyword, Solvent, Task from opi.input.structures import BaseStructureFile, Structure -from opi.tasks.method_settings import DFTSettings -from opi.tasks.task_base import SimpleTask, TaskResults, TaskSettings +from opi.simpletasks.method_settings import DFTSettings +from opi.simpletasks.base_task import SimpleTask, TaskResults, TaskSettings class SinglePointSettings(TaskSettings): @@ -13,22 +13,15 @@ class SinglePointSettings(TaskSettings): class SinglePointTask(SimpleTask): - def __init__( - self, - method: str | SimpleKeyword, - basis_set: str | SimpleKeyword | None = None, - solvation_model: str | SimpleKeyword | None = None, - solvent: str | Solvent | None = None, - task: str | SimpleKeyword | None = None, - ): - self._method_settings = DFTSettings( - method=method, basis_set=basis_set, solvation_model=solvation_model, solvent=solvent - ) - self._task_settings = ( - SinglePointSettings(task_keyword=task) - ) if task else SinglePointSettings() + _task_settings: SinglePointSettings + + def __init__(self, method: str | SimpleKeyword, basis_set: str | SimpleKeyword | None = None, + solvation_model: str | SimpleKeyword | None = None, solvent: str | Solvent | None = None, + task_settings: SinglePointSettings | None = None, method_settings: DFTSettings | None = None): + self._task_settings_type = SinglePointSettings + super().__init__(method, basis_set, solvation_model, solvent, task_settings, method_settings) - self._results_type = SinglePointResults + self._results_type = SinglePointResults def run( self, diff --git a/src/opi/tasks/task_base.py b/src/opi/tasks/task_base.py deleted file mode 100644 index c9af038f..00000000 --- a/src/opi/tasks/task_base.py +++ /dev/null @@ -1,534 +0,0 @@ -import shutil -import typing -from abc import ABC, abstractmethod -from functools import cached_property, wraps -from pathlib import Path - -from pydantic import BaseModel, ConfigDict, field_validator, model_validator - -from opi.core import Calculator -from opi.input import Input -from opi.input.blocks import Block -from opi.input.simple_keywords import ( - BasisSet, - Method, - SimpleKeyword, - SimpleKeywordBox, - SolvationModel, - Solvent, -) -from opi.input.structures import BaseStructureFile, Structure -from opi.output.core import Output - - -class Settings(BaseModel): - """ - TODO: - - add checking for Solvent and SolvationModel now that they are optional. - """ - model_config = ConfigDict(arbitrary_types_allowed=True, validate_assignment=True) - _name: str - - 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: typing.Any) -> tuple: - """ - 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) - if origin is typing.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]) - return args[1:] if len(args) > 1 else () - - @staticmethod - def _resolve_field_value( - value: typing.Any, metadata: tuple[type["SimpleKeywordBox"]] | tuple[str, str] - ) -> typing.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. - - 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. - - - Parameters - ---------- - value: typing.Any - User input value. - metadata: tuple - Tuple of metadata about the field. - - Returns - ------- - typing.Any - User input value translated to OPI compatible types. - - """ - match metadata: - case (validator,): - return validator.find_keyword(value) - - case (validator, key): - block_cls = Block.get_subclass_by_name(validator) - instance = block_cls.model_validate({key: value}) - return getattr(instance, key) - - case _: - return value - - def _get_simple_keyword(self, validator: type[SimpleKeywordBox], value) -> SimpleKeyword: - if validator == SolvationModel: - solvent = getattr(self, "solvent", None) - new_keyword = value(solvent) - else: - new_keyword = value - - return new_keyword - - 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. - """ - 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,): - if validator == Solvent: - continue - - new_keyword = self._get_simple_keyword(validator, value) - if new_keyword: - input_object.add_simple_keywords(new_keyword) - - case (validator, key): - block_type = Block.get_subclass_by_name(validator) - block_class = block_type(**{key: value}) - - block_exists, *_ = input_object.has_blocks(block_type) - if not block_exists: - input_object.add_blocks(block_class) - else: - existing_block = next(iter(input_object.get_blocks(block_type).values())) - new_block = existing_block + block_class - input_object.add_blocks(new_block, overwrite=True) - - return input_object - - @field_validator("*", mode="before") - @classmethod - def validate_fields(cls, value: typing.Any, info): - """ - 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 - 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, typing.Any]) -> dict[str, typing.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 - - -class TaskSettings(Settings): - task_keyword: typing.Annotated[SimpleKeyword, SimpleKeywordBox] - - -class MethodSettings(Settings): - method: typing.Annotated[SimpleKeyword, Method] - basis_set: typing.Annotated[SimpleKeyword, BasisSet] | None = None - solvation_model: typing.Annotated[SimpleKeyword, SolvationModel] | None = None - solvent: typing.Annotated[str, Solvent] | None = None - - -class SimpleTask(ABC): - _results_type: type["TaskResults"] - _task_settings: TaskSettings - _method_settings: MethodSettings - - @property - def task_settings(self) -> TaskSettings: - return self._task_settings - - @property - def method_settings(self) -> MethodSettings: - return self._method_settings - - @property - def input_object(self) -> Input: - """ - Creates configured `Input` object. First it initializes an empty instance of `Input` , and then passes it as - to corresponding `TaskSettings` and `MethodSettings` objects to be configured by user-defined data stored in those - objects. - - Returns - ------- - `Input` - `Input` object configured by user-defined data. - - """ - inp = Input() - inp = self._task_settings.map_to_input(input_object=inp) - inp = self.method_settings.map_to_input(input_object=inp) - return inp - - def __getattr__(self, name: str) -> typing.Any: - """ - Dynamically resolve attribute access by delegating to internal settings objects. - - This method is called when an attribute is not found on the instance through - the normal lookup process. It attempts to retrieve the attribute from the - internal `_task_settings` and `_method_settings` objects, in that order. - - Parameters - ---------- - name: str - Attribute name. - - Returns - -------- - Any - Attribute value. - """ - if name.startswith("_"): - raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") - - try: - return getattr(self._task_settings, name) - except AttributeError: - pass - - try: - return getattr(self._method_settings, name) - except AttributeError: - raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") - - def __setattr__(self, name: str, value: typing.Any) -> None: - """ - Dynamically assign attributes, delegating to internal settings objects when appropriate. - - This method overrides the default attribute assignment behavior to route - assignments to `_task_settings` or `_method_settings` if the attribute - exists. Otherwise, the attribute is set directly on the instance. - - Parameters - ---------- - name : str - The name of the attribute to assign. - value : Any - The value to assign to the attribute. - """ - # Allow setting private attributes and special attributes normally - if name.startswith("_") or name in ("task_settings", "run"): - super().__setattr__(name, value) - else: - # Check if _task_settings exists and has this attribute - if hasattr(self, "_task_settings") and hasattr(self._task_settings, name): - setattr(self._task_settings, name, value) - elif hasattr(self, "_method_settings") and hasattr(self._method_settings, name): - setattr(self._method_settings, name, value) - else: - super().__setattr__(name, value) - - def run( - self, - basename: str, - struct: Structure | BaseStructureFile, - working_dir: Path = Path("RUN"), - ncores: int | None = None, - memory: int | None = None, - moinp: Path | None = None, - strict: bool = False - ) -> "TaskResults": - """ - 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. - struct : 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. - """ - 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_object - - 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 = struct - calc.input = inp - - calc.write_and_run() - - return self._results_type(calc_object=calc) - - def _restart( - self, - previous_results: "TaskResults", - basename: str | None = None, - struct: 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, - ) -> "TaskResults": - """ - TODO: - - finish restart implementation (low on priority list) - Parameters - ---------- - previous_results - basename - struct - working_dir - ncores - memory - moinp - use_previous_orbitals - - Returns - ------- - - """ - prev_calc = previous_results.calc_object - - basename = basename if basename else prev_calc.basename - struct = struct if struct else prev_calc.structure - 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): - def __init__(self, calc_object: Calculator): - self.calc_object = calc_object - self._parsed = False - - @staticmethod - def output_parse( - func: typing.Callable[["TaskResults"], typing.Any], - ) -> typing.Callable[["TaskResults"], typing.Any]: - """ - Decorator to ensure output parsing is performed before accessing results. - - This decorator wraps methods of a `TaskResults` instance and guarantees - that the associated output has been parsed before the method is executed. - Parsing is performed lazily and only once per instance. - - Parameters - ---------- - func : Callable[[TaskResults], Any] - The method to wrap. It must be a method of `TaskResults` that relies - on parsed output data. - - Returns - ------- - Callable[[TaskResults], Any] - A wrapped method that ensures `self.output.parse()` has been called - before delegating to the original function. - """ - - @wraps(func) - def wrapper(self: "TaskResults"): - if not self._parsed: - self.output.parse() - self._parsed = True - return func(self) - - return wrapper - - @cached_property - def output(self) -> Output: - if not self.calc_object: - raise ValueError("calc_object not set") - - return self.calc_object.get_output() - - @cached_property - def status(self) -> bool: - return self.output.terminated_normally() and self.output.scf_converged() - - @cached_property - @abstractmethod - def primary_property(self) -> typing.Any: - pass - - def __getattr__(self, name): - """ - First tries to get attribute from the object itself. - If not found, tries to get it from self.output. - """ - # Check if 'output' exists to avoid infinite recursion - if name == 'output': - raise AttributeError(f"'{type(self).__name__}' object has no attribute 'output'") - - # Try to get the attribute from self.output - try: - return getattr(self.output, name) - except AttributeError: - raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") - From ebf38ea75548ae7ff4f25a1b20246fb66a71b5b5 Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Fri, 24 Apr 2026 16:50:31 +0200 Subject: [PATCH 13/52] fixed nox errors --- examples/exmp054_simpletask/job.py | 11 ++-- src/opi/input/simple_keywords/base.py | 10 +++- src/opi/input/simple_keywords/scf.py | 1 + src/opi/simpletasks/base_task.py | 76 +++++++++++++++++--------- src/opi/simpletasks/engrad_task.py | 22 +++++--- src/opi/simpletasks/freq_task.py | 25 ++++++--- src/opi/simpletasks/method_settings.py | 30 ++++++---- src/opi/simpletasks/opt_task.py | 36 ++++++------ src/opi/simpletasks/settings.py | 30 +++++----- src/opi/simpletasks/singlepointtask.py | 26 ++++++--- 10 files changed, 162 insertions(+), 105 deletions(-) diff --git a/examples/exmp054_simpletask/job.py b/examples/exmp054_simpletask/job.py index 1534cd73..9b96ed7b 100644 --- a/examples/exmp054_simpletask/job.py +++ b/examples/exmp054_simpletask/job.py @@ -3,7 +3,7 @@ from pathlib import Path from opi.input.structures import Structure -from opi.simpletasks.singlepointtask import SinglePointTask, SinglePointResults +from opi.simpletasks.singlepointtask import SinglePointResults, SinglePointTask def run_exmp054( @@ -18,10 +18,9 @@ def run_exmp054( structure = Structure.from_xyz("inp.xyz") # > set up the task - simple_task = SinglePointTask(method = "b3lyp", - basis_set="def2-svp", - solvation_model="cpcm", - solvent="water") + 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 # > run the calculation with given data @@ -42,4 +41,4 @@ def run_exmp054( if __name__ == "__main__": - run_exmp054() \ No newline at end of file + run_exmp054() diff --git a/src/opi/input/simple_keywords/base.py b/src/opi/input/simple_keywords/base.py index f71e03a7..e33a24dc 100644 --- a/src/opi/input/simple_keywords/base.py +++ b/src/opi/input/simple_keywords/base.py @@ -8,6 +8,7 @@ class SimpleKeywordBox: TODO: - rework registry to account for latest changes. """ + _registry: list[type["SimpleKeywordBox"]] = [] def __init_subclass__(cls, **kwargs: Any) -> None: @@ -29,14 +30,16 @@ def from_string(cls, s: str) -> "SimpleKeyword": norm = s.lower() for c in cls._registry: for attr in dir(c): - if attr.startswith('_'): # Skip private/magic attributes + if attr.startswith("_"): # Skip private/magic attributes continue value = getattr(c, attr) if isinstance(value, SimpleKeyword) and value.keyword.lower() == norm: return value elif isinstance(value, SimpleKeyword) and attr.lower() == norm: return value - elif isinstance(value, SimpleKeyword) and value.alias and value.alias.lower() == norm: + elif ( + isinstance(value, SimpleKeyword) and value.alias and value.alias.lower() == norm + ): return value raise ValueError(f"Keyword {s} not found in class {cls.__name__}") @@ -61,9 +64,10 @@ class SimpleKeyword: keyword: str simple keyword as it will appear in the ORCA .inp file """ + alias: str | None = None - def __init__(self, keyword: str, alias:str|None=None) -> None: + def __init__(self, keyword: str, alias: str | None = None) -> None: self._keyword: str = "" self.keyword = keyword self._name: str = "" diff --git a/src/opi/input/simple_keywords/scf.py b/src/opi/input/simple_keywords/scf.py index f4d53929..201bdcf6 100644 --- a/src/opi/input/simple_keywords/scf.py +++ b/src/opi/input/simple_keywords/scf.py @@ -5,6 +5,7 @@ __all__ = ("Scf",) + class ScfThreshold(SimpleKeywordBox): SLOPPYSCF = SimpleKeyword("sloppyscf", alias="sloppy") """SimpleKeyword: SCF convergence threshold settings.""" diff --git a/src/opi/simpletasks/base_task.py b/src/opi/simpletasks/base_task.py index 81a8cc77..be9f2c1e 100644 --- a/src/opi/simpletasks/base_task.py +++ b/src/opi/simpletasks/base_task.py @@ -7,10 +7,14 @@ from opi.core import Calculator from opi.input import Input from opi.input.simple_keywords import ( + Dft, + ForceField, Method, SimpleKeyword, SimpleKeywordBox, - Solvent, ForceField, Dft, Sqm, Wft, + Solvent, + Sqm, + Wft, ) from opi.input.structures import BaseStructureFile, Structure from opi.output.core import Output @@ -24,20 +28,28 @@ class TaskSettings(Settings): class SimpleTask(ABC): _results_type: type["TaskResults"] - _task_settings_type: type[TaskSettings] + _task_settings_type: typing.Callable[[], TaskSettings] _task_settings: TaskSettings _method_settings: MethodSettings @classmethod - def resolve_method_settings_type(cls, method: str | SimpleKeyword) -> typing.Type[MethodSettings]: - from opi.simpletasks.method_settings import DFTSettings, SQMSettings, WftSettings, HFSettings, ForceFieldSettings + def resolve_method_settings_type( + cls, method: str | SimpleKeyword + ) -> typing.Type[MethodSettings]: + from opi.simpletasks.method_settings import ( + DFTSettings, + ForceFieldSettings, + HFSettings, + SQMSettings, + WftSettings, + ) enum_to_settings = { - Dft : DFTSettings, - Sqm : SQMSettings, - Wft : WftSettings, - Method : HFSettings, - ForceField : ForceFieldSettings + Dft: DFTSettings, + Sqm: SQMSettings, + Wft: WftSettings, + Method: HFSettings, + ForceField: ForceFieldSettings, } for enum_class, settings_type in enum_to_settings.items(): try: @@ -48,21 +60,25 @@ def resolve_method_settings_type(cls, method: str | SimpleKeyword) -> typing.Typ raise ValueError(f"Keyword {method} not found in any of the valid groupings") - def __init__(self, method: str | SimpleKeyword, - basis_set: str | SimpleKeyword | None = None, - solvation_model: str | SimpleKeyword | None = None, - solvent: str | Solvent | None = None, - task_settings: TaskSettings | None = None, - method_settings: MethodSettings | None = None): + def __init__( + self, + method: str | SimpleKeyword, + basis_set: str | SimpleKeyword | None = None, + solvation_model: str | SimpleKeyword | None = None, + solvent: str | Solvent | None = None, + task_settings: TaskSettings | None = None, + method_settings: MethodSettings | None = None, + ): resolved_methods_settings_type = self.resolve_method_settings_type(method) - user_method_settings = resolved_methods_settings_type(method=method, basis_set=basis_set, solvation_model=solvation_model, solvent=solvent) + user_method_settings = resolved_methods_settings_type( + method=method, basis_set=basis_set, solvation_model=solvation_model, solvent=solvent + ) self._task_settings = task_settings or self._task_settings_type() if method_settings: - self._method_settings = method_settings | user_method_settings #type:ignore + self._method_settings = method_settings | user_method_settings # type:ignore else: self._method_settings = user_method_settings - @property def task_settings(self) -> TaskSettings: return self._task_settings @@ -103,7 +119,7 @@ def method(self) -> SimpleKeyword | None: def method(self, new_value: str | SimpleKeyword | None) -> None: if not hasattr(self._method_settings, "method"): raise AttributeError("method is not defined in method_settings object") - self._method_settings.method = new_value #type:ignore + self._method_settings.method = new_value # type:ignore @property def basis_set(self) -> SimpleKeyword | None: @@ -115,7 +131,7 @@ def basis_set(self) -> SimpleKeyword | None: def basis_set(self, new_value: str | SimpleKeyword | None) -> None: 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 + self._method_settings.basis_set = new_value # type:ignore @property def solvent(self) -> str | None: @@ -139,7 +155,7 @@ def solvation_model(self) -> SimpleKeyword | None: def solvation_model(self, new_value: str | SimpleKeyword | None) -> None: 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 + self._method_settings.solvation_model = new_value # type:ignore @property def grid(self) -> SimpleKeyword | None: @@ -180,7 +196,7 @@ def scf_threshold(self, new_value: str | SimpleKeyword | None) -> None: @property def scf_solver(self) -> SimpleKeyword | None: if hasattr(self._method_settings, "scf_solver"): - return typing.cast(SimpleKeyword| None, self._method_settings.scf_solver) + return typing.cast(SimpleKeyword | None, self._method_settings.scf_solver) return None @scf_solver.setter @@ -221,7 +237,7 @@ def run( ncores: int | None = None, memory: int | None = None, moinp: Path | None = None, - strict: bool = False + strict: bool = False, ) -> "TaskResults": """ Execute the computational task with the given structure and settings. @@ -259,11 +275,15 @@ def run( if strict: # Must already exist if not working_dir.exists(): - raise ValueError(f"Working directory {working_dir.resolve()} does not exist (strict mode)") + 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)") + raise ValueError( + f"Working directory {working_dir.resolve()} is not empty (strict mode)" + ) else: # Non-strict: recreate directory @@ -323,6 +343,10 @@ def _restart( basename = basename if basename else prev_calc.basename struct = struct if struct 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 @@ -391,5 +415,3 @@ def status(self) -> bool: @abstractmethod def primary_property(self) -> typing.Any: pass - - diff --git a/src/opi/simpletasks/engrad_task.py b/src/opi/simpletasks/engrad_task.py index 7b9ebe64..a7409229 100644 --- a/src/opi/simpletasks/engrad_task.py +++ b/src/opi/simpletasks/engrad_task.py @@ -1,7 +1,7 @@ import typing -from opi.input.simple_keywords import Task, SimpleKeyword, Solvent -from opi.simpletasks.base_task import SimpleTask, TaskSettings, TaskResults +from opi.input.simple_keywords import SimpleKeyword, Solvent, Task +from opi.simpletasks.base_task import SimpleTask, TaskResults, TaskSettings from opi.simpletasks.method_settings import MethodSettings @@ -13,11 +13,19 @@ class EngradSettings(TaskSettings): class EngradTask(SimpleTask): _task_settings: EngradSettings - def __init__(self, method: str | SimpleKeyword, basis_set: str | SimpleKeyword | None = None, - solvation_model: str | SimpleKeyword | None = None, solvent: str | Solvent | None = None, - task_settings: EngradSettings | None = None, method_settings: MethodSettings | None = None): + def __init__( + self, + method: str | SimpleKeyword, + basis_set: str | SimpleKeyword | None = None, + solvation_model: str | SimpleKeyword | None = None, + solvent: str | Solvent | None = None, + task_settings: EngradSettings | None = None, + method_settings: MethodSettings | None = None, + ): self._task_settings_type = EngradSettings - super().__init__(method, basis_set, solvation_model, solvent, task_settings, method_settings) + super().__init__( + method, basis_set, solvation_model, solvent, task_settings, method_settings + ) self._results_type = EngradResults @@ -45,4 +53,4 @@ def gradient(self) -> list[float]: @property def primary_property(self) -> tuple[float, list[float]]: - return self.final_energy, self.gradient \ No newline at end of file + return self.final_energy, self.gradient diff --git a/src/opi/simpletasks/freq_task.py b/src/opi/simpletasks/freq_task.py index a9c4ef46..b0d58954 100644 --- a/src/opi/simpletasks/freq_task.py +++ b/src/opi/simpletasks/freq_task.py @@ -1,24 +1,32 @@ import typing from functools import cached_property -from opi.input.simple_keywords import Task, SimpleKeyword, Solvent -from opi.simpletasks.base_task import TaskSettings, TaskResults, SimpleTask +from opi.input.simple_keywords import SimpleKeyword, Solvent, Task +from opi.simpletasks.base_task import SimpleTask, TaskResults, TaskSettings from opi.simpletasks.method_settings import MethodSettings class FreqSettings(TaskSettings): _name: str = "freq" - task_keyword: typing.Annotated[SimpleKeyword, Task] = Task.FREQ + task_keyword: typing.Annotated[SimpleKeyword, Task] = Task.FREQ class FreqTask(SimpleTask): _task_settings: FreqSettings - def __init__(self, method: str | SimpleKeyword, basis_set: str | SimpleKeyword | None = None, - solvation_model: str | SimpleKeyword | None = None, solvent: str | Solvent | None = None, - task_settings: FreqSettings | None = None, method_settings: MethodSettings | None = None): + def __init__( + self, + method: str | SimpleKeyword, + basis_set: str | SimpleKeyword | None = None, + solvation_model: str | SimpleKeyword | None = None, + solvent: str | Solvent | None = None, + task_settings: FreqSettings | None = None, + method_settings: MethodSettings | None = None, + ): self._task_settings_type = FreqSettings - super().__init__(method, basis_set, solvation_model, solvent, task_settings, method_settings) + super().__init__( + method, basis_set, solvation_model, solvent, task_settings, method_settings + ) self._results_type = FreqResults @@ -33,7 +41,7 @@ def status(self) -> bool: def free_energy_delta(self) -> float: free_energy_delta = self.output.get_free_energy_delta() - if not free_energy_delta: + if free_energy_delta is None: raise ValueError("Could not get free energy delta from ORCA output") return free_energy_delta @@ -41,4 +49,3 @@ def free_energy_delta(self) -> float: @property def primary_property(self) -> float: return float(self.free_energy_delta) - diff --git a/src/opi/simpletasks/method_settings.py b/src/opi/simpletasks/method_settings.py index acad01a5..865baa08 100644 --- a/src/opi/simpletasks/method_settings.py +++ b/src/opi/simpletasks/method_settings.py @@ -5,13 +5,23 @@ from pydantic_core.core_schema import ValidationInfo from opi.input import Input -from opi.input.simple_keywords import Dft, SimpleKeyword, Grid, DispersionCorrection, Sqm, Wft, Method, ForceField, \ - BasisSet, SolvationModel, Solvent -from opi.input.simple_keywords.scf import ScfThreshold, ScfSolver, Scf, ScfConvergence +from opi.input.simple_keywords import ( + BasisSet, + Dft, + DispersionCorrection, + ForceField, + Grid, + Method, + SimpleKeyword, + SolvationModel, + Solvent, + Sqm, + Wft, +) +from opi.input.simple_keywords.scf import Scf, ScfConvergence, ScfSolver, ScfThreshold from opi.simpletasks.settings import Settings - class MethodSettings(Settings): method: typing.Annotated[SimpleKeyword, Method] | None = None basis_set: typing.Annotated[SimpleKeyword, BasisSet] | None = None @@ -29,7 +39,6 @@ class DFTSettings(MethodSettings): scf_stab: bool = False scf_conv: typing.Annotated[SimpleKeyword, ScfConvergence] | None = None - @field_validator("*", mode="before") @classmethod def validate_fields(cls, value: typing.Any, info: ValidationInfo) -> typing.Any: @@ -42,8 +51,6 @@ def validate_fields(cls, value: typing.Any, info: ValidationInfo) -> typing.Any: else: return super().validate_fields(value, info) - - @model_validator(mode="after") def cross_validate(self) -> "DFTSettings": """ @@ -103,9 +110,9 @@ def _find_dft_disp_keyword(cls, value: str | SimpleKeyword) -> SimpleKeyword: if isinstance(value, SimpleKeyword): value = value.keyword - if '-' in value: + if "-" in value: try: - keywords = value.split('-') + keywords = value.split("-") Dft.find_keyword(keywords[0]) DispersionCorrection.find_keyword(keywords[1]) @@ -125,13 +132,12 @@ class WftSettings(MethodSettings): _name: str = "wft" method: typing.Annotated[SimpleKeyword, Wft] + class HFSettings(MethodSettings): _name: str = "hf" method: typing.Annotated[SimpleKeyword, Method] + class ForceFieldSettings(MethodSettings): _name: str = "forcefield" method: typing.Annotated[SimpleKeyword, ForceField] - - - diff --git a/src/opi/simpletasks/opt_task.py b/src/opi/simpletasks/opt_task.py index 01846f50..f30d3e09 100644 --- a/src/opi/simpletasks/opt_task.py +++ b/src/opi/simpletasks/opt_task.py @@ -1,20 +1,20 @@ import typing from opi.input import Input -from opi.input.simple_keywords import Task, SimpleKeyword, Solvent -from opi.input.simple_keywords.opt import OptThreshold, Opt +from opi.input.simple_keywords import SimpleKeyword, Solvent, Task +from opi.input.simple_keywords.opt import Opt, OptThreshold from opi.input.structures import Structure -from opi.simpletasks.base_task import TaskSettings, TaskResults, SimpleTask +from opi.simpletasks.base_task import SimpleTask, TaskResults, TaskSettings from opi.simpletasks.method_settings import MethodSettings class OptSettings(TaskSettings): - _name:str = "opt" + _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 + lopt: bool = False opt_maxiter: typing.Annotated[int, "BlockGeom", "maxiter"] | None = None def map_to_input(self, input_object: Input) -> Input: @@ -39,22 +39,29 @@ def map_to_input(self, input_object: Input) -> Input: class OptTask(SimpleTask): _task_settings: OptSettings - def __init__(self, method: str | SimpleKeyword, basis_set: str | SimpleKeyword | None = None, - solvation_model: str | SimpleKeyword | None = None, solvent: str | Solvent | None = None, - task_settings: OptSettings | None = None, method_settings: MethodSettings | None = None): + def __init__( + self, + method: str | SimpleKeyword, + basis_set: str | SimpleKeyword | None = None, + solvation_model: str | SimpleKeyword | None = None, + solvent: str | Solvent | None = None, + task_settings: OptSettings | None = None, + method_settings: MethodSettings | None = None, + ): self._task_settings_type = OptSettings - super().__init__(method, basis_set, solvation_model, solvent, task_settings, method_settings) + super().__init__( + method, basis_set, solvation_model, solvent, task_settings, method_settings + ) self._results_type = OptResults - @property def opt_threshold(self) -> SimpleKeyword | None: 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 + self._task_settings.opt_threshold = new_value # type:ignore @property def optrigid(self) -> bool: @@ -89,7 +96,6 @@ def opt_maxiter(self, new_value: int | None) -> None: self._task_settings.opt_maxiter = new_value - class OptResults(TaskResults): @property @TaskResults.output_parse @@ -101,7 +107,6 @@ def final_energy(self) -> float: return final_energy - @property @TaskResults.output_parse def structure(self) -> Structure: @@ -111,11 +116,6 @@ def structure(self) -> Structure: return structure - @property def primary_property(self) -> tuple[float, Structure]: return self.final_energy, self.structure - - - - diff --git a/src/opi/simpletasks/settings.py b/src/opi/simpletasks/settings.py index 16fc9147..f85ef907 100644 --- a/src/opi/simpletasks/settings.py +++ b/src/opi/simpletasks/settings.py @@ -5,7 +5,7 @@ from opi.input import Input from opi.input.blocks import Block -from opi.input.simple_keywords import SimpleKeywordBox, SimpleKeyword, SolvationModel, Solvent +from opi.input.simple_keywords import SimpleKeyword, SimpleKeywordBox, SolvationModel, Solvent from opi.input.simple_keywords.solvation_model import SolvationModelAndSolvent @@ -14,6 +14,7 @@ class Settings(BaseModel): TODO: - add checking for Solvent and SolvationModel now that they are optional. """ + model_config = ConfigDict(arbitrary_types_allowed=True, validate_assignment=True) _name: str @@ -33,7 +34,7 @@ def __str__(self) -> str: return "\n".join(lines) @staticmethod - def _get_field_metadata(hint: typing.Any) -> tuple: + def _get_field_metadata(hint: typing.Any) -> tuple[typing.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. @@ -94,14 +95,15 @@ class is first fetched, and then the attribute of the block is set to the user-g match metadata: case (validator,): # Case 1: 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}") return validator.find_keyword(value) case (validator, key): # Case 2: Block option - if not isinstance(validator, type) or not issubclass(validator, Block): - raise TypeError(f"Expected Block subclass, got {validator}") if not isinstance(key, str): raise TypeError(f"Expected str key, got {type(key)}") @@ -112,12 +114,16 @@ class is first fetched, and then the attribute of the block is set to the user-g case _: return value - def _get_simple_keyword(self, validator: type[SimpleKeywordBox], value: str | SimpleKeyword | SolvationModelAndSolvent) -> SimpleKeyword: + def _get_simple_keyword( + self, + validator: type[SimpleKeywordBox], + value: str | SimpleKeyword | SolvationModelAndSolvent, + ) -> SimpleKeyword: if validator == SolvationModel: solvent_value = getattr(self, "solvent", None) if solvent_value is None: raise ValueError("solvent is required for SolvationModel") - solvent = Solvent(str(solvent_value)) + solvent = Solvent(Solvent.find_keyword(str(solvent_value))) if not isinstance(value, SolvationModelAndSolvent): raise TypeError("Wrong type for solvent or solvation model") @@ -127,15 +133,11 @@ def _get_simple_keyword(self, validator: type[SimpleKeywordBox], value: str | Si return new_keyword - def __or__(self, other: "Settings") -> "Settings": - if not type(self) == type(other): + if type(self) is not type(other): return NotImplemented - combined_data = { - **self.model_dump(), - **other.model_dump(exclude_unset=True) - } + 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: @@ -169,7 +171,7 @@ def map_to_input(self, input_object: Input) -> Input: match metadata: case (validator,): - if validator == Solvent: + if issubclass(validator, Solvent): continue new_keyword = self._get_simple_keyword(validator, value) @@ -180,7 +182,7 @@ def map_to_input(self, input_object: Input) -> Input: block_type = Block.get_subclass_by_name(validator) block_class = block_type(**{key: value}) - block_exists, *_ = input_object.has_blocks(block_type) #type:ignore + block_exists, *_ = input_object.has_blocks(block_type) # type:ignore if not block_exists: input_object.add_blocks(block_class) else: diff --git a/src/opi/simpletasks/singlepointtask.py b/src/opi/simpletasks/singlepointtask.py index f45993b0..fa192255 100644 --- a/src/opi/simpletasks/singlepointtask.py +++ b/src/opi/simpletasks/singlepointtask.py @@ -3,8 +3,8 @@ from opi.input.simple_keywords import SimpleKeyword, Solvent, Task from opi.input.structures import BaseStructureFile, Structure -from opi.simpletasks.method_settings import DFTSettings from opi.simpletasks.base_task import SimpleTask, TaskResults, TaskSettings +from opi.simpletasks.method_settings import MethodSettings class SinglePointSettings(TaskSettings): @@ -15,13 +15,21 @@ class SinglePointSettings(TaskSettings): class SinglePointTask(SimpleTask): _task_settings: SinglePointSettings - def __init__(self, method: str | SimpleKeyword, basis_set: str | SimpleKeyword | None = None, - solvation_model: str | SimpleKeyword | None = None, solvent: str | Solvent | None = None, - task_settings: SinglePointSettings | None = None, method_settings: DFTSettings | None = None): - self._task_settings_type = SinglePointSettings - super().__init__(method, basis_set, solvation_model, solvent, task_settings, method_settings) + def __init__( + self, + method: str | SimpleKeyword, + basis_set: str | SimpleKeyword | None = None, + solvation_model: str | SimpleKeyword | None = None, + solvent: str | Solvent | None = None, + task_settings: SinglePointSettings | None = None, + method_settings: MethodSettings | None = None, + ): + self._task_settings_type = SinglePointSettings + super().__init__( + method, basis_set, solvation_model, solvent, task_settings, method_settings + ) - self._results_type = SinglePointResults + self._results_type = SinglePointResults def run( self, @@ -31,7 +39,7 @@ def run( ncores: int | None = None, memory: int | None = None, moinp: Path | None = None, - strict: bool = False + strict: bool = False, ) -> "SinglePointResults": single_point_result = super().run( basename=basename, @@ -40,7 +48,7 @@ def run( ncores=ncores, memory=memory, moinp=moinp, - strict=strict + strict=strict, ) return typing.cast(SinglePointResults, single_point_result) From 196c3c1af8f6f408c571168f5948688de6c1dfbd Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Wed, 29 Apr 2026 10:46:54 +0200 Subject: [PATCH 14/52] correct run() method for OptTask --- src/opi/simpletasks/opt_task.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/opi/simpletasks/opt_task.py b/src/opi/simpletasks/opt_task.py index f30d3e09..7d20a4e6 100644 --- a/src/opi/simpletasks/opt_task.py +++ b/src/opi/simpletasks/opt_task.py @@ -1,9 +1,10 @@ import typing +from pathlib import Path from opi.input import Input from opi.input.simple_keywords import SimpleKeyword, Solvent, Task from opi.input.simple_keywords.opt import Opt, OptThreshold -from opi.input.structures import Structure +from opi.input.structures import BaseStructureFile, Structure from opi.simpletasks.base_task import SimpleTask, TaskResults, TaskSettings from opi.simpletasks.method_settings import MethodSettings @@ -55,6 +56,28 @@ def __init__( self._results_type = OptResults + def run( + self, + basename: str, + struct: Structure | BaseStructureFile, + working_dir: Path = Path("RUN"), + ncores: int | None = None, + memory: int | None = None, + moinp: Path | None = None, + strict: bool = False, + ) -> "OptResults": + single_point_result = super().run( + basename=basename, + struct=struct, + working_dir=working_dir, + ncores=ncores, + memory=memory, + moinp=moinp, + strict=strict, + ) + + return typing.cast(OptResults, single_point_result) + @property def opt_threshold(self) -> SimpleKeyword | None: return self._task_settings.opt_threshold From 569b2b6a56c385b2312378efa5603324e14eda55 Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Mon, 27 Apr 2026 15:49:12 +0200 Subject: [PATCH 15/52] alternate API for initializing method settings --- src/opi/simpletasks/base_task.py | 68 ++++++++++---------------- src/opi/simpletasks/engrad_task.py | 2 +- src/opi/simpletasks/freq_task.py | 2 +- src/opi/simpletasks/method_settings.py | 62 ++++++++++++++++++++++- src/opi/simpletasks/opt_task.py | 2 +- src/opi/simpletasks/singlepointtask.py | 2 +- 6 files changed, 92 insertions(+), 46 deletions(-) diff --git a/src/opi/simpletasks/base_task.py b/src/opi/simpletasks/base_task.py index be9f2c1e..0eeead58 100644 --- a/src/opi/simpletasks/base_task.py +++ b/src/opi/simpletasks/base_task.py @@ -7,14 +7,9 @@ from opi.core import Calculator from opi.input import Input from opi.input.simple_keywords import ( - Dft, - ForceField, - Method, SimpleKeyword, SimpleKeywordBox, Solvent, - Sqm, - Wft, ) from opi.input.structures import BaseStructureFile, Structure from opi.output.core import Output @@ -32,52 +27,43 @@ class SimpleTask(ABC): _task_settings: TaskSettings _method_settings: MethodSettings - @classmethod - def resolve_method_settings_type( - cls, method: str | SimpleKeyword - ) -> typing.Type[MethodSettings]: - from opi.simpletasks.method_settings import ( - DFTSettings, - ForceFieldSettings, - HFSettings, - SQMSettings, - WftSettings, - ) - - 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") - def __init__( self, - method: str | SimpleKeyword, + 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 | None = None, method_settings: MethodSettings | None = None, ): - resolved_methods_settings_type = self.resolve_method_settings_type(method) - user_method_settings = resolved_methods_settings_type( - method=method, basis_set=basis_set, solvation_model=solvation_model, solvent=solvent - ) self._task_settings = task_settings or self._task_settings_type() - if method_settings: - self._method_settings = method_settings | user_method_settings # type:ignore + + if method is not None: + resolved_type = MethodSettings.resolve_method_settings_type(method) + 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 method_settings is not None: + extra: dict[str, typing.Any] = { + **method_settings.model_dump(exclude_unset=True), + **(method_settings.model_extra or {}), + } + self._method_settings = resolved_type(**{**extra, **base_data}) + else: + self._method_settings = resolved_type(**base_data) else: - self._method_settings = user_method_settings + if method_settings is None: + raise ValueError( + "Either 'method' or a 'method_settings' object with a method must be provided" + ) + self._method_settings = method_settings @property def task_settings(self) -> TaskSettings: diff --git a/src/opi/simpletasks/engrad_task.py b/src/opi/simpletasks/engrad_task.py index a7409229..2c7e2fc6 100644 --- a/src/opi/simpletasks/engrad_task.py +++ b/src/opi/simpletasks/engrad_task.py @@ -15,7 +15,7 @@ class EngradTask(SimpleTask): def __init__( self, - method: str | SimpleKeyword, + method: str | SimpleKeyword | None = None, basis_set: str | SimpleKeyword | None = None, solvation_model: str | SimpleKeyword | None = None, solvent: str | Solvent | None = None, diff --git a/src/opi/simpletasks/freq_task.py b/src/opi/simpletasks/freq_task.py index b0d58954..0a274a1c 100644 --- a/src/opi/simpletasks/freq_task.py +++ b/src/opi/simpletasks/freq_task.py @@ -16,7 +16,7 @@ class FreqTask(SimpleTask): def __init__( self, - method: str | SimpleKeyword, + method: str | SimpleKeyword | None = None, basis_set: str | SimpleKeyword | None = None, solvation_model: str | SimpleKeyword | None = None, solvent: str | Solvent | None = None, diff --git a/src/opi/simpletasks/method_settings.py b/src/opi/simpletasks/method_settings.py index 865baa08..9c70801a 100644 --- a/src/opi/simpletasks/method_settings.py +++ b/src/opi/simpletasks/method_settings.py @@ -1,7 +1,7 @@ import typing import warnings -from pydantic import field_validator, model_validator +from pydantic import ConfigDict, field_validator, model_validator from pydantic_core.core_schema import ValidationInfo from opi.input import Input @@ -23,13 +23,61 @@ class MethodSettings(Settings): + 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: typing.Any) -> "MethodSettings": + 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: typing.Any) -> typing.Any: + 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"]: + + 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): + 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 @@ -124,20 +172,32 @@ def _find_dft_disp_keyword(cls, value: str | SimpleKeyword) -> SimpleKeyword: class SQMSettings(MethodSettings): + model_config = ConfigDict( + arbitrary_types_allowed=True, validate_assignment=True, extra="forbid" + ) _name: str = "sqm" method: typing.Annotated[SimpleKeyword, Sqm] class WftSettings(MethodSettings): + model_config = ConfigDict( + arbitrary_types_allowed=True, validate_assignment=True, extra="forbid" + ) _name: str = "wft" method: typing.Annotated[SimpleKeyword, Wft] class HFSettings(MethodSettings): + model_config = ConfigDict( + arbitrary_types_allowed=True, validate_assignment=True, extra="forbid" + ) _name: str = "hf" method: typing.Annotated[SimpleKeyword, Method] class ForceFieldSettings(MethodSettings): + model_config = ConfigDict( + arbitrary_types_allowed=True, validate_assignment=True, extra="forbid" + ) _name: str = "forcefield" method: typing.Annotated[SimpleKeyword, ForceField] diff --git a/src/opi/simpletasks/opt_task.py b/src/opi/simpletasks/opt_task.py index 7d20a4e6..d36ab0ff 100644 --- a/src/opi/simpletasks/opt_task.py +++ b/src/opi/simpletasks/opt_task.py @@ -42,7 +42,7 @@ class OptTask(SimpleTask): def __init__( self, - method: str | SimpleKeyword, + method: str | SimpleKeyword | None = None, basis_set: str | SimpleKeyword | None = None, solvation_model: str | SimpleKeyword | None = None, solvent: str | Solvent | None = None, diff --git a/src/opi/simpletasks/singlepointtask.py b/src/opi/simpletasks/singlepointtask.py index fa192255..b43d6f74 100644 --- a/src/opi/simpletasks/singlepointtask.py +++ b/src/opi/simpletasks/singlepointtask.py @@ -17,7 +17,7 @@ class SinglePointTask(SimpleTask): def __init__( self, - method: str | SimpleKeyword, + method: str | SimpleKeyword | None = None, basis_set: str | SimpleKeyword | None = None, solvation_model: str | SimpleKeyword | None = None, solvent: str | Solvent | None = None, From 6a4cbd5b0e0678dd8ebc998492aa56d3c168c2ee Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Wed, 29 Apr 2026 16:11:56 +0200 Subject: [PATCH 16/52] added possibility to switch methods between families, dicct now allowed for method settings and task settings --- src/opi/input/simple_keywords/base.py | 5 + src/opi/simpletasks/base_task.py | 169 ++++++++----------------- src/opi/simpletasks/engrad_task.py | 2 - src/opi/simpletasks/freq_task.py | 1 - src/opi/simpletasks/opt_task.py | 2 - src/opi/simpletasks/singlepointtask.py | 1 - 6 files changed, 56 insertions(+), 124 deletions(-) diff --git a/src/opi/input/simple_keywords/base.py b/src/opi/input/simple_keywords/base.py index e33a24dc..cb1aca34 100644 --- a/src/opi/input/simple_keywords/base.py +++ b/src/opi/input/simple_keywords/base.py @@ -49,6 +49,11 @@ def find_keyword(cls, inp: "SimpleKeyword | str") -> "SimpleKeyword": 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) diff --git a/src/opi/simpletasks/base_task.py b/src/opi/simpletasks/base_task.py index 0eeead58..3792d9e5 100644 --- a/src/opi/simpletasks/base_task.py +++ b/src/opi/simpletasks/base_task.py @@ -1,9 +1,12 @@ import shutil import typing +import warnings from abc import ABC, abstractmethod -from functools import cached_property, wraps +from functools import cached_property from pathlib import Path +from pydantic import ConfigDict + from opi.core import Calculator from opi.input import Input from opi.input.simple_keywords import ( @@ -18,12 +21,13 @@ class TaskSettings(Settings): + model_config = ConfigDict(arbitrary_types_allowed=True, validate_assignment=True, extra="forbid") task_keyword: typing.Annotated[SimpleKeyword, SimpleKeywordBox] class SimpleTask(ABC): _results_type: type["TaskResults"] - _task_settings_type: typing.Callable[[], TaskSettings] + _task_settings_type: type[TaskSettings] _task_settings: TaskSettings _method_settings: MethodSettings @@ -33,10 +37,19 @@ def __init__( basis_set: str | SimpleKeyword | None = None, solvation_model: str | SimpleKeyword | None = None, solvent: str | Solvent | None = None, - task_settings: TaskSettings | None = None, - method_settings: MethodSettings | None = None, + task_settings: "TaskSettings | dict[str, typing.Any] | None" = None, + method_settings: "MethodSettings | dict[str, typing.Any] | None" = None, ): - self._task_settings = task_settings or self._task_settings_type() + if isinstance(task_settings, dict): + self._task_settings = self._task_settings_type.model_validate(task_settings) + else: + self._task_settings = task_settings or self._task_settings_type() # type: ignore[call-arg] + + resolved_method_settings: MethodSettings | None = ( + MethodSettings(**method_settings) + if isinstance(method_settings, dict) + else method_settings + ) if method is not None: resolved_type = MethodSettings.resolve_method_settings_type(method) @@ -50,20 +63,20 @@ def __init__( }.items() if v is not None } - if method_settings is not None: + if resolved_method_settings is not None: extra: dict[str, typing.Any] = { - **method_settings.model_dump(exclude_unset=True), - **(method_settings.model_extra or {}), + **resolved_method_settings.model_dump(exclude_unset=True), + **(resolved_method_settings.model_extra or {}), } self._method_settings = resolved_type(**{**extra, **base_data}) else: self._method_settings = resolved_type(**base_data) else: - if method_settings is None: + if resolved_method_settings is None: raise ValueError( "Either 'method' or a 'method_settings' object with a method must be provided" ) - self._method_settings = method_settings + self._method_settings = resolved_method_settings @property def task_settings(self) -> TaskSettings: @@ -105,7 +118,31 @@ def method(self) -> SimpleKeyword | None: def method(self, new_value: str | SimpleKeyword | None) -> None: if not hasattr(self._method_settings, "method"): raise AttributeError("method is not defined in method_settings object") - self._method_settings.method = new_value # type:ignore + if new_value is None: + self._method_settings.method = None # type:ignore + return + resolved_type = MethodSettings.resolve_method_settings_type(new_value) + if isinstance(self._method_settings, resolved_type): + self._method_settings.method = new_value # type:ignore + else: + common_fields: dict[str, typing.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: @@ -143,78 +180,6 @@ def solvation_model(self, new_value: str | SimpleKeyword | None) -> None: raise AttributeError("solvation_model is not defined in method_settings object") self._method_settings.solvation_model = new_value # type:ignore - @property - def grid(self) -> SimpleKeyword | None: - if hasattr(self._method_settings, "grid"): - return typing.cast(SimpleKeyword | None, self._method_settings.grid) - return None - - @grid.setter - def grid(self, new_value: str | SimpleKeyword | None) -> None: - if not hasattr(self._method_settings, "grid"): - raise AttributeError("grid is not defined in method_settings object") - self._method_settings.grid = new_value - - @property - def scf_maxiter(self) -> int | None: - if hasattr(self._method_settings, "scf_maxiter"): - return typing.cast(int | None, self._method_settings.scf_maxiter) - return None - - @scf_maxiter.setter - def scf_maxiter(self, new_value: int | None) -> None: - if not hasattr(self._method_settings, "scf_maxiter"): - raise AttributeError("scf_maxiter is not defined in method_settings object") - self._method_settings.scf_maxiter = new_value - - @property - def scf_threshold(self) -> SimpleKeyword | None: - if hasattr(self._method_settings, "scf_threshold"): - return typing.cast(SimpleKeyword | None, self._method_settings.scf_threshold) - return None - - @scf_threshold.setter - def scf_threshold(self, new_value: str | SimpleKeyword | None) -> None: - if not hasattr(self._method_settings, "scf_threshold"): - raise AttributeError("scf_threshold is not defined in method_settings object") - self._method_settings.scf_threshold = new_value - - @property - def scf_solver(self) -> SimpleKeyword | None: - if hasattr(self._method_settings, "scf_solver"): - return typing.cast(SimpleKeyword | None, self._method_settings.scf_solver) - return None - - @scf_solver.setter - def scf_solver(self, new_value: str | SimpleKeyword | None) -> None: - if not hasattr(self._method_settings, "scf_solver"): - raise AttributeError("scf_solver is not defined in method_settings object") - self._method_settings.scf_solver = new_value - - @property - def scf_stab(self) -> bool | None: - if hasattr(self._method_settings, "scf_stab"): - return typing.cast(bool | None, self._method_settings.scf_stab) - return None - - @scf_stab.setter - def scf_stab(self, new_value: bool | None) -> None: - if not hasattr(self._method_settings, "scf_stab"): - raise AttributeError("scf_stab is not defined in method_settings object") - self._method_settings.scf_stab = new_value - - @property - def scf_conv(self) -> SimpleKeyword | None: - if hasattr(self._method_settings, "scf_conv"): - return typing.cast(SimpleKeyword | None, self._method_settings.scf_conv) - return None - - @scf_conv.setter - def scf_conv(self, new_value: str | SimpleKeyword | None) -> None: - if not hasattr(self._method_settings, "scf_conv"): - raise AttributeError("scf_conv is not defined in method_settings object") - self._method_settings.scf_conv = new_value - def run( self, basename: str, @@ -351,47 +316,15 @@ def _restart( class TaskResults(ABC): def __init__(self, calc_object: Calculator): self.calc_object = calc_object - self._parsed = False - - @staticmethod - def output_parse( - func: typing.Callable[[typing.Any], typing.Any], - ) -> typing.Callable[[typing.Any], typing.Any]: - """ - Decorator to ensure output parsing is performed before accessing results. - - This decorator wraps methods of a `TaskResults` instance and guarantees - that the associated output has been parsed before the method is executed. - Parsing is performed lazily and only once per instance. - - Parameters - ---------- - func : Callable[[TaskResults], Any] - The method to wrap. It must be a method of `TaskResults` that relies - on parsed output data. - - Returns - ------- - Callable[[TaskResults], Any] - A wrapped method that ensures `self.output.parse()` has been called - before delegating to the original function. - """ - - @wraps(func) - def wrapper(self: "TaskResults") -> typing.Any: - if not self._parsed: - self.output.parse() - self._parsed = True - return func(self) - - return wrapper @cached_property def output(self) -> Output: if not self.calc_object: raise ValueError("calc_object not set") - return self.calc_object.get_output() + out = self.calc_object.get_output() + out.parse() + return out @cached_property def status(self) -> bool: diff --git a/src/opi/simpletasks/engrad_task.py b/src/opi/simpletasks/engrad_task.py index 2c7e2fc6..85c04209 100644 --- a/src/opi/simpletasks/engrad_task.py +++ b/src/opi/simpletasks/engrad_task.py @@ -32,7 +32,6 @@ def __init__( class EngradResults(TaskResults): @property - @TaskResults.output_parse def final_energy(self) -> float: final_energy = self.output.get_final_energy() @@ -42,7 +41,6 @@ def final_energy(self) -> float: return final_energy @property - @TaskResults.output_parse def gradient(self) -> list[float]: gradient = self.output.get_gradient() diff --git a/src/opi/simpletasks/freq_task.py b/src/opi/simpletasks/freq_task.py index 0a274a1c..6dc69ba9 100644 --- a/src/opi/simpletasks/freq_task.py +++ b/src/opi/simpletasks/freq_task.py @@ -37,7 +37,6 @@ def status(self) -> bool: return self.output.terminated_normally() @cached_property - @TaskResults.output_parse def free_energy_delta(self) -> float: free_energy_delta = self.output.get_free_energy_delta() diff --git a/src/opi/simpletasks/opt_task.py b/src/opi/simpletasks/opt_task.py index d36ab0ff..8825d6fd 100644 --- a/src/opi/simpletasks/opt_task.py +++ b/src/opi/simpletasks/opt_task.py @@ -121,7 +121,6 @@ def opt_maxiter(self, new_value: int | None) -> None: class OptResults(TaskResults): @property - @TaskResults.output_parse def final_energy(self) -> float: final_energy = self.output.get_final_energy() @@ -131,7 +130,6 @@ def final_energy(self) -> float: return final_energy @property - @TaskResults.output_parse def structure(self) -> Structure: structure = self.output.get_structure() if structure is None: diff --git a/src/opi/simpletasks/singlepointtask.py b/src/opi/simpletasks/singlepointtask.py index b43d6f74..14f288a8 100644 --- a/src/opi/simpletasks/singlepointtask.py +++ b/src/opi/simpletasks/singlepointtask.py @@ -56,7 +56,6 @@ def run( class SinglePointResults(TaskResults): @property - @TaskResults.output_parse def final_energy(self) -> float: final_energy = self.output.get_final_energy() From 022dac6349bbc5f05f9b5c2296cd40eb04e90ec2 Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Wed, 6 May 2026 02:59:14 +0200 Subject: [PATCH 17/52] add goat task, flesh out sqmsettings and add dlpnoccsettings --- src/opi/input/simple_keywords/dlpno.py | 16 +++--- src/opi/simpletasks/goat_task.py | 75 ++++++++++++++++++++++++++ src/opi/simpletasks/method_settings.py | 54 ++++++++++++++++++- 3 files changed, 137 insertions(+), 8 deletions(-) create mode 100644 src/opi/simpletasks/goat_task.py diff --git a/src/opi/input/simple_keywords/dlpno.py b/src/opi/input/simple_keywords/dlpno.py index 115a58a6..31602f00 100644 --- a/src/opi/input/simple_keywords/dlpno.py +++ b/src/opi/input/simple_keywords/dlpno.py @@ -5,20 +5,22 @@ __all__ = ("Dlpno",) +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(SimpleKeywordBox): +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..""" - LOOSEPNO = SimpleKeyword("loosepno") - """SimpleKeyword: Select loose PNO settings..""" - NORMALPNO = SimpleKeyword("normalpno") - """SimpleKeyword: Select normal PNO settings..""" - TIGHTPNO = SimpleKeyword("tightpno") - """SimpleKeyword: Select Tight PNO settings..""" ADLD = SimpleKeyword("adld") """SimpleKeyword: Atomic decomposition of the London Dispersion energy..""" ADEX = SimpleKeyword("adex") diff --git a/src/opi/simpletasks/goat_task.py b/src/opi/simpletasks/goat_task.py new file mode 100644 index 00000000..91b64f93 --- /dev/null +++ b/src/opi/simpletasks/goat_task.py @@ -0,0 +1,75 @@ +import typing +from functools import cached_property + +from opi.input import Input +from opi.input.simple_keywords import Goat, SimpleKeyword, Solvent +from opi.input.structures import Structure, Properties +from opi.simpletasks.base_task import TaskSettings, SimpleTask, TaskResults +from opi.simpletasks.method_settings import MethodSettings + + +class GoatSettings(TaskSettings): + _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: + 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 GoatTask(SimpleTask): + _task_settings: GoatSettings + + 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: GoatSettings | None = None, + method_settings: MethodSettings | None = None, + ): + self._task_settings_type = GoatSettings + super().__init__( + method, basis_set, solvation_model, solvent, task_settings, method_settings + ) + + self._results_type = GoatResults + + +class GoatResults(TaskResults): + @cached_property + def status(self) -> bool: + return self.output.terminated_normally() + + @cached_property + def structures(self) -> list[Structure]: + structures = Structure.from_trj_xyz(self.output.working_dir/ f"{self.output.basename}.finalensemble.xyz") + return structures + + @cached_property + def properties(self) -> list[Properties]: + properties = Properties.from_trj_xyz(self.output.working_dir / f"{self.output.basename}.finalensemble.xyz", mode="goat") + return properties + + @cached_property + def primary_property(self) -> tuple[list[Structure], list[Properties]]: + return self.structures, self.properties + + + + diff --git a/src/opi/simpletasks/method_settings.py b/src/opi/simpletasks/method_settings.py index 9c70801a..f1cd0d18 100644 --- a/src/opi/simpletasks/method_settings.py +++ b/src/opi/simpletasks/method_settings.py @@ -16,8 +16,9 @@ SolvationModel, Solvent, Sqm, - Wft, + Wft, AuxBasisSet, ) +from opi.input.simple_keywords.dlpno import PNOThresh, Dlpno from opi.input.simple_keywords.scf import Scf, ScfConvergence, ScfSolver, ScfThreshold from opi.simpletasks.settings import Settings @@ -177,6 +178,19 @@ class SQMSettings(MethodSettings): ) _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: + 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): @@ -201,3 +215,41 @@ class ForceFieldSettings(MethodSettings): ) _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): + model_config = ConfigDict( + arbitrary_types_allowed=True, validate_assignment=True, extra="forbid" + ) + _name: str = "dlpnocc" + method: typing.Annotated[SimpleKeyword, Dft] | 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: + 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 + From 8887d46c74e53f3b3287e97de54d63ddc3afe869 Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Mon, 11 May 2026 18:07:52 +0200 Subject: [PATCH 18/52] added documentation --- src/opi/simpletasks/base_task.py | 116 ++++++++++++++++++++ src/opi/simpletasks/engrad_task.py | 27 +++++ src/opi/simpletasks/freq_task.py | 21 ++++ src/opi/simpletasks/goat_task.py | 37 +++++++ src/opi/simpletasks/method_settings.py | 142 +++++++++++++++++++++++++ src/opi/simpletasks/opt_task.py | 59 ++++++++++ src/opi/simpletasks/settings.py | 51 ++++++++- src/opi/simpletasks/singlepointtask.py | 20 ++++ 8 files changed, 471 insertions(+), 2 deletions(-) diff --git a/src/opi/simpletasks/base_task.py b/src/opi/simpletasks/base_task.py index 3792d9e5..9ab3bc59 100644 --- a/src/opi/simpletasks/base_task.py +++ b/src/opi/simpletasks/base_task.py @@ -21,11 +21,37 @@ 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] class SimpleTask(ABC): + """ + Abstract base class for all high-level OPI calculation 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``. + + 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["TaskResults"] _task_settings_type: type[TaskSettings] _task_settings: TaskSettings @@ -40,6 +66,33 @@ def __init__( task_settings: "TaskSettings | dict[str, typing.Any] | None" = None, method_settings: "MethodSettings | dict[str, typing.Any] | None" = None, ): + """ + 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 isinstance(task_settings, dict): self._task_settings = self._task_settings_type.model_validate(task_settings) else: @@ -80,10 +133,12 @@ def __init__( @property def task_settings(self) -> TaskSettings: + """Task-level settings (keyword, thresholds, flags).""" return self._task_settings @property def method_settings(self) -> MethodSettings: + """Method-level settings (functional, basis set, solvent, …).""" return self._method_settings @property @@ -106,16 +161,32 @@ def input_object(self) -> Input: @property def keyword(self) -> SimpleKeyword: + """The primary task keyword (e.g. ``Task.SP``, ``Task.OPT``).""" return self._task_settings.task_keyword @property def method(self) -> SimpleKeyword | None: + """Active method keyword, or ``None`` if the settings type has no method field.""" if 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 hasattr(self._method_settings, "method"): raise AttributeError("method is not defined in method_settings object") if new_value is None: @@ -146,36 +217,57 @@ def method(self, new_value: str | SimpleKeyword | None) -> None: @property def basis_set(self) -> SimpleKeyword | None: + """Active basis-set keyword, or ``None`` if the settings type has no basis_set field.""" if 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 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 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 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 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 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 @@ -314,11 +406,33 @@ def _restart( 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, calc_object: Calculator): + """ + Parameters + ---------- + calc_object : Calculator + The calculator that ran the calculation. + """ self.calc_object = calc_object @cached_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.calc_object: raise ValueError("calc_object not set") @@ -328,9 +442,11 @@ def output(self) -> Output: @cached_property def status(self) -> bool: + """``True`` if the job terminated normally and SCF converged.""" return self.output.terminated_normally() and self.output.scf_converged() @cached_property @abstractmethod def primary_property(self) -> typing.Any: + """The most important result for this task type (energy, structure, …).""" pass diff --git a/src/opi/simpletasks/engrad_task.py b/src/opi/simpletasks/engrad_task.py index 85c04209..6c105ca0 100644 --- a/src/opi/simpletasks/engrad_task.py +++ b/src/opi/simpletasks/engrad_task.py @@ -6,11 +6,19 @@ class EngradSettings(TaskSettings): + """Task settings for energy + gradient calculations (``! ENGRAD``).""" + _name: str = "engrad" task_keyword: typing.Annotated[SimpleKeyword, Task] = Task.ENGRAD class EngradTask(SimpleTask): + """ + High-level task for single-point energy and gradient calculations. + + Returns an ``EngradResults`` object containing the total energy and + the Cartesian gradient vector. + """ _task_settings: EngradSettings def __init__( @@ -31,8 +39,18 @@ def __init__( class EngradResults(TaskResults): + """Results from an energy + gradient calculation.""" + @property def final_energy(self) -> float: + """ + Total energy in Hartree. + + Raises + ------ + ValueError + If the energy is not present in the ORCA output. + """ final_energy = self.output.get_final_energy() if final_energy is None: @@ -42,6 +60,14 @@ def final_energy(self) -> float: @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: @@ -51,4 +77,5 @@ def gradient(self) -> list[float]: @property def primary_property(self) -> tuple[float, list[float]]: + """``(final_energy, gradient)`` tuple.""" return self.final_energy, self.gradient diff --git a/src/opi/simpletasks/freq_task.py b/src/opi/simpletasks/freq_task.py index 6dc69ba9..4ad757ce 100644 --- a/src/opi/simpletasks/freq_task.py +++ b/src/opi/simpletasks/freq_task.py @@ -7,11 +7,20 @@ class FreqSettings(TaskSettings): + """Task settings for frequency calculations (``! FREQ``).""" + _name: str = "freq" task_keyword: typing.Annotated[SimpleKeyword, Task] = Task.FREQ class FreqTask(SimpleTask): + """ + High-level 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 def __init__( @@ -32,12 +41,23 @@ def __init__( class FreqResults(TaskResults): + """Results from a harmonic frequency calculation.""" + @cached_property def status(self) -> bool: + """``True`` if the job terminated normally (SCF convergence not re-checked).""" return self.output.terminated_normally() @cached_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: @@ -47,4 +67,5 @@ def free_energy_delta(self) -> float: @property def primary_property(self) -> float: + """Alias for ``free_energy_delta``.""" return float(self.free_energy_delta) diff --git a/src/opi/simpletasks/goat_task.py b/src/opi/simpletasks/goat_task.py index 91b64f93..a9659aea 100644 --- a/src/opi/simpletasks/goat_task.py +++ b/src/opi/simpletasks/goat_task.py @@ -9,6 +9,14 @@ 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 @@ -17,6 +25,22 @@ class GoatSettings(TaskSettings): 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. + """ super().map_to_input(input_object) if self.goat_react: @@ -32,6 +56,13 @@ def map_to_input(self, input_object: Input) -> Input: class GoatTask(SimpleTask): + """ + 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 def __init__( @@ -52,22 +83,28 @@ def __init__( class GoatResults(TaskResults): + """Results from a GOAT conformer-ensemble exploration.""" + @cached_property def status(self) -> bool: + """``True`` if the job terminated normally.""" return self.output.terminated_normally() @cached_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 @cached_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 @cached_property def primary_property(self) -> tuple[list[Structure], list[Properties]]: + """``(structures, properties)`` tuple for the final conformer ensemble.""" return self.structures, self.properties diff --git a/src/opi/simpletasks/method_settings.py b/src/opi/simpletasks/method_settings.py index f1cd0d18..21e0166a 100644 --- a/src/opi/simpletasks/method_settings.py +++ b/src/opi/simpletasks/method_settings.py @@ -24,6 +24,15 @@ 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 @@ -32,6 +41,13 @@ class MethodSettings(Settings): solvent: typing.Annotated[str, Solvent] | None = None def __new__(cls, /, **data: typing.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: @@ -42,6 +58,13 @@ def __new__(cls, /, **data: typing.Any) -> "MethodSettings": @model_validator(mode="before") @classmethod def _check_valid_fields(cls, data: typing.Any) -> typing.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) @@ -57,6 +80,27 @@ def _check_valid_fields(cls, data: typing.Any) -> typing.Any: 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, @@ -76,6 +120,14 @@ def resolve_method_settings_type( 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" ) @@ -91,6 +143,14 @@ class DFTSettings(MethodSettings): @field_validator("*", mode="before") @classmethod def validate_fields(cls, value: typing.Any, info: ValidationInfo) -> typing.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) @@ -125,6 +185,22 @@ def cross_validate(self) -> "DFTSettings": 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: @@ -173,6 +249,14 @@ def _find_dft_disp_keyword(cls, value: str | SimpleKeyword) -> SimpleKeyword: 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" ) @@ -185,6 +269,19 @@ class SQMSettings(MethodSettings): 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: @@ -194,6 +291,13 @@ def map_to_input(self, input_object: Input) -> Input: 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" ) @@ -202,6 +306,12 @@ class WftSettings(MethodSettings): 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" ) @@ -210,6 +320,13 @@ class HFSettings(MethodSettings): 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" ) @@ -226,6 +343,14 @@ def cross_validate(self) -> "ForceFieldSettings": 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" ) @@ -243,6 +368,23 @@ class DlpnoCcSettings(MethodSettings): 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: diff --git a/src/opi/simpletasks/opt_task.py b/src/opi/simpletasks/opt_task.py index 8825d6fd..01b37348 100644 --- a/src/opi/simpletasks/opt_task.py +++ b/src/opi/simpletasks/opt_task.py @@ -10,6 +10,15 @@ 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 @@ -19,6 +28,23 @@ class OptSettings(TaskSettings): 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: @@ -38,6 +64,15 @@ def map_to_input(self, input_object: Input) -> Input: class OptTask(SimpleTask): + """ + 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 def __init__( @@ -80,6 +115,7 @@ def run( @property def opt_threshold(self) -> SimpleKeyword | None: + """Convergence threshold keyword (e.g. ``OptThreshold.TIGHTOPT``).""" return self._task_settings.opt_threshold @opt_threshold.setter @@ -88,6 +124,7 @@ def opt_threshold(self, new_value: SimpleKeyword | str) -> None: @property def optrigid(self) -> bool: + """When ``True``, adds ``RIGIDBODYOPT`` for rigid-body optimisation.""" return self._task_settings.optrigid @optrigid.setter @@ -96,6 +133,7 @@ def optrigid(self, new_value: bool) -> None: @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 @@ -104,6 +142,7 @@ def opt_h(self, new_value: bool) -> None: @property def lopt(self) -> bool: + """When ``True``, uses loose optimisation criteria (``L_OPT`` / ``L_OPT_H``).""" return self._task_settings.lopt @lopt.setter @@ -112,6 +151,7 @@ def lopt(self, new_value: bool) -> None: @property def opt_maxiter(self) -> int | None: + """Maximum number of geometry optimisation steps (``%geom MaxIter``).""" return self._task_settings.opt_maxiter @opt_maxiter.setter @@ -120,8 +160,18 @@ def opt_maxiter(self, new_value: int | None) -> None: class OptResults(TaskResults): + """Results from a geometry optimisation.""" + @property def final_energy(self) -> float: + """ + Energy at the optimised geometry in Hartree. + + Raises + ------ + ValueError + If the energy is not present in the ORCA output. + """ final_energy = self.output.get_final_energy() if final_energy is None: @@ -131,6 +181,14 @@ def final_energy(self) -> float: @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") @@ -139,4 +197,5 @@ def structure(self) -> Structure: @property def primary_property(self) -> tuple[float, Structure]: + """``(final_energy, optimised_structure)`` tuple.""" return self.final_energy, self.structure diff --git a/src/opi/simpletasks/settings.py b/src/opi/simpletasks/settings.py index f85ef907..f3ca25d6 100644 --- a/src/opi/simpletasks/settings.py +++ b/src/opi/simpletasks/settings.py @@ -11,8 +11,13 @@ class Settings(BaseModel): """ - TODO: - - add checking for Solvent and SolvationModel now that they are optional. + 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) @@ -119,6 +124,32 @@ def _get_simple_keyword( 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: solvent_value = getattr(self, "solvent", None) if solvent_value is None: @@ -134,6 +165,22 @@ def _get_simple_keyword( return new_keyword 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 diff --git a/src/opi/simpletasks/singlepointtask.py b/src/opi/simpletasks/singlepointtask.py index 14f288a8..b5a533de 100644 --- a/src/opi/simpletasks/singlepointtask.py +++ b/src/opi/simpletasks/singlepointtask.py @@ -8,11 +8,20 @@ class SinglePointSettings(TaskSettings): + """Task settings for a single-point energy calculation (``! SP``).""" + _name: str = "singlepoint" task_keyword: typing.Annotated[SimpleKeyword, Task] = Task.SP class SinglePointTask(SimpleTask): + """ + 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 def __init__( @@ -55,8 +64,18 @@ def run( class SinglePointResults(TaskResults): + """Results from a single-point energy calculation.""" + @property def final_energy(self) -> float: + """ + Total energy of the last SCF cycle in Hartree. + + Raises + ------ + ValueError + If the energy is not present in the ORCA output. + """ final_energy = self.output.get_final_energy() if final_energy is None: @@ -66,4 +85,5 @@ def final_energy(self) -> float: @property def primary_property(self) -> float: + """Alias for ``final_energy``.""" return float(self.final_energy) From 5cb47e10c27e61ab5fea226837e4c7aa5d85840c Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Wed, 13 May 2026 16:48:03 +0200 Subject: [PATCH 19/52] add unit tests --- pyproject.toml | 1 + src/opi/simpletasks/base_task.py | 6 +- src/opi/simpletasks/engrad_task.py | 1 + src/opi/simpletasks/freq_task.py | 1 + src/opi/simpletasks/goat_task.py | 18 +- src/opi/simpletasks/method_settings.py | 10 +- src/opi/simpletasks/settings.py | 4 +- src/opi/simpletasks/singlepointtask.py | 1 + .../unit/test_simpletasks_method_settings.py | 186 ++++++++++++++ tests/unit/test_simpletasks_task.py | 231 ++++++++++++++++++ 10 files changed, 443 insertions(+), 16 deletions(-) create mode 100644 tests/unit/test_simpletasks_method_settings.py create mode 100644 tests/unit/test_simpletasks_task.py 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/simpletasks/base_task.py b/src/opi/simpletasks/base_task.py index 9ab3bc59..4b7326f1 100644 --- a/src/opi/simpletasks/base_task.py +++ b/src/opi/simpletasks/base_task.py @@ -30,7 +30,9 @@ class TaskSettings(Settings): subclass. """ - model_config = ConfigDict(arbitrary_types_allowed=True, validate_assignment=True, extra="forbid") + model_config = ConfigDict( + arbitrary_types_allowed=True, validate_assignment=True, extra="forbid" + ) task_keyword: typing.Annotated[SimpleKeyword, SimpleKeywordBox] @@ -190,7 +192,7 @@ def method(self, new_value: str | SimpleKeyword | None) -> None: if not hasattr(self._method_settings, "method"): raise AttributeError("method is not defined in method_settings object") if new_value is None: - self._method_settings.method = None # type:ignore + self._method_settings.method = None return resolved_type = MethodSettings.resolve_method_settings_type(new_value) if isinstance(self._method_settings, resolved_type): diff --git a/src/opi/simpletasks/engrad_task.py b/src/opi/simpletasks/engrad_task.py index 6c105ca0..9c8d4cc5 100644 --- a/src/opi/simpletasks/engrad_task.py +++ b/src/opi/simpletasks/engrad_task.py @@ -19,6 +19,7 @@ class EngradTask(SimpleTask): Returns an ``EngradResults`` object containing the total energy and the Cartesian gradient vector. """ + _task_settings: EngradSettings def __init__( diff --git a/src/opi/simpletasks/freq_task.py b/src/opi/simpletasks/freq_task.py index 4ad757ce..03424d61 100644 --- a/src/opi/simpletasks/freq_task.py +++ b/src/opi/simpletasks/freq_task.py @@ -21,6 +21,7 @@ class FreqTask(SimpleTask): terminated normally (SCF convergence is not checked separately because frequency jobs always follow an SCF step). """ + _task_settings: FreqSettings def __init__( diff --git a/src/opi/simpletasks/goat_task.py b/src/opi/simpletasks/goat_task.py index a9659aea..52d4285f 100644 --- a/src/opi/simpletasks/goat_task.py +++ b/src/opi/simpletasks/goat_task.py @@ -3,8 +3,8 @@ from opi.input import Input from opi.input.simple_keywords import Goat, SimpleKeyword, Solvent -from opi.input.structures import Structure, Properties -from opi.simpletasks.base_task import TaskSettings, SimpleTask, TaskResults +from opi.input.structures import Properties, Structure +from opi.simpletasks.base_task import SimpleTask, TaskResults, TaskSettings from opi.simpletasks.method_settings import MethodSettings @@ -18,7 +18,7 @@ class GoatSettings(TaskSettings): """ _name: str = "goat" - task_keyword : typing.Annotated[SimpleKeyword, Goat]= Goat.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 @@ -93,20 +93,20 @@ def status(self) -> bool: @cached_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") + structures = Structure.from_trj_xyz( + self.output.working_dir / f"{self.output.basename}.finalensemble.xyz" + ) return structures @cached_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") + properties = Properties.from_trj_xyz( + self.output.working_dir / f"{self.output.basename}.finalensemble.xyz", mode="goat" + ) return properties @cached_property def primary_property(self) -> tuple[list[Structure], list[Properties]]: """``(structures, properties)`` tuple for the final conformer ensemble.""" return self.structures, self.properties - - - - diff --git a/src/opi/simpletasks/method_settings.py b/src/opi/simpletasks/method_settings.py index 21e0166a..da90bbf3 100644 --- a/src/opi/simpletasks/method_settings.py +++ b/src/opi/simpletasks/method_settings.py @@ -6,6 +6,7 @@ from opi.input import Input from opi.input.simple_keywords import ( + AuxBasisSet, BasisSet, Dft, DispersionCorrection, @@ -16,9 +17,9 @@ SolvationModel, Solvent, Sqm, - Wft, AuxBasisSet, + Wft, ) -from opi.input.simple_keywords.dlpno import PNOThresh, Dlpno +from opi.input.simple_keywords.dlpno import Dlpno, PNOThresh from opi.input.simple_keywords.scf import Scf, ScfConvergence, ScfSolver, ScfThreshold from opi.simpletasks.settings import Settings @@ -336,7 +337,9 @@ class ForceFieldSettings(MethodSettings): @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) + warnings.warn( + "Basis Set will be ignored due to selection of Force Field method", UserWarning + ) self.basis_set = None return self @@ -394,4 +397,3 @@ def map_to_input(self, input_object: Input) -> Input: input_object.add_simple_keywords(Dlpno.LED) return input_object - diff --git a/src/opi/simpletasks/settings.py b/src/opi/simpletasks/settings.py index f3ca25d6..89f30481 100644 --- a/src/opi/simpletasks/settings.py +++ b/src/opi/simpletasks/settings.py @@ -1,3 +1,4 @@ +import types as builtin_types import typing from pydantic import BaseModel, ConfigDict, field_validator, model_validator @@ -56,7 +57,8 @@ def _get_field_metadata(hint: typing.Any) -> tuple[typing.Any, ...]: """ origin = typing.get_origin(hint) args = typing.get_args(hint) - if origin is typing.Union: + 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]) diff --git a/src/opi/simpletasks/singlepointtask.py b/src/opi/simpletasks/singlepointtask.py index b5a533de..ab34d489 100644 --- a/src/opi/simpletasks/singlepointtask.py +++ b/src/opi/simpletasks/singlepointtask.py @@ -22,6 +22,7 @@ class SinglePointTask(SimpleTask): ``SinglePointResults`` object whose ``final_energy`` attribute holds the total energy in Hartree. """ + _task_settings: SinglePointSettings def __init__( diff --git a/tests/unit/test_simpletasks_method_settings.py b/tests/unit/test_simpletasks_method_settings.py new file mode 100644 index 00000000..a7947f8c --- /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.simpletasks.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..e07925a8 --- /dev/null +++ b/tests/unit/test_simpletasks_task.py @@ -0,0 +1,231 @@ +import pytest + +from opi.input import Input +from opi.input.blocks import BlockGeom +from opi.input.simple_keywords import BasisSet, Dft, Goat, SimpleKeyword, Task +from opi.input.simple_keywords.opt import Opt +from opi.simpletasks.engrad_task import EngradTask +from opi.simpletasks.freq_task import FreqTask +from opi.simpletasks.goat_task import GoatSettings, GoatTask +from opi.simpletasks.method_settings import DFTSettings, SQMSettings +from opi.simpletasks.opt_task import OptSettings, OptTask +from opi.simpletasks.singlepointtask import SinglePointTask + +""" +Unit tests for SimpleTask subclasses and TaskSettings: +- Constructor argument validation (method dispatch, dict args, no-method error) +- input_object 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_object — 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_object_has_task_keyword(task_cls: type, expected_kw: SimpleKeyword) -> None: + """input_object contains the primary task keyword for each task type.""" + inp = task_cls(method="pbe").input_object + assert inp.has_simple_keywords(expected_kw) == (True,) + + +@pytest.mark.unit +@pytest.mark.simpletasks +def test_input_object_has_method_and_basis_set() -> None: + """input_object contains the task keyword, method, and basis-set keywords.""" + inp = SinglePointTask(method="pbe", basis_set="def2-svp").input_object + 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 From d741c80fa99cd61458ee999316e34ccabc43fffd Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Mon, 18 May 2026 13:43:03 +0200 Subject: [PATCH 20/52] added examples for simple tasks --- examples/README.md | 12 +++-- .../exmp054_singlepoint_simpletask/inp.xyz | 5 ++ .../exmp054_singlepoint_simpletask/job.py | 39 ++++++++++++++ examples/exmp055_goat_simpletask/inp.xyz | 10 ++++ examples/exmp055_goat_simpletask/job.py | 39 ++++++++++++++ examples/exmp056_freq_simpletask/inp.xyz | 5 ++ examples/exmp056_freq_simpletask/job.py | 51 +++++++++++++++++++ examples/exmp057_opt_simpletask/inp.xyz | 10 ++++ examples/exmp057_opt_simpletask/job.py | 44 ++++++++++++++++ examples/exmp058_engrad_simpletask/inp.xyz | 5 ++ examples/exmp058_engrad_simpletask/job.py | 32 ++++++++++++ src/opi/input/simple_keywords/dlpno.py | 1 + src/opi/simpletasks/engrad_task.py | 24 +++++++++ src/opi/simpletasks/freq_task.py | 24 +++++++++ src/opi/simpletasks/goat_task.py | 25 ++++++++- 15 files changed, 321 insertions(+), 5 deletions(-) create mode 100644 examples/exmp054_singlepoint_simpletask/inp.xyz create mode 100644 examples/exmp054_singlepoint_simpletask/job.py create mode 100644 examples/exmp055_goat_simpletask/inp.xyz create mode 100644 examples/exmp055_goat_simpletask/job.py create mode 100644 examples/exmp056_freq_simpletask/inp.xyz create mode 100644 examples/exmp056_freq_simpletask/job.py create mode 100644 examples/exmp057_opt_simpletask/inp.xyz create mode 100644 examples/exmp057_opt_simpletask/job.py create mode 100644 examples/exmp058_engrad_simpletask/inp.xyz create mode 100644 examples/exmp058_engrad_simpletask/job.py diff --git a/examples/README.md b/examples/README.md index 4385bee0..01746f88 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 +- exmp054_singlepoint_simpletask: B3LYP/def2-SVP single-point with CPCM(water) using the `SinglePointTask` functionality +- exmp055_goat_simpletask: GFN2-xTB GOAT conformer search using the `GoatTask` functionality +- exmp056_freq_simpletask: TPSS/def2-SVP frequency calculation using the `FreqTask` functionality +- exmp057_opt_simpletask: B3LYP/def2-SVP geometry optimization with CPCM(water) using the `OptTask` functionality +- exmp058_engrad_simpletask: r²SCAN-3c energy & gradient calculation using the `EngradTask` functionality diff --git a/examples/exmp054_singlepoint_simpletask/inp.xyz b/examples/exmp054_singlepoint_simpletask/inp.xyz new file mode 100644 index 00000000..8a2e30ef --- /dev/null +++ b/examples/exmp054_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/exmp054_singlepoint_simpletask/job.py b/examples/exmp054_singlepoint_simpletask/job.py new file mode 100644 index 00000000..c1b51e8c --- /dev/null +++ b/examples/exmp054_singlepoint_simpletask/job.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +import sys +from pathlib import Path + +from opi.input.structures import Structure +from opi.simpletasks.singlepointtask import SinglePointResults, SinglePointTask + + +def run_exmp054( + 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 + + # > 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 final energy from the `TaskResults` object + final_energy = singlepoint_result.final_energy + + print(f"Final single point energy: {final_energy: 10f} Eh") + + return singlepoint_result + + +if __name__ == "__main__": + run_exmp054() diff --git a/examples/exmp055_goat_simpletask/inp.xyz b/examples/exmp055_goat_simpletask/inp.xyz new file mode 100644 index 00000000..420f0f9e --- /dev/null +++ b/examples/exmp055_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/exmp055_goat_simpletask/job.py b/examples/exmp055_goat_simpletask/job.py new file mode 100644 index 00000000..910392e1 --- /dev/null +++ b/examples/exmp055_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.simpletasks.goat_task import GoatSettings, GoatTask + + +def run_exmp055( + 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_exmp055() diff --git a/examples/exmp056_freq_simpletask/inp.xyz b/examples/exmp056_freq_simpletask/inp.xyz new file mode 100644 index 00000000..ff1fa22c --- /dev/null +++ b/examples/exmp056_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/exmp056_freq_simpletask/job.py b/examples/exmp056_freq_simpletask/job.py new file mode 100644 index 00000000..acf83619 --- /dev/null +++ b/examples/exmp056_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.simpletasks.freq_task import FreqResults, FreqTask + + +def run_exmp056(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_exmp056() diff --git a/examples/exmp057_opt_simpletask/inp.xyz b/examples/exmp057_opt_simpletask/inp.xyz new file mode 100644 index 00000000..420f0f9e --- /dev/null +++ b/examples/exmp057_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/exmp057_opt_simpletask/job.py b/examples/exmp057_opt_simpletask/job.py new file mode 100644 index 00000000..5bb26d3c --- /dev/null +++ b/examples/exmp057_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.simpletasks.method_settings import DFTSettings +from opi.simpletasks.opt_task import OptResults, OptSettings, OptTask + + +def run_exmp057(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("../exmp054_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_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..19bad60c --- /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.simpletasks.engrad_task 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/src/opi/input/simple_keywords/dlpno.py b/src/opi/input/simple_keywords/dlpno.py index 31602f00..5b018149 100644 --- a/src/opi/input/simple_keywords/dlpno.py +++ b/src/opi/input/simple_keywords/dlpno.py @@ -5,6 +5,7 @@ __all__ = ("Dlpno",) + class PNOThresh(SimpleKeywordBox): LOOSEPNO = SimpleKeyword("loosepno") """SimpleKeyword: Select loose PNO settings..""" diff --git a/src/opi/simpletasks/engrad_task.py b/src/opi/simpletasks/engrad_task.py index 9c8d4cc5..7b8e0fa3 100644 --- a/src/opi/simpletasks/engrad_task.py +++ b/src/opi/simpletasks/engrad_task.py @@ -1,6 +1,8 @@ import typing +from pathlib import Path from opi.input.simple_keywords import SimpleKeyword, Solvent, Task +from opi.input.structures import BaseStructureFile, Structure from opi.simpletasks.base_task import SimpleTask, TaskResults, TaskSettings from opi.simpletasks.method_settings import MethodSettings @@ -38,6 +40,28 @@ def __init__( self._results_type = EngradResults + def run( + self, + basename: str, + struct: Structure | BaseStructureFile, + working_dir: Path = Path("RUN"), + ncores: int | None = None, + memory: int | None = None, + moinp: Path | None = None, + strict: bool = False, + ) -> "EngradResults": + single_point_result = super().run( + basename=basename, + struct=struct, + working_dir=working_dir, + ncores=ncores, + memory=memory, + moinp=moinp, + strict=strict, + ) + + return typing.cast(EngradResults, single_point_result) + class EngradResults(TaskResults): """Results from an energy + gradient calculation.""" diff --git a/src/opi/simpletasks/freq_task.py b/src/opi/simpletasks/freq_task.py index 03424d61..7336a37d 100644 --- a/src/opi/simpletasks/freq_task.py +++ b/src/opi/simpletasks/freq_task.py @@ -1,7 +1,9 @@ import typing from functools import cached_property +from pathlib import Path from opi.input.simple_keywords import SimpleKeyword, Solvent, Task +from opi.input.structures import BaseStructureFile, Structure from opi.simpletasks.base_task import SimpleTask, TaskResults, TaskSettings from opi.simpletasks.method_settings import MethodSettings @@ -40,6 +42,28 @@ def __init__( self._results_type = FreqResults + def run( + self, + basename: str, + struct: Structure | BaseStructureFile, + working_dir: Path = Path("RUN"), + ncores: int | None = None, + memory: int | None = None, + moinp: Path | None = None, + strict: bool = False, + ) -> "FreqResults": + single_point_result = super().run( + basename=basename, + struct=struct, + working_dir=working_dir, + ncores=ncores, + memory=memory, + moinp=moinp, + strict=strict, + ) + + return typing.cast(FreqResults, single_point_result) + class FreqResults(TaskResults): """Results from a harmonic frequency calculation.""" diff --git a/src/opi/simpletasks/goat_task.py b/src/opi/simpletasks/goat_task.py index 52d4285f..443e69de 100644 --- a/src/opi/simpletasks/goat_task.py +++ b/src/opi/simpletasks/goat_task.py @@ -1,9 +1,10 @@ import typing from functools import cached_property +from pathlib import Path from opi.input import Input from opi.input.simple_keywords import Goat, SimpleKeyword, Solvent -from opi.input.structures import Properties, Structure +from opi.input.structures import BaseStructureFile, Properties, Structure from opi.simpletasks.base_task import SimpleTask, TaskResults, TaskSettings from opi.simpletasks.method_settings import MethodSettings @@ -81,6 +82,28 @@ def __init__( self._results_type = GoatResults + def run( + self, + basename: str, + struct: Structure | BaseStructureFile, + working_dir: Path = Path("RUN"), + ncores: int | None = None, + memory: int | None = None, + moinp: Path | None = None, + strict: bool = False, + ) -> "GoatResults": + single_point_result = super().run( + basename=basename, + struct=struct, + working_dir=working_dir, + ncores=ncores, + memory=memory, + moinp=moinp, + strict=strict, + ) + + return typing.cast(GoatResults, single_point_result) + class GoatResults(TaskResults): """Results from a GOAT conformer-ensemble exploration.""" From 6e75dace9f2482c6a118d0b17290fb105aa9a07f Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Mon, 18 May 2026 15:57:45 +0200 Subject: [PATCH 21/52] add from_string() method --- examples/exmp054_simpletask/inp.xyz | 5 - examples/exmp054_simpletask/job.py | 44 ------- src/opi/simpletasks/base_task.py | 166 ++++++++++++++++++++++--- src/opi/simpletasks/engrad_task.py | 76 ++++------- src/opi/simpletasks/freq_task.py | 78 ++++-------- src/opi/simpletasks/goat_task.py | 79 ++++-------- src/opi/simpletasks/opt_task.py | 115 +++++++---------- src/opi/simpletasks/singlepointtask.py | 78 ++++-------- 8 files changed, 299 insertions(+), 342 deletions(-) delete mode 100644 examples/exmp054_simpletask/inp.xyz delete mode 100644 examples/exmp054_simpletask/job.py diff --git a/examples/exmp054_simpletask/inp.xyz b/examples/exmp054_simpletask/inp.xyz deleted file mode 100644 index 8a2e30ef..00000000 --- a/examples/exmp054_simpletask/inp.xyz +++ /dev/null @@ -1,5 +0,0 @@ -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/exmp054_simpletask/job.py b/examples/exmp054_simpletask/job.py deleted file mode 100644 index 9b96ed7b..00000000 --- a/examples/exmp054_simpletask/job.py +++ /dev/null @@ -1,44 +0,0 @@ -import shutil -import sys -from pathlib import Path - -from opi.input.structures import Structure -from opi.simpletasks.singlepointtask import SinglePointResults, SinglePointTask - - -def run_exmp054( - structure: Structure | None = None, working_dir: Path | None = Path("RUN") -) -> SinglePointResults: - # > recreate the working dir - shutil.rmtree(working_dir, ignore_errors=True) - working_dir.mkdir() - - # > 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 - - # > run the calculation with given data - singlepoint_result = simple_task.run("job", structure) - # also possible for user to restart a calculation after changing any input options already given - - # > check if the ORCA calculation terminated normally - if not singlepoint_result.status: - print("SinglePoint task failed") - sys.exit(1) - - # > extract final energy from the `TaskResults` object - final_energy = singlepoint_result.final_energy - - print(f"Final single point energy: {final_energy: 10f} Eh") - - return singlepoint_result - - -if __name__ == "__main__": - run_exmp054() diff --git a/src/opi/simpletasks/base_task.py b/src/opi/simpletasks/base_task.py index 4b7326f1..e18249fa 100644 --- a/src/opi/simpletasks/base_task.py +++ b/src/opi/simpletasks/base_task.py @@ -36,7 +36,10 @@ class TaskSettings(Settings): task_keyword: typing.Annotated[SimpleKeyword, SimpleKeywordBox] -class SimpleTask(ABC): +_RT = typing.TypeVar("_RT", bound="TaskResults") + + +class SimpleTask(ABC, typing.Generic[_RT]): """ Abstract base class for all high-level OPI calculation tasks. @@ -47,14 +50,17 @@ class SimpleTask(ABC): Concrete subclasses (``SinglePointTask``, ``OptTask``, …) bind the specific settings and results types through ``_task_settings_type`` and - ``_results_type``. + ``_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["TaskResults"] + _results_type: type[_RT] _task_settings_type: type[TaskSettings] _task_settings: TaskSettings _method_settings: MethodSettings @@ -274,6 +280,100 @@ def solvation_model(self, new_value: str | SimpleKeyword | None) -> None: 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. Useful as an escape hatch when + the desired keyword combination is not yet covered by the settings + classes. + + 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(calc_object=calc) + def run( self, basename: str, @@ -283,7 +383,7 @@ def run( memory: int | None = None, moinp: Path | None = None, strict: bool = False, - ) -> "TaskResults": + ) -> _RT: """ Execute the computational task with the given structure and settings. @@ -355,7 +455,7 @@ def run( return self._results_type(calc_object=calc) - def _restart( + def restart( self, previous_results: "TaskResults", basename: str | None = None, @@ -365,24 +465,58 @@ def _restart( memory: int | None = None, moinp: Path | None = None, use_previous_orbitals: bool = False, - ) -> "TaskResults": + ) -> _RT: """ - TODO: - - finish restart implementation (low on priority list) + 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 - basename - struct - working_dir - ncores - memory - moinp - use_previous_orbitals + 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. + struct : 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.calc_object diff --git a/src/opi/simpletasks/engrad_task.py b/src/opi/simpletasks/engrad_task.py index 7b8e0fa3..4f5496da 100644 --- a/src/opi/simpletasks/engrad_task.py +++ b/src/opi/simpletasks/engrad_task.py @@ -1,8 +1,6 @@ import typing -from pathlib import Path from opi.input.simple_keywords import SimpleKeyword, Solvent, Task -from opi.input.structures import BaseStructureFile, Structure from opi.simpletasks.base_task import SimpleTask, TaskResults, TaskSettings from opi.simpletasks.method_settings import MethodSettings @@ -14,55 +12,6 @@ class EngradSettings(TaskSettings): task_keyword: typing.Annotated[SimpleKeyword, Task] = Task.ENGRAD -class EngradTask(SimpleTask): - """ - High-level task for single-point energy and gradient calculations. - - Returns an ``EngradResults`` object containing the total energy and - the Cartesian gradient vector. - """ - - _task_settings: EngradSettings - - 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: EngradSettings | None = None, - method_settings: MethodSettings | None = None, - ): - self._task_settings_type = EngradSettings - super().__init__( - method, basis_set, solvation_model, solvent, task_settings, method_settings - ) - - self._results_type = EngradResults - - def run( - self, - basename: str, - struct: Structure | BaseStructureFile, - working_dir: Path = Path("RUN"), - ncores: int | None = None, - memory: int | None = None, - moinp: Path | None = None, - strict: bool = False, - ) -> "EngradResults": - single_point_result = super().run( - basename=basename, - struct=struct, - working_dir=working_dir, - ncores=ncores, - memory=memory, - moinp=moinp, - strict=strict, - ) - - return typing.cast(EngradResults, single_point_result) - - class EngradResults(TaskResults): """Results from an energy + gradient calculation.""" @@ -104,3 +53,28 @@ def gradient(self) -> list[float]: def primary_property(self) -> tuple[float, list[float]]: """``(final_energy, gradient)`` tuple.""" return self.final_energy, self.gradient + + +class EngradTask(SimpleTask[EngradResults]): + """ + High-level 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 + + 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: EngradSettings | None = None, + method_settings: MethodSettings | None = None, + ): + super().__init__( + method, basis_set, solvation_model, solvent, task_settings, method_settings + ) diff --git a/src/opi/simpletasks/freq_task.py b/src/opi/simpletasks/freq_task.py index 7336a37d..a2faafbb 100644 --- a/src/opi/simpletasks/freq_task.py +++ b/src/opi/simpletasks/freq_task.py @@ -1,9 +1,7 @@ import typing from functools import cached_property -from pathlib import Path from opi.input.simple_keywords import SimpleKeyword, Solvent, Task -from opi.input.structures import BaseStructureFile, Structure from opi.simpletasks.base_task import SimpleTask, TaskResults, TaskSettings from opi.simpletasks.method_settings import MethodSettings @@ -15,56 +13,6 @@ class FreqSettings(TaskSettings): task_keyword: typing.Annotated[SimpleKeyword, Task] = Task.FREQ -class FreqTask(SimpleTask): - """ - High-level 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 - - 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: FreqSettings | None = None, - method_settings: MethodSettings | None = None, - ): - self._task_settings_type = FreqSettings - super().__init__( - method, basis_set, solvation_model, solvent, task_settings, method_settings - ) - - self._results_type = FreqResults - - def run( - self, - basename: str, - struct: Structure | BaseStructureFile, - working_dir: Path = Path("RUN"), - ncores: int | None = None, - memory: int | None = None, - moinp: Path | None = None, - strict: bool = False, - ) -> "FreqResults": - single_point_result = super().run( - basename=basename, - struct=struct, - working_dir=working_dir, - ncores=ncores, - memory=memory, - moinp=moinp, - strict=strict, - ) - - return typing.cast(FreqResults, single_point_result) - - class FreqResults(TaskResults): """Results from a harmonic frequency calculation.""" @@ -94,3 +42,29 @@ def free_energy_delta(self) -> float: def primary_property(self) -> float: """Alias for ``free_energy_delta``.""" return float(self.free_energy_delta) + + +class FreqTask(SimpleTask[FreqResults]): + """ + High-level 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 + + 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: FreqSettings | None = None, + method_settings: MethodSettings | None = None, + ): + super().__init__( + method, basis_set, solvation_model, solvent, task_settings, method_settings + ) diff --git a/src/opi/simpletasks/goat_task.py b/src/opi/simpletasks/goat_task.py index 443e69de..aaf553d7 100644 --- a/src/opi/simpletasks/goat_task.py +++ b/src/opi/simpletasks/goat_task.py @@ -1,10 +1,9 @@ import typing from functools import cached_property -from pathlib import Path from opi.input import Input from opi.input.simple_keywords import Goat, SimpleKeyword, Solvent -from opi.input.structures import BaseStructureFile, Properties, Structure +from opi.input.structures import Properties, Structure from opi.simpletasks.base_task import SimpleTask, TaskResults, TaskSettings from opi.simpletasks.method_settings import MethodSettings @@ -42,7 +41,7 @@ def map_to_input(self, input_object: Input) -> Input: Input Modified ``Input`` object. """ - super().map_to_input(input_object) + input_object = super().map_to_input(input_object) if self.goat_react: input_object.add_simple_keywords(Goat.GOAT_REACT) @@ -56,55 +55,6 @@ def map_to_input(self, input_object: Input) -> Input: return input_object -class GoatTask(SimpleTask): - """ - 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 - - 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: GoatSettings | None = None, - method_settings: MethodSettings | None = None, - ): - self._task_settings_type = GoatSettings - super().__init__( - method, basis_set, solvation_model, solvent, task_settings, method_settings - ) - - self._results_type = GoatResults - - def run( - self, - basename: str, - struct: Structure | BaseStructureFile, - working_dir: Path = Path("RUN"), - ncores: int | None = None, - memory: int | None = None, - moinp: Path | None = None, - strict: bool = False, - ) -> "GoatResults": - single_point_result = super().run( - basename=basename, - struct=struct, - working_dir=working_dir, - ncores=ncores, - memory=memory, - moinp=moinp, - strict=strict, - ) - - return typing.cast(GoatResults, single_point_result) - - class GoatResults(TaskResults): """Results from a GOAT conformer-ensemble exploration.""" @@ -133,3 +83,28 @@ def properties(self) -> list[Properties]: 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 + + 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: GoatSettings | None = None, + method_settings: MethodSettings | None = None, + ): + super().__init__( + method, basis_set, solvation_model, solvent, task_settings, method_settings + ) diff --git a/src/opi/simpletasks/opt_task.py b/src/opi/simpletasks/opt_task.py index 01b37348..0e427a1f 100644 --- a/src/opi/simpletasks/opt_task.py +++ b/src/opi/simpletasks/opt_task.py @@ -1,10 +1,9 @@ import typing -from pathlib import Path from opi.input import Input from opi.input.simple_keywords import SimpleKeyword, Solvent, Task from opi.input.simple_keywords.opt import Opt, OptThreshold -from opi.input.structures import BaseStructureFile, Structure +from opi.input.structures import Structure from opi.simpletasks.base_task import SimpleTask, TaskResults, TaskSettings from opi.simpletasks.method_settings import MethodSettings @@ -63,7 +62,49 @@ def map_to_input(self, input_object: Input) -> Input: return input_object -class OptTask(SimpleTask): +class OptResults(TaskResults): + """Results from a geometry optimisation.""" + + @property + def final_energy(self) -> float: + """ + Energy at the optimised geometry in Hartree. + + 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 + + @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. @@ -74,6 +115,7 @@ class OptTask(SimpleTask): """ _task_settings: OptSettings + _results_type = OptResults def __init__( self, @@ -84,35 +126,10 @@ def __init__( task_settings: OptSettings | None = None, method_settings: MethodSettings | None = None, ): - self._task_settings_type = OptSettings super().__init__( method, basis_set, solvation_model, solvent, task_settings, method_settings ) - self._results_type = OptResults - - def run( - self, - basename: str, - struct: Structure | BaseStructureFile, - working_dir: Path = Path("RUN"), - ncores: int | None = None, - memory: int | None = None, - moinp: Path | None = None, - strict: bool = False, - ) -> "OptResults": - single_point_result = super().run( - basename=basename, - struct=struct, - working_dir=working_dir, - ncores=ncores, - memory=memory, - moinp=moinp, - strict=strict, - ) - - return typing.cast(OptResults, single_point_result) - @property def opt_threshold(self) -> SimpleKeyword | None: """Convergence threshold keyword (e.g. ``OptThreshold.TIGHTOPT``).""" @@ -157,45 +174,3 @@ def opt_maxiter(self) -> int | None: @opt_maxiter.setter def opt_maxiter(self, new_value: int | None) -> None: self._task_settings.opt_maxiter = new_value - - -class OptResults(TaskResults): - """Results from a geometry optimisation.""" - - @property - def final_energy(self) -> float: - """ - Energy at the optimised geometry in Hartree. - - 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 - - @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 diff --git a/src/opi/simpletasks/singlepointtask.py b/src/opi/simpletasks/singlepointtask.py index ab34d489..ee7300ad 100644 --- a/src/opi/simpletasks/singlepointtask.py +++ b/src/opi/simpletasks/singlepointtask.py @@ -1,8 +1,6 @@ import typing -from pathlib import Path from opi.input.simple_keywords import SimpleKeyword, Solvent, Task -from opi.input.structures import BaseStructureFile, Structure from opi.simpletasks.base_task import SimpleTask, TaskResults, TaskSettings from opi.simpletasks.method_settings import MethodSettings @@ -14,56 +12,6 @@ class SinglePointSettings(TaskSettings): task_keyword: typing.Annotated[SimpleKeyword, Task] = Task.SP -class SinglePointTask(SimpleTask): - """ - 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 - - 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: SinglePointSettings | None = None, - method_settings: MethodSettings | None = None, - ): - self._task_settings_type = SinglePointSettings - super().__init__( - method, basis_set, solvation_model, solvent, task_settings, method_settings - ) - - self._results_type = SinglePointResults - - def run( - self, - basename: str, - struct: Structure | BaseStructureFile, - working_dir: Path = Path("RUN"), - ncores: int | None = None, - memory: int | None = None, - moinp: Path | None = None, - strict: bool = False, - ) -> "SinglePointResults": - single_point_result = super().run( - basename=basename, - struct=struct, - working_dir=working_dir, - ncores=ncores, - memory=memory, - moinp=moinp, - strict=strict, - ) - - return typing.cast(SinglePointResults, single_point_result) - - class SinglePointResults(TaskResults): """Results from a single-point energy calculation.""" @@ -88,3 +36,29 @@ def final_energy(self) -> float: 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 + + 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: SinglePointSettings | None = None, + method_settings: MethodSettings | None = None, + ): + super().__init__( + method, basis_set, solvation_model, solvent, task_settings, method_settings + ) From 812d290c23e7081ebd4cc15e91ff5842a73199a1 Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Wed, 20 May 2026 14:01:22 +0200 Subject: [PATCH 22/52] add unit tests for SimpleTask.from_string() --- src/opi/simpletasks/base_task.py | 14 +- tests/unit/test_simpletasks_from_string.py | 181 +++++++++++++++++++++ 2 files changed, 192 insertions(+), 3 deletions(-) create mode 100644 tests/unit/test_simpletasks_from_string.py diff --git a/src/opi/simpletasks/base_task.py b/src/opi/simpletasks/base_task.py index e18249fa..0385d965 100644 --- a/src/opi/simpletasks/base_task.py +++ b/src/opi/simpletasks/base_task.py @@ -4,6 +4,7 @@ from abc import ABC, abstractmethod from functools import cached_property from pathlib import Path +from typing import get_type_hints from pydantic import ConfigDict @@ -61,7 +62,6 @@ class SimpleTask(ABC, typing.Generic[_RT]): """ _results_type: type[_RT] - _task_settings_type: type[TaskSettings] _task_settings: TaskSettings _method_settings: MethodSettings @@ -101,10 +101,11 @@ def __init__( If neither ``method`` nor a ``method_settings`` object with a method is provided. """ + task_settings_type = self._get_task_settings_type() if isinstance(task_settings, dict): - self._task_settings = self._task_settings_type.model_validate(task_settings) + self._task_settings = task_settings_type.model_validate(task_settings) else: - self._task_settings = task_settings or self._task_settings_type() # type: ignore[call-arg] + self._task_settings = task_settings or task_settings_type() # type: ignore[call-arg] resolved_method_settings: MethodSettings | None = ( MethodSettings(**method_settings) @@ -139,6 +140,13 @@ def __init__( ) self._method_settings = resolved_method_settings + @classmethod + def _get_task_settings_type(cls) -> type[TaskSettings]: + 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: """Task-level settings (keyword, thresholds, flags).""" diff --git a/tests/unit/test_simpletasks_from_string.py b/tests/unit/test_simpletasks_from_string.py new file mode 100644 index 00000000..6fd9e493 --- /dev/null +++ b/tests/unit/test_simpletasks_from_string.py @@ -0,0 +1,181 @@ +from pathlib import Path +from unittest.mock import patch + +import pytest + +from opi.input.simple_keywords import SimpleKeyword +from opi.simpletasks.freq_task import FreqResults, FreqTask +from opi.simpletasks.singlepointtask import 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.calc_object.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.calc_object.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.calc_object.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.calc_object.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.calc_object.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.calc_object.structure is h2 From eabdb097e0f83dc21e5d8a6b0af5fe061cd4b833 Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Mon, 1 Jun 2026 11:09:50 +0200 Subject: [PATCH 23/52] fix:allow users to freely edit SimpleTask.input_object --- src/opi/simpletasks/base_task.py | 11 +++++++---- tests/unit/test_simpletasks_task.py | 23 +++++++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/opi/simpletasks/base_task.py b/src/opi/simpletasks/base_task.py index 0385d965..2f370947 100644 --- a/src/opi/simpletasks/base_task.py +++ b/src/opi/simpletasks/base_task.py @@ -157,13 +157,18 @@ def method_settings(self) -> MethodSettings: """Method-level settings (functional, basis set, solvent, …).""" return self._method_settings - @property + @cached_property def input_object(self) -> Input: """ Creates configured `Input` object. First it initializes an empty instance of `Input` , and then passes it as to corresponding `TaskSettings` and `MethodSettings` objects to be configured by user-defined data stored in those objects. + The result is cached — the same `Input` instance is returned on every access, so mutations + (e.g. ``task.input_object.add_simple_keywords(...)``) persist and are included when ``run()`` is called. + To reset the cache after changing ``task_settings`` or ``method_settings``, delete the attribute: + ``del task.input_object``. + Returns ------- `Input` @@ -304,9 +309,7 @@ def from_string( Run a calculation from a raw ORCA keyword string. Bypasses the typed ``TaskSettings``/``MethodSettings`` API and feeds - keywords directly into the input file. Useful as an escape hatch when - the desired keyword combination is not yet covered by the settings - classes. + keywords directly into the input file. Parameters ---------- diff --git a/tests/unit/test_simpletasks_task.py b/tests/unit/test_simpletasks_task.py index e07925a8..75d32d92 100644 --- a/tests/unit/test_simpletasks_task.py +++ b/tests/unit/test_simpletasks_task.py @@ -229,3 +229,26 @@ def test_basis_set_getter_and_setter() -> None: task.basis_set = "def2-tzvp" assert task.basis_set == BasisSet.DEF2_TZVP + + +# --------------------------------------------------------------------------- +# input_object caching +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +@pytest.mark.simpletasks +def test_input_object_is_cached() -> None: + """Accessing input_object twice returns the same object instance.""" + task = SinglePointTask(method="pbe") + assert task.input_object is task.input_object + + +@pytest.mark.unit +@pytest.mark.simpletasks +def test_input_object_mutation_persists() -> None: + """Keywords added to input_object are present on the next access.""" + task = SinglePointTask(method="pbe") + kw = SimpleKeyword("RIJCOSX") + task.input_object.add_simple_keywords(kw) + assert task.input_object.has_simple_keywords(kw) == (True,) From 810ff7cd630f009d5bc8f4777f75cb4db3d7a4d2 Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Mon, 1 Jun 2026 11:20:06 +0200 Subject: [PATCH 24/52] fix: add final_energy property to case TaskResults class --- src/opi/simpletasks/base_task.py | 19 +++++++++++++++++-- src/opi/simpletasks/engrad_task.py | 19 +------------------ src/opi/simpletasks/method_settings.py | 4 ++-- src/opi/simpletasks/opt_task.py | 17 ----------------- src/opi/simpletasks/singlepointtask.py | 17 ----------------- 5 files changed, 20 insertions(+), 56 deletions(-) diff --git a/src/opi/simpletasks/base_task.py b/src/opi/simpletasks/base_task.py index 2f370947..2dcf5f50 100644 --- a/src/opi/simpletasks/base_task.py +++ b/src/opi/simpletasks/base_task.py @@ -42,7 +42,7 @@ class TaskSettings(Settings): class SimpleTask(ABC, typing.Generic[_RT]): """ - Abstract base class for all high-level OPI calculation tasks. + Abstract base class for all OPI calculation tasks. Combines a ``TaskSettings`` (what kind of calculation to run) with a ``MethodSettings`` (which method/basis/solvent to use) and exposes a @@ -590,10 +590,25 @@ def output(self) -> Output: @cached_property def status(self) -> bool: """``True`` if the job terminated normally and SCF converged.""" - return self.output.terminated_normally() and self.output.scf_converged() + return self.output.terminated_normally() @cached_property @abstractmethod def primary_property(self) -> typing.Any: """The most important result for this task type (energy, structure, …).""" pass + + @cached_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/simpletasks/engrad_task.py b/src/opi/simpletasks/engrad_task.py index 4f5496da..edaf4fd7 100644 --- a/src/opi/simpletasks/engrad_task.py +++ b/src/opi/simpletasks/engrad_task.py @@ -15,23 +15,6 @@ class EngradSettings(TaskSettings): class EngradResults(TaskResults): """Results from an energy + gradient calculation.""" - @property - def final_energy(self) -> float: - """ - Total energy in Hartree. - - 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 - @property def gradient(self) -> list[float]: """ @@ -57,7 +40,7 @@ def primary_property(self) -> tuple[float, list[float]]: class EngradTask(SimpleTask[EngradResults]): """ - High-level task for single-point energy and gradient calculations. + Task for single-point energy and gradient calculations. Returns an ``EngradResults`` object containing the total energy and the Cartesian gradient vector. diff --git a/src/opi/simpletasks/method_settings.py b/src/opi/simpletasks/method_settings.py index da90bbf3..526a4cb1 100644 --- a/src/opi/simpletasks/method_settings.py +++ b/src/opi/simpletasks/method_settings.py @@ -120,7 +120,7 @@ def resolve_method_settings_type( raise ValueError(f"Keyword {method} not found in any of the valid groupings") -class DFTSettings(MethodSettings): +class DftSettings(MethodSettings): """ Method settings for DFT calculations. @@ -249,7 +249,7 @@ def _find_dft_disp_keyword(cls, value: str | SimpleKeyword) -> SimpleKeyword: raise ValueError(f"Invalid Dft keyword '{value}'") -class SQMSettings(MethodSettings): +class SqmSettings(MethodSettings): """ Method settings for semi-empirical (SQM) calculations. diff --git a/src/opi/simpletasks/opt_task.py b/src/opi/simpletasks/opt_task.py index 0e427a1f..7abf2c97 100644 --- a/src/opi/simpletasks/opt_task.py +++ b/src/opi/simpletasks/opt_task.py @@ -65,23 +65,6 @@ def map_to_input(self, input_object: Input) -> Input: class OptResults(TaskResults): """Results from a geometry optimisation.""" - @property - def final_energy(self) -> float: - """ - Energy at the optimised geometry in Hartree. - - 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 - @property def structure(self) -> Structure: """ diff --git a/src/opi/simpletasks/singlepointtask.py b/src/opi/simpletasks/singlepointtask.py index ee7300ad..70d2e0f7 100644 --- a/src/opi/simpletasks/singlepointtask.py +++ b/src/opi/simpletasks/singlepointtask.py @@ -15,23 +15,6 @@ class SinglePointSettings(TaskSettings): class SinglePointResults(TaskResults): """Results from a single-point energy calculation.""" - @property - def final_energy(self) -> float: - """ - Total energy of the last SCF cycle in Hartree. - - 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 - @property def primary_property(self) -> float: """Alias for ``final_energy``.""" From 234e99b4ae3dc98796903566a3cc03c528b65ad9 Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Mon, 1 Jun 2026 12:34:14 +0200 Subject: [PATCH 25/52] fix: renamed simple tasks folder --- src/opi/{simpletasks => simple_tasks}/__init__.py | 0 src/opi/{simpletasks => simple_tasks}/engrad_task.py | 4 ++-- src/opi/{simpletasks => simple_tasks}/freq_task.py | 4 ++-- src/opi/{simpletasks => simple_tasks}/goat_task.py | 4 ++-- src/opi/{simpletasks => simple_tasks}/method_settings.py | 2 +- src/opi/{simpletasks => simple_tasks}/opt_task.py | 4 ++-- src/opi/{simpletasks => simple_tasks}/settings.py | 0 .../{simpletasks/base_task.py => simple_tasks/simple_task.py} | 4 ++-- .../singlepointtask.py => simple_tasks/single_point_task.py} | 4 ++-- 9 files changed, 13 insertions(+), 13 deletions(-) rename src/opi/{simpletasks => simple_tasks}/__init__.py (100%) rename src/opi/{simpletasks => simple_tasks}/engrad_task.py (92%) rename src/opi/{simpletasks => simple_tasks}/freq_task.py (93%) rename src/opi/{simpletasks => simple_tasks}/goat_task.py (96%) rename src/opi/{simpletasks => simple_tasks}/method_settings.py (99%) rename src/opi/{simpletasks => simple_tasks}/opt_task.py (97%) rename src/opi/{simpletasks => simple_tasks}/settings.py (100%) rename src/opi/{simpletasks/base_task.py => simple_tasks/simple_task.py} (99%) rename src/opi/{simpletasks/singlepointtask.py => simple_tasks/single_point_task.py} (90%) diff --git a/src/opi/simpletasks/__init__.py b/src/opi/simple_tasks/__init__.py similarity index 100% rename from src/opi/simpletasks/__init__.py rename to src/opi/simple_tasks/__init__.py diff --git a/src/opi/simpletasks/engrad_task.py b/src/opi/simple_tasks/engrad_task.py similarity index 92% rename from src/opi/simpletasks/engrad_task.py rename to src/opi/simple_tasks/engrad_task.py index edaf4fd7..4f177198 100644 --- a/src/opi/simpletasks/engrad_task.py +++ b/src/opi/simple_tasks/engrad_task.py @@ -1,8 +1,8 @@ import typing from opi.input.simple_keywords import SimpleKeyword, Solvent, Task -from opi.simpletasks.base_task import SimpleTask, TaskResults, TaskSettings -from opi.simpletasks.method_settings import MethodSettings +from opi.simple_tasks.simple_task import SimpleTask, TaskResults, TaskSettings +from opi.simple_tasks.method_settings import MethodSettings class EngradSettings(TaskSettings): diff --git a/src/opi/simpletasks/freq_task.py b/src/opi/simple_tasks/freq_task.py similarity index 93% rename from src/opi/simpletasks/freq_task.py rename to src/opi/simple_tasks/freq_task.py index a2faafbb..1b649523 100644 --- a/src/opi/simpletasks/freq_task.py +++ b/src/opi/simple_tasks/freq_task.py @@ -2,8 +2,8 @@ from functools import cached_property from opi.input.simple_keywords import SimpleKeyword, Solvent, Task -from opi.simpletasks.base_task import SimpleTask, TaskResults, TaskSettings -from opi.simpletasks.method_settings import MethodSettings +from opi.simple_tasks.simple_task import SimpleTask, TaskResults, TaskSettings +from opi.simple_tasks.method_settings import MethodSettings class FreqSettings(TaskSettings): diff --git a/src/opi/simpletasks/goat_task.py b/src/opi/simple_tasks/goat_task.py similarity index 96% rename from src/opi/simpletasks/goat_task.py rename to src/opi/simple_tasks/goat_task.py index aaf553d7..a9986195 100644 --- a/src/opi/simpletasks/goat_task.py +++ b/src/opi/simple_tasks/goat_task.py @@ -4,8 +4,8 @@ from opi.input import Input from opi.input.simple_keywords import Goat, SimpleKeyword, Solvent from opi.input.structures import Properties, Structure -from opi.simpletasks.base_task import SimpleTask, TaskResults, TaskSettings -from opi.simpletasks.method_settings import MethodSettings +from opi.simple_tasks.simple_task import SimpleTask, TaskResults, TaskSettings +from opi.simple_tasks.method_settings import MethodSettings class GoatSettings(TaskSettings): diff --git a/src/opi/simpletasks/method_settings.py b/src/opi/simple_tasks/method_settings.py similarity index 99% rename from src/opi/simpletasks/method_settings.py rename to src/opi/simple_tasks/method_settings.py index 526a4cb1..7924a5dc 100644 --- a/src/opi/simpletasks/method_settings.py +++ b/src/opi/simple_tasks/method_settings.py @@ -21,7 +21,7 @@ ) from opi.input.simple_keywords.dlpno import Dlpno, PNOThresh from opi.input.simple_keywords.scf import Scf, ScfConvergence, ScfSolver, ScfThreshold -from opi.simpletasks.settings import Settings +from opi.simple_tasks.settings import Settings class MethodSettings(Settings): diff --git a/src/opi/simpletasks/opt_task.py b/src/opi/simple_tasks/opt_task.py similarity index 97% rename from src/opi/simpletasks/opt_task.py rename to src/opi/simple_tasks/opt_task.py index 7abf2c97..cff3d9b8 100644 --- a/src/opi/simpletasks/opt_task.py +++ b/src/opi/simple_tasks/opt_task.py @@ -4,8 +4,8 @@ from opi.input.simple_keywords import SimpleKeyword, Solvent, Task from opi.input.simple_keywords.opt import Opt, OptThreshold from opi.input.structures import Structure -from opi.simpletasks.base_task import SimpleTask, TaskResults, TaskSettings -from opi.simpletasks.method_settings import MethodSettings +from opi.simple_tasks.simple_task import SimpleTask, TaskResults, TaskSettings +from opi.simple_tasks.method_settings import MethodSettings class OptSettings(TaskSettings): diff --git a/src/opi/simpletasks/settings.py b/src/opi/simple_tasks/settings.py similarity index 100% rename from src/opi/simpletasks/settings.py rename to src/opi/simple_tasks/settings.py diff --git a/src/opi/simpletasks/base_task.py b/src/opi/simple_tasks/simple_task.py similarity index 99% rename from src/opi/simpletasks/base_task.py rename to src/opi/simple_tasks/simple_task.py index 2dcf5f50..a3634742 100644 --- a/src/opi/simpletasks/base_task.py +++ b/src/opi/simple_tasks/simple_task.py @@ -17,8 +17,8 @@ ) from opi.input.structures import BaseStructureFile, Structure from opi.output.core import Output -from opi.simpletasks.method_settings import MethodSettings -from opi.simpletasks.settings import Settings +from opi.simple_tasks.method_settings import MethodSettings +from opi.simple_tasks.settings import Settings class TaskSettings(Settings): diff --git a/src/opi/simpletasks/singlepointtask.py b/src/opi/simple_tasks/single_point_task.py similarity index 90% rename from src/opi/simpletasks/singlepointtask.py rename to src/opi/simple_tasks/single_point_task.py index 70d2e0f7..28f2e5ae 100644 --- a/src/opi/simpletasks/singlepointtask.py +++ b/src/opi/simple_tasks/single_point_task.py @@ -1,8 +1,8 @@ import typing from opi.input.simple_keywords import SimpleKeyword, Solvent, Task -from opi.simpletasks.base_task import SimpleTask, TaskResults, TaskSettings -from opi.simpletasks.method_settings import MethodSettings +from opi.simple_tasks.simple_task import SimpleTask, TaskResults, TaskSettings +from opi.simple_tasks.method_settings import MethodSettings class SinglePointSettings(TaskSettings): From 685239982eb0bf64761de64cb3a1055410f55e50 Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Mon, 1 Jun 2026 12:43:33 +0200 Subject: [PATCH 26/52] fix: reorder simple tasks examples on complexity --- examples/exmp054_singlepoint_simpletask/job.py | 2 +- .../inp.xyz | 0 .../job.py | 6 +++--- .../inp.xyz | 0 .../job.py | 10 +++++----- .../inp.xyz | 0 .../job.py | 6 +++--- .../inp.xyz | 0 .../job.py | 6 +++--- 9 files changed, 15 insertions(+), 15 deletions(-) rename examples/{exmp058_engrad_simpletask => exmp055_engrad_simpletask}/inp.xyz (100%) rename examples/{exmp058_engrad_simpletask => exmp055_engrad_simpletask}/job.py (88%) rename examples/{exmp055_goat_simpletask => exmp056_opt_simpletask}/inp.xyz (100%) rename examples/{exmp057_opt_simpletask => exmp056_opt_simpletask}/job.py (80%) rename examples/{exmp056_freq_simpletask => exmp057_freq_simpletask}/inp.xyz (100%) rename examples/{exmp056_freq_simpletask => exmp057_freq_simpletask}/job.py (90%) rename examples/{exmp057_opt_simpletask => exmp058_goat_simpletask}/inp.xyz (100%) rename examples/{exmp055_goat_simpletask => exmp058_goat_simpletask}/job.py (91%) diff --git a/examples/exmp054_singlepoint_simpletask/job.py b/examples/exmp054_singlepoint_simpletask/job.py index c1b51e8c..8f924398 100644 --- a/examples/exmp054_singlepoint_simpletask/job.py +++ b/examples/exmp054_singlepoint_simpletask/job.py @@ -3,7 +3,7 @@ from pathlib import Path from opi.input.structures import Structure -from opi.simpletasks.singlepointtask import SinglePointResults, SinglePointTask +from opi.simple_tasks.single_point_task import SinglePointResults, SinglePointTask def run_exmp054( diff --git a/examples/exmp058_engrad_simpletask/inp.xyz b/examples/exmp055_engrad_simpletask/inp.xyz similarity index 100% rename from examples/exmp058_engrad_simpletask/inp.xyz rename to examples/exmp055_engrad_simpletask/inp.xyz diff --git a/examples/exmp058_engrad_simpletask/job.py b/examples/exmp055_engrad_simpletask/job.py similarity index 88% rename from examples/exmp058_engrad_simpletask/job.py rename to examples/exmp055_engrad_simpletask/job.py index 19bad60c..b1ca132b 100644 --- a/examples/exmp058_engrad_simpletask/job.py +++ b/examples/exmp055_engrad_simpletask/job.py @@ -3,10 +3,10 @@ from pathlib import Path from opi.input.structures import Structure -from opi.simpletasks.engrad_task import EngradResults, EngradTask +from opi.simple_tasks.engrad_task import EngradResults, EngradTask -def run_exmp058( +def run_exmp055( structure: Structure | None = None, working_dir: Path = Path("RUN") ) -> EngradResults: # > if no structure is given read structure from inp.xyz @@ -29,4 +29,4 @@ def run_exmp058( if __name__ == "__main__": - run_exmp058() + run_exmp055() diff --git a/examples/exmp055_goat_simpletask/inp.xyz b/examples/exmp056_opt_simpletask/inp.xyz similarity index 100% rename from examples/exmp055_goat_simpletask/inp.xyz rename to examples/exmp056_opt_simpletask/inp.xyz diff --git a/examples/exmp057_opt_simpletask/job.py b/examples/exmp056_opt_simpletask/job.py similarity index 80% rename from examples/exmp057_opt_simpletask/job.py rename to examples/exmp056_opt_simpletask/job.py index 5bb26d3c..aa247134 100644 --- a/examples/exmp057_opt_simpletask/job.py +++ b/examples/exmp056_opt_simpletask/job.py @@ -3,11 +3,11 @@ from pathlib import Path from opi.input.structures import Structure -from opi.simpletasks.method_settings import DFTSettings -from opi.simpletasks.opt_task import OptResults, OptSettings, OptTask +from opi.simple_tasks.method_settings import DftSettings +from opi.simple_tasks.opt_task import OptResults, OptSettings, OptTask -def run_exmp057(structure: Structure | None = None, working_dir: Path = Path("RUN")) -> OptResults: +def run_exmp056(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("../exmp054_singlepoint_simpletask/inp.xyz") @@ -18,7 +18,7 @@ def run_exmp057(structure: Structure | None = None, working_dir: Path = Path("RU basis_set="def2-svp", solvation_model="cpcm", solvent="water", - method_settings=DFTSettings(scf_maxiter=150), + 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 @@ -41,4 +41,4 @@ def run_exmp057(structure: Structure | None = None, working_dir: Path = Path("RU if __name__ == "__main__": - run_exmp057() + run_exmp056() diff --git a/examples/exmp056_freq_simpletask/inp.xyz b/examples/exmp057_freq_simpletask/inp.xyz similarity index 100% rename from examples/exmp056_freq_simpletask/inp.xyz rename to examples/exmp057_freq_simpletask/inp.xyz diff --git a/examples/exmp056_freq_simpletask/job.py b/examples/exmp057_freq_simpletask/job.py similarity index 90% rename from examples/exmp056_freq_simpletask/job.py rename to examples/exmp057_freq_simpletask/job.py index acf83619..819c25e3 100644 --- a/examples/exmp056_freq_simpletask/job.py +++ b/examples/exmp057_freq_simpletask/job.py @@ -4,10 +4,10 @@ from opi.input.structures import Structure from opi.output.ir_mode import IrMode -from opi.simpletasks.freq_task import FreqResults, FreqTask +from opi.simple_tasks.freq_task import FreqResults, FreqTask -def run_exmp056(structure: Structure | None = None, working_dir: Path = Path("RUN")) -> FreqResults: +def run_exmp057(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") @@ -48,4 +48,4 @@ def run_exmp056(structure: Structure | None = None, working_dir: Path = Path("RU if __name__ == "__main__": - run_exmp056() + run_exmp057() diff --git a/examples/exmp057_opt_simpletask/inp.xyz b/examples/exmp058_goat_simpletask/inp.xyz similarity index 100% rename from examples/exmp057_opt_simpletask/inp.xyz rename to examples/exmp058_goat_simpletask/inp.xyz diff --git a/examples/exmp055_goat_simpletask/job.py b/examples/exmp058_goat_simpletask/job.py similarity index 91% rename from examples/exmp055_goat_simpletask/job.py rename to examples/exmp058_goat_simpletask/job.py index 910392e1..d6c53dce 100644 --- a/examples/exmp055_goat_simpletask/job.py +++ b/examples/exmp058_goat_simpletask/job.py @@ -4,10 +4,10 @@ from pathlib import Path from opi.input.structures import Properties, Structure -from opi.simpletasks.goat_task import GoatSettings, GoatTask +from opi.simple_tasks.goat_task import GoatSettings, GoatTask -def run_exmp055( +def run_exmp058( structure: Structure | None = None, working_dir: Path = Path("RUN") ) -> tuple[list[Structure], list[Properties]]: @@ -36,4 +36,4 @@ def run_exmp055( if __name__ == "__main__": - run_exmp055() + run_exmp058() From 858d0940d41e23f26afe14369721c78a56b9ef4c Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Mon, 1 Jun 2026 15:05:16 +0200 Subject: [PATCH 27/52] fix: separate tasks into their own folders --- .../exmp054_singlepoint_simpletask/job.py | 2 +- examples/exmp055_engrad_simpletask/job.py | 2 +- examples/exmp056_opt_simpletask/job.py | 2 +- examples/exmp057_freq_simpletask/job.py | 2 +- examples/exmp058_goat_simpletask/job.py | 2 +- src/opi/simple_tasks/__init__.py | 47 +++++++++++++++++++ src/opi/simple_tasks/engrad/__init__.py | 3 ++ .../simple_tasks/{ => engrad}/engrad_task.py | 0 src/opi/simple_tasks/freq/__init__.py | 3 ++ src/opi/simple_tasks/{ => freq}/freq_task.py | 0 src/opi/simple_tasks/goat/__init__.py | 3 ++ src/opi/simple_tasks/{ => goat}/goat_task.py | 0 src/opi/simple_tasks/method_settings.py | 4 +- src/opi/simple_tasks/opt/__init__.py | 3 ++ src/opi/simple_tasks/{ => opt}/opt_task.py | 0 src/opi/simple_tasks/single_point/__init__.py | 3 ++ .../{ => single_point}/single_point_task.py | 0 17 files changed, 69 insertions(+), 7 deletions(-) create mode 100644 src/opi/simple_tasks/engrad/__init__.py rename src/opi/simple_tasks/{ => engrad}/engrad_task.py (100%) create mode 100644 src/opi/simple_tasks/freq/__init__.py rename src/opi/simple_tasks/{ => freq}/freq_task.py (100%) create mode 100644 src/opi/simple_tasks/goat/__init__.py rename src/opi/simple_tasks/{ => goat}/goat_task.py (100%) create mode 100644 src/opi/simple_tasks/opt/__init__.py rename src/opi/simple_tasks/{ => opt}/opt_task.py (100%) create mode 100644 src/opi/simple_tasks/single_point/__init__.py rename src/opi/simple_tasks/{ => single_point}/single_point_task.py (100%) diff --git a/examples/exmp054_singlepoint_simpletask/job.py b/examples/exmp054_singlepoint_simpletask/job.py index 8f924398..46566688 100644 --- a/examples/exmp054_singlepoint_simpletask/job.py +++ b/examples/exmp054_singlepoint_simpletask/job.py @@ -3,7 +3,7 @@ from pathlib import Path from opi.input.structures import Structure -from opi.simple_tasks.single_point_task import SinglePointResults, SinglePointTask +from opi.simple_tasks import SinglePointResults, SinglePointTask def run_exmp054( diff --git a/examples/exmp055_engrad_simpletask/job.py b/examples/exmp055_engrad_simpletask/job.py index b1ca132b..50bc36f7 100644 --- a/examples/exmp055_engrad_simpletask/job.py +++ b/examples/exmp055_engrad_simpletask/job.py @@ -3,7 +3,7 @@ from pathlib import Path from opi.input.structures import Structure -from opi.simple_tasks.engrad_task import EngradResults, EngradTask +from opi.simple_tasks import EngradResults, EngradTask def run_exmp055( diff --git a/examples/exmp056_opt_simpletask/job.py b/examples/exmp056_opt_simpletask/job.py index aa247134..f6b1797b 100644 --- a/examples/exmp056_opt_simpletask/job.py +++ b/examples/exmp056_opt_simpletask/job.py @@ -4,7 +4,7 @@ from opi.input.structures import Structure from opi.simple_tasks.method_settings import DftSettings -from opi.simple_tasks.opt_task import OptResults, OptSettings, OptTask +from opi.simple_tasks import OptResults, OptSettings, OptTask def run_exmp056(structure: Structure | None = None, working_dir: Path = Path("RUN")) -> OptResults: diff --git a/examples/exmp057_freq_simpletask/job.py b/examples/exmp057_freq_simpletask/job.py index 819c25e3..48ba4c03 100644 --- a/examples/exmp057_freq_simpletask/job.py +++ b/examples/exmp057_freq_simpletask/job.py @@ -4,7 +4,7 @@ from opi.input.structures import Structure from opi.output.ir_mode import IrMode -from opi.simple_tasks.freq_task import FreqResults, FreqTask +from opi.simple_tasks import FreqResults, FreqTask def run_exmp057(structure: Structure | None = None, working_dir: Path = Path("RUN")) -> FreqResults: diff --git a/examples/exmp058_goat_simpletask/job.py b/examples/exmp058_goat_simpletask/job.py index d6c53dce..0a3a2c1b 100644 --- a/examples/exmp058_goat_simpletask/job.py +++ b/examples/exmp058_goat_simpletask/job.py @@ -4,7 +4,7 @@ from pathlib import Path from opi.input.structures import Properties, Structure -from opi.simple_tasks.goat_task import GoatSettings, GoatTask +from opi.simple_tasks import GoatSettings, GoatTask def run_exmp058( diff --git a/src/opi/simple_tasks/__init__.py b/src/opi/simple_tasks/__init__.py index e69de29b..960ea7b3 100644 --- a/src/opi/simple_tasks/__init__.py +++ b/src/opi/simple_tasks/__init__.py @@ -0,0 +1,47 @@ +from engrad import EngradResults, EngradSettings, EngradTask +from freq import FreqResults, FreqSettings, FreqTask +from goat import GoatResults, GoatSettings, GoatTask +from method_settings import ( + DftSettings, + DlpnoCcSettings, + ForceFieldSettings, + HFSettings, + MethodSettings, + SqmSettings, + WftSettings, +) +from opt import OptResults, OptSettings, OptTask +from simple_task import SimpleTask, TaskResults, TaskSettings +from single_point import ( + 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/engrad/__init__.py b/src/opi/simple_tasks/engrad/__init__.py new file mode 100644 index 00000000..a2e5dbdc --- /dev/null +++ b/src/opi/simple_tasks/engrad/__init__.py @@ -0,0 +1,3 @@ +from engrad_task import EngradResults, EngradSettings, EngradTask + +__all__ = ["EngradResults", "EngradSettings", "EngradTask"] \ No newline at end of file diff --git a/src/opi/simple_tasks/engrad_task.py b/src/opi/simple_tasks/engrad/engrad_task.py similarity index 100% rename from src/opi/simple_tasks/engrad_task.py rename to src/opi/simple_tasks/engrad/engrad_task.py diff --git a/src/opi/simple_tasks/freq/__init__.py b/src/opi/simple_tasks/freq/__init__.py new file mode 100644 index 00000000..551fbbe6 --- /dev/null +++ b/src/opi/simple_tasks/freq/__init__.py @@ -0,0 +1,3 @@ +from freq_task import FreqResults, FreqSettings, FreqTask + +__all__ = ["FreqResults", "FreqTask", "FreqSettings"] diff --git a/src/opi/simple_tasks/freq_task.py b/src/opi/simple_tasks/freq/freq_task.py similarity index 100% rename from src/opi/simple_tasks/freq_task.py rename to src/opi/simple_tasks/freq/freq_task.py diff --git a/src/opi/simple_tasks/goat/__init__.py b/src/opi/simple_tasks/goat/__init__.py new file mode 100644 index 00000000..db9bf60b --- /dev/null +++ b/src/opi/simple_tasks/goat/__init__.py @@ -0,0 +1,3 @@ +from goat_task import GoatResults, GoatSettings, GoatTask + +__all__ = ["GoatResults", "GoatSettings", "GoatTask"] \ No newline at end of file diff --git a/src/opi/simple_tasks/goat_task.py b/src/opi/simple_tasks/goat/goat_task.py similarity index 100% rename from src/opi/simple_tasks/goat_task.py rename to src/opi/simple_tasks/goat/goat_task.py diff --git a/src/opi/simple_tasks/method_settings.py b/src/opi/simple_tasks/method_settings.py index 7924a5dc..5e3f9d92 100644 --- a/src/opi/simple_tasks/method_settings.py +++ b/src/opi/simple_tasks/method_settings.py @@ -104,8 +104,8 @@ def resolve_method_settings_type( """ enum_to_settings = { - Dft: DFTSettings, - Sqm: SQMSettings, + Dft: DftSettings, + Sqm: SqmSettings, Wft: WftSettings, Method: HFSettings, ForceField: ForceFieldSettings, diff --git a/src/opi/simple_tasks/opt/__init__.py b/src/opi/simple_tasks/opt/__init__.py new file mode 100644 index 00000000..d1ec7c10 --- /dev/null +++ b/src/opi/simple_tasks/opt/__init__.py @@ -0,0 +1,3 @@ +from opt_task import OptResults, OptSettings, OptTask + +__all__ = ["OptResults", "OptSettings", "OptTask"] \ No newline at end of file diff --git a/src/opi/simple_tasks/opt_task.py b/src/opi/simple_tasks/opt/opt_task.py similarity index 100% rename from src/opi/simple_tasks/opt_task.py rename to src/opi/simple_tasks/opt/opt_task.py diff --git a/src/opi/simple_tasks/single_point/__init__.py b/src/opi/simple_tasks/single_point/__init__.py new file mode 100644 index 00000000..58fb7687 --- /dev/null +++ b/src/opi/simple_tasks/single_point/__init__.py @@ -0,0 +1,3 @@ +from single_point_task import SinglePointResults, SinglePointTask, SinglePointSettings + +__all__ = ["SinglePointResults", "SinglePointTask", "SinglePointSettings"] \ No newline at end of file diff --git a/src/opi/simple_tasks/single_point_task.py b/src/opi/simple_tasks/single_point/single_point_task.py similarity index 100% rename from src/opi/simple_tasks/single_point_task.py rename to src/opi/simple_tasks/single_point/single_point_task.py From fb9f7586f5999f2b5b4638c385e826521e657861 Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Mon, 1 Jun 2026 15:34:51 +0200 Subject: [PATCH 28/52] fix: edit __init__ file of simple tasks subfolders to allow for easy import --- src/opi/simple_tasks/__init__.py | 14 ++--- src/opi/simple_tasks/engrad/__init__.py | 4 +- src/opi/simple_tasks/freq/__init__.py | 4 +- src/opi/simple_tasks/goat/__init__.py | 4 +- src/opi/simple_tasks/opt/__init__.py | 4 +- src/opi/simple_tasks/single_point/__init__.py | 8 ++- tests/unit/test_simpletasks_from_string.py | 4 +- .../unit/test_simpletasks_method_settings.py | 52 +++++++++---------- tests/unit/test_simpletasks_task.py | 24 ++++----- 9 files changed, 61 insertions(+), 57 deletions(-) diff --git a/src/opi/simple_tasks/__init__.py b/src/opi/simple_tasks/__init__.py index 960ea7b3..00868da0 100644 --- a/src/opi/simple_tasks/__init__.py +++ b/src/opi/simple_tasks/__init__.py @@ -1,7 +1,7 @@ -from engrad import EngradResults, EngradSettings, EngradTask -from freq import FreqResults, FreqSettings, FreqTask -from goat import GoatResults, GoatSettings, GoatTask -from method_settings import ( +from opi.simple_tasks.engrad.engrad_task import EngradResults, EngradSettings, EngradTask +from opi.simple_tasks.freq.freq_task import FreqResults, FreqSettings, FreqTask +from opi.simple_tasks.goat.goat_task import GoatResults, GoatSettings, GoatTask +from opi.simple_tasks.method_settings import ( DftSettings, DlpnoCcSettings, ForceFieldSettings, @@ -10,9 +10,9 @@ SqmSettings, WftSettings, ) -from opt import OptResults, OptSettings, OptTask -from simple_task import SimpleTask, TaskResults, TaskSettings -from single_point import ( +from opi.simple_tasks.opt.opt_task import OptResults, OptSettings, OptTask +from opi.simple_tasks.simple_task import SimpleTask, TaskResults, TaskSettings +from opi.simple_tasks.single_point.single_point_task import ( SinglePointResults, SinglePointSettings, SinglePointTask, diff --git a/src/opi/simple_tasks/engrad/__init__.py b/src/opi/simple_tasks/engrad/__init__.py index a2e5dbdc..f47c4647 100644 --- a/src/opi/simple_tasks/engrad/__init__.py +++ b/src/opi/simple_tasks/engrad/__init__.py @@ -1,3 +1,3 @@ -from engrad_task import EngradResults, EngradSettings, EngradTask +from opi.simple_tasks.engrad.engrad_task import EngradResults, EngradSettings, EngradTask -__all__ = ["EngradResults", "EngradSettings", "EngradTask"] \ No newline at end of file +__all__ = ["EngradResults", "EngradSettings", "EngradTask"] diff --git a/src/opi/simple_tasks/freq/__init__.py b/src/opi/simple_tasks/freq/__init__.py index 551fbbe6..9bb53e86 100644 --- a/src/opi/simple_tasks/freq/__init__.py +++ b/src/opi/simple_tasks/freq/__init__.py @@ -1,3 +1,3 @@ -from freq_task import FreqResults, FreqSettings, FreqTask +from opi.simple_tasks.freq.freq_task import FreqResults, FreqSettings, FreqTask -__all__ = ["FreqResults", "FreqTask", "FreqSettings"] +__all__ = ["FreqResults", "FreqSettings", "FreqTask"] diff --git a/src/opi/simple_tasks/goat/__init__.py b/src/opi/simple_tasks/goat/__init__.py index db9bf60b..16269598 100644 --- a/src/opi/simple_tasks/goat/__init__.py +++ b/src/opi/simple_tasks/goat/__init__.py @@ -1,3 +1,3 @@ -from goat_task import GoatResults, GoatSettings, GoatTask +from opi.simple_tasks.goat.goat_task import GoatResults, GoatSettings, GoatTask -__all__ = ["GoatResults", "GoatSettings", "GoatTask"] \ No newline at end of file +__all__ = ["GoatResults", "GoatSettings", "GoatTask"] diff --git a/src/opi/simple_tasks/opt/__init__.py b/src/opi/simple_tasks/opt/__init__.py index d1ec7c10..47b6035a 100644 --- a/src/opi/simple_tasks/opt/__init__.py +++ b/src/opi/simple_tasks/opt/__init__.py @@ -1,3 +1,3 @@ -from opt_task import OptResults, OptSettings, OptTask +from opi.simple_tasks.opt.opt_task import OptResults, OptSettings, OptTask -__all__ = ["OptResults", "OptSettings", "OptTask"] \ No newline at end of file +__all__ = ["OptResults", "OptSettings", "OptTask"] diff --git a/src/opi/simple_tasks/single_point/__init__.py b/src/opi/simple_tasks/single_point/__init__.py index 58fb7687..fe4b7fd0 100644 --- a/src/opi/simple_tasks/single_point/__init__.py +++ b/src/opi/simple_tasks/single_point/__init__.py @@ -1,3 +1,7 @@ -from single_point_task import SinglePointResults, SinglePointTask, SinglePointSettings +from opi.simple_tasks.single_point.single_point_task import ( + SinglePointResults, + SinglePointSettings, + SinglePointTask, +) -__all__ = ["SinglePointResults", "SinglePointTask", "SinglePointSettings"] \ No newline at end of file +__all__ = ["SinglePointResults", "SinglePointSettings", "SinglePointTask"] diff --git a/tests/unit/test_simpletasks_from_string.py b/tests/unit/test_simpletasks_from_string.py index 6fd9e493..1d71abc1 100644 --- a/tests/unit/test_simpletasks_from_string.py +++ b/tests/unit/test_simpletasks_from_string.py @@ -4,8 +4,8 @@ import pytest from opi.input.simple_keywords import SimpleKeyword -from opi.simpletasks.freq_task import FreqResults, FreqTask -from opi.simpletasks.singlepointtask import SinglePointResults, SinglePointTask +from opi.simple_tasks import FreqResults, FreqTask +from opi.simple_tasks import SinglePointResults, SinglePointTask """ Unit tests for SimpleTask.from_string classmethod: diff --git a/tests/unit/test_simpletasks_method_settings.py b/tests/unit/test_simpletasks_method_settings.py index a7947f8c..d2db4f1a 100644 --- a/tests/unit/test_simpletasks_method_settings.py +++ b/tests/unit/test_simpletasks_method_settings.py @@ -5,11 +5,11 @@ from opi.input.blocks import BlockScf from opi.input.simple_keywords import BasisSet, Dft from opi.input.simple_keywords.scf import Scf -from opi.simpletasks.method_settings import ( - DFTSettings, +from opi.simple_tasks.method_settings import ( + DftSettings, ForceFieldSettings, MethodSettings, - SQMSettings, + SqmSettings, WftSettings, ) @@ -17,7 +17,7 @@ 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") +- 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 (|) @@ -30,8 +30,8 @@ @pytest.mark.parametrize( "method,expected_cls", [ - ("pbe", DFTSettings), - ("gfn2-xtb", SQMSettings), + ("pbe", DftSettings), + ("gfn2-xtb", SqmSettings), ("ccsd(t)", WftSettings), ("hf", WftSettings), ("gfn-ff", ForceFieldSettings), @@ -48,8 +48,8 @@ def test_method_settings_dispatch(method: str, expected_cls: type) -> None: @pytest.mark.parametrize( "method,expected_cls", [ - ("pbe", DFTSettings), - ("gfn2-xtb", SQMSettings), + ("pbe", DftSettings), + ("gfn2-xtb", SqmSettings), ("ccsd(t)", WftSettings), ("gfn-ff", ForceFieldSettings), ], @@ -62,8 +62,8 @@ def test_resolve_method_settings_type(method: str, expected_cls: type) -> None: @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") + """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" @@ -71,9 +71,9 @@ def test_dft_compound_keyword_parsed() -> None: @pytest.mark.unit @pytest.mark.simpletasks def test_dft_invalid_compound_keyword_raises() -> None: - """DFTSettings raises ValidationError for unrecognised compound method strings.""" + """DftSettings raises ValidationError for unrecognised compound method strings.""" with pytest.raises(ValidationError): - DFTSettings(method="INVALID-NOTADISP") + DftSettings(method="INVALID-NOTADISP") @pytest.mark.unit @@ -81,7 +81,7 @@ def test_dft_invalid_compound_keyword_raises() -> None: 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") + s = DftSettings(method="r2scan-3c", basis_set="def2-svp") assert s.basis_set is None @@ -97,8 +97,8 @@ def test_forcefield_drops_basis_set_with_warning() -> 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) + """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,) @@ -106,8 +106,8 @@ def test_dft_scf_stab_adds_scfstab_keyword() -> None: @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") + """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,) @@ -116,8 +116,8 @@ def test_dft_scf_stab_false_no_scfstab_keyword() -> None: @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") + 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 @@ -127,8 +127,8 @@ def test_settings_merge_right_wins_left_preserved() -> None: @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") + 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 @@ -137,9 +137,9 @@ def test_settings_merge_right_overrides_both() -> None: @pytest.mark.unit @pytest.mark.simpletasks def test_dft_invalid_field_raises_validation_error() -> None: - """DFTSettings rejects unknown fields with a clear ValidationError.""" + """DftSettings rejects unknown fields with a clear ValidationError.""" with pytest.raises(ValidationError): - DFTSettings(method="pbe", invalid_field="x") # type: ignore[call-arg] + DftSettings(method="pbe", invalid_field="x") # type: ignore[call-arg] @pytest.mark.unit @@ -157,7 +157,7 @@ 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] + s = DftSettings(**{field: text_value}) # type: ignore[arg-type] assert getattr(s, field) == expected_kw @@ -166,8 +166,8 @@ def test_string_fields_converted_to_simple_keyword( @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"), + (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( diff --git a/tests/unit/test_simpletasks_task.py b/tests/unit/test_simpletasks_task.py index 75d32d92..d611c2c1 100644 --- a/tests/unit/test_simpletasks_task.py +++ b/tests/unit/test_simpletasks_task.py @@ -4,12 +4,12 @@ from opi.input.blocks import BlockGeom from opi.input.simple_keywords import BasisSet, Dft, Goat, SimpleKeyword, Task from opi.input.simple_keywords.opt import Opt -from opi.simpletasks.engrad_task import EngradTask -from opi.simpletasks.freq_task import FreqTask -from opi.simpletasks.goat_task import GoatSettings, GoatTask -from opi.simpletasks.method_settings import DFTSettings, SQMSettings -from opi.simpletasks.opt_task import OptSettings, OptTask -from opi.simpletasks.singlepointtask import SinglePointTask +from opi.simple_tasks import EngradTask +from opi.simple_tasks import FreqTask +from opi.simple_tasks import GoatSettings, GoatTask +from opi.simple_tasks.method_settings import DftSettings, SqmSettings +from opi.simple_tasks import OptSettings, OptTask +from opi.simple_tasks import SinglePointTask """ Unit tests for SimpleTask subclasses and TaskSettings: @@ -41,8 +41,8 @@ def test_simple_task_no_method_raises() -> None: @pytest.mark.parametrize( "method,expected_cls", [ - ("pbe", DFTSettings), - ("gfn2-xtb", SQMSettings), + ("pbe", DftSettings), + ("gfn2-xtb", SqmSettings), ], ) def test_simple_task_method_dispatch(method: str, expected_cls: type) -> None: @@ -56,7 +56,7 @@ def test_simple_task_method_dispatch(method: str, expected_cls: type) -> None: 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) + assert isinstance(task.method_settings, DftSettings) @pytest.mark.unit @@ -201,7 +201,7 @@ 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 isinstance(task.method_settings, DftSettings) assert task.method == Dft.TPSS @@ -209,10 +209,10 @@ def test_method_switch_same_family_updates_in_place() -> None: @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)) + 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) + assert isinstance(task.method_settings, SqmSettings) # --------------------------------------------------------------------------- From 87b40aafcc99825cdc2f164ca413ff0f7bc4e301 Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Mon, 1 Jun 2026 16:12:22 +0200 Subject: [PATCH 29/52] docs: add documentation to SimpleKeyword --- src/opi/input/simple_keywords/base.py | 59 +++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 9 deletions(-) diff --git a/src/opi/input/simple_keywords/base.py b/src/opi/input/simple_keywords/base.py index cb1aca34..01f69219 100644 --- a/src/opi/input/simple_keywords/base.py +++ b/src/opi/input/simple_keywords/base.py @@ -5,8 +5,12 @@ class SimpleKeywordBox: """ - TODO: - - rework registry to account for latest changes. + Registry base class for groups of related ``SimpleKeyword`` constants. + + Each concrete subclass (e.g. ``Task``, ``Dft``, ``BasisSet``) declares its + members as class-level ``SimpleKeyword`` attributes. Subclassing + automatically registers the new class so that ``from_string`` can search + across all members in the group. """ _registry: list[type["SimpleKeywordBox"]] = [] @@ -23,20 +27,35 @@ def __init_subclass__(cls, **kwargs: Any) -> None: @classmethod def registry(cls) -> list[type["SimpleKeywordBox"]]: + """Return all subclasses registered under this box.""" return cls._registry @classmethod def from_string(cls, s: str) -> "SimpleKeyword": + """ + Look up a ``SimpleKeyword`` by string across all registered subclasses. + + 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() for c in cls._registry: for attr in dir(c): if attr.startswith("_"): # Skip private/magic attributes continue value = getattr(c, attr) + # Case 1: if the user searches for keyword through how it woould appear in ORCA input if isinstance(value, SimpleKeyword) and value.keyword.lower() == norm: return value + # Case 2: if the user searches for keyword through how it is stored in OPI elif isinstance(value, SimpleKeyword) and attr.lower() == norm: return value + # Case 3: if the user searches for keyword through a known alias elif ( isinstance(value, SimpleKeyword) and value.alias and value.alias.lower() == norm ): @@ -46,6 +65,18 @@ def from_string(cls, s: str) -> "SimpleKeyword": @classmethod def find_keyword(cls, inp: "SimpleKeyword | str") -> "SimpleKeyword": + """ + Resolve a string or ``SimpleKeyword`` to a registered ``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 in the registry. + """ if isinstance(inp, SimpleKeyword): inp = inp.keyword @@ -59,24 +90,34 @@ def find_keyword(cls, inp: "SimpleKeyword | str") -> "SimpleKeyword": 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 : str or None + Optional alternative name accepted by ``SimpleKeywordBox.from_string``. """ alias: str | None = None def __init__(self, keyword: str, alias: 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: str = "" self.keyword = keyword self._name: str = "" - # self.name = name self.alias = alias @property From a40f634cf7da76b6ea5441164371d3ea8332c3a7 Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Mon, 1 Jun 2026 16:31:25 +0200 Subject: [PATCH 30/52] fix: fix code formatting and extra imports --- examples/exmp056_opt_simpletask/job.py | 2 +- src/opi/simple_tasks/engrad/engrad_task.py | 2 +- src/opi/simple_tasks/freq/freq_task.py | 2 +- src/opi/simple_tasks/goat/goat_task.py | 2 +- src/opi/simple_tasks/method_settings.py | 2 +- src/opi/simple_tasks/opt/opt_task.py | 2 +- src/opi/simple_tasks/simple_task.py | 3 +-- .../simple_tasks/single_point/single_point_task.py | 2 +- tests/unit/test_simpletasks_from_string.py | 3 +-- tests/unit/test_simpletasks_task.py | 14 +++++++++----- 10 files changed, 18 insertions(+), 16 deletions(-) diff --git a/examples/exmp056_opt_simpletask/job.py b/examples/exmp056_opt_simpletask/job.py index f6b1797b..49c1c395 100644 --- a/examples/exmp056_opt_simpletask/job.py +++ b/examples/exmp056_opt_simpletask/job.py @@ -3,8 +3,8 @@ from pathlib import Path from opi.input.structures import Structure -from opi.simple_tasks.method_settings import DftSettings from opi.simple_tasks import OptResults, OptSettings, OptTask +from opi.simple_tasks.method_settings import DftSettings def run_exmp056(structure: Structure | None = None, working_dir: Path = Path("RUN")) -> OptResults: diff --git a/src/opi/simple_tasks/engrad/engrad_task.py b/src/opi/simple_tasks/engrad/engrad_task.py index 4f177198..0e6db353 100644 --- a/src/opi/simple_tasks/engrad/engrad_task.py +++ b/src/opi/simple_tasks/engrad/engrad_task.py @@ -1,8 +1,8 @@ import typing from opi.input.simple_keywords import SimpleKeyword, Solvent, Task -from opi.simple_tasks.simple_task import SimpleTask, TaskResults, TaskSettings from opi.simple_tasks.method_settings import MethodSettings +from opi.simple_tasks.simple_task import SimpleTask, TaskResults, TaskSettings class EngradSettings(TaskSettings): diff --git a/src/opi/simple_tasks/freq/freq_task.py b/src/opi/simple_tasks/freq/freq_task.py index 1b649523..e7394e8d 100644 --- a/src/opi/simple_tasks/freq/freq_task.py +++ b/src/opi/simple_tasks/freq/freq_task.py @@ -2,8 +2,8 @@ from functools import cached_property from opi.input.simple_keywords import SimpleKeyword, Solvent, Task -from opi.simple_tasks.simple_task import SimpleTask, TaskResults, TaskSettings from opi.simple_tasks.method_settings import MethodSettings +from opi.simple_tasks.simple_task import SimpleTask, TaskResults, TaskSettings class FreqSettings(TaskSettings): diff --git a/src/opi/simple_tasks/goat/goat_task.py b/src/opi/simple_tasks/goat/goat_task.py index a9986195..52293b76 100644 --- a/src/opi/simple_tasks/goat/goat_task.py +++ b/src/opi/simple_tasks/goat/goat_task.py @@ -4,8 +4,8 @@ from opi.input import Input from opi.input.simple_keywords import Goat, SimpleKeyword, Solvent from opi.input.structures import Properties, Structure -from opi.simple_tasks.simple_task import SimpleTask, TaskResults, TaskSettings from opi.simple_tasks.method_settings import MethodSettings +from opi.simple_tasks.simple_task import SimpleTask, TaskResults, TaskSettings class GoatSettings(TaskSettings): diff --git a/src/opi/simple_tasks/method_settings.py b/src/opi/simple_tasks/method_settings.py index 5e3f9d92..81d675e8 100644 --- a/src/opi/simple_tasks/method_settings.py +++ b/src/opi/simple_tasks/method_settings.py @@ -162,7 +162,7 @@ def validate_fields(cls, value: typing.Any, info: ValidationInfo) -> typing.Any: return super().validate_fields(value, info) @model_validator(mode="after") - def cross_validate(self) -> "DFTSettings": + def cross_validate(self) -> "DftSettings": """ Cross-validation for `DftSettings`. If the method keyword contains '3c', the `basis_set` attribute will be set to `None`. diff --git a/src/opi/simple_tasks/opt/opt_task.py b/src/opi/simple_tasks/opt/opt_task.py index cff3d9b8..8fd17e98 100644 --- a/src/opi/simple_tasks/opt/opt_task.py +++ b/src/opi/simple_tasks/opt/opt_task.py @@ -4,8 +4,8 @@ from opi.input.simple_keywords import SimpleKeyword, Solvent, 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 from opi.simple_tasks.method_settings import MethodSettings +from opi.simple_tasks.simple_task import SimpleTask, TaskResults, TaskSettings class OptSettings(TaskSettings): diff --git a/src/opi/simple_tasks/simple_task.py b/src/opi/simple_tasks/simple_task.py index a3634742..485b3d02 100644 --- a/src/opi/simple_tasks/simple_task.py +++ b/src/opi/simple_tasks/simple_task.py @@ -164,8 +164,7 @@ def input_object(self) -> Input: to corresponding `TaskSettings` and `MethodSettings` objects to be configured by user-defined data stored in those objects. - The result is cached — the same `Input` instance is returned on every access, so mutations - (e.g. ``task.input_object.add_simple_keywords(...)``) persist and are included when ``run()`` is called. + The result is cached — the same `Input` instance is returned on every access, so mutations persist and are included when ``run()`` is called. To reset the cache after changing ``task_settings`` or ``method_settings``, delete the attribute: ``del task.input_object``. diff --git a/src/opi/simple_tasks/single_point/single_point_task.py b/src/opi/simple_tasks/single_point/single_point_task.py index 28f2e5ae..9adb046c 100644 --- a/src/opi/simple_tasks/single_point/single_point_task.py +++ b/src/opi/simple_tasks/single_point/single_point_task.py @@ -1,8 +1,8 @@ import typing from opi.input.simple_keywords import SimpleKeyword, Solvent, Task -from opi.simple_tasks.simple_task import SimpleTask, TaskResults, TaskSettings from opi.simple_tasks.method_settings import MethodSettings +from opi.simple_tasks.simple_task import SimpleTask, TaskResults, TaskSettings class SinglePointSettings(TaskSettings): diff --git a/tests/unit/test_simpletasks_from_string.py b/tests/unit/test_simpletasks_from_string.py index 1d71abc1..02d4e4a9 100644 --- a/tests/unit/test_simpletasks_from_string.py +++ b/tests/unit/test_simpletasks_from_string.py @@ -4,8 +4,7 @@ import pytest from opi.input.simple_keywords import SimpleKeyword -from opi.simple_tasks import FreqResults, FreqTask -from opi.simple_tasks import SinglePointResults, SinglePointTask +from opi.simple_tasks import FreqResults, FreqTask, SinglePointResults, SinglePointTask """ Unit tests for SimpleTask.from_string classmethod: diff --git a/tests/unit/test_simpletasks_task.py b/tests/unit/test_simpletasks_task.py index d611c2c1..575f102a 100644 --- a/tests/unit/test_simpletasks_task.py +++ b/tests/unit/test_simpletasks_task.py @@ -4,12 +4,16 @@ from opi.input.blocks import BlockGeom 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 -from opi.simple_tasks import FreqTask -from opi.simple_tasks import GoatSettings, GoatTask +from opi.simple_tasks import ( + EngradTask, + FreqTask, + GoatSettings, + GoatTask, + OptSettings, + OptTask, + SinglePointTask, +) from opi.simple_tasks.method_settings import DftSettings, SqmSettings -from opi.simple_tasks import OptSettings, OptTask -from opi.simple_tasks import SinglePointTask """ Unit tests for SimpleTask subclasses and TaskSettings: From e8c2c1f0397a049c2b58c311745523088811f000 Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Wed, 3 Jun 2026 09:46:14 +0200 Subject: [PATCH 31/52] fix: move all implemented simple tasks to task folder --- src/opi/simple_tasks/__init__.py | 18 +++++++++---- src/opi/simple_tasks/engrad/__init__.py | 3 --- src/opi/simple_tasks/freq/__init__.py | 3 --- src/opi/simple_tasks/goat/__init__.py | 3 --- src/opi/simple_tasks/opt/__init__.py | 3 --- src/opi/simple_tasks/single_point/__init__.py | 7 ----- src/opi/simple_tasks/tasks/__init__.py | 27 +++++++++++++++++++ .../{engrad => tasks}/engrad_task.py | 0 .../simple_tasks/{freq => tasks}/freq_task.py | 0 .../simple_tasks/{goat => tasks}/goat_task.py | 0 .../simple_tasks/{opt => tasks}/opt_task.py | 0 .../single_point_task.py | 0 12 files changed, 40 insertions(+), 24 deletions(-) delete mode 100644 src/opi/simple_tasks/engrad/__init__.py delete mode 100644 src/opi/simple_tasks/freq/__init__.py delete mode 100644 src/opi/simple_tasks/goat/__init__.py delete mode 100644 src/opi/simple_tasks/opt/__init__.py delete mode 100644 src/opi/simple_tasks/single_point/__init__.py create mode 100644 src/opi/simple_tasks/tasks/__init__.py rename src/opi/simple_tasks/{engrad => tasks}/engrad_task.py (100%) rename src/opi/simple_tasks/{freq => tasks}/freq_task.py (100%) rename src/opi/simple_tasks/{goat => tasks}/goat_task.py (100%) rename src/opi/simple_tasks/{opt => tasks}/opt_task.py (100%) rename src/opi/simple_tasks/{single_point => tasks}/single_point_task.py (100%) diff --git a/src/opi/simple_tasks/__init__.py b/src/opi/simple_tasks/__init__.py index 00868da0..f3a9160b 100644 --- a/src/opi/simple_tasks/__init__.py +++ b/src/opi/simple_tasks/__init__.py @@ -1,6 +1,3 @@ -from opi.simple_tasks.engrad.engrad_task import EngradResults, EngradSettings, EngradTask -from opi.simple_tasks.freq.freq_task import FreqResults, FreqSettings, FreqTask -from opi.simple_tasks.goat.goat_task import GoatResults, GoatSettings, GoatTask from opi.simple_tasks.method_settings import ( DftSettings, DlpnoCcSettings, @@ -10,9 +7,20 @@ SqmSettings, WftSettings, ) -from opi.simple_tasks.opt.opt_task import OptResults, OptSettings, OptTask from opi.simple_tasks.simple_task import SimpleTask, TaskResults, TaskSettings -from opi.simple_tasks.single_point.single_point_task import ( +from opi.simple_tasks.tasks import ( + EngradResults, + EngradSettings, + EngradTask, + FreqResults, + FreqSettings, + FreqTask, + GoatResults, + GoatSettings, + GoatTask, + OptResults, + OptSettings, + OptTask, SinglePointResults, SinglePointSettings, SinglePointTask, diff --git a/src/opi/simple_tasks/engrad/__init__.py b/src/opi/simple_tasks/engrad/__init__.py deleted file mode 100644 index f47c4647..00000000 --- a/src/opi/simple_tasks/engrad/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from opi.simple_tasks.engrad.engrad_task import EngradResults, EngradSettings, EngradTask - -__all__ = ["EngradResults", "EngradSettings", "EngradTask"] diff --git a/src/opi/simple_tasks/freq/__init__.py b/src/opi/simple_tasks/freq/__init__.py deleted file mode 100644 index 9bb53e86..00000000 --- a/src/opi/simple_tasks/freq/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from opi.simple_tasks.freq.freq_task import FreqResults, FreqSettings, FreqTask - -__all__ = ["FreqResults", "FreqSettings", "FreqTask"] diff --git a/src/opi/simple_tasks/goat/__init__.py b/src/opi/simple_tasks/goat/__init__.py deleted file mode 100644 index 16269598..00000000 --- a/src/opi/simple_tasks/goat/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from opi.simple_tasks.goat.goat_task import GoatResults, GoatSettings, GoatTask - -__all__ = ["GoatResults", "GoatSettings", "GoatTask"] diff --git a/src/opi/simple_tasks/opt/__init__.py b/src/opi/simple_tasks/opt/__init__.py deleted file mode 100644 index 47b6035a..00000000 --- a/src/opi/simple_tasks/opt/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from opi.simple_tasks.opt.opt_task import OptResults, OptSettings, OptTask - -__all__ = ["OptResults", "OptSettings", "OptTask"] diff --git a/src/opi/simple_tasks/single_point/__init__.py b/src/opi/simple_tasks/single_point/__init__.py deleted file mode 100644 index fe4b7fd0..00000000 --- a/src/opi/simple_tasks/single_point/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from opi.simple_tasks.single_point.single_point_task import ( - SinglePointResults, - SinglePointSettings, - SinglePointTask, -) - -__all__ = ["SinglePointResults", "SinglePointSettings", "SinglePointTask"] diff --git a/src/opi/simple_tasks/tasks/__init__.py b/src/opi/simple_tasks/tasks/__init__.py new file mode 100644 index 00000000..7271a024 --- /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", +] \ No newline at end of file diff --git a/src/opi/simple_tasks/engrad/engrad_task.py b/src/opi/simple_tasks/tasks/engrad_task.py similarity index 100% rename from src/opi/simple_tasks/engrad/engrad_task.py rename to src/opi/simple_tasks/tasks/engrad_task.py diff --git a/src/opi/simple_tasks/freq/freq_task.py b/src/opi/simple_tasks/tasks/freq_task.py similarity index 100% rename from src/opi/simple_tasks/freq/freq_task.py rename to src/opi/simple_tasks/tasks/freq_task.py diff --git a/src/opi/simple_tasks/goat/goat_task.py b/src/opi/simple_tasks/tasks/goat_task.py similarity index 100% rename from src/opi/simple_tasks/goat/goat_task.py rename to src/opi/simple_tasks/tasks/goat_task.py diff --git a/src/opi/simple_tasks/opt/opt_task.py b/src/opi/simple_tasks/tasks/opt_task.py similarity index 100% rename from src/opi/simple_tasks/opt/opt_task.py rename to src/opi/simple_tasks/tasks/opt_task.py diff --git a/src/opi/simple_tasks/single_point/single_point_task.py b/src/opi/simple_tasks/tasks/single_point_task.py similarity index 100% rename from src/opi/simple_tasks/single_point/single_point_task.py rename to src/opi/simple_tasks/tasks/single_point_task.py From f0c1d7f4d59eba3de566ff0c369c4829616b0f0d Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Wed, 3 Jun 2026 09:58:42 +0200 Subject: [PATCH 32/52] fix:changed __add__() to __or__() for merging of Block objects. __or__( ) makes more sense and communicates intent of operator better since it is meant to merge rather than concatenate. --- src/opi/input/blocks/base.py | 15 ++++++++++++++- src/opi/simple_tasks/settings.py | 6 +++--- src/opi/simple_tasks/tasks/__init__.py | 2 +- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/opi/input/blocks/base.py b/src/opi/input/blocks/base.py index 348e556e..e45e0251 100644 --- a/src/opi/input/blocks/base.py +++ b/src/opi/input/blocks/base.py @@ -169,7 +169,20 @@ def format_orca(self) -> str: return s - def __add__(self, other: "Block") -> "Block": + 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(), **other.model_dump(exclude_unset=True)} ) diff --git a/src/opi/simple_tasks/settings.py b/src/opi/simple_tasks/settings.py index 89f30481..c8ebabb4 100644 --- a/src/opi/simple_tasks/settings.py +++ b/src/opi/simple_tasks/settings.py @@ -229,14 +229,14 @@ def map_to_input(self, input_object: Input) -> Input: case (validator, key): block_type = Block.get_subclass_by_name(validator) - block_class = block_type(**{key: value}) + 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_class) + input_object.add_blocks(block_instant) else: existing_block = next(iter(input_object.get_blocks(block_type).values())) - new_block = existing_block + block_class + new_block = existing_block | block_instant input_object.add_blocks(new_block, overwrite=True) return input_object diff --git a/src/opi/simple_tasks/tasks/__init__.py b/src/opi/simple_tasks/tasks/__init__.py index 7271a024..89b916a5 100644 --- a/src/opi/simple_tasks/tasks/__init__.py +++ b/src/opi/simple_tasks/tasks/__init__.py @@ -24,4 +24,4 @@ "SinglePointResults", "SinglePointSettings", "SinglePointTask", -] \ No newline at end of file +] From ef37088f928fe9be33774895cc8dd67a73eb6a52 Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Wed, 3 Jun 2026 11:07:57 +0200 Subject: [PATCH 33/52] fix: remove registries from SimpleKeywordBox classes. There are not required anymore since the inheritance scheme of the SimpleKeywordBox classes has been changed --- src/opi/input/simple_keywords/base.py | 52 ++++++++++----------------- 1 file changed, 19 insertions(+), 33 deletions(-) diff --git a/src/opi/input/simple_keywords/base.py b/src/opi/input/simple_keywords/base.py index 01f69219..13c2405e 100644 --- a/src/opi/input/simple_keywords/base.py +++ b/src/opi/input/simple_keywords/base.py @@ -1,7 +1,5 @@ __all__ = ("SimpleKeyword", "SimpleKeywordBox") -from typing import Any - class SimpleKeywordBox: """ @@ -15,21 +13,6 @@ class SimpleKeywordBox: _registry: list[type["SimpleKeywordBox"]] = [] - def __init_subclass__(cls, **kwargs: Any) -> None: - super().__init_subclass__(**kwargs) - cls._registry = [] - - for base in cls.__bases__: - if hasattr(base, "_registry"): - base._registry.append(cls) - - cls._registry.append(cls) - - @classmethod - def registry(cls) -> list[type["SimpleKeywordBox"]]: - """Return all subclasses registered under this box.""" - return cls._registry - @classmethod def from_string(cls, s: str) -> "SimpleKeyword": """ @@ -44,22 +27,25 @@ def from_string(cls, s: str) -> "SimpleKeyword": If no matching keyword is found in the registry. """ norm = s.lower() - for c in cls._registry: - for attr in dir(c): - if attr.startswith("_"): # Skip private/magic attributes - continue - value = getattr(c, attr) - # Case 1: if the user searches for keyword through how it woould appear in ORCA input - if isinstance(value, SimpleKeyword) and value.keyword.lower() == norm: - return value - # Case 2: if the user searches for keyword through how it is stored in OPI - elif isinstance(value, SimpleKeyword) and attr.lower() == norm: - return value - # Case 3: if the user searches for keyword through a known alias - elif ( - isinstance(value, SimpleKeyword) and value.alias and value.alias.lower() == norm - ): - return value + + # 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. + for attr in dir(cls): + value = getattr(cls, attr) + if attr.startswith("_") or not isinstance( + value, 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 value.keyword.lower() == norm: + return value + # Case 2: if the user searches for keyword through how it is stored in OPI + elif attr.lower() == norm: + return value + # Case 3: if the user searches for keyword through a known alias + elif value.alias and value.alias.lower() == norm: + return value raise ValueError(f"Keyword {s} not found in class {cls.__name__}") From d0d96d939128a072fd8cdbcc8527387492ce9dd6 Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Wed, 3 Jun 2026 11:33:10 +0200 Subject: [PATCH 34/52] fix: remove dead attribute `name` from `SimpleKeyword` and store alias as list of strings --- src/opi/input/simple_keywords/base.py | 59 ++++++++------------------- 1 file changed, 18 insertions(+), 41 deletions(-) diff --git a/src/opi/input/simple_keywords/base.py b/src/opi/input/simple_keywords/base.py index 13c2405e..0cf59448 100644 --- a/src/opi/input/simple_keywords/base.py +++ b/src/opi/input/simple_keywords/base.py @@ -3,16 +3,12 @@ class SimpleKeywordBox: """ - Registry base class for groups of related ``SimpleKeyword`` constants. + Base class for groups of related ``SimpleKeyword`` constants. Each concrete subclass (e.g. ``Task``, ``Dft``, ``BasisSet``) declares its - members as class-level ``SimpleKeyword`` attributes. Subclassing - automatically registers the new class so that ``from_string`` can search - across all members in the group. + members as class-level ``SimpleKeyword`` attributes. """ - _registry: list[type["SimpleKeywordBox"]] = [] - @classmethod def from_string(cls, s: str) -> "SimpleKeyword": """ @@ -32,21 +28,23 @@ def from_string(cls, s: str) -> "SimpleKeyword": # 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. for attr in dir(cls): - value = getattr(cls, attr) + # Get the value associated with attribute + simple_keyword = getattr(cls, attr) if attr.startswith("_") or not isinstance( - value, SimpleKeyword + simple_keyword, 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 value.keyword.lower() == norm: - return value + if simple_keyword.keyword.lower() == norm: + return simple_keyword # Case 2: if the user searches for keyword through how it is stored in OPI elif attr.lower() == norm: - return value + return simple_keyword # Case 3: if the user searches for keyword through a known alias - elif value.alias and value.alias.lower() == norm: - return value + elif simple_keyword.alias and any(a.lower() == norm for a in simple_keyword.alias): + return simple_keyword + # In the case that no matches are found, raise ValueError raise ValueError(f"Keyword {s} not found in class {cls.__name__}") @classmethod @@ -63,6 +61,7 @@ def find_keyword(cls, inp: "SimpleKeyword | str") -> "SimpleKeyword": If ``inp`` is not a str or SimpleKeyword, or if the string is not found in the registry. """ + # If input is SimpleKeyword, convert to string representation if isinstance(inp, SimpleKeyword): inp = inp.keyword @@ -85,13 +84,14 @@ class SimpleKeyword: ---------- keyword : str The keyword string as it will appear in the ORCA ``.inp`` file. - alias : str or None - Optional alternative name accepted by ``SimpleKeywordBox.from_string``. + alias : list[str] | None + Optional alternative name(s) accepted by ``SimpleKeywordBox.from_string``. """ - alias: str | None = None + _keyword: str + alias: list[str] | None = None - def __init__(self, keyword: str, alias: str | None = None) -> None: + def __init__(self, keyword: str, alias: str | list[str] | None = None) -> None: """ Parameters ---------- @@ -101,10 +101,8 @@ def __init__(self, keyword: str, alias: str | None = None) -> None: alias : str, optional Alternative lookup name for ``SimpleKeywordBox.from_string``. """ - self._keyword: str = "" self.keyword = keyword - self._name: str = "" - self.alias = alias + self.alias = [alias] if isinstance(alias, str) else alias @property def keyword(self) -> str: @@ -127,27 +125,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 From 4971a0025e13a8b9e987b97a2d1608bc5f9305f8 Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Wed, 3 Jun 2026 11:38:20 +0200 Subject: [PATCH 35/52] docs: add documentation for get_subclass_by_name() in `Block` --- src/opi/input/blocks/base.py | 26 +++++++++++++++++++++++++ src/opi/simple_tasks/tasks/freq_task.py | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/opi/input/blocks/base.py b/src/opi/input/blocks/base.py index e45e0251..abc0dc4f 100644 --- a/src/opi/input/blocks/base.py +++ b/src/opi/input/blocks/base.py @@ -211,10 +211,36 @@ def init_inputpath(cls, inp: Any) -> Any: @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 matches = {sub.__name__.lower(): sub for sub in cls.__subclasses__()} + # Search for `Block` class by ORCA block name name_matches = {sub().name.lower(): sub for sub in cls.__subclasses__()} + # Collect matches across both criteria match = matches.get(name.lower()) or name_matches.get(name.lower()) + # If no match is found, raise ValueError if match is None: raise ValueError( f"No Block subclass found with name {name!r}. Available: {list(matches.keys())}" diff --git a/src/opi/simple_tasks/tasks/freq_task.py b/src/opi/simple_tasks/tasks/freq_task.py index e7394e8d..359d85d8 100644 --- a/src/opi/simple_tasks/tasks/freq_task.py +++ b/src/opi/simple_tasks/tasks/freq_task.py @@ -46,7 +46,7 @@ def primary_property(self) -> float: class FreqTask(SimpleTask[FreqResults]): """ - High-level task for harmonic frequency calculations. + Task for harmonic frequency calculations. Returns a ``FreqResults`` object. ``status`` is ``True`` when the job terminated normally (SCF convergence is not checked separately because From 3abe6148cc38f0dde76ac59bd8f9748b9c014e97 Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Wed, 3 Jun 2026 11:57:43 +0200 Subject: [PATCH 36/52] fix:add example for user modification of input_object in SimpleTask object. Add example to showcase how the user can modify the input_object directly in a simple task ,a long with unit tests that user-changes to input object persist --- .../exmp054_singlepoint_simpletask/job.py | 4 + tests/unit/test_simpletasks_task.py | 79 ++++++++++++++++++- 2 files changed, 79 insertions(+), 4 deletions(-) diff --git a/examples/exmp054_singlepoint_simpletask/job.py b/examples/exmp054_singlepoint_simpletask/job.py index 46566688..1b2775a3 100644 --- a/examples/exmp054_singlepoint_simpletask/job.py +++ b/examples/exmp054_singlepoint_simpletask/job.py @@ -2,6 +2,7 @@ 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 @@ -19,6 +20,9 @@ def run_exmp054( ) # > 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_object.add_simple_keywords(AtomicCharge.HIRSHFELD) + # > run the calculation with given data singlepoint_result = simple_task.run("job", structure, working_dir=working_dir) diff --git a/tests/unit/test_simpletasks_task.py b/tests/unit/test_simpletasks_task.py index 575f102a..1ae6db67 100644 --- a/tests/unit/test_simpletasks_task.py +++ b/tests/unit/test_simpletasks_task.py @@ -1,7 +1,10 @@ +from pathlib import Path + import pytest from opi.input import Input -from opi.input.blocks import BlockGeom +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 ( @@ -250,9 +253,77 @@ def test_input_object_is_cached() -> None: @pytest.mark.unit @pytest.mark.simpletasks -def test_input_object_mutation_persists() -> None: - """Keywords added to input_object are present on the next access.""" +def test_user_added_keyword_persists() -> None: + """A keyword added directly to input_object is visible on every subsequent access.""" task = SinglePointTask(method="pbe") - kw = SimpleKeyword("RIJCOSX") + kw = SimpleKeyword("NORI") task.input_object.add_simple_keywords(kw) assert task.input_object.has_simple_keywords(kw) == (True,) + assert task.input_object.has_simple_keywords(kw) == (True,) + + +@pytest.mark.unit +@pytest.mark.simpletasks +def test_user_added_block_persists() -> None: + """A block added directly to input_object is visible on every subsequent access.""" + task = SinglePointTask(method="pbe") + block = BlockScf(maxiter=500) + task.input_object.add_blocks(block) + assert task.input_object.has_blocks(BlockScf()) == (True,) + assert task.input_object.blocks[BlockScf].maxiter == 500 + + +@pytest.mark.unit +@pytest.mark.simpletasks +def test_user_modified_block_persists() -> None: + """Mutating an existing block on input_object is reflected on every subsequent access.""" + task = OptTask(method="pbe", task_settings={"opt_maxiter": 50}) + task.input_object.blocks[BlockGeom].maxiter = 99 + assert task.input_object.blocks[BlockGeom].maxiter == 99 + + +@pytest.mark.unit +@pytest.mark.simpletasks +def test_user_set_ncores_persists() -> None: + """Setting ncores on input_object is preserved across accesses.""" + task = SinglePointTask(method="pbe") + task.input_object.ncores = 8 + assert task.input_object.ncores == 8 + + +@pytest.mark.unit +@pytest.mark.simpletasks +def test_user_set_memory_persists() -> None: + """Setting memory on input_object is preserved across accesses.""" + task = SinglePointTask(method="pbe") + task.input_object.memory = 4096 + assert task.input_object.memory == 4096 + + +@pytest.mark.unit +@pytest.mark.simpletasks +def test_user_added_arbitrary_string_persists() -> None: + """An arbitrary string added to input_object is preserved across accesses.""" + task = SinglePointTask(method="pbe") + task.input_object.add_arbitrary_string("% some custom block\nend", pos=ArbitraryStringPos.BOTTOM) + assert len(task.input_object.arbitrary_strings) == 1 + assert len(task.input_object.arbitrary_strings) == 1 + + +@pytest.mark.unit +@pytest.mark.simpletasks +def test_multiple_user_modifications_all_persist() -> None: + """Multiple independent modifications to input_object all survive repeated accesses.""" + task = SinglePointTask(method="pbe") + kw = SimpleKeyword("RIJCOSX") + block = BlockScf(maxiter=300) + + task.input_object.add_simple_keywords(kw) + task.input_object.add_blocks(block) + task.input_object.ncores = 4 + + inp = task.input_object + assert inp.has_simple_keywords(kw) == (True,) + assert inp.has_blocks(BlockScf()) == (True,) + assert inp.blocks[BlockScf].maxiter == 300 + assert inp.ncores == 4 From 63a8ab7733b805613e4f21697964ceb28870fbdb Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Wed, 3 Jun 2026 13:59:51 +0200 Subject: [PATCH 37/52] fix:add input_object init to SimpleTask init --- src/opi/input/blocks/base.py | 2 +- src/opi/simple_tasks/simple_task.py | 34 +++++++++++++++++------------ tests/unit/test_simpletasks_task.py | 19 +++++++++++----- 3 files changed, 34 insertions(+), 21 deletions(-) diff --git a/src/opi/input/blocks/base.py b/src/opi/input/blocks/base.py index abc0dc4f..0d2e8518 100644 --- a/src/opi/input/blocks/base.py +++ b/src/opi/input/blocks/base.py @@ -184,7 +184,7 @@ def __or__(self, other: "Block") -> "Block": """ new_block = self.__class__.model_validate( - {**self.model_dump(), **other.model_dump(exclude_unset=True)} + {**self.model_dump(exclude_none=True), **other.model_dump(exclude_unset=True)} ) return new_block diff --git a/src/opi/simple_tasks/simple_task.py b/src/opi/simple_tasks/simple_task.py index 485b3d02..ab4f87b8 100644 --- a/src/opi/simple_tasks/simple_task.py +++ b/src/opi/simple_tasks/simple_task.py @@ -64,6 +64,7 @@ class SimpleTask(ABC, typing.Generic[_RT]): _results_type: type[_RT] _task_settings: TaskSettings _method_settings: MethodSettings + _input_object: Input def __init__( self, @@ -140,6 +141,8 @@ def __init__( ) self._method_settings = resolved_method_settings + self._input_object: Input = Input() + @classmethod def _get_task_settings_type(cls) -> type[TaskSettings]: hints = get_type_hints(cls) @@ -157,27 +160,30 @@ def method_settings(self) -> MethodSettings: """Method-level settings (functional, basis set, solvent, …).""" return self._method_settings - @cached_property + @property def input_object(self) -> Input: """ - Creates configured `Input` object. First it initializes an empty instance of `Input` , and then passes it as - to corresponding `TaskSettings` and `MethodSettings` objects to be configured by user-defined data stored in those - objects. + Returns the ``Input`` object for this task, with ``task_settings`` and + ``method_settings`` applied on top of any user modifications. - The result is cached — the same `Input` instance is returned on every access, so mutations persist and are included when ``run()`` is called. - To reset the cache after changing ``task_settings`` or ``method_settings``, delete the attribute: - ``del task.input_object``. + 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` - `Input` object configured by user-defined data. - + Input + The task's ``Input`` object, ready for inspection or further + user customisation before calling ``run()``. """ - inp = Input() - inp = self._task_settings.map_to_input(input_object=inp) - inp = self.method_settings.map_to_input(input_object=inp) - return inp + self._task_settings.map_to_input(self._input_object) + self.method_settings.map_to_input(self._input_object) + return self._input_object + + @input_object.setter + def input_object(self, value: Input) -> None: + self._input_object = value @property def keyword(self) -> SimpleKeyword: diff --git a/tests/unit/test_simpletasks_task.py b/tests/unit/test_simpletasks_task.py index 1ae6db67..aae95510 100644 --- a/tests/unit/test_simpletasks_task.py +++ b/tests/unit/test_simpletasks_task.py @@ -1,5 +1,3 @@ -from pathlib import Path - import pytest from opi.input import Input @@ -275,11 +273,20 @@ def test_user_added_block_persists() -> None: @pytest.mark.unit @pytest.mark.simpletasks -def test_user_modified_block_persists() -> None: - """Mutating an existing block on input_object is reflected on every subsequent access.""" +def test_settings_win_over_user_block_modification() -> None: + """Settings-controlled block fields overwrite user modifications on every access.""" task = OptTask(method="pbe", task_settings={"opt_maxiter": 50}) - task.input_object.blocks[BlockGeom].maxiter = 99 - assert task.input_object.blocks[BlockGeom].maxiter == 99 + task.input_object.blocks[BlockGeom].maxiter = 99 # mutate after first access + assert task.input_object.blocks[BlockGeom].maxiter == 50 + + +@pytest.mark.unit +@pytest.mark.simpletasks +def test_settings_keyword_restored_after_user_removal() -> None: + """A settings-controlled keyword removed from _input_object is re-added on the next access.""" + task = SinglePointTask(method="pbe", basis_set="def2-svp") + task._input_object.remove_simple_keywords(BasisSet.DEF2_SVP) + assert task.input_object.has_simple_keywords(BasisSet.DEF2_SVP) == (True,) @pytest.mark.unit From 7723a91fb87f71d7c5a88fcf5f624b02c0b96f9e Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Wed, 17 Jun 2026 16:18:40 +0200 Subject: [PATCH 38/52] fix: give preference to user input settings over data that exists in settings class --- src/opi/input/blocks/base.py | 2 +- src/opi/simple_tasks/settings.py | 2 +- tests/unit/test_simpletasks_task.py | 12 +++++++----- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/opi/input/blocks/base.py b/src/opi/input/blocks/base.py index 0d2e8518..9850de99 100644 --- a/src/opi/input/blocks/base.py +++ b/src/opi/input/blocks/base.py @@ -184,7 +184,7 @@ def __or__(self, other: "Block") -> "Block": """ new_block = self.__class__.model_validate( - {**self.model_dump(exclude_none=True), **other.model_dump(exclude_unset=True)} + {**self.model_dump(exclude_none=True), **other.model_dump(exclude_none=True)} ) return new_block diff --git a/src/opi/simple_tasks/settings.py b/src/opi/simple_tasks/settings.py index c8ebabb4..1e69a2fe 100644 --- a/src/opi/simple_tasks/settings.py +++ b/src/opi/simple_tasks/settings.py @@ -236,7 +236,7 @@ def map_to_input(self, input_object: Input) -> Input: input_object.add_blocks(block_instant) else: existing_block = next(iter(input_object.get_blocks(block_type).values())) - new_block = existing_block | block_instant + new_block = block_instant | existing_block input_object.add_blocks(new_block, overwrite=True) return input_object diff --git a/tests/unit/test_simpletasks_task.py b/tests/unit/test_simpletasks_task.py index aae95510..7f6b8c74 100644 --- a/tests/unit/test_simpletasks_task.py +++ b/tests/unit/test_simpletasks_task.py @@ -274,18 +274,18 @@ def test_user_added_block_persists() -> None: @pytest.mark.unit @pytest.mark.simpletasks def test_settings_win_over_user_block_modification() -> None: - """Settings-controlled block fields overwrite user modifications on every access.""" + """Settings-controlled block fields do not overwrite user modifications.""" task = OptTask(method="pbe", task_settings={"opt_maxiter": 50}) task.input_object.blocks[BlockGeom].maxiter = 99 # mutate after first access - assert task.input_object.blocks[BlockGeom].maxiter == 50 + assert task.input_object.blocks[BlockGeom].maxiter == 99 @pytest.mark.unit @pytest.mark.simpletasks def test_settings_keyword_restored_after_user_removal() -> None: - """A settings-controlled keyword removed from _input_object is re-added on the next access.""" + """A method-controlled keyword removed from input_object is re-added on the next access.""" task = SinglePointTask(method="pbe", basis_set="def2-svp") - task._input_object.remove_simple_keywords(BasisSet.DEF2_SVP) + task.input_object.remove_simple_keywords(BasisSet.DEF2_SVP) assert task.input_object.has_simple_keywords(BasisSet.DEF2_SVP) == (True,) @@ -312,7 +312,9 @@ def test_user_set_memory_persists() -> None: def test_user_added_arbitrary_string_persists() -> None: """An arbitrary string added to input_object is preserved across accesses.""" task = SinglePointTask(method="pbe") - task.input_object.add_arbitrary_string("% some custom block\nend", pos=ArbitraryStringPos.BOTTOM) + task.input_object.add_arbitrary_string( + "% some custom block\nend", pos=ArbitraryStringPos.BOTTOM + ) assert len(task.input_object.arbitrary_strings) == 1 assert len(task.input_object.arbitrary_strings) == 1 From c21e7972555e3b2660fba295503570340aca633a Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Wed, 17 Jun 2026 16:25:18 +0200 Subject: [PATCH 39/52] fix:refactor variables in `Block.get_subclasses_by_name()` --- src/opi/input/blocks/base.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/opi/input/blocks/base.py b/src/opi/input/blocks/base.py index 9850de99..1842c276 100644 --- a/src/opi/input/blocks/base.py +++ b/src/opi/input/blocks/base.py @@ -234,16 +234,18 @@ def get_subclass_by_name(cls, name: str) -> type["Block"]: If no subclass matches ``name``. """ # Search for `Block` class by OPI name - matches = {sub.__name__.lower(): sub for sub in cls.__subclasses__()} + opi_block_name_matches = {sub.__name__.lower(): sub for sub in cls.__subclasses__()} # Search for `Block` class by ORCA block name - name_matches = {sub().name.lower(): sub for sub in cls.__subclasses__()} + orca_block_name_matches = {sub().name.lower(): sub for sub in cls.__subclasses__()} # Collect matches across both criteria - match = matches.get(name.lower()) or name_matches.get(name.lower()) + 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 match is None: + if all_matches is None: raise ValueError( - f"No Block subclass found with name {name!r}. Available: {list(matches.keys())}" + f"No Block subclass found with name {name!r}. Available: {list(opi_block_name_matches.keys())}" ) - return match + return all_matches From 66cf5af4de6267b2b95f029b384bd33118797cff Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Wed, 17 Jun 2026 16:49:14 +0200 Subject: [PATCH 40/52] fix: refactor variables and correct docstrings in `SimpleKeywordBox` in light of recent design changes --- src/opi/input/simple_keywords/base.py | 29 ++++++++++++++------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/src/opi/input/simple_keywords/base.py b/src/opi/input/simple_keywords/base.py index 0cf59448..7c164359 100644 --- a/src/opi/input/simple_keywords/base.py +++ b/src/opi/input/simple_keywords/base.py @@ -12,7 +12,7 @@ class SimpleKeywordBox: @classmethod def from_string(cls, s: str) -> "SimpleKeyword": """ - Look up a ``SimpleKeyword`` by string across all registered subclasses. + 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. @@ -26,23 +26,24 @@ def from_string(cls, s: str) -> "SimpleKeyword": # 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. - for attr in dir(cls): + # 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 - simple_keyword = getattr(cls, attr) - if attr.startswith("_") or not isinstance( - simple_keyword, SimpleKeyword + 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 simple_keyword.keyword.lower() == norm: - return simple_keyword + if candidate_attr.keyword.lower() == norm: + return candidate_attr # Case 2: if the user searches for keyword through how it is stored in OPI - elif attr.lower() == norm: - return simple_keyword + if attr_name.lower() == norm: + return candidate_attr # Case 3: if the user searches for keyword through a known alias - elif simple_keyword.alias and any(a.lower() == norm for a in simple_keyword.alias): - return simple_keyword + 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__}") @@ -50,7 +51,7 @@ def from_string(cls, s: str) -> "SimpleKeyword": @classmethod def find_keyword(cls, inp: "SimpleKeyword | str") -> "SimpleKeyword": """ - Resolve a string or ``SimpleKeyword`` to a registered ``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). @@ -59,7 +60,7 @@ def find_keyword(cls, inp: "SimpleKeyword | str") -> "SimpleKeyword": ------ ValueError If ``inp`` is not a str or SimpleKeyword, or if the string is not - found in the registry. + found. """ # If input is SimpleKeyword, convert to string representation if isinstance(inp, SimpleKeyword): From 1b84e1dc87983d279bbd5323b97ecb71bdfe064a Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Mon, 22 Jun 2026 11:10:16 +0200 Subject: [PATCH 41/52] fix: add documentation to improve code clarity --- .../exmp054_singlepoint_simpletask/job.py | 2 +- src/opi/simple_tasks/method_settings.py | 7 +- src/opi/simple_tasks/settings.py | 201 ++++++++++-------- src/opi/simple_tasks/simple_task.py | 16 +- src/opi/simple_tasks/tasks/freq_task.py | 5 +- src/opi/simple_tasks/tasks/goat_task.py | 9 +- 6 files changed, 134 insertions(+), 106 deletions(-) diff --git a/examples/exmp054_singlepoint_simpletask/job.py b/examples/exmp054_singlepoint_simpletask/job.py index 1b2775a3..67d1079d 100644 --- a/examples/exmp054_singlepoint_simpletask/job.py +++ b/examples/exmp054_singlepoint_simpletask/job.py @@ -31,7 +31,7 @@ def run_exmp054( print("SinglePoint task failed") sys.exit(1) - # > extract final energy from the `TaskResults` object + # > 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") diff --git a/src/opi/simple_tasks/method_settings.py b/src/opi/simple_tasks/method_settings.py index 81d675e8..217a70ca 100644 --- a/src/opi/simple_tasks/method_settings.py +++ b/src/opi/simple_tasks/method_settings.py @@ -1,5 +1,6 @@ import typing import warnings +from typing import Any from pydantic import ConfigDict, field_validator, model_validator from pydantic_core.core_schema import ValidationInfo @@ -41,7 +42,7 @@ class MethodSettings(Settings): solvation_model: typing.Annotated[SimpleKeyword, SolvationModel] | None = None solvent: typing.Annotated[str, Solvent] | None = None - def __new__(cls, /, **data: typing.Any) -> "MethodSettings": + def __new__(cls, /, **data: Any) -> "MethodSettings": """ Dispatch to the correct ``MethodSettings`` subclass. @@ -58,7 +59,7 @@ def __new__(cls, /, **data: typing.Any) -> "MethodSettings": @model_validator(mode="before") @classmethod - def _check_valid_fields(cls, data: typing.Any) -> typing.Any: + def _check_valid_fields(cls, data: Any) -> Any: """ Reject unknown fields before Pydantic processes them. @@ -143,7 +144,7 @@ class DftSettings(MethodSettings): @field_validator("*", mode="before") @classmethod - def validate_fields(cls, value: typing.Any, info: ValidationInfo) -> typing.Any: + def validate_fields(cls, value: Any, info: ValidationInfo) -> Any: """ Pre-validate all fields, with special handling for ``method``. diff --git a/src/opi/simple_tasks/settings.py b/src/opi/simple_tasks/settings.py index 1e69a2fe..1261c948 100644 --- a/src/opi/simple_tasks/settings.py +++ b/src/opi/simple_tasks/settings.py @@ -1,5 +1,6 @@ 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 @@ -24,6 +25,87 @@ class Settings(BaseModel): 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 = next(iter(input_object.get_blocks(block_type).values())) + 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. @@ -40,7 +122,7 @@ def __str__(self) -> str: return "\n".join(lines) @staticmethod - def _get_field_metadata(hint: typing.Any) -> tuple[typing.Any, ...]: + 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. @@ -57,17 +139,19 @@ def _get_field_metadata(hint: typing.Any) -> tuple[typing.Any, ...]: """ 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: typing.Any, metadata: tuple[type["SimpleKeywordBox"]] | tuple[str, str] - ) -> typing.Any: + 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: @@ -78,6 +162,13 @@ def _resolve_field_value( 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 @@ -85,32 +176,40 @@ class is first fetched, and then the attribute of the block is set to the user-g 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: typing.Any + value: Any User input value. metadata: tuple Tuple of metadata about the field. Returns ------- - typing.Any + Any User input value translated to OPI compatible types. """ match metadata: case (validator,): - # Case 1: Simple keyword - + # 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 + # 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)}") @@ -119,6 +218,7 @@ class is first fetched, and then the attribute of the block is set to the user-g return getattr(instance, str(key)) case _: + # No metadata: field needs no translation return value def _get_simple_keyword( @@ -153,97 +253,26 @@ def _get_simple_keyword( 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("solvent is required for SolvationModel") + 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 - 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. - """ - 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,): - if issubclass(validator, Solvent): - continue - - new_keyword = self._get_simple_keyword(validator, value) - if new_keyword: - input_object.add_simple_keywords(new_keyword) - - case (validator, key): - 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: - existing_block = next(iter(input_object.get_blocks(block_type).values())) - new_block = block_instant | existing_block - input_object.add_blocks(new_block, overwrite=True) - - return input_object @field_validator("*", mode="before") @classmethod - def validate_fields(cls, value: typing.Any, info: ValidationInfo) -> typing.Any: + def validate_fields(cls, value: Any, info: ValidationInfo) -> Any: """ This field validator handles validation upon reassignment of values of class attributes. @@ -259,7 +288,7 @@ def validate_fields(cls, value: typing.Any, info: ValidationInfo) -> typing.Any: ---------- value: Any User input value. - info + info: ValidationInfo Object containing contextual information about field being validated. Returns @@ -281,7 +310,7 @@ def validate_fields(cls, value: typing.Any, info: ValidationInfo) -> typing.Any: @model_validator(mode="before") @classmethod - def cross_validate(cls, data: dict[str, typing.Any]) -> dict[str, typing.Any]: + 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. diff --git a/src/opi/simple_tasks/simple_task.py b/src/opi/simple_tasks/simple_task.py index ab4f87b8..095ce43f 100644 --- a/src/opi/simple_tasks/simple_task.py +++ b/src/opi/simple_tasks/simple_task.py @@ -4,7 +4,7 @@ from abc import ABC, abstractmethod from functools import cached_property from pathlib import Path -from typing import get_type_hints +from typing import Any, get_type_hints from pydantic import ConfigDict @@ -42,7 +42,7 @@ class TaskSettings(Settings): class SimpleTask(ABC, typing.Generic[_RT]): """ - Abstract base class for all OPI calculation tasks. + 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 @@ -72,8 +72,8 @@ def __init__( 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, + task_settings: "TaskSettings | dict[str, Any] | None" = None, + method_settings: "MethodSettings | dict[str, Any] | None" = None, ): """ Parameters @@ -116,7 +116,7 @@ def __init__( if method is not None: resolved_type = MethodSettings.resolve_method_settings_type(method) - base_data: dict[str, typing.Any] = { + base_data: dict[str, Any] = { k: v for k, v in { "method": method, @@ -127,7 +127,7 @@ def __init__( if v is not None } if resolved_method_settings is not None: - extra: dict[str, typing.Any] = { + extra: dict[str, Any] = { **resolved_method_settings.model_dump(exclude_unset=True), **(resolved_method_settings.model_extra or {}), } @@ -222,7 +222,7 @@ def method(self, new_value: str | SimpleKeyword | None) -> None: if isinstance(self._method_settings, resolved_type): self._method_settings.method = new_value # type:ignore else: - common_fields: dict[str, typing.Any] = {"method": new_value} + 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) @@ -599,7 +599,7 @@ def status(self) -> bool: @cached_property @abstractmethod - def primary_property(self) -> typing.Any: + def primary_property(self) -> Any: """The most important result for this task type (energy, structure, …).""" pass diff --git a/src/opi/simple_tasks/tasks/freq_task.py b/src/opi/simple_tasks/tasks/freq_task.py index 359d85d8..1c36eab3 100644 --- a/src/opi/simple_tasks/tasks/freq_task.py +++ b/src/opi/simple_tasks/tasks/freq_task.py @@ -1,5 +1,4 @@ import typing -from functools import cached_property from opi.input.simple_keywords import SimpleKeyword, Solvent, Task from opi.simple_tasks.method_settings import MethodSettings @@ -16,12 +15,12 @@ class FreqSettings(TaskSettings): class FreqResults(TaskResults): """Results from a harmonic frequency calculation.""" - @cached_property + @property def status(self) -> bool: """``True`` if the job terminated normally (SCF convergence not re-checked).""" return self.output.terminated_normally() - @cached_property + @property def free_energy_delta(self) -> float: """ Thermal free-energy correction (ΔG) in Hartree. diff --git a/src/opi/simple_tasks/tasks/goat_task.py b/src/opi/simple_tasks/tasks/goat_task.py index 52293b76..76d88c8e 100644 --- a/src/opi/simple_tasks/tasks/goat_task.py +++ b/src/opi/simple_tasks/tasks/goat_task.py @@ -1,5 +1,4 @@ import typing -from functools import cached_property from opi.input import Input from opi.input.simple_keywords import Goat, SimpleKeyword, Solvent @@ -58,12 +57,12 @@ def map_to_input(self, input_object: Input) -> Input: class GoatResults(TaskResults): """Results from a GOAT conformer-ensemble exploration.""" - @cached_property + @property def status(self) -> bool: """``True`` if the job terminated normally.""" return self.output.terminated_normally() - @cached_property + @property def structures(self) -> list[Structure]: """All structures in the final conformer ensemble.""" structures = Structure.from_trj_xyz( @@ -71,7 +70,7 @@ def structures(self) -> list[Structure]: ) return structures - @cached_property + @property def properties(self) -> list[Properties]: """Per-conformer properties (energies, …) from the final ensemble file.""" properties = Properties.from_trj_xyz( @@ -79,7 +78,7 @@ def properties(self) -> list[Properties]: ) return properties - @cached_property + @property def primary_property(self) -> tuple[list[Structure], list[Properties]]: """``(structures, properties)`` tuple for the final conformer ensemble.""" return self.structures, self.properties From 1736aa7e0717b1a5a32137f031cd297b1a00fe86 Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Mon, 22 Jun 2026 11:50:27 +0200 Subject: [PATCH 42/52] fix: change type annotation in DlpnoCcSettings --- src/opi/input/simple_keywords/wft.py | 14 +++++++++----- src/opi/simple_tasks/method_settings.py | 3 ++- src/opi/simple_tasks/settings.py | 2 -- 3 files changed, 11 insertions(+), 8 deletions(-) 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/method_settings.py b/src/opi/simple_tasks/method_settings.py index 217a70ca..ccf76d0f 100644 --- a/src/opi/simple_tasks/method_settings.py +++ b/src/opi/simple_tasks/method_settings.py @@ -22,6 +22,7 @@ ) 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 @@ -359,7 +360,7 @@ class DlpnoCcSettings(MethodSettings): arbitrary_types_allowed=True, validate_assignment=True, extra="forbid" ) _name: str = "dlpnocc" - method: typing.Annotated[SimpleKeyword, Dft] | None = None + 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 diff --git a/src/opi/simple_tasks/settings.py b/src/opi/simple_tasks/settings.py index 1261c948..ce5e0ed0 100644 --- a/src/opi/simple_tasks/settings.py +++ b/src/opi/simple_tasks/settings.py @@ -105,7 +105,6 @@ def map_to_input(self, input_object: Input) -> Input: return input_object - def __str__(self) -> str: """ String representation of `Settings`. Mostly for debugging purposes. @@ -269,7 +268,6 @@ def _get_simple_keyword( return new_keyword - @field_validator("*", mode="before") @classmethod def validate_fields(cls, value: Any, info: ValidationInfo) -> Any: From b833ff081484ada2e767291db6e353c718a16619 Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Mon, 22 Jun 2026 12:00:31 +0200 Subject: [PATCH 43/52] fix: edit examples to take into account new examples --- examples/README.md | 8 ++++---- .../inp.xyz | 0 .../job.py | 4 ++-- .../inp.xyz | 0 .../job.py | 4 ++-- .../inp.xyz | 0 .../job.py | 6 +++--- .../inp.xyz | 0 .../job.py | 4 ++-- .../inp.xyz | 0 .../job.py | 4 ++-- src/opi/simple_tasks/tasks/opt_task.py | 16 +--------------- 12 files changed, 16 insertions(+), 30 deletions(-) rename examples/{exmp054_singlepoint_simpletask => exmp057_singlepoint_simpletask}/inp.xyz (100%) rename examples/{exmp054_singlepoint_simpletask => exmp057_singlepoint_simpletask}/job.py (97%) rename examples/{exmp055_engrad_simpletask => exmp058_engrad_simpletask}/inp.xyz (100%) rename examples/{exmp055_engrad_simpletask => exmp058_engrad_simpletask}/job.py (96%) rename examples/{exmp056_opt_simpletask => exmp059_opt_simpletask}/inp.xyz (100%) rename examples/{exmp056_opt_simpletask => exmp059_opt_simpletask}/job.py (89%) rename examples/{exmp057_freq_simpletask => exmp060_freq_simpletask}/inp.xyz (100%) rename examples/{exmp057_freq_simpletask => exmp060_freq_simpletask}/job.py (94%) rename examples/{exmp058_goat_simpletask => exmp061_goat_simpletask}/inp.xyz (100%) rename examples/{exmp058_goat_simpletask => exmp061_goat_simpletask}/job.py (97%) diff --git a/examples/README.md b/examples/README.md index 01746f88..2deea2af 100644 --- a/examples/README.md +++ b/examples/README.md @@ -74,8 +74,8 @@ python3 job.py - 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 -- exmp054_singlepoint_simpletask: B3LYP/def2-SVP single-point with CPCM(water) using the `SinglePointTask` functionality -- exmp055_goat_simpletask: GFN2-xTB GOAT conformer search using the `GoatTask` functionality -- exmp056_freq_simpletask: TPSS/def2-SVP frequency calculation using the `FreqTask` functionality -- exmp057_opt_simpletask: B3LYP/def2-SVP geometry optimization with CPCM(water) using the `OptTask` functionality +- 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/exmp054_singlepoint_simpletask/inp.xyz b/examples/exmp057_singlepoint_simpletask/inp.xyz similarity index 100% rename from examples/exmp054_singlepoint_simpletask/inp.xyz rename to examples/exmp057_singlepoint_simpletask/inp.xyz diff --git a/examples/exmp054_singlepoint_simpletask/job.py b/examples/exmp057_singlepoint_simpletask/job.py similarity index 97% rename from examples/exmp054_singlepoint_simpletask/job.py rename to examples/exmp057_singlepoint_simpletask/job.py index 67d1079d..9decf117 100644 --- a/examples/exmp054_singlepoint_simpletask/job.py +++ b/examples/exmp057_singlepoint_simpletask/job.py @@ -7,7 +7,7 @@ from opi.simple_tasks import SinglePointResults, SinglePointTask -def run_exmp054( +def run_exmp057( structure: Structure | None = None, working_dir: Path = Path("RUN") ) -> SinglePointResults: # > if no structure is given read structure from inp.xyz @@ -40,4 +40,4 @@ def run_exmp054( if __name__ == "__main__": - run_exmp054() + run_exmp057() diff --git a/examples/exmp055_engrad_simpletask/inp.xyz b/examples/exmp058_engrad_simpletask/inp.xyz similarity index 100% rename from examples/exmp055_engrad_simpletask/inp.xyz rename to examples/exmp058_engrad_simpletask/inp.xyz diff --git a/examples/exmp055_engrad_simpletask/job.py b/examples/exmp058_engrad_simpletask/job.py similarity index 96% rename from examples/exmp055_engrad_simpletask/job.py rename to examples/exmp058_engrad_simpletask/job.py index 50bc36f7..db427ce8 100644 --- a/examples/exmp055_engrad_simpletask/job.py +++ b/examples/exmp058_engrad_simpletask/job.py @@ -6,7 +6,7 @@ from opi.simple_tasks import EngradResults, EngradTask -def run_exmp055( +def run_exmp058( structure: Structure | None = None, working_dir: Path = Path("RUN") ) -> EngradResults: # > if no structure is given read structure from inp.xyz @@ -29,4 +29,4 @@ def run_exmp055( if __name__ == "__main__": - run_exmp055() + run_exmp058() diff --git a/examples/exmp056_opt_simpletask/inp.xyz b/examples/exmp059_opt_simpletask/inp.xyz similarity index 100% rename from examples/exmp056_opt_simpletask/inp.xyz rename to examples/exmp059_opt_simpletask/inp.xyz diff --git a/examples/exmp056_opt_simpletask/job.py b/examples/exmp059_opt_simpletask/job.py similarity index 89% rename from examples/exmp056_opt_simpletask/job.py rename to examples/exmp059_opt_simpletask/job.py index 49c1c395..04152569 100644 --- a/examples/exmp056_opt_simpletask/job.py +++ b/examples/exmp059_opt_simpletask/job.py @@ -7,10 +7,10 @@ from opi.simple_tasks.method_settings import DftSettings -def run_exmp056(structure: Structure | None = None, working_dir: Path = Path("RUN")) -> OptResults: +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("../exmp054_singlepoint_simpletask/inp.xyz") + structure = Structure.from_xyz("../exmp057_singlepoint_simpletask/inp.xyz") # > set up the task simple_task = OptTask( @@ -41,4 +41,4 @@ def run_exmp056(structure: Structure | None = None, working_dir: Path = Path("RU if __name__ == "__main__": - run_exmp056() + run_exmp059() diff --git a/examples/exmp057_freq_simpletask/inp.xyz b/examples/exmp060_freq_simpletask/inp.xyz similarity index 100% rename from examples/exmp057_freq_simpletask/inp.xyz rename to examples/exmp060_freq_simpletask/inp.xyz diff --git a/examples/exmp057_freq_simpletask/job.py b/examples/exmp060_freq_simpletask/job.py similarity index 94% rename from examples/exmp057_freq_simpletask/job.py rename to examples/exmp060_freq_simpletask/job.py index 48ba4c03..a1bb9302 100644 --- a/examples/exmp057_freq_simpletask/job.py +++ b/examples/exmp060_freq_simpletask/job.py @@ -7,7 +7,7 @@ from opi.simple_tasks import FreqResults, FreqTask -def run_exmp057(structure: Structure | None = None, working_dir: Path = Path("RUN")) -> FreqResults: +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") @@ -48,4 +48,4 @@ def run_exmp057(structure: Structure | None = None, working_dir: Path = Path("RU if __name__ == "__main__": - run_exmp057() + run_exmp060() diff --git a/examples/exmp058_goat_simpletask/inp.xyz b/examples/exmp061_goat_simpletask/inp.xyz similarity index 100% rename from examples/exmp058_goat_simpletask/inp.xyz rename to examples/exmp061_goat_simpletask/inp.xyz diff --git a/examples/exmp058_goat_simpletask/job.py b/examples/exmp061_goat_simpletask/job.py similarity index 97% rename from examples/exmp058_goat_simpletask/job.py rename to examples/exmp061_goat_simpletask/job.py index 0a3a2c1b..201a4785 100644 --- a/examples/exmp058_goat_simpletask/job.py +++ b/examples/exmp061_goat_simpletask/job.py @@ -7,7 +7,7 @@ from opi.simple_tasks import GoatSettings, GoatTask -def run_exmp058( +def run_exmp061( structure: Structure | None = None, working_dir: Path = Path("RUN") ) -> tuple[list[Structure], list[Properties]]: @@ -36,4 +36,4 @@ def run_exmp058( if __name__ == "__main__": - run_exmp058() + run_exmp061() diff --git a/src/opi/simple_tasks/tasks/opt_task.py b/src/opi/simple_tasks/tasks/opt_task.py index 8fd17e98..00d67c86 100644 --- a/src/opi/simple_tasks/tasks/opt_task.py +++ b/src/opi/simple_tasks/tasks/opt_task.py @@ -1,10 +1,9 @@ import typing from opi.input import Input -from opi.input.simple_keywords import SimpleKeyword, Solvent, Task +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.method_settings import MethodSettings from opi.simple_tasks.simple_task import SimpleTask, TaskResults, TaskSettings @@ -100,19 +99,6 @@ class OptTask(SimpleTask[OptResults]): _task_settings: OptSettings _results_type = OptResults - 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: OptSettings | None = None, - method_settings: MethodSettings | None = None, - ): - super().__init__( - method, basis_set, solvation_model, solvent, task_settings, method_settings - ) - @property def opt_threshold(self) -> SimpleKeyword | None: """Convergence threshold keyword (e.g. ``OptThreshold.TIGHTOPT``).""" From abac0c2e452d9feb3fbc90d1864297e9e039ae3a Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Mon, 22 Jun 2026 12:09:04 +0200 Subject: [PATCH 44/52] fix: add __init__() of OptTask that was mistakenly removed --- src/opi/simple_tasks/tasks/opt_task.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/opi/simple_tasks/tasks/opt_task.py b/src/opi/simple_tasks/tasks/opt_task.py index 00d67c86..cbd72468 100644 --- a/src/opi/simple_tasks/tasks/opt_task.py +++ b/src/opi/simple_tasks/tasks/opt_task.py @@ -1,9 +1,10 @@ import typing from opi.input import Input -from opi.input.simple_keywords import SimpleKeyword, Task +from opi.input.simple_keywords import SimpleKeyword, Solvent, Task from opi.input.simple_keywords.opt import Opt, OptThreshold from opi.input.structures import Structure +from opi.simple_tasks import MethodSettings from opi.simple_tasks.simple_task import SimpleTask, TaskResults, TaskSettings @@ -99,6 +100,19 @@ class OptTask(SimpleTask[OptResults]): _task_settings: OptSettings _results_type = OptResults + 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: OptSettings | None = None, + method_settings: MethodSettings | None = None, + ): + super().__init__( + method, basis_set, solvation_model, solvent, task_settings, method_settings + ) + @property def opt_threshold(self) -> SimpleKeyword | None: """Convergence threshold keyword (e.g. ``OptThreshold.TIGHTOPT``).""" From fe4d01595298c244b0fb9f8f67e0e6540085c275 Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Wed, 3 Jun 2026 13:59:51 +0200 Subject: [PATCH 45/52] fix:add input_object init to SimpleTask init --- tests/unit/test_simpletasks_task.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/unit/test_simpletasks_task.py b/tests/unit/test_simpletasks_task.py index 7f6b8c74..8b71dbeb 100644 --- a/tests/unit/test_simpletasks_task.py +++ b/tests/unit/test_simpletasks_task.py @@ -1,3 +1,5 @@ +from pathlib import Path + import pytest from opi.input import Input @@ -273,10 +275,10 @@ def test_user_added_block_persists() -> None: @pytest.mark.unit @pytest.mark.simpletasks -def test_settings_win_over_user_block_modification() -> None: - """Settings-controlled block fields do not overwrite user modifications.""" +def test_user_modified_block_persists() -> None: + """Mutating an existing block on input_object is reflected on every subsequent access.""" task = OptTask(method="pbe", task_settings={"opt_maxiter": 50}) - task.input_object.blocks[BlockGeom].maxiter = 99 # mutate after first access + task.input_object.blocks[BlockGeom].maxiter = 99 assert task.input_object.blocks[BlockGeom].maxiter == 99 From 71ce3d46cb5952f22774481c03a0223b9a13e28a Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Wed, 17 Jun 2026 16:09:52 +0200 Subject: [PATCH 46/52] fix:remove overridden constructors in SimpleTasks and allow init of SimpleTask through Input object or string --- src/opi/simple_tasks/simple_task.py | 128 ++++++++++-------- src/opi/simple_tasks/tasks/engrad_task.py | 16 +-- src/opi/simple_tasks/tasks/freq_task.py | 16 +-- src/opi/simple_tasks/tasks/goat_task.py | 16 +-- src/opi/simple_tasks/tasks/opt_task.py | 13 -- .../simple_tasks/tasks/single_point_task.py | 16 +-- 6 files changed, 79 insertions(+), 126 deletions(-) diff --git a/src/opi/simple_tasks/simple_task.py b/src/opi/simple_tasks/simple_task.py index 095ce43f..7ed23400 100644 --- a/src/opi/simple_tasks/simple_task.py +++ b/src/opi/simple_tasks/simple_task.py @@ -62,8 +62,8 @@ class SimpleTask(ABC, typing.Generic[_RT]): """ _results_type: type[_RT] - _task_settings: TaskSettings - _method_settings: MethodSettings + _task_settings: TaskSettings | None = None + _method_settings: MethodSettings | None = None _input_object: Input def __init__( @@ -72,8 +72,9 @@ def __init__( basis_set: str | SimpleKeyword | None = None, solvation_model: str | SimpleKeyword | None = None, solvent: str | Solvent | None = None, - task_settings: "TaskSettings | dict[str, Any] | None" = None, - method_settings: "MethodSettings | dict[str, Any] | 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, ): """ Parameters @@ -102,46 +103,57 @@ def __init__( If neither ``method`` nor a ``method_settings`` object with a method is provided. """ - task_settings_type = self._get_task_settings_type() - if isinstance(task_settings, dict): - self._task_settings = task_settings_type.model_validate(task_settings) + if input: + 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: - self._task_settings = task_settings or task_settings_type() # type: ignore[call-arg] - - resolved_method_settings: MethodSettings | None = ( - MethodSettings(**method_settings) - if isinstance(method_settings, dict) - else method_settings - ) - - if method is not None: - resolved_type = MethodSettings.resolve_method_settings_type(method) - base_data: dict[str, 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: - extra: dict[str, Any] = { - **resolved_method_settings.model_dump(exclude_unset=True), - **(resolved_method_settings.model_extra or {}), + task_settings_type = self._get_task_settings_type() + if isinstance(task_settings, dict): + self._task_settings = task_settings_type.model_validate(task_settings) + else: + self._task_settings = task_settings or task_settings_type() # type: ignore[call-arg] + + resolved_method_settings: MethodSettings | None = ( + MethodSettings(**method_settings) + if isinstance(method_settings, dict) + else method_settings + ) + + if method is not None: + resolved_type = MethodSettings.resolve_method_settings_type(method) + 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 } - self._method_settings = resolved_type(**{**extra, **base_data}) + if resolved_method_settings is not None: + extra: dict[str, typing.Any] = { + **resolved_method_settings.model_dump(exclude_unset=True), + **(resolved_method_settings.model_extra or {}), + } + self._method_settings = resolved_type(**{**extra, **base_data}) + else: + self._method_settings = resolved_type(**base_data) else: - self._method_settings = resolved_type(**base_data) - else: - 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 + 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() + self._input_object: Input = Input() @classmethod def _get_task_settings_type(cls) -> type[TaskSettings]: @@ -151,14 +163,14 @@ def _get_task_settings_type(cls) -> type[TaskSettings]: return typing.cast(type[TaskSettings], task_setting_type) @property - def task_settings(self) -> TaskSettings: + def task_settings(self) -> TaskSettings | None: """Task-level settings (keyword, thresholds, flags).""" - return self._task_settings + return self._task_settings if self._task_settings else None @property - def method_settings(self) -> MethodSettings: + def method_settings(self) -> MethodSettings | None: """Method-level settings (functional, basis set, solvent, …).""" - return self._method_settings + return self._method_settings if self._method_settings else None @property def input_object(self) -> Input: @@ -177,8 +189,10 @@ def input_object(self) -> Input: The task's ``Input`` object, ready for inspection or further user customisation before calling ``run()``. """ - self._task_settings.map_to_input(self._input_object) - self.method_settings.map_to_input(self._input_object) + 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_object.setter @@ -186,14 +200,14 @@ def input_object(self, value: Input) -> None: self._input_object = value @property - def keyword(self) -> SimpleKeyword: + def keyword(self) -> SimpleKeyword | None: """The primary task keyword (e.g. ``Task.SP``, ``Task.OPT``).""" - return self._task_settings.task_keyword + 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 hasattr(self._method_settings, "method"): + if self._method_settings and hasattr(self._method_settings, "method"): return self._method_settings.method return None @@ -203,7 +217,7 @@ 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 + 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. @@ -213,6 +227,8 @@ def method(self, new_value: str | SimpleKeyword | None) -> None: 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: @@ -244,7 +260,7 @@ def method(self, new_value: str | SimpleKeyword | None) -> None: @property def basis_set(self) -> SimpleKeyword | None: """Active basis-set keyword, or ``None`` if the settings type has no basis_set field.""" - if hasattr(self._method_settings, "basis_set"): + if self._method_settings and hasattr(self._method_settings, "basis_set"): return self._method_settings.basis_set return None @@ -256,6 +272,8 @@ def basis_set(self, new_value: str | SimpleKeyword | None) -> None: 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 @@ -263,7 +281,7 @@ def basis_set(self, new_value: str | SimpleKeyword | None) -> None: @property def solvent(self) -> str | None: """Solvent name, or ``None`` if the settings type has no solvent field.""" - if hasattr(self._method_settings, "solvent"): + if self._method_settings and hasattr(self._method_settings, "solvent"): return self._method_settings.solvent return None @@ -275,6 +293,8 @@ def solvent(self, new_value: str | None) -> None: 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 @@ -282,7 +302,7 @@ def solvent(self, new_value: str | None) -> None: @property def solvation_model(self) -> SimpleKeyword | None: """Solvation-model keyword, or ``None`` if the settings type has no solvation_model field.""" - if hasattr(self._method_settings, "solvation_model"): + if self._method_settings and hasattr(self._method_settings, "solvation_model"): return self._method_settings.solvation_model return None @@ -294,6 +314,8 @@ def solvation_model(self, new_value: str | SimpleKeyword | None) -> None: 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 diff --git a/src/opi/simple_tasks/tasks/engrad_task.py b/src/opi/simple_tasks/tasks/engrad_task.py index 0e6db353..4e358162 100644 --- a/src/opi/simple_tasks/tasks/engrad_task.py +++ b/src/opi/simple_tasks/tasks/engrad_task.py @@ -1,7 +1,6 @@ import typing -from opi.input.simple_keywords import SimpleKeyword, Solvent, Task -from opi.simple_tasks.method_settings import MethodSettings +from opi.input.simple_keywords import SimpleKeyword, Task from opi.simple_tasks.simple_task import SimpleTask, TaskResults, TaskSettings @@ -48,16 +47,3 @@ class EngradTask(SimpleTask[EngradResults]): _task_settings: EngradSettings _results_type = EngradResults - - 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: EngradSettings | None = None, - method_settings: MethodSettings | None = None, - ): - super().__init__( - method, basis_set, solvation_model, solvent, task_settings, method_settings - ) diff --git a/src/opi/simple_tasks/tasks/freq_task.py b/src/opi/simple_tasks/tasks/freq_task.py index 1c36eab3..6338b88c 100644 --- a/src/opi/simple_tasks/tasks/freq_task.py +++ b/src/opi/simple_tasks/tasks/freq_task.py @@ -1,7 +1,6 @@ import typing -from opi.input.simple_keywords import SimpleKeyword, Solvent, Task -from opi.simple_tasks.method_settings import MethodSettings +from opi.input.simple_keywords import SimpleKeyword, Task from opi.simple_tasks.simple_task import SimpleTask, TaskResults, TaskSettings @@ -54,16 +53,3 @@ class FreqTask(SimpleTask[FreqResults]): _task_settings: FreqSettings _results_type = FreqResults - - 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: FreqSettings | None = None, - method_settings: MethodSettings | None = None, - ): - super().__init__( - method, basis_set, solvation_model, solvent, task_settings, method_settings - ) diff --git a/src/opi/simple_tasks/tasks/goat_task.py b/src/opi/simple_tasks/tasks/goat_task.py index 76d88c8e..acddb6ff 100644 --- a/src/opi/simple_tasks/tasks/goat_task.py +++ b/src/opi/simple_tasks/tasks/goat_task.py @@ -1,9 +1,8 @@ import typing from opi.input import Input -from opi.input.simple_keywords import Goat, SimpleKeyword, Solvent +from opi.input.simple_keywords import Goat, SimpleKeyword from opi.input.structures import Properties, Structure -from opi.simple_tasks.method_settings import MethodSettings from opi.simple_tasks.simple_task import SimpleTask, TaskResults, TaskSettings @@ -94,16 +93,3 @@ class GoatTask(SimpleTask[GoatResults]): _task_settings: GoatSettings _results_type = GoatResults - - 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: GoatSettings | None = None, - method_settings: MethodSettings | None = None, - ): - super().__init__( - method, basis_set, solvation_model, solvent, task_settings, method_settings - ) diff --git a/src/opi/simple_tasks/tasks/opt_task.py b/src/opi/simple_tasks/tasks/opt_task.py index cbd72468..b504cf63 100644 --- a/src/opi/simple_tasks/tasks/opt_task.py +++ b/src/opi/simple_tasks/tasks/opt_task.py @@ -100,19 +100,6 @@ class OptTask(SimpleTask[OptResults]): _task_settings: OptSettings _results_type = OptResults - 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: OptSettings | None = None, - method_settings: MethodSettings | None = None, - ): - super().__init__( - method, basis_set, solvation_model, solvent, task_settings, method_settings - ) - @property def opt_threshold(self) -> SimpleKeyword | None: """Convergence threshold keyword (e.g. ``OptThreshold.TIGHTOPT``).""" diff --git a/src/opi/simple_tasks/tasks/single_point_task.py b/src/opi/simple_tasks/tasks/single_point_task.py index 9adb046c..12ab9899 100644 --- a/src/opi/simple_tasks/tasks/single_point_task.py +++ b/src/opi/simple_tasks/tasks/single_point_task.py @@ -1,7 +1,6 @@ import typing -from opi.input.simple_keywords import SimpleKeyword, Solvent, Task -from opi.simple_tasks.method_settings import MethodSettings +from opi.input.simple_keywords import SimpleKeyword, Task from opi.simple_tasks.simple_task import SimpleTask, TaskResults, TaskSettings @@ -32,16 +31,3 @@ class SinglePointTask(SimpleTask[SinglePointResults]): _task_settings: SinglePointSettings _results_type = SinglePointResults - - 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: SinglePointSettings | None = None, - method_settings: MethodSettings | None = None, - ): - super().__init__( - method, basis_set, solvation_model, solvent, task_settings, method_settings - ) From 92a1c81bce47d85eb332337701b1388ca2b6cca8 Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Wed, 24 Jun 2026 13:57:39 +0200 Subject: [PATCH 47/52] fix: remove unused imports --- src/opi/simple_tasks/tasks/opt_task.py | 3 +-- tests/unit/test_simpletasks_task.py | 2 -- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/opi/simple_tasks/tasks/opt_task.py b/src/opi/simple_tasks/tasks/opt_task.py index b504cf63..00d67c86 100644 --- a/src/opi/simple_tasks/tasks/opt_task.py +++ b/src/opi/simple_tasks/tasks/opt_task.py @@ -1,10 +1,9 @@ import typing from opi.input import Input -from opi.input.simple_keywords import SimpleKeyword, Solvent, Task +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 import MethodSettings from opi.simple_tasks.simple_task import SimpleTask, TaskResults, TaskSettings diff --git a/tests/unit/test_simpletasks_task.py b/tests/unit/test_simpletasks_task.py index 8b71dbeb..6f57c9b2 100644 --- a/tests/unit/test_simpletasks_task.py +++ b/tests/unit/test_simpletasks_task.py @@ -1,5 +1,3 @@ -from pathlib import Path - import pytest from opi.input import Input From 6bbbca1d0b2f63872a88137109ce293de8d851fb Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Wed, 24 Jun 2026 14:13:05 +0200 Subject: [PATCH 48/52] fix: change leftoover cached properties into properties and update code for fetching existing block --- src/opi/simple_tasks/settings.py | 2 +- src/opi/simple_tasks/simple_task.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/opi/simple_tasks/settings.py b/src/opi/simple_tasks/settings.py index ce5e0ed0..1b503514 100644 --- a/src/opi/simple_tasks/settings.py +++ b/src/opi/simple_tasks/settings.py @@ -99,7 +99,7 @@ def map_to_input(self, input_object: Input) -> Input: input_object.add_blocks(block_instant) else: # Merge with any existing block of this type so no other attributes are lost - existing_block = next(iter(input_object.get_blocks(block_type).values())) + existing_block = input_object.get_blocks(block_type)[block_type] new_block = block_instant | existing_block input_object.add_blocks(new_block, overwrite=True) diff --git a/src/opi/simple_tasks/simple_task.py b/src/opi/simple_tasks/simple_task.py index 7ed23400..0ffee259 100644 --- a/src/opi/simple_tasks/simple_task.py +++ b/src/opi/simple_tasks/simple_task.py @@ -598,7 +598,7 @@ def __init__(self, calc_object: Calculator): """ self.calc_object = calc_object - @cached_property + @property def output(self) -> Output: """ Parsed ORCA output. @@ -614,18 +614,18 @@ def output(self) -> Output: out.parse() return out - @cached_property + @property def status(self) -> bool: """``True`` if the job terminated normally and SCF converged.""" return self.output.terminated_normally() - @cached_property + @property @abstractmethod def primary_property(self) -> Any: """The most important result for this task type (energy, structure, …).""" pass - @cached_property + @property def final_energy(self) -> float: """The final energy of the calculation. From c66d29cde1cbf679c1bccd8668b698d553d0f590 Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Wed, 24 Jun 2026 16:26:47 +0200 Subject: [PATCH 49/52] fix: add documentation to functions in `SimpleTask` class --- src/opi/simple_tasks/simple_task.py | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/src/opi/simple_tasks/simple_task.py b/src/opi/simple_tasks/simple_task.py index 0ffee259..ab9e0a20 100644 --- a/src/opi/simple_tasks/simple_task.py +++ b/src/opi/simple_tasks/simple_task.py @@ -2,7 +2,6 @@ import typing import warnings from abc import ABC, abstractmethod -from functools import cached_property from pathlib import Path from typing import Any, get_type_hints @@ -34,7 +33,7 @@ class TaskSettings(Settings): model_config = ConfigDict( arbitrary_types_allowed=True, validate_assignment=True, extra="forbid" ) - task_keyword: typing.Annotated[SimpleKeyword, SimpleKeywordBox] + task_keyword: typing.Annotated[SimpleKeyword, SimpleKeywordBox] | None = None _RT = typing.TypeVar("_RT", bound="TaskResults") @@ -77,6 +76,15 @@ def __init__( 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 @@ -104,6 +112,7 @@ def __init__( 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") @@ -114,11 +123,15 @@ def __init__( 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: - self._task_settings = task_settings or task_settings_type() # type: ignore[call-arg] + # 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) @@ -127,7 +140,9 @@ def __init__( ) 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 { @@ -139,14 +154,20 @@ def __init__( 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" @@ -232,10 +253,12 @@ def method(self, new_value: str | SimpleKeyword | None) -> None: 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} From 8e568b9499585c275ec903387b30b111b286ab06 Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Wed, 24 Jun 2026 16:28:29 +0200 Subject: [PATCH 50/52] fix: add documentation for task settings type getter --- src/opi/simple_tasks/simple_task.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/opi/simple_tasks/simple_task.py b/src/opi/simple_tasks/simple_task.py index ab9e0a20..df5a47f9 100644 --- a/src/opi/simple_tasks/simple_task.py +++ b/src/opi/simple_tasks/simple_task.py @@ -178,6 +178,7 @@ def __init__( @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"] From fc201cbe5661612ee831ab31374b92356a6dd9b8 Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Tue, 7 Jul 2026 22:02:06 +0200 Subject: [PATCH 51/52] fix: rename `input_object` to `input` and `calc_object` to `calculator` --- .../exmp057_singlepoint_simpletask/job.py | 2 +- src/opi/simple_tasks/simple_task.py | 21 ++--- tests/unit/test_simpletasks_from_string.py | 12 +-- tests/unit/test_simpletasks_task.py | 82 +++++++++---------- 4 files changed, 59 insertions(+), 58 deletions(-) diff --git a/examples/exmp057_singlepoint_simpletask/job.py b/examples/exmp057_singlepoint_simpletask/job.py index 9decf117..6593eb3b 100644 --- a/examples/exmp057_singlepoint_simpletask/job.py +++ b/examples/exmp057_singlepoint_simpletask/job.py @@ -21,7 +21,7 @@ def run_exmp057( # > 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_object.add_simple_keywords(AtomicCharge.HIRSHFELD) + 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) diff --git a/src/opi/simple_tasks/simple_task.py b/src/opi/simple_tasks/simple_task.py index df5a47f9..65c8883d 100644 --- a/src/opi/simple_tasks/simple_task.py +++ b/src/opi/simple_tasks/simple_task.py @@ -1,3 +1,4 @@ +import os import shutil import typing import warnings @@ -195,7 +196,7 @@ def method_settings(self) -> MethodSettings | None: return self._method_settings if self._method_settings else None @property - def input_object(self) -> Input: + def input(self) -> Input: """ Returns the ``Input`` object for this task, with ``task_settings`` and ``method_settings`` applied on top of any user modifications. @@ -217,8 +218,8 @@ def input_object(self) -> Input: return self._input_object - @input_object.setter - def input_object(self, value: Input) -> None: + @input.setter + def input(self, value: Input) -> None: self._input_object = value @property @@ -498,7 +499,7 @@ def run( shutil.rmtree(working_dir) working_dir.mkdir() - inp = self.input_object + inp = self.input if ncores is not None: inp.ncores = ncores @@ -580,7 +581,7 @@ def restart( If ``use_previous_orbitals=True`` and the ``.gbw`` file from the previous run is missing. """ - prev_calc = previous_results.calc_object + prev_calc = previous_results.calculator basename = basename if basename else prev_calc.basename struct = struct if struct else prev_calc.structure @@ -613,14 +614,14 @@ class TaskResults(ABC): ``primary_property``. """ - def __init__(self, calc_object: Calculator): + def __init__(self, calculator: Calculator): """ Parameters ---------- - calc_object : Calculator + calculator : Calculator The calculator that ran the calculation. """ - self.calc_object = calc_object + self.calculator = calculator @property def output(self) -> Output: @@ -631,10 +632,10 @@ def output(self) -> Output: that result objects can be created without immediately hitting the filesystem. """ - if not self.calc_object: + if not self.calculator: raise ValueError("calc_object not set") - out = self.calc_object.get_output() + out = self.calculator.get_output() out.parse() return out diff --git a/tests/unit/test_simpletasks_from_string.py b/tests/unit/test_simpletasks_from_string.py index 02d4e4a9..2042daf5 100644 --- a/tests/unit/test_simpletasks_from_string.py +++ b/tests/unit/test_simpletasks_from_string.py @@ -61,7 +61,7 @@ def test_from_string_keywords_without_exclamation(tmp_path: Path) -> None: basename="test", working_dir=tmp_path / "RUN", ) - inp = result.calc_object.input + inp = result.calculator.input assert inp.has_simple_keywords(SimpleKeyword("B3LYP"), SimpleKeyword("def2-SVP")) == ( True, True, @@ -78,7 +78,7 @@ def test_from_string_strips_exclamation_prefix(tmp_path: Path) -> None: basename="test", working_dir=tmp_path / "RUN", ) - inp = result.calc_object.input + inp = result.calculator.input assert inp.has_simple_keywords(SimpleKeyword("B3LYP"), SimpleKeyword("def2-SVP")) == ( True, True, @@ -130,7 +130,7 @@ def test_from_string_sets_ncores(tmp_path: Path) -> None: result = SinglePointTask.from_string( "B3LYP", basename="test", working_dir=tmp_path / "RUN", ncores=4 ) - assert result.calc_object.input.ncores == 4 + assert result.calculator.input.ncores == 4 @pytest.mark.unit @@ -141,7 +141,7 @@ def test_from_string_sets_memory(tmp_path: Path) -> None: result = SinglePointTask.from_string( "B3LYP", basename="test", working_dir=tmp_path / "RUN", memory=2000 ) - assert result.calc_object.input.memory == 2000 + assert result.calculator.input.memory == 2000 @pytest.mark.unit @@ -157,7 +157,7 @@ def test_from_string_sets_moinp(tmp_path: Path) -> None: working_dir=tmp_path / "RUN", moinp=mo_file, ) - assert result.calc_object.input.moinp == mo_file + assert result.calculator.input.moinp == mo_file @pytest.mark.unit @@ -177,4 +177,4 @@ def test_from_string_assigns_structure(tmp_path: Path) -> None: working_dir=tmp_path / "RUN", structure=h2, ) - assert result.calc_object.structure is h2 + assert result.calculator.structure is h2 diff --git a/tests/unit/test_simpletasks_task.py b/tests/unit/test_simpletasks_task.py index 6f57c9b2..72b92eaf 100644 --- a/tests/unit/test_simpletasks_task.py +++ b/tests/unit/test_simpletasks_task.py @@ -19,7 +19,7 @@ """ Unit tests for SimpleTask subclasses and TaskSettings: - Constructor argument validation (method dispatch, dict args, no-method error) -- input_object builds correct simple keywords for each task type +- 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 @@ -73,7 +73,7 @@ def test_simple_task_dict_task_settings() -> None: # --------------------------------------------------------------------------- -# input_object — task keyword +# input — task keyword # --------------------------------------------------------------------------- @@ -89,17 +89,17 @@ def test_simple_task_dict_task_settings() -> None: (GoatTask, Goat.GOAT), ], ) -def test_input_object_has_task_keyword(task_cls: type, expected_kw: SimpleKeyword) -> None: - """input_object contains the primary task keyword for each task type.""" - inp = task_cls(method="pbe").input_object +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_object_has_method_and_basis_set() -> None: - """input_object contains the task keyword, method, and basis-set keywords.""" - inp = SinglePointTask(method="pbe", basis_set="def2-svp").input_object +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) @@ -237,101 +237,101 @@ def test_basis_set_getter_and_setter() -> None: # --------------------------------------------------------------------------- -# input_object caching +# input caching # --------------------------------------------------------------------------- @pytest.mark.unit @pytest.mark.simpletasks -def test_input_object_is_cached() -> None: - """Accessing input_object twice returns the same object instance.""" +def test_input_is_cached() -> None: + """Accessing input twice returns the same object instance.""" task = SinglePointTask(method="pbe") - assert task.input_object is task.input_object + 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_object is visible on every subsequent access.""" + """A keyword added directly to input is visible on every subsequent access.""" task = SinglePointTask(method="pbe") kw = SimpleKeyword("NORI") - task.input_object.add_simple_keywords(kw) - assert task.input_object.has_simple_keywords(kw) == (True,) - assert task.input_object.has_simple_keywords(kw) == (True,) + 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_object is visible on every subsequent access.""" + """A block added directly to input is visible on every subsequent access.""" task = SinglePointTask(method="pbe") block = BlockScf(maxiter=500) - task.input_object.add_blocks(block) - assert task.input_object.has_blocks(BlockScf()) == (True,) - assert task.input_object.blocks[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_object is reflected on every subsequent access.""" + """Mutating an existing block on input is reflected on every subsequent access.""" task = OptTask(method="pbe", task_settings={"opt_maxiter": 50}) - task.input_object.blocks[BlockGeom].maxiter = 99 - assert task.input_object.blocks[BlockGeom].maxiter == 99 + 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_object is re-added on the next access.""" + """A method-controlled keyword removed from input is re-added on the next access.""" task = SinglePointTask(method="pbe", basis_set="def2-svp") - task.input_object.remove_simple_keywords(BasisSet.DEF2_SVP) - assert task.input_object.has_simple_keywords(BasisSet.DEF2_SVP) == (True,) + 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_object is preserved across accesses.""" + """Setting ncores on input is preserved across accesses.""" task = SinglePointTask(method="pbe") - task.input_object.ncores = 8 - assert task.input_object.ncores == 8 + 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_object is preserved across accesses.""" + """Setting memory on input is preserved across accesses.""" task = SinglePointTask(method="pbe") - task.input_object.memory = 4096 - assert task.input_object.memory == 4096 + 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_object is preserved across accesses.""" + """An arbitrary string added to input is preserved across accesses.""" task = SinglePointTask(method="pbe") - task.input_object.add_arbitrary_string( + task.input.add_arbitrary_string( "% some custom block\nend", pos=ArbitraryStringPos.BOTTOM ) - assert len(task.input_object.arbitrary_strings) == 1 - assert len(task.input_object.arbitrary_strings) == 1 + 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_object all survive repeated accesses.""" + """Multiple independent modifications to input all survive repeated accesses.""" task = SinglePointTask(method="pbe") kw = SimpleKeyword("RIJCOSX") block = BlockScf(maxiter=300) - task.input_object.add_simple_keywords(kw) - task.input_object.add_blocks(block) - task.input_object.ncores = 4 + task.input.add_simple_keywords(kw) + task.input.add_blocks(block) + task.input.ncores = 4 - inp = task.input_object + inp = task.input assert inp.has_simple_keywords(kw) == (True,) assert inp.has_blocks(BlockScf()) == (True,) assert inp.blocks[BlockScf].maxiter == 300 From a8a52fec2a27eebc3047dd70aa14a5b2edf760b7 Mon Sep 17 00:00:00 2001 From: Nakul Santhosh Date: Wed, 8 Jul 2026 11:13:50 +0200 Subject: [PATCH 52/52] fix: change working_dir type annotations to include strings --- src/opi/simple_tasks/simple_task.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/opi/simple_tasks/simple_task.py b/src/opi/simple_tasks/simple_task.py index 65c8883d..0678a028 100644 --- a/src/opi/simple_tasks/simple_task.py +++ b/src/opi/simple_tasks/simple_task.py @@ -435,13 +435,13 @@ def from_string( calc.write_and_run() - return cls._results_type(calc_object=calc) + return cls._results_type(calculator=calc) def run( self, basename: str, - struct: Structure | BaseStructureFile, - working_dir: Path = Path("RUN"), + structure: Structure | BaseStructureFile, + working_dir: Path | str | os.PathLike[str] = Path("RUN"), ncores: int | None = None, memory: int | None = None, moinp: Path | None = None, @@ -458,7 +458,7 @@ def run( ---------- basename : str Base name for the calculation. - struct : Structure or BaseStructureFile + structure : Structure or BaseStructureFile The input structure for the calculation. working_dir : pathlib.Path, optional Directory in which the calculation will be executed. @@ -480,6 +480,8 @@ def run( 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(): @@ -511,18 +513,18 @@ def run( inp.moinp = moinp calc = Calculator(basename, working_dir=working_dir) - calc.structure = struct + calc.structure = structure calc.input = inp calc.write_and_run() - return self._results_type(calc_object=calc) + return self._results_type(calculator=calc) def restart( self, previous_results: "TaskResults", basename: str | None = None, - struct: Structure | BaseStructureFile | None = None, + structure: Structure | BaseStructureFile | None = None, working_dir: Path | None = None, ncores: int | None = None, memory: int | None = None, @@ -547,7 +549,7 @@ def restart( basename : str, optional Job name for the new calculation. Defaults to the basename of the previous run. - struct : Structure or BaseStructureFile, optional + 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 @@ -584,7 +586,7 @@ def restart( prev_calc = previous_results.calculator basename = basename if basename else prev_calc.basename - struct = struct if struct else prev_calc.structure + 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" @@ -633,7 +635,7 @@ def output(self) -> Output: filesystem. """ if not self.calculator: - raise ValueError("calc_object not set") + raise ValueError("calculator not set") out = self.calculator.get_output() out.parse()