Skip to content

Commit 4edb8ab

Browse files
Add NEB convergence and restart (#685)
1 parent 5a3df4e commit 4edb8ab

File tree

3 files changed

+183
-17
lines changed

3 files changed

+183
-17
lines changed

docs/source/tutorials/python/neb.ipynb

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,66 @@
375375
"v2.avr.show_hydrogen_bonds = True\n",
376376
"v2"
377377
]
378+
},
379+
{
380+
"cell_type": "markdown",
381+
"metadata": {},
382+
"source": [
383+
"## Continuing NEBs"
384+
]
385+
},
386+
{
387+
"cell_type": "markdown",
388+
"metadata": {},
389+
"source": [
390+
"You can also continue a NEB to a tighter `fmax`:"
391+
]
392+
},
393+
{
394+
"cell_type": "code",
395+
"execution_count": null,
396+
"metadata": {},
397+
"outputs": [],
398+
"source": [
399+
"neb_b.optimize(fmax=0.05)\n",
400+
"neb_b.run_nebtools()\n",
401+
"print(neb_b.results)\n",
402+
"\n",
403+
"fig = neb_b.nebtools.plot_band()\n",
404+
"v1=WeasWidget()\n",
405+
"v1.from_ase(neb_b.nebtools.images)\n",
406+
"v1.avr.model_style = 1\n",
407+
"v1.avr.show_hydrogen_bonds = True\n",
408+
"v1"
409+
]
410+
},
411+
{
412+
"cell_type": "markdown",
413+
"metadata": {},
414+
"source": [
415+
"It is also possible to change NEB parameters, accessed through the attributes of `NEB.neb`, such as to continue or retry a NEB using different methods.\n",
416+
"\n",
417+
"Note that although the \"success\" of the NEB optimisation can be accessed through `NEB.converged`, this is typically only set to `False` if a `OptimizerConvergenceError`, not when the maximum number of steps is reached. We therefore recommend checking `NEB.opt.nsteps` or `NEB.results[\"max_force\"]`."
418+
]
419+
},
420+
{
421+
"cell_type": "code",
422+
"execution_count": null,
423+
"metadata": {},
424+
"outputs": [],
425+
"source": [
426+
"neb_c.neb.climb = True\n",
427+
"neb_c.run(fmax=0.05)\n",
428+
"print(neb_c.results)\n",
429+
"print(neb_c.opt.nsteps)\n",
430+
"\n",
431+
"fig = neb_c.nebtools.plot_band()\n",
432+
"v1=WeasWidget()\n",
433+
"v1.from_ase(neb_c.nebtools.images)\n",
434+
"v1.avr.model_style = 1\n",
435+
"v1.avr.show_hydrogen_bonds = True\n",
436+
"v1"
437+
]
378438
}
379439
],
380440
"metadata": {
@@ -387,7 +447,7 @@
387447
"provenance": []
388448
},
389449
"kernelspec": {
390-
"display_name": "janus",
450+
"display_name": "janus-core (3.12.8)",
391451
"language": "python",
392452
"name": "python3"
393453
},
@@ -401,7 +461,7 @@
401461
"name": "python",
402462
"nbconvert_exporter": "python",
403463
"pygments_lexer": "ipython3",
404-
"version": "3.12.4"
464+
"version": "3.12.8"
405465
}
406466
},
407467
"nbformat": 4,

janus_core/calculations/neb.py

Lines changed: 60 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from collections.abc import Callable, Sequence
66
from copy import copy
77
from typing import Any, get_args
8+
import warnings
89

910
from ase import Atoms
1011
import ase.mep
@@ -475,15 +476,42 @@ def interpolate(self) -> None:
475476
case _:
476477
raise ValueError("Invalid interpolator selected")
477478

478-
def optimize(self):
479-
"""Run NEB optimization."""
479+
def optimize(self, fmax: float | None = None, steps: int | None = None) -> None:
480+
"""
481+
Run NEB optimisation.
482+
483+
Parameters
484+
----------
485+
fmax
486+
Maximum force for NEB optimiser. Default is `self.fmax`.
487+
steps
488+
Maximum steps for NEB optimiser. Default is `self.steps`.
489+
"""
490+
fmax = fmax if fmax is not None else self.fmax
491+
steps = steps if steps is not None else self.steps
492+
480493
if not hasattr(self, "neb"):
481494
self.interpolate()
482495

483-
optimizer = self.optimizer(self.neb, **self.optimizer_kwargs)
484-
optimizer.run(fmax=self.fmax, steps=self.steps)
496+
if not hasattr(self, "opt"):
497+
self.opt = self.optimizer(self.neb, **self.optimizer_kwargs)
498+
499+
self.converged = self.opt.run(fmax=fmax, steps=steps)
500+
501+
if not self.converged:
502+
message = f"NEB optimization has not converged after {steps} steps."
503+
try:
504+
message += (
505+
f" Current max force {self.neb.get_residual()} > target force "
506+
f"{fmax}"
507+
)
508+
except RuntimeError:
509+
pass
510+
511+
warnings.warn(message, stacklevel=2)
512+
485513
if self.logger:
486-
self.logger.info("Optimization steps: %s", optimizer.nsteps)
514+
self.logger.info("Optimization steps: %s", self.opt.nsteps)
487515

