Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add AIMNET2 harness #443

Merged
merged 12 commits into from
May 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ jobs:
runs-on: ubuntu-latest
pytest: ""

- conda-env: aimnet2
python-version: 3.11
label: AIMNET2
runs-on: ubuntu-latest
pytest: ""

name: "🐍 ${{ matrix.cfg.python-version }} • ${{ matrix.cfg.label }} • ${{ matrix.cfg.runs-on }}"
runs-on: ${{ matrix.cfg.runs-on }}

Expand Down
19 changes: 19 additions & 0 deletions devtools/conda-envs/aimnet2.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
name: test
channels:
- conda-forge
dependencies:

# Core
- python
- pip
- pyyaml
- py-cpuinfo
- psutil
- qcelemental >=0.12.0
- pydantic>=1.0.0

# Testing
- pytest
- pytest-cov
- codecov
- pyaimnet2
122 changes: 122 additions & 0 deletions qcengine/programs/aimnet2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
from typing import TYPE_CHECKING, Dict
from qcengine.programs.model import ProgramHarness
from qcelemental.util import safe_version, which_import
from qcelemental.models import AtomicResult, Provenance
from qcengine.exceptions import InputError

if TYPE_CHECKING:
from qcelemental.models import AtomicInput
from qcengine.config import TaskConfig


class AIMNET2Harness(ProgramHarness):
"""A harness to run AIMNET2 models <https://github.com/isayevlab/AIMNet2>"""

_CACHE = {}

_defaults = {
"name": "AIMNET2",
"scratch": False,
"thread_safe": True,
"thread_parallel": False,
"node_parallel": False,
"managed_memory": False,
}

version_cache: Dict[str, str] = {}

@staticmethod
def found(raise_error: bool = False) -> bool:
return which_import(
"pyaimnet2",
return_bool=True,
raise_error=raise_error,
raise_msg="Please install via `pip install git+https://github.com/jthorton/AIMNet2.git@main`",
)

def get_version(self) -> str:
self.found(raise_error=True)

which_prog = which_import("pyaimnet2")
if which_prog not in self.version_cache:
import pyaimnet2

self.version_cache[which_prog] = safe_version(pyaimnet2.__version__)

return self.version_cache[which_prog]

def load_model(self, name: str):
model_name = name.lower()
if model_name in self._CACHE:
return self._CACHE[model_name]

from pyaimnet2 import load_model

model = load_model(model_name=model_name)
self._CACHE[model_name] = model
return self._CACHE[model_name]

def compute(self, input_data: "AtomicInput", config: "TaskConfig"):
self.found(raise_error=True)
import torch
from qcengine.units import ureg

# check we can run on the set of elements
known_elements = {"H", "B", "C", "N", "O", "F", "Si", "P", "S", "Cl", "As", "Se", "Br", "I"}
target_elements = set(input_data.molecule.symbols)

unknown_elements = target_elements - known_elements
if unknown_elements:
raise InputError(f"AIMNET2 model {input_data.model.method} does not support elements {unknown_elements}.")

method = input_data.model.method
# load the model using the method as the file name
model = self.load_model(name=method)

# build the required input data
aimnet_input = {
"coord": torch.tensor(
[input_data.molecule.geometry * ureg.conversion_factor("bohr", "angstrom")],
dtype=torch.float64,
device="cpu",
),
"numbers": torch.tensor([input_data.molecule.atomic_numbers], dtype=torch.long, device="cpu"),
"charge": torch.tensor([input_data.molecule.molecular_charge], dtype=torch.float64, device="cpu"),
}

if input_data.driver == "gradient":
aimnet_input["coord"].requires_grad_(True)
out = model(aimnet_input)

ret_data = {
"success": False,
"properties": {
"return_energy": out["energy"].item() * ureg.conversion_factor("eV", "hartree"),
"return_gradient": (
-1.0 * out["forces"][0].detach().numpy() * ureg.conversion_factor("eV / angstrom", "hartree / bohr")
),
"calcinfo_natom": len(input_data.molecule.atomic_numbers),
},
"extras": input_data.extras.copy(),
}
# update with calculated extras
ret_data["extras"]["aimnet2"] = {
"charges": out["charges"].detach()[0].cpu().numpy(),
"ensemble_charges_std": out["charges_std"].detach()[0].cpu().numpy(),
"ensemble_energy_std": out["energy_std"].item(),
"ensemble_forces_std": out["forces_std"].detach()[0].cpu().numpy(),
}
if input_data.driver == "energy":
ret_data["return_result"] = ret_data["properties"]["return_energy"]
elif input_data.driver == "gradient":
ret_data["return_result"] = ret_data["properties"]["return_gradient"]
else:
raise InputError(
f"AIMNET2 can only compute energy and gradients driver methods. Requested {input_data.driver} not supported."
)

