Skip to content

Commit 116d6af

Browse files
add forcefield approx/neb tests
1 parent 0742aa6 commit 116d6af

File tree

6 files changed

+168
-28
lines changed

6 files changed

+168
-28
lines changed

src/atomate2/ase/neb.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ class AseNebMaker(AseMaker):
2525

2626
name: str = "ASE NEB maker"
2727
neb_kwargs: dict = field(default_factory=dict)
28-
relax_cell: bool = True
2928
fix_symmetry: bool = False
3029
symprec: float | None = 1e-2
3130
steps: int = 500
@@ -55,17 +54,16 @@ def run_ase(
5554
return AseNebInterface(
5655
calculator=self.calculator,
5756
fix_symmetry=self.fix_symmetry,
58-
relax_cell=self.relax_cell,
5957
symprec=self.symprec,
60-
neb_kwargs=self.neb_kwargs,
61-
**self.optimizer_kwargs,
6258
).run_neb(
6359
images,
6460
steps=self.steps,
6561
traj_file=self.traj_file,
6662
traj_file_fmt=self.traj_file_fmt,
6763
interval=self.traj_interval,
6864
neb_doc_kwargs=self.neb_doc_kwargs,
65+
neb_kwargs=self.neb_kwargs,
66+
optimizer_kwargs=self.optimizer_kwargs,
6967
**self.relax_kwargs,
7068
)
7169

src/atomate2/ase/utils.py

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,8 @@ def save(
162162
self.to_pymatgen_trajectory(filename=filename, file_format=fmt) # type: ignore[arg-type]
163163
elif fmt == "ase":
164164
self.to_ase_trajectory(filename=filename)
165+
else:
166+
raise ValueError(f"Unknown trajectory format {fmt}.")
165167

166168
def to_ase_trajectory(
167169
self, filename: str | None = "atoms.traj"
@@ -443,18 +445,15 @@ def __init__(
443445
self,
444446
calculator: Calculator,
445447
optimizer: Optimizer | str = "FIRE",
446-
relax_cell: bool = True,
447448
fix_symmetry: bool = False,
448449
symprec: float = 1e-2,
449-
neb_kwargs: dict | None = None,
450450
) -> None:
451451
"""Initialize the interface.
452452
453453
Parameters
454454
----------
455455
calculator (ase Calculator): an ase calculator
456456
optimizer (str or ase Optimizer): the optimization algorithm.
457-
relax_cell (bool): if True, cell parameters will be optimized.
458457
fix_symmetry (bool): if True, symmetry will be fixed during relaxation.
459458
symprec (float): Tolerance for symmetry finding in case of fix_symmetry.
460459
"""
@@ -468,11 +467,9 @@ def __init__(
468467
optimizer_obj = optimizer
469468

470469
self.opt_class: Optimizer = optimizer_obj
471-
self.relax_cell = relax_cell
472470
self.ase_adaptor = AseAtomsAdaptor()
473471
self.fix_symmetry = fix_symmetry
474472
self.symprec = symprec
475-
self.neb_kwargs = neb_kwargs or DEFAULT_NEB_KWARGS.copy()
476473

477474
def run_neb(
478475
self,
@@ -484,7 +481,8 @@ def run_neb(
484481
interval: int = 1,
485482
verbose: bool = False,
486483
neb_doc_kwargs: dict | None = None,
487-
**kwargs,
484+
neb_kwargs: dict = DEFAULT_NEB_KWARGS,
485+
optimizer_kwargs: dict | None = None,
488486
) -> NebResult:
489487
"""
490488
Perform NEB on a list of molecules or structures.
@@ -511,8 +509,10 @@ def run_neb(
511509
The step interval for saving the trajectories.
512510
verbose : bool
513511
If True, screen output will be shown.
514-
**kwargs
515-
Further kwargs.
512+
neb_kwargs : dict, defaults to DEFAULT_NEB_KWARGS
513+
kwargs to pass to ASE's NEB.
514+
optimizer_kwargs : dict or None (default)
515+
kwargs to pass to the optimizer.
516516
517517
Returns
518518
-------
@@ -522,6 +522,7 @@ def run_neb(
522522
isinstance(images[0], Atoms) and all(not pbc for pbc in images[0].pbc)
523523
)
524524
num_images = len(images)
525+
initial_images = [img.copy() for img in images]
525526

526527
for idx, image in enumerate(images):
527528
if isinstance(image, Structure | Molecule):
@@ -531,11 +532,11 @@ def run_neb(
531532
images[idx].set_constraint(FixSymmetry(image, symprec=self.symprec))
532533
images[idx].calc = deepcopy(self.calculator)
533534

534-
neb_calc = NEB(images, **self.neb_kwargs)
535+
neb_calc = NEB(images, **neb_kwargs)
535536

536537
with contextlib.redirect_stdout(sys.stdout if verbose else io.StringIO()):
537538
observers = [TrajectoryObserver(image) for image in images]
538-
optimizer = self.opt_class(neb_calc, **kwargs)
539+
optimizer = self.opt_class(neb_calc, **(optimizer_kwargs or {}))
539540
for idx in range(num_images):
540541
optimizer.attach(observers[idx], interval=interval)
541542
t_i = time.perf_counter()
@@ -546,8 +547,10 @@ def run_neb(
546547
if traj_file is not None:
547548
if isinstance(traj_file, str | Path):
548549
traj_file = Path(traj_file)
549-
traj_file_suffix = "".join(traj_file.suffixes)
550-
traj_file_prefix = str(traj_file).split(traj_file_suffix)[0]
550+
if traj_file_suffix := "".join(traj_file.suffixes):
551+
traj_file_prefix = str(traj_file).split(traj_file_suffix)[0]
552+
else:
553+
traj_file_prefix = str(traj_file)
551554
traj_files = [
552555
f"{traj_file_prefix}-image-{idx + 1}{traj_file_suffix}"
553556
for idx in range(num_images)
@@ -563,21 +566,27 @@ def run_neb(
563566
for image in images
564567
]
565568
num_sites = len(images[0])
569+
570+
tags = [os.getcwd()]
566571
is_force_conv = all(
567572
np.linalg.norm(observers[image_idx].forces[-1][site_idx]) < abs(fmax)
568573
for site_idx in range(num_sites)
569574
for image_idx in range(num_images)
570575
)
576+
tags += ["force converged" if is_force_conv else "forces not converged"]
577+
tags += [f"elapsed time {t_f - t_i} seconds"]
578+
571579
return NebResult(
572580
images=images,
581+
initial_images=initial_images,
573582
energies=[
574583
observers[image_idx].energies[-1] for image_idx in range(num_images)
575584
],
576585
method=NebMethod.CLIMBING_IMAGE
577-
if self.neb_kwargs.get("climb", False)
586+
if neb_kwargs.get("climb", False)
578587
else NebMethod.STANDARD,
579-
is_force_converged=is_force_conv,
580-
dir_name=os.getcwd(),
581-
elapsed_time=t_f - t_i,
588+
# dir_name=os.getcwd(), # NB: this should be migrated in emmet-core
589+
state="successful" if is_force_conv else "failed",
590+
tags=tags,
582591
**neb_doc_kwargs,
583592
)

src/atomate2/forcefields/flows/approx_neb.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515

1616
@dataclass
17-
class MLFFApproxNebFromEndpointsMaker(ApproxNebFromEndpointsMaker):
17+
class ForceFieldApproxNebFromEndpointsMaker(ApproxNebFromEndpointsMaker):
1818
"""
1919
Perform ApproxNEB on a single hop using ML forcefields.
2020
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import pytest
2+
from emmet.core.neb import NebResult
3+
from jobflow import run_locally
4+
from pymatgen.core import Structure
5+
6+
from atomate2.forcefields.flows.approx_neb import ForceFieldApproxNebFromEndpointsMaker
7+
from atomate2.forcefields.jobs import ForceFieldStaticMaker
8+
from atomate2.utils.testing.common import get_job_uuid_name_map
9+
10+
11+
def test_approx_neb_from_endpoints(test_dir, clean_dir):
12+
vasp_aneb_dir = test_dir / "vasp" / "ApproxNEB"
13+
14+
endpoints = [
15+
Structure.from_file(
16+
vasp_aneb_dir / f"ApproxNEB_image_relax_endpoint_{idx}/inputs/POSCAR.gz"
17+
)
18+
for idx in (0, 3)
19+
]
20+
21+
flow = ForceFieldApproxNebFromEndpointsMaker(
22+
image_relax_maker=ForceFieldStaticMaker(force_field_name="MATPES_R2SCAN")
23+
).make("Zn", endpoints, vasp_aneb_dir / "host_structure_relax_2/outputs/CHGCAR.bz2")
24+
25+
response = run_locally(flow)
26+
output = {
27+
job_name: response[uuid][1].output
28+
for uuid, job_name in get_job_uuid_name_map(flow).items()
29+
}
30+
31+
assert isinstance(output["collate_images_single_hop"], NebResult)
32+
assert all(
33+
output["collate_images_single_hop"].energies[i] == pytest.approx(energy)
34+
for i, energy in enumerate(
35+
[
36+
-1559.5150146484375,
37+
-1554.3154296875,
38+
-1529.771484375,
39+
-1525.8846435546875,
40+
-1533.1453857421875,
41+
-1554.561767578125,
42+
-1559.5150146484375,
43+
]
44+
)
45+
)
46+
47+
assert len(output["collate_images_single_hop"].images) == 7
48+
assert all(
49+
image.volume == pytest.approx(endpoints[0].volume)
50+
for image in output["collate_images_single_hop"].images
51+
)

tests/forcefields/test_neb.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
from pathlib import Path
2+
3+
import pytest
4+
from jobflow import run_locally
5+
from monty.serialization import loadfn
6+
from pymatgen.core import Structure
7+
from pymatgen.io.vasp.outputs import Xdatcar
8+
9+
from atomate2.forcefields.neb import ForceFieldNebMaker
10+
11+
12+
def test_neb_from_images(test_dir, clean_dir):
13+
endpoints = [
14+
Structure.from_file(
15+
test_dir
16+
/ "vasp"
17+
/ "Si_NEB"
18+
/ f"relax_endpoint_{1 + i}"
19+
/ "inputs"
20+
/ "POSCAR.gz"
21+
)
22+
for i in range(2)
23+
]
24+
25+
images = endpoints[0].interpolate(endpoints[1], nimages=4, autosort_tol=0.5)
26+
27+
job = ForceFieldNebMaker(
28+
force_field_name="MATPES_PBE",
29+
traj_file="XDATCAR_si_self_diffusion",
30+
traj_file_fmt="xdatcar",
31+
relax_kwargs={"fmax": 0.5},
32+
).make(images)
33+
34+
response = run_locally(job)
35+
output = response[job.uuid][1].output
36+
37+
cwd = next(Path(p) for p in output.tags if Path(p).exists())
38+
xdatcars = [
39+
Xdatcar(cwd / f"XDATCAR_si_self_diffusion-image-{i + 1}") for i in range(5)
40+
]
41+
42+
# Check that trajectory initial and final images are consistent with document
43+
assert all(
44+
xdatcars[i].structures[0] == image
45+
for i, image in enumerate(output.initial_images)
46+
)
47+
48+
all(xdatcars[i].structures[-1] == image for i, image in enumerate(output.images))
49+
50+
assert all(
51+
output.energies[i] == pytest.approx(energy)
52+
for i, energy in enumerate(
53+
[
54+
-339.3764953613281,
55+
-339.2301025390625,
56+
-338.865234375,
57+
-339.23004150390625,
58+
-339.37652587890625,
59+
]
60+
)
61+
)
62+
63+
if output.state.value == "successful":
64+
assert "force converged" in output.tags
65+
else:
66+
assert "forces not converged" in output.tags
67+
68+
images = endpoints[0].interpolate(endpoints[1], nimages=2, autosort_tol=0.5)
69+
job = ForceFieldNebMaker(
70+
force_field_name="MACE",
71+
traj_file="si_self_diffusion.json.gz",
72+
traj_file_fmt="pmg",
73+
relax_kwargs={"fmax": 0.5},
74+
).make(images)
75+
76+
response = run_locally(job)
77+
output = response[job.uuid][1].output
78+
79+
trajectories = [
80+
loadfn(cwd / f"si_self_diffusion-image-{idx + 1}.json.gz") for idx in range(3)
81+
]
82+
83+
assert all(
84+
trajectories[i].frame_properties[-1]["energy"] == pytest.approx(energy)
85+
for i, energy in enumerate(output.energies)
86+
)

tests/vasp/flows/test_approx_neb.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from monty.serialization import loadfn
99
from pymatgen.core import Structure
1010

11+
from atomate2.utils.testing.common import get_job_uuid_name_map
1112
from atomate2.vasp.flows.approx_neb import ApproxNebMaker
1213

1314

@@ -66,9 +67,8 @@ def test_approx_neb_flow(mock_vasp, clean_dir, vasp_test_dir):
6667
# ApproxNEB image relax hop 3+1 image 2
6768
responses = run_locally(flow, create_folders=True, ensure_success=False)
6869
output = {
69-
job.name: responses[job.uuid][1].output
70-
for job in flow.jobs
71-
if job.uuid in responses
70+
job_name: responses[uuid][1].output
71+
for uuid, job_name in get_job_uuid_name_map(flow).items()
7272
}
7373

7474
assert len(output["collate_results"].hops) == 2
@@ -113,10 +113,6 @@ def test_approx_neb_flow(mock_vasp, clean_dir, vasp_test_dir):
113113
for idx, energy in enumerate(ref_hop.energies)
114114
)
115115

116-
from monty.serialization import dumpfn
117-
118-
dumpfn(output["collate_results"], "/Users/aaronkaplan/Desktop/temp_aneb.json.gz")
119-
120116
assert all(
121117
getattr(output["collate_results"], f"{direction}_barriers")[k]
122118
== pytest.approx(getattr(ref_results, f"{direction}_barriers")[k])

0 commit comments

Comments
 (0)