Skip to content

Commit 4b0c0c6

Browse files
Add NEB, ApproxNEB jobs / workflows (#1007)
* Add NEB and ApproxNEB jobs and workflows * Supports VASP, ASE, and ML forcefields
1 parent eb2cd1d commit 4b0c0c6

File tree

373 files changed

+3337
-201
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

373 files changed

+3337
-201
lines changed

.github/workflows/testing.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,8 @@ jobs:
6666
python -m pip install --upgrade pip
6767
mkdir -p ~/.abinit/pseudos
6868
cp -r tests/test_data/abinit/pseudos/ONCVPSP-PBE-SR-PDv0.4 ~/.abinit/pseudos
69-
uv pip install .[strict,strict-forcefields,tests,abinit]
70-
uv pip install torch-runstats
69+
uv pip install .[strict,strict-forcefields,tests,abinit,approxneb]
70+
uv pip install torch-runstats torch_dftd
7171
uv pip install --no-deps nequip==0.5.6
7272
7373
- name: Install pymatgen from master if triggered by pymatgen repo dispatch
@@ -321,7 +321,7 @@ jobs:
321321
run: sphinx-build docs docs_build
322322

323323
automerge:
324-
needs: [lint, test-non-ase, test-notebooks-and-ase, test-force-field-notebook, docs]
324+
needs: [docs, lint, test-force-field-notebook, test-non-ase, test-notebooks-and-ase, test-openff]
325325
runs-on: ubuntu-latest
326326

327327
permissions:

pyproject.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ dependencies = [
2828
"PyYAML",
2929
"click",
3030
"custodian>=2024.4.18",
31-
"emmet-core>=0.84.8",
31+
"emmet-core>=0.84.9",
3232
"jobflow>=0.1.11",
3333
"monty>=2024.12.10",
3434
"numpy",
@@ -62,6 +62,7 @@ forcefields = [
6262
"sevenn>=0.9.3",
6363
"torchdata<=0.7.1", # TODO: remove when issue fixed
6464
]
65+
approxneb = ["pymatgen-analysis-diffusion>=2024.7.15"]
6566
ase = ["ase>=3.25.0"]
6667
ase-ext = ["tblite>=0.3.0; platform_system=='Linux'"]
6768
openmm = [
@@ -99,7 +100,7 @@ strict = [
99100
"click==8.2.1",
100101
"custodian==2025.5.12",
101102
"dscribe==2.1.1",
102-
"emmet-core==0.84.8",
103+
"emmet-core==0.84.9",
103104
"ijson==3.4.0",
104105
"jobflow==0.2.0",
105106
"lobsterpy==0.4.9",

src/atomate2/ase/jobs.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,27 @@ def run_ase(
267267
return relaxer.relax(mol_or_struct, steps=self.steps, **self.relax_kwargs)
268268

269269

270+
@dataclass
271+
class EmtRelaxMaker(AseRelaxMaker):
272+
"""
273+
Relax a structure with an EMT potential.
274+
275+
This serves mostly as an example of how to create atomate2
276+
jobs with existing ASE calculators, and test purposes.
277+
278+
See `atomate2.ase.AseRelaxMaker` for further documentation.
279+
"""
280+
281+
name: str = "EMT relaxation"
282+
283+
@property
284+
def calculator(self) -> Calculator:
285+
"""EMT calculator."""
286+
from ase.calculators.emt import EMT
287+
288+
return EMT(**self.calculator_kwargs)
289+
290+
270291
@dataclass
271292
class LennardJonesRelaxMaker(AseRelaxMaker):
272293
"""

src/atomate2/ase/md.py

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ class AseMDMaker(AseMaker, metaclass=ABCMeta):
169169
ionic_step_data: tuple[str, ...] | None = None
170170
store_trajectory: StoreTrajectoryOption = StoreTrajectoryOption.PARTIAL
171171
traj_file: str | Path | None = None
172-
traj_file_fmt: Literal["pmg", "ase"] = "ase"
172+
traj_file_fmt: Literal["pmg", "ase", "xdatcar"] = "ase"
173173
traj_interval: int = 1
174174
mb_velocity_seed: int | None = None
175175
zero_linear_momentum: bool = False
@@ -247,12 +247,16 @@ def _get_ensemble_defaults(self) -> None:
247247

248248
# These use different kwargs for pressure
249249
if (
250-
isinstance(self.dynamics, DynamicsPresets)
251-
and DynamicsPresets(self.dynamics) == DynamicsPresets.npt_berendsen
252-
) or (
253-
isinstance(self.dynamics, type)
254-
and issubclass(self.dynamics, MolecularDynamics)
255-
and self.dynamics.__name__ == "NPTBerendsen"
250+
(
251+
isinstance(self.dynamics, DynamicsPresets)
252+
and DynamicsPresets(self.dynamics) == DynamicsPresets.npt_berendsen
253+
)
254+
or (
255+
isinstance(self.dynamics, type)
256+
and issubclass(self.dynamics, MolecularDynamics)
257+
and self.dynamics.__name__ == "NPTBerendsen"
258+
)
259+
or (isinstance(self.dynamics, str) and self.dynamics == "berendsen")
256260
):
257261
stress_kwarg = "pressure_au"
258262
else:
@@ -388,7 +392,12 @@ def _callback(dyn: MolecularDynamics = md_runner) -> None:
388392
dyn.set_temperature(temperature_K=self.t_schedule[dyn.nsteps])
389393
if self.ensemble == MDEnsemble.nvt:
390394
return
391-
dyn.set_stress(self.p_schedule[dyn.nsteps] * 1e3 * units.bar)
395+
396+
if "pressure_au" in self.ase_md_kwargs:
397+
# set_pressure is broken for NPTBerendsen
398+
dyn.pressure = self.p_schedule[dyn.nsteps] * 1e3 * units.bar
399+
else:
400+
dyn.set_stress(self.p_schedule[dyn.nsteps] * 1e3 * units.bar)
392401

393402
md_runner.attach(_callback, interval=1)
394403
with contextlib.redirect_stdout(sys.stdout if self.verbose else io.StringIO()):

src/atomate2/ase/neb.py

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
"""Create NEB jobs with ASE."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass, field
6+
from typing import TYPE_CHECKING
7+
8+
from ase.mep.neb import idpp_interpolate, interpolate
9+
from emmet.core.neb import NebResult
10+
from jobflow import Flow, Maker, Response, job
11+
from pymatgen.core import Molecule, Structure
12+
from pymatgen.io.ase import AseAtomsAdaptor
13+
14+
from atomate2.ase.jobs import _ASE_DATA_OBJECTS, AseMaker
15+
from atomate2.ase.utils import AseNebInterface
16+
from atomate2.common.jobs.neb import NebInterpolation, _get_images_from_endpoints
17+
18+
if TYPE_CHECKING:
19+
from pathlib import Path
20+
from typing import Literal
21+
22+
from ase.atoms import Atoms
23+
from ase.calculators.calculator import Calculator
24+
25+
26+
@dataclass
27+
class AseNebFromImagesMaker(AseMaker):
28+
"""Define scheme for performing ASE NEB calculations."""
29+
30+
name: str = "ASE NEB maker"
31+
neb_kwargs: dict = field(default_factory=dict)
32+
fix_symmetry: bool = False
33+
symprec: float | None = 1e-2
34+
steps: int = 500
35+
relax_kwargs: dict = field(default_factory=dict)
36+
optimizer_kwargs: dict = field(default_factory=dict)
37+
traj_file: str | None = None
38+
traj_file_fmt: Literal["pmg", "ase", "xdatcar"] = "ase"
39+
traj_interval: int = 1
40+
neb_doc_kwargs: dict = field(default_factory=dict)
41+
42+
def run_ase(
43+
self,
44+
images: list[Atoms | Structure | Molecule],
45+
prev_dir: str | Path | None = None,
46+
) -> NebResult:
47+
"""
48+
Run an ASE NEB job from a list of images.
49+
50+
Parameters
51+
----------
52+
images: list of pymatgen .Molecule or .Structure
53+
pymatgen molecule or structure images
54+
prev_dir : str or Path or None
55+
A previous calculation directory to copy output files from. Unused, just
56+
added to match the method signature of other makers.
57+
"""
58+
return AseNebInterface(
59+
calculator=self.calculator,
60+
fix_symmetry=self.fix_symmetry,
61+
symprec=self.symprec,
62+
).run_neb(
63+
images,
64+
steps=self.steps,
65+
traj_file=self.traj_file,
66+
traj_file_fmt=self.traj_file_fmt,
67+
interval=self.traj_interval,
68+
neb_doc_kwargs=self.neb_doc_kwargs,
69+
neb_kwargs=self.neb_kwargs,
70+
optimizer_kwargs=self.optimizer_kwargs,
71+
**self.relax_kwargs,
72+
)
73+
74+
@job(data=_ASE_DATA_OBJECTS, schema=NebResult)
75+
def make(
76+
self,
77+
images: list[Structure | Molecule],
78+
prev_dir: str | Path | None = None,
79+
) -> NebResult:
80+
"""
81+
Run an ASE NEB job from a list of images.
82+
83+
Parameters
84+
----------
85+
images: list of pymatgen .Molecule or .Structure
86+
pymatgen molecule or structure images
87+
prev_dir : str or Path or None
88+
A previous calculation directory to copy output files from. Unused, just
89+
added to match the method signature of other makers.
90+
"""
91+
# Note that images are copied to prevent them from being overwritten
92+
# by ASE during the NEB run
93+
return self.run_ase([image.copy() for image in images], prev_dir=prev_dir)
94+
95+
96+
@dataclass
97+
class AseNebFromEndpointsMaker(AseNebFromImagesMaker):
98+
"""Maker to create ASE NEB jobs from two endpoints.
99+
100+
Optionally relax the two endpoints and return a full NEB hop analysis.
101+
If a maker to relax the endpoints is not specified, this job
102+
interpolates the provided endpoints and performs NEB on the
103+
interpolated images.
104+
105+
Parameters
106+
----------
107+
endpoint_relax_maker : Maker or None (default)
108+
Optional maker to initially relax the endpoints.
109+
"""
110+
111+
endpoint_relax_maker: Maker | None = None
112+
113+
@job
114+
def interpolate_endpoints(
115+
self,
116+
endpoints: tuple[Structure | Molecule, Structure | Molecule],
117+
num_images: int,
118+
interpolation_method: NebInterpolation | str = NebInterpolation.LINEAR,
119+
**interpolation_kwargs,
120+
) -> list[Atoms]:
121+
"""
122+
Interpolate between two endpoints using ASE's methods, as a job.
123+
124+
Note that `num_images` specifies the number of intermediate images
125+
between two endpoints. Thus, specifying `num_images = 5` will return
126+
the endpoints and 5 intermediate images.
127+
128+
Parameters
129+
----------
130+
endpoints : tuple[Structure,Structure] or list[Structure]
131+
A set of two endpoints to interpolate NEB images from.
132+
num_images : int
133+
The number of images to include in the interpolation.
134+
interpolation_method : .NebInterpolation
135+
The method to use to interpolate between images.
136+
**interpolation_kwargs
137+
kwargs to pass to the interpolation function.
138+
"""
139+
# return interpolate_endpoints_ase(
140+
# endpoints, num_images, interpolation_method, **interpolation_kwargs
141+
# )
142+
interpolated = _get_images_from_endpoints(
143+
endpoints,
144+
num_images,
145+
interpolation_method=interpolation_method,
146+
**interpolation_kwargs,
147+
)
148+
adaptor = AseAtomsAdaptor()
149+
return [adaptor.get_atoms(image) for image in interpolated]
150+
151+
@job(data=_ASE_DATA_OBJECTS)
152+
def make(
153+
self,
154+
endpoints: tuple[Structure | Molecule, Structure | Molecule]
155+
| list[Structure | Molecule],
156+
num_images: int,
157+
prev_dir: str | Path = None,
158+
interpolation_method: NebInterpolation | str = NebInterpolation.LINEAR,
159+
**interpolation_kwargs,
160+
) -> Flow:
161+
"""
162+
Make an NEB job from a set of endpoints.
163+
164+
Parameters
165+
----------
166+
endpoints : tuple[Structure,Structure] or list[Structure]
167+
A set of two endpoints to interpolate NEB images from.
168+
num_images : int
169+
The number of images to include in the interpolation.
170+
prev_dir : str or Path or None (default)
171+
A previous directory to copy outputs from.
172+
interpolation_method : .NebInterpolation
173+
The method to use to interpolate between images.
174+
**interpolation_kwargs
175+
kwargs to pass to the interpolation function.
176+
"""
177+
if len(endpoints) != 2:
178+
raise ValueError("Please specify exactly two endpoint structures.")
179+
180+
endpoint_jobs = []
181+
if self.endpoint_relax_maker is not None:
182+
endpoint_jobs += [
183+
self.endpoint_relax_maker.make(endpoint, prev_dir=prev_dir)
184+
for endpoint in endpoints
185+
]
186+
for idx in range(2):
187+
endpoint_jobs[idx].append_name(f" endpoint {idx + 1}")
188+
endpoints = [relax_job.output.structure for relax_job in endpoint_jobs]
189+
190+
get_images = self.interpolate_endpoints(
191+
endpoints,
192+
num_images,
193+
interpolation_method=interpolation_method,
194+
**interpolation_kwargs,
195+
)
196+
197+
neb_from_images = super().make(get_images.output)
198+
199+
flow = Flow(
200+
[*endpoint_jobs, get_images, neb_from_images],
201+
output=neb_from_images.output,
202+
)
203+
204+
return Response(replace=flow, output=neb_from_images.output)
205+
206+
207+
def interpolate_endpoints_ase(
208+
endpoints: tuple[Structure | Molecule | Atoms, Structure | Molecule | Atoms],
209+
num_images: int,
210+
interpolation_method: NebInterpolation | str = NebInterpolation.LINEAR,
211+
**interpolation_kwargs,
212+
) -> list[Atoms]:
213+
"""
214+
Interpolate between two endpoints using ASE's methods.
215+
216+
Note that `num_images` specifies the number of intermediate images
217+
between two endpoints. Thus, specifying `num_images = 5` will return
218+
the endpoints and 5 intermediate images.
219+
220+
Parameters
221+
----------
222+
endpoints : tuple[Structure,Structure] or list[Structure]
223+
A set of two endpoints to interpolate NEB images from.
224+
num_images : int
225+
The number of images to include in the interpolation.
226+
interpolation_method : .NebInterpolation
227+
The method to use to interpolate between images.
228+
**interpolation_kwargs
229+
kwargs to pass to the interpolation function.
230+
231+
Returns
232+
-------
233+
list of Atoms : the atoms interpolated between endpoints.
234+
"""
235+
endpoint_atoms = [
236+
AseAtomsAdaptor().get_atoms(ions)
237+
if isinstance(ions, Structure | Molecule)
238+
else ions.copy()
239+
for ions in endpoints
240+
]
241+
images = [
242+
endpoint_atoms[0],
243+
*[endpoint_atoms[0].copy() for _ in range(num_images)],
244+
endpoint_atoms[1],
245+
]
246+
247+
interp_method = NebInterpolation(interpolation_method)
248+
if interp_method == NebInterpolation.LINEAR:
249+
interpolate(images, **interpolation_kwargs)
250+
elif interp_method == NebInterpolation.IDPP:
251+
idpp_interpolate(images, **interpolation_kwargs)
252+
return images
253+
254+
255+
class EmtNebFromImagesMaker(AseNebFromImagesMaker):
256+
"""EMT NEB from images maker."""
257+
258+
name: str = "EMT NEB from images maker"
259+
260+
@property
261+
def calculator(self) -> Calculator:
262+
"""EMT calculator."""
263+
from ase.calculators.emt import EMT
264+
265+
return EMT(**self.calculator_kwargs)

0 commit comments

Comments
 (0)