Skip to content
Merged
2 changes: 1 addition & 1 deletion src/mindlessgen/generator/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,7 @@ def setup_engines(
raise ImportError("orca not found.")
except ImportError as e:
raise ImportError("orca not found.") from e
return ORCA(path, cfg.orca)
return ORCA(path, cfg.orca, cfg.xtb)
elif engine_type == "turbomole":
try:
jobex_path = jobex_path_func(cfg.turbomole.jobex_path)
Expand Down
148 changes: 134 additions & 14 deletions src/mindlessgen/qm/orca.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,19 @@
from tempfile import TemporaryDirectory

from ..molecules import Molecule
from ..prog import ORCAConfig
from ..prog import ORCAConfig, XTBConfig
from .base import QMMethod
from .xtb import XTB, get_xtb_path


class ORCA(QMMethod):
"""
This class handles all interaction with the ORCA external dependency.
"""

def __init__(self, path: str | Path, orcacfg: ORCAConfig) -> None:
def __init__(
self, path: str | Path, orcacfg: ORCAConfig, xtb_config: XTBConfig | None = None
) -> None:
"""
Initialize the ORCA class.
"""
Expand All @@ -28,6 +31,7 @@ def __init__(self, path: str | Path, orcacfg: ORCAConfig) -> None:
else:
raise TypeError("orca_path should be a string or a Path object.")
self.cfg = orcacfg
self.xtb_cfg = xtb_config
# must be explicitly initialized in current parallelization implementation
# as accessing parent class variables might not be possible
self.tmp_dir = self.__class__.get_temporary_directory()
Expand All @@ -51,11 +55,22 @@ def optimize(
# NOTE: "prefix" and "dir" are valid keyword arguments for TemporaryDirectory
temp_path = Path(temp_dir).resolve()
# write the molecule to a temporary file
molecule.write_xyz_to_file(temp_path / "molecule.xyz")
xyz_filename = "molecule.xyz"
molecule.write_xyz_to_file(temp_path / xyz_filename)

inputname = "orca_opt.inp"
use_xtb_driver = self._should_use_xtb_driver()
xtb_input = temp_path / "xtb.inp"
if use_xtb_driver:
self._write_xtb_input(molecule, xtb_input, inputname)
orca_input = self._gen_input(
molecule, "molecule.xyz", ncores, True, max_cycles
molecule,
xyz_filename,
temp_path,
ncores,
True,
max_cycles,
use_xtb_driver=use_xtb_driver,
)
if verbosity > 1:
print("ORCA input file:\n##################")
Expand All @@ -65,13 +80,20 @@ def optimize(
f.write(orca_input)

# run orca
arguments = [
inputname,
]

orca_log_out, orca_log_err, return_code = self._run(
temp_path=temp_path, arguments=arguments
)
if use_xtb_driver:
orca_log_out, orca_log_err, return_code = self._run_xtb_driver(
temp_path=temp_path,
geometry_filename=xyz_filename,
xcontrol_name=xtb_input.name,
ncores=ncores,
)
else:
arguments = [
inputname,
]
orca_log_out, orca_log_err, return_code = self._run(
temp_path=temp_path, arguments=arguments
)
if verbosity > 2:
print(orca_log_out)
if return_code != 0:
Expand All @@ -80,7 +102,14 @@ def optimize(
)

# read the optimized molecule from the output file
xyzfile = Path(temp_path / inputname).resolve().with_suffix(".xyz")
if use_xtb_driver:
xyzfile = temp_path / "xtbopt.xyz"
if not xyzfile.exists():
raise RuntimeError(
"xTB-driven ORCA optimization did not produce 'xtbopt.xyz'."
)
else:
xyzfile = Path(temp_path / inputname).resolve().with_suffix(".xyz")
optimized_molecule = molecule.copy()
optimized_molecule.read_xyz_from_file(xyzfile)
return optimized_molecule
Expand All @@ -103,10 +132,10 @@ def singlepoint(self, molecule: Molecule, ncores: int, verbosity: int = 1) -> st

# write the input file
inputname = "orca.inp"
orca_input = self._gen_input(molecule, molfile, ncores)
orca_input = self._gen_input(molecule, molfile, temp_path, ncores)
if verbosity > 1:
print("ORCA input file:\n##################")
print(self._gen_input(molecule, molfile, ncores))
print(self._gen_input(molecule, molfile, temp_path, ncores))
print("##################")
with open(temp_path / inputname, "w", encoding="utf8") as f:
f.write(orca_input)
Expand Down Expand Up @@ -170,13 +199,102 @@ def _run(self, temp_path: Path, arguments: list[str]) -> tuple[str, str, int]:
orca_log_err = e.stderr.decode("utf8", errors="replace")
return orca_log_out, orca_log_err, e.returncode

def _run_xtb_driver(
self,
temp_path: Path,
geometry_filename: str,
xcontrol_name: str,
ncores: int,
) -> tuple[str, str, int]:
"""
Run the optimization through the xTB external driver when constraints are requested.
"""
xtb_executable = self._get_xtb_executable()
arguments = [
str(xtb_executable),
geometry_filename,
"--opt",
]
opt_level = getattr(self.cfg, "optlevel", None)
if opt_level not in (None, ""):
arguments.append(str(opt_level))
arguments.extend(["--orca", "-I", xcontrol_name])
try:
xtb_out = sp.run(
arguments,
cwd=temp_path,
capture_output=True,
check=True,
)
xtb_log_out = xtb_out.stdout.decode("utf8", errors="replace")
xtb_log_err = xtb_out.stderr.decode("utf8", errors="replace")
return xtb_log_out, xtb_log_err, 0
except sp.CalledProcessError as e:
xtb_log_out = e.stdout.decode("utf8", errors="replace")
xtb_log_err = e.stderr.decode("utf8", errors="replace")
return xtb_log_out, xtb_log_err, e.returncode
Comment thread
jonathan-schoeps marked this conversation as resolved.
Outdated

def _get_xtb_executable(self) -> Path:
"""
Determine the path to the xTB executable for external ORCA optimizations.
"""
for attr_name in ("xtb_driver_path", "xtb_path"):
Comment thread
jonathan-schoeps marked this conversation as resolved.
Outdated
candidate = getattr(self.cfg, attr_name, None)
if candidate:
try:
return get_xtb_path(candidate)
except ImportError as exc:
raise RuntimeError(
f"xTB executable defined via '{attr_name}' could not be found."
) from exc
try:
return get_xtb_path(None)
except ImportError as exc:
raise RuntimeError(
"xTB executable not found. Required for constrained ORCA optimizations."
) from exc

def _should_use_xtb_driver(self) -> bool:
"""
Determine if the xTB external driver should be used (constraints configured).
"""
return bool(self.xtb_cfg and self.xtb_cfg.distance_constraints)
Comment thread
jonathan-schoeps marked this conversation as resolved.
Outdated

def _write_xtb_input(
self, molecule: Molecule, xtb_input: Path, input_file: str
) -> None:
"""
Write the xcontrol file containing constraints and ORCA driver info.
"""
if not self.xtb_cfg:
raise RuntimeError(
"xTB configuration missing but constraints were requested."
)
xtb_path = self._get_xtb_executable()
xtb_writer = XTB(xtb_path, self.xtb_cfg)
generated = xtb_writer._prepare_distance_constraint_file(
molecule, xtb_input.parent
)
if not generated:
raise RuntimeError(
"xTB driver requested but no distance constraints were generated."
)
with xtb_input.open("a", encoding="utf8") as handle:
handle.write("$external\n")
handle.write(f" orca input file= {input_file}\n")
handle.write(f" orca bin= {self.path}\n")
handle.write("$end\n")

def _gen_input(
self,
molecule: Molecule,
xyzfile: str,
temp_path: Path,
ncores: int,
optimization: bool = False,
opt_cycles: int | None = None,
*,
use_xtb_driver: bool = False,
) -> str:
"""
Generate a default input file for ORCA.
Expand All @@ -185,6 +303,8 @@ def _gen_input(
orca_input += f"! DEFGRID{self.cfg.gridsize}\n"
orca_input += "! MiniPrint\n"
orca_input += "! NoTRAH\n"
if use_xtb_driver:
orca_input += "! Engrad\n"
# "! AutoAux" keyword for super-heavy elements as def2/J ends at Rn
if any(atom >= 86 for atom in molecule.ati):
orca_input += "! AutoAux\n"
Expand Down
153 changes: 153 additions & 0 deletions test/test_qm/test_orca.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import subprocess as sp
from pathlib import Path
from types import SimpleNamespace
import pytest
from mindlessgen.qm.orca import ORCA


class DummyORCAConfig(SimpleNamespace):
Comment thread
jonathan-schoeps marked this conversation as resolved.
def __init__(self, **kwargs):
defaults = dict(
functional="B3LYP",
basis="def2-SVP",
gridsize=2,
scf_cycles=50,
optlevel="",
xtb_driver_path=None,
xtb_path=None,
)
defaults.update(kwargs)
super().__init__(**defaults)


class DummyXTBConfig(SimpleNamespace):
def __init__(self, **kwargs):
defaults = dict(
distance_constraints=None, distance_constraint_force_constant=None
)
defaults.update(kwargs)
super().__init__(**defaults)


class DummyMolecule:
ati = [1, 1, 6, 8]


def make_orca(cfg=None, xtb_cfg=None):
cfg = cfg or DummyORCAConfig()
return ORCA(path="/usr/bin/orca", orcacfg=cfg, xtb_config=xtb_cfg)


def test_run_xtb_driver_success(monkeypatch, tmp_path):
orca = make_orca(cfg=DummyORCAConfig(optlevel="tight"))
monkeypatch.setattr(orca, "_get_xtb_executable", lambda: Path("/fake/xtb"))
captured = {}

def fake_run(args, cwd, capture_output, check):
captured["args"] = args
assert cwd == tmp_path
assert capture_output and check
return SimpleNamespace(stdout=b"ok", stderr=b"")

monkeypatch.setattr(sp, "run", fake_run)
out, err, code = orca._run_xtb_driver(tmp_path, "geom.xyz", "ctrl.inp", ncores=4)
assert captured["args"] == [
str(Path("/fake/xtb")),
"geom.xyz",
"--opt",
"tight",
"--orca",
"-I",
"ctrl.inp",
]
assert out == "ok"
assert err == ""
assert code == 0


def test_run_xtb_driver_failure_returns_error(monkeypatch, tmp_path):
"""Ensure the ORCA wrapper surfaces errors from the xTB driver."""
orca = make_orca()
monkeypatch.setattr(orca, "_get_xtb_executable", lambda: Path("/fake/xtb"))

def fake_run(*_, **kwargs):
del kwargs
raise sp.CalledProcessError(1, "xtb", output=b"bad", stderr=b"worse")

monkeypatch.setattr(sp, "run", fake_run)
out, err, code = orca._run_xtb_driver( # pylint: disable=protected-access
tmp_path, "geom.xyz", "ctrl.inp", ncores=1
)
assert (out, err, code) == ("bad", "worse", 1)


def test_get_xtb_executable_prefers_configured_path(monkeypatch):
cfg = DummyORCAConfig(xtb_driver_path="custom_xtb")
orca = make_orca(cfg=cfg)
called = {}

def fake_get_xtb_path(candidate):
called["candidate"] = candidate
return Path("/resolved/xtb")

monkeypatch.setattr("mindlessgen.qm.orca.get_xtb_path", fake_get_xtb_path)
assert orca._get_xtb_executable() == Path("/resolved/xtb")
assert called["candidate"] == "custom_xtb"


def test_get_xtb_executable_raises_when_missing(monkeypatch):
orca = make_orca()

def fake_get_xtb_path(candidate):
raise ImportError("not found")

monkeypatch.setattr("mindlessgen.qm.orca.get_xtb_path", fake_get_xtb_path)
with pytest.raises(RuntimeError, match="xTB executable not found"):
orca._get_xtb_executable()


def test_should_use_xtb_driver_checks_distance_constraints():
orca = make_orca(xtb_cfg=DummyXTBConfig(distance_constraints=[object()]))
assert orca._should_use_xtb_driver() is True
orca_no_constraints = make_orca(xtb_cfg=DummyXTBConfig(distance_constraints=[]))
assert orca_no_constraints._should_use_xtb_driver() is False


def test_write_xtb_input_creates_expected_file(monkeypatch, tmp_path):
xtb_cfg = DummyXTBConfig(
distance_constraints=["dummy"], distance_constraint_force_constant=0.7
)
orca = make_orca(xtb_cfg=xtb_cfg)
monkeypatch.setattr(orca, "_get_xtb_executable", lambda: Path("/fake/xtb"))

def fake_prepare(self, molecule, temp_dir):
assert temp_dir == tmp_path
(temp_dir / "xtb.inp").write_text(
"\n".join(
[
"$constrain",
" force constant= 0.7",
" distance: 1, 2, 1.00000",
"$end",
"",
]
),
encoding="utf8",
)
return True

monkeypatch.setattr(
"mindlessgen.qm.orca.XTB._prepare_distance_constraint_file", fake_prepare
)
target = tmp_path / "xtb.inp"
orca._write_xtb_input(DummyMolecule(), target, "orca.inp")
content = target.read_text().splitlines()
assert content[:4] == [
"$constrain",
" force constant= 0.7",
" distance: 1, 2, 1.00000",
"$end",
]
assert "$external" in content
assert " orca input file= orca.inp" in content
assert f" orca bin= {orca.path}" in content