488516
# Optionally write band images to file
489517
output_structs(
@@ -511,10 +539,19 @@ def run_nebtools(self):
511539
print("#Barrier [eV] | delta E [eV] | Max force [eV/Å] ", file=out)
512540
print(*self.results.values(), file=out)
513541

514-
def run(self) -> dict[str, float]:
542+
def run(
543+
self, fmax: float | None = None, steps: int | None = None
544+
) -> dict[str, float]:
515545
"""
516546
Run Nudged Elastic Band method.
517547
548+
Parameters
549+
----------
550+
fmax
551+
Maximum force for NEB optimiser. Default is `self.fmax`.
552+
steps
553+
Maximum steps for NEB optimiser. Default is `self.steps`.
554+
518555
Returns
519556
-------
520557
dict[str, float]
@@ -527,17 +564,25 @@ def run(self) -> dict[str, float]:
527564

528565
self._set_info_units()
529566

530-
if self.minimize:
531-
GeomOpt(self.init_struct, **self.minimize_kwargs).run()
567+
fmax = fmax if fmax is not None else self.fmax
568+
steps = steps if steps is not None else self.steps
532569

533-
# Change filename to be written
534-
self.minimize_kwargs["write_kwargs"]["filename"] = (
535-
self.final_struct_min_path
536-
)
537-
GeomOpt(self.final_struct, **self.minimize_kwargs).run()
570+
if hasattr(self, "neb"):
571+
if self.logger:
572+
self.logger.info("Continuing from previous NEB optimisation")
573+
else:
574+
if self.minimize:
575+
GeomOpt(self.init_struct, **self.minimize_kwargs).run()
576+
577+
# Change filename to be written
578+
self.minimize_kwargs["write_kwargs"]["filename"] = (
579+
self.final_struct_min_path
580+
)
581+
GeomOpt(self.final_struct, **self.minimize_kwargs).run()
582+
583+
self.interpolate()
538584

539-
self.interpolate()
540-
self.optimize()
585+
self.optimize(fmax=fmax, steps=steps)
541586
self.run_nebtools()
542587
self.plot()
543588

tests/test_neb.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,67 @@ def test_neb_plot(tmp_path):
168168
assert neb.results["max_force"] == pytest.approx(1.5425684122118983)
169169

170170

171+
def test_converge_warning(tmp_path, LFPO_start_b, LFPO_end_b):
172+
"""Test warning raised if NEB does not converge."""
173+
neb = NEB(
174+
init_struct=LFPO_start_b,
175+
final_struct=LFPO_end_b,
176+
arch="mace_mp",
177+
model=MODEL_PATH,
178+
n_images=5,
179+
steps=1,
180+
fmax=0.1,
181+
file_prefix=tmp_path / "LFPO",
182+
)
183+
with pytest.warns(UserWarning, match="NEB optimization has not converged"):
184+
neb.run()
185+
assert not neb.converged
186+
187+
188+
def test_restart(tmp_path):
189+
"""Test restarting NEB optimisation."""
190+
neb = NEB(
191+
neb_structs=DATA_PATH / "LiFePO4-neb-band.xyz",
192+
arch="mace",
193+
model=MODEL_PATH,
194+
interpolator=None,
195+
file_prefix=tmp_path / "LFPO",
196+
fmax=1.3,
197+
)
198+
neb.optimize()
199+
neb.run_nebtools()
200+
init_force = neb.results["max_force"]
201+
202+
neb.optimize(fmax=1.2)
203+
204+
neb.run_nebtools()
205+
final_force = neb.results["max_force"]
206+
207+
assert final_force < init_force
208+
209+
210+
def test_restart_update_climb(tmp_path):
211+
"""Test updating NEB climb setting when continuing optimization."""
212+
results = {}
213+
214+
for label in ("climb", "no-climb"):
215+
neb = NEB(
216+
neb_structs=DATA_PATH / "LiFePO4-neb-band.xyz",
217+
arch="mace",
218+
model=MODEL_PATH,
219+
interpolator=None,
220+
fmax=1.3,
221+
file_prefix=tmp_path / "LFPO",
222+
)
223+
neb.run()
224+
neb.neb.climb = label == "climb"
225+
226+
neb.run(fmax=1.2)
227+
results[label] = neb.results
228+
229+
assert results["climb"]["barrier"] != results["no-climb"]["barrier"]
230+
231+
171232
@pytest.mark.parametrize("n_images", (-5, 0, 1.5))
172233
def test_invalid_n_images(LFPO_start_b, LFPO_end_b, n_images):
173234
"""Test setting invalid invalid n_images."""

0 commit comments

Comments
 (0)