ret_data["provenance"] = Provenance(creator="pyaimnet2", version=self.get_version(), routine="load_model")

ret_data["success"] = True

return AtomicResult(**{**input_data.dict(), **ret_data})
2 changes: 2 additions & 0 deletions qcengine/programs/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from .turbomole import TurbomoleHarness
from .xtb import XTBHarness
from .mace import MACEHarness
from .aimnet2 import AIMNET2Harness

__all__ = ["register_program", "get_program", "list_all_programs", "list_available_programs"]

Expand Down Expand Up @@ -127,6 +128,7 @@ def list_available_programs() -> Set[str]:
# AI
register_program(TorchANIHarness())
register_program(MACEHarness())
register_program(AIMNET2Harness())

# Molecular Mechanics
register_program(RDKitHarness())
Expand Down
47 changes: 47 additions & 0 deletions qcengine/programs/tests/test_programs.py
Original file line number Diff line number Diff line change
Expand Up @@ -413,3 +413,50 @@ def test_mace_gradient():
result = qcng.compute(atomic_input, "mace")
assert result.success
assert pytest.approx(result.return_result) == expected_result


@using("aimnet2")
@pytest.mark.parametrize(
"model, expected_energy",
[
pytest.param("b973c", -76.39604306960972, id="b973c"),
pytest.param("wb97m-d3", -76.47412023758551, id="wb97m-d3"),
],
)
def test_aimnet2_energy(model, expected_energy):
"""Test computing the energies of water with two aimnet2 models."""

water = qcng.get_molecule("water")
atomic_input = AtomicInput(molecule=water, model={"method": model, "basis": None}, driver="energy")

result = qcng.compute(atomic_input, "aimnet2")
assert result.success
assert pytest.approx(result.return_result) == expected_energy
assert "charges" in result.extras["aimnet2"]
assert "ensemble_charges_std" in result.extras["aimnet2"]
assert "ensemble_forces_std" in result.extras["aimnet2"]


@using("aimnet2")
def test_aimnet2_gradient():
"""Test computing the gradient of water using one aimnet2 model."""

water = qcng.get_molecule("water")
atomic_input = AtomicInput(molecule=water, model={"method": "wb97m-d3", "basis": None}, driver="gradient")

result = qcng.compute(atomic_input, "aimnet2")
assert result.success
# make sure the gradient is now the return result
assert np.allclose(
result.return_result,
np.array(
[
[-0.0, 2.6080331227973375e-09, -0.04097248986363411],
[-0.0, -0.029529934749007225, 0.020486244931817055],
[-0.0, 0.029529931023716927, 0.020486244931817055],
jthorton marked this conversation as resolved.
Show resolved Hide resolved
]
),
)
assert pytest.approx(result.properties.return_energy) == -76.47412023758551
# make sure the other properties were also saved
assert "charges" in result.extras["aimnet2"]
1 change: 1 addition & 0 deletions qcengine/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ def get_job(self):
"xtb": which_import("xtb", return_bool=True),
"mrchem": is_program_new_enough("mrchem", "1.0.0"),
"mace": is_program_new_enough("mace", "0.3.2"),
"aimnet2": which_import("pyaimnet2", return_bool=True),
}
_programs["openmm"] = _programs["rdkit"] and which_import(".openmm", package="simtk", return_bool=True)

Expand Down
3 changes: 2 additions & 1 deletion qcengine/tests/test_harness_canonical.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@
("cfour", {"method": "hf", "basis": "6-31G"}, {}),
("gamess", {"method": "hf", "basis": "n31"}, {"basis__NGAUSS": 6}),
("mctc-gcp", {"method": "dft/sv"}, {}),
("mace", {"method": "small"}, {})
("mace", {"method": "small"}, {}),
("aimnet2", {"method": "b973c"}, {}),
# add as programs available
# ("terachem", {"method": "bad"}),
]
Expand Down
Loading