Skip to content

Commit 0b66db3

Browse files
Electrode workflow and documentation improvements (#1055)
* Ensure electrode workflow doesn't store charge density in JobStore by default / better naming of tasks in wf * Documentation improvements * Remove strict-tblite dependence and patch openmm tests to work with newer dependency stack
1 parent 747ae12 commit 0b66db3

File tree

11 files changed

+123
-72
lines changed

11 files changed

+123
-72
lines changed

.github/workflows/testing.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ jobs:
199199
micromamba activate a2
200200
python -m pip install --upgrade pip
201201
uv pip install .[strict,tests]
202+
uv pip install tblite>=0.4.0
202203
203204
- name: Install pymatgen from master if triggered by pymatgen repo dispatch
204205
if: github.event_name == 'repository_dispatch' && github.event.action == 'pymatgen-ci-trigger'

docs/user/codes/vasp.md

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,25 @@ functional. Full structural relaxation is performed.
9797
### Double Relax
9898

9999
Perform two back-to-back relaxations. This can often help avoid errors arising from
100-
Pulay stress.
100+
[Pulay stress](https://www.vasp.at/wiki/index.php/Pulay_stress).
101+
102+
In short: While the cell size, shape, symmetry, etc. can change during a relaxation, the *k* point grid does not change with it.
103+
Additionally, the number of plane waves is held constant during a relaxation.
104+
Both features lead to artificial (numerical) stress due to under-convergence of a relaxation with respect to the basis set.
105+
To avoid this, we perform a single relaxation, and input its final structure to another relaxation calculation.
106+
At the start of the second relaxation, the *k*-point mesh and plane waves are adjusted to reflect the new symmetry of the cell.
107+
108+
### Materials Project structure optimization
109+
110+
The Materials Project hosts a large database of, among other physical properties, optimized structures and their associated total energy, formation enthalpy, and basic electronic structure properties.
111+
To generate this data, the Materials Project uses a simple double-relaxation followed by a final static calculation.
112+
While in principle, if the second relaxation calculation is converged, a final static calculation would not be needed.
113+
However, the second relaxation may have residual Pulay stress, and VASP averages some electronic structure data ([like the density of states](https://www.vasp.at/wiki/index.php/DOSCAR)) during a relaxation.
114+
Thus we need to perform a final single-point (static) calculation, usually using the corrected tetrahedron method (`ISMEAR=-5`) to ensure accurate electronic structure properties.
115+
116+
The workflows used to produce PBE GGA or GGA+*U* and r<sup>2</sup>SCAN thermodynamic data are, respectively, `MPGGADoubleRelaxStaticMaker` and `MPMetaGGADoubleRelaxStaticMaker` in `atomate2.vasp.flows.mp`.
117+
Moving forward, the Materials Project prefers r<sup>2</sup>SCAN calculations, but maintains its older set of GGA-level data which currently has wider coverage.
118+
For documentation about the calculation parameters used, see the [Materials Project documentation.](https://docs.materialsproject.org/methodology/materials-methodology/calculation-details)
101119

102120
### Band Structure
103121

@@ -616,6 +634,33 @@ written:
616634
static_job.maker.input_set_generator.user_incar_settings["LOPTICS"] = True
617635
```
618636

637+
To update *k*-points, use the `user_kpoints_settings` keyword argument of an input set generator.
638+
You can supply either a `pymatgen.io.vasp.inputs.Kpoints` object, or a `dict` containing certain [keys](https://github.com/materialsproject/pymatgen/blob/b54ac3e65e46b876de40402e8da59f551fb7d005/src/pymatgen/io/vasp/sets.py#L812).
639+
We generally recommend the former approach unless the user is familiar with the specific style of *k*-point updates used by `pymatgen`.
640+
For example, to use just the $\Gamma$ point:
641+
642+
```py
643+
from pymatgen.io.vasp.inputs import Kpoints
644+
from atomate2.vasp.sets.core import StaticSetGenerator
645+
from atomate2.vasp.jobs.core import StaticMaker
646+
647+
custom_gamma_only_set = StaticSetGenerator(user_kpoints_settings=Kpoints())
648+
gamma_only_static_maker = StaticMaker(input_set_generator=custom_gamma_only_set)
649+
```
650+
651+
For those who are more familiar with manual *k*-point generation, you can use a VASP-style KPOINTS file or string to set the *k*-points as well:
652+
653+
```py
654+
kpoints = Kpoints.from_str(
655+
"""Uniform density Monkhorst-Pack mesh
656+
0
657+
Monkhorst-pack
658+
5 5 5
659+
"""
660+
)
661+
custom_static_set = StaticSetGenerator(user_kpoints_settings=kpoints)
662+
```
663+
619664
Finally, sometimes you have a workflow containing many VASP jobs. In this case it can be
620665
tedious to update the input sets for each job individually. Atomate2 provides helper
621666
functions called "powerups" that can apply settings updates to all VASP jobs in a flow.
@@ -663,8 +708,7 @@ modification of several additional VASP settings, such as the k-points
663708

664709
If a greater degree of flexibility is needed, the user can define a default set of input
665710
arguments (`config_dict`) that can be provided to the {obj}`.VaspInputGenerator`.
666-
By default, the {obj}`.VaspInputGenerator` uses a base set of VASP input parameters
667-
from {obj}`.BaseVaspSet.yaml`, which each `Maker` is built upon. If desired, the user can
711+
By default, the {obj}`.VaspInputGenerator` uses a base set of VASP input parameters (`atomate2.vasp.sets.base._BASE_VASP_SET`), which each `Maker` is built upon. If desired, the user can
668712
define a custom `.yaml` file that contains a different base set of VASP settings to use.
669713
An example of how this can be done is shown below for a representative static
670714
calculation.

pyproject.toml

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,9 @@ forcefields = [
6363
"torchdata<=0.7.1", # TODO: remove when issue fixed
6464
]
6565
ase = ["ase>=3.23.0"]
66-
# tblite py3.12 support tracked in https://github.com/tblite/tblite/issues/198
67-
ase-ext = ["tblite>=0.3.0; python_version < '3.12'"]
66+
ase-ext = ["tblite>=0.3.0; platform_system=='Linux'"]
6867
openmm = [
69-
"mdanalysis>=2.7.0",
68+
"mdanalysis>=2.8.0",
7069
"openmm-mdanalysis-reporter>=0.1.0",
7170
"openmm>=8.1.0",
7271
]
@@ -115,16 +114,14 @@ strict = [
115114
"pymongo==4.10.1",
116115
"python-ulid==3.0.0",
117116
"seekpath==2.1.0",
118-
"tblite==0.3.0; python_version < '3.12'",
119117
"typing-extensions==4.13.2",
120118
]
121119
strict-openff = [
122120
"mdanalysis==2.9.0",
123121
"monty==2025.3.3",
124122
"openmm-mdanalysis-reporter==0.1.0",
125123
"openmm==8.1.1",
126-
"pymatgen==2025.4.20", # TODO: open ff is extremely sensitive to pymatgen version
127-
"mdanalysis==2.9.0"
124+
"pymatgen==2024.11.13", # TODO: open ff is extremely sensitive to pymatgen version
128125
]
129126
strict-forcefields = [
130127
"calorine==3.0",
@@ -185,6 +182,7 @@ exclude_lines = [
185182
'^\s*@overload( |$)',
186183
'^\s*assert False(,|$)',
187184
'if typing.TYPE_CHECKING:',
185+
'if TYPE_CHECKING:',
188186
]
189187

190188
[tool.ruff]

src/atomate2/common/flows/electrode.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,10 @@ def make(
120120
relax = self.bulk_relax_maker.make(structure)
121121
else:
122122
relax = self.relax_maker.make(structure)
123+
124+
_shown_steps = str(n_steps) if n_steps else "inf"
125+
relax.append_name(f" 0/{_shown_steps}")
126+
123127
# add ignored_species to the structure matcher
124128
sm = _add_ignored_species(self.structure_matcher, inserted_element)
125129
# Get the inserted structure
@@ -132,6 +136,7 @@ def make(
132136
get_charge_density=self.get_charge_density,
133137
n_steps=n_steps,
134138
insertions_per_step=insertions_per_step,
139+
n_inserted=1,
135140
)
136141
relaxed_summary = RelaxJobSummary(
137142
structure=relax.output.structure,

src/atomate2/common/jobs/electrode.py

Lines changed: 17 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from pymatgen.analysis.structure_matcher import StructureMatcher
2222
from pymatgen.core import Structure
2323
from pymatgen.entries.computed_entries import ComputedEntry
24-
from pymatgen.io.vasp.outputs import VolumetricData
24+
from pymatgen.io.common import VolumetricData
2525

2626

2727
logger = logging.getLogger(__name__)
@@ -84,17 +84,21 @@ def get_stable_inserted_results(
8484
The number of ions inserted so far, used to help assign a unique name to the
8585
different jobs.
8686
"""
87-
if structure is None:
88-
return []
89-
if n_steps is not None and n_steps <= 0:
87+
if (
88+
(structure is None)
89+
or (n_steps is not None and n_steps <= 0)
90+
or (n_inserted > n_steps)
91+
):
9092
return []
9193
# append job name
92-
add_name = f"{n_inserted}"
94+
_shown_steps = str(n_steps) if n_steps else "inf"
95+
add_name = f"{n_inserted}/{_shown_steps}"
9396

9497
static_job = static_maker.make(structure=structure)
95-
chg_job = get_charge_density_job(static_job.output.dir_name, get_charge_density)
98+
static_job.append_name(f" {n_inserted - 1}/{_shown_steps}")
9699
insertion_job = get_inserted_structures(
97-
chg_job.output,
100+
static_job.output.dir_name,
101+
get_charge_density,
98102
inserted_species=inserted_element,
99103
insertions_per_step=insertions_per_step,
100104
)
@@ -107,7 +111,6 @@ def get_stable_inserted_results(
107111
ref_structure=structure,
108112
structure_matcher=structure_matcher,
109113
)
110-
nn_step = n_steps - 1 if n_steps is not None else None
111114
next_step = get_stable_inserted_results(
112115
structure=min_en_job.output[0],
113116
inserted_element=inserted_element,
@@ -116,17 +119,14 @@ def get_stable_inserted_results(
116119
relax_maker=relax_maker,
117120
get_charge_density=get_charge_density,
118121
insertions_per_step=insertions_per_step,
119-
n_steps=nn_step,
122+
n_steps=n_steps,
120123
n_inserted=n_inserted + 1,
121124
)
122125

123-
for job_ in [static_job, chg_job, insertion_job, min_en_job, relax_jobs, next_step]:
124-
job_.append_name(f" {add_name}")
125126
combine_job = get_computed_entries(next_step.output, min_en_job.output)
126127
replace_flow = Flow(
127128
jobs=[
128129
static_job,
129-
chg_job,
130130
insertion_job,
131131
relax_jobs,
132132
min_en_job,
@@ -204,7 +204,8 @@ def get_insertion_electrode_doc(
204204

205205
@job
206206
def get_inserted_structures(
207-
chg: VolumetricData,
207+
prev_dir: Path | str,
208+
get_charge_density: Callable[[str | Path], VolumetricData],
208209
inserted_species: ElementLike,
209210
insertions_per_step: int = 4,
210211
charge_insertion_generator: ChargeInterstitialGenerator | None = None,
@@ -213,7 +214,8 @@ def get_inserted_structures(
213214
214215
Parameters
215216
----------
216-
chg: The charge density.
217+
prev_dir: The previous directory where the static calculation was performed.
218+
get_charge_density: A function to get the charge density from a run directory.
217219
inserted_species: The species to insert.
218220
insertions_per_step: The maximum number of ion insertion sites to attempt.
219221
charge_insertion_generator: The charge insertion generator to use,
@@ -226,6 +228,7 @@ def get_inserted_structures(
226228
"""
227229
if charge_insertion_generator is None:
228230
charge_insertion_generator = ChargeInterstitialGenerator()
231+
chg = get_charge_density(prev_dir)
229232
gen = charge_insertion_generator.generate(chg, insert_species=[inserted_species])
230233
inserted_structures = [defect.defect_structure for defect in gen]
231234
return inserted_structures[:insertions_per_step]
@@ -297,22 +300,3 @@ def get_min_energy_summary(
297300
return None
298301

299302
return min(topotactic_summaries, key=lambda x: x.entry.energy_per_atom)
300-
301-
302-
@job
303-
def get_charge_density_job(
304-
prev_dir: Path | str,
305-
get_charge_density: Callable,
306-
) -> VolumetricData:
307-
"""Get the charge density from a task document.
308-
309-
Parameters
310-
----------
311-
prev_dir: The previous directory where the static calculation was performed.
312-
get_charge_density: A function to get the charge density from a task document.
313-
314-
Returns
315-
-------
316-
The charge density.
317-
"""
318-
return get_charge_density(prev_dir)

src/atomate2/openmm/jobs/base.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -317,12 +317,6 @@ def _add_reporters(
317317
if traj_file_type in ("h5md", "nc", "ncdf", "json"):
318318
writer_kwargs["velocities"] = report_velocities
319319
writer_kwargs["forces"] = False
320-
elif report_velocities and traj_file_type != "trr":
321-
raise ValueError(
322-
f"File type {traj_file_type} does not support velocities as"
323-
f"of MDAnalysis 2.7.0. Select another file type"
324-
f"or do not attempt to report velocities."
325-
)
326320

327321
traj_file = dir_name / f"{traj_file_name}.{traj_file_type}"
328322

@@ -341,15 +335,23 @@ def _add_reporters(
341335
else:
342336
if report_velocities:
343337
# assert package version
344-
345-
kwargs["writer_kwargs"] = writer_kwargs
346338
warnings.warn(
347339
"Reporting velocities is only supported with the"
348340
"development version of MDAnalysis, >= 2.8.0, "
349341
"proceed with caution.",
350342
stacklevel=1,
351343
)
352-
traj_reporter = MDAReporter(**kwargs)
344+
345+
try:
346+
traj_reporter = MDAReporter(**kwargs, writer_kwargs=writer_kwargs)
347+
except TypeError:
348+
warnings.warn(
349+
"The current version of `openmm-mdanalysis-reporter` "
350+
"does not support `writer_kwargs`. To use these features, "
351+
"pip install this package from the github source.",
352+
stacklevel=2,
353+
)
354+
traj_reporter = MDAReporter(**kwargs)
353355

354356
sim.reporters.append(traj_reporter)
355357

tests/abinit/conftest.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,9 +130,9 @@ def check_run_abi(ref_path: str | Path):
130130

131131
user = AbinitInputFile.from_file("run.abi")
132132
assert user.ndtset == 1, f"'run.abi' has multiple datasets (ndtset={user.ndtset})."
133-
with zopen(ref_path / "inputs" / "run.abi.gz") as file:
133+
with zopen(ref_path / "inputs" / "run.abi.gz", "rt", encoding="utf-8") as file:
134134
ref_str = file.read()
135-
ref = AbinitInputFile.from_string(ref_str.decode("utf-8"))
135+
ref = AbinitInputFile.from_string(ref_str)
136136
# Ignore the pseudos as the directory depends on the pseudo root directory
137137
# diffs = user.get_differences(ref, ignore_vars=["pseudos"])
138138
diffs = _get_differences_tol(user, ref, ignore_vars=["pseudos"])

tests/openmm_md/flows/test_core.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import io
44
from pathlib import Path
55

6+
import pytest
67
from emmet.core.openmm import OpenMMInterchange, OpenMMTaskDocument
78
from jobflow import Flow
89
from MDAnalysis import Universe
@@ -11,6 +12,11 @@
1112
from atomate2.openmm.flows.core import OpenMMFlowMaker
1213
from atomate2.openmm.jobs import EnergyMinimizationMaker, NPTMaker, NVTMaker
1314

15+
try:
16+
import h5py
17+
except ImportError:
18+
h5py = None
19+
1420

1521
def test_anneal_maker(interchange, run_job):
1622
# Create an instance of AnnealMaker with custom parameters
@@ -54,6 +60,9 @@ def test_anneal_maker(interchange, run_job):
5460

5561

5662
# @pytest.mark.skip("Reporting to HDF5 is broken in MDA upstream.")
63+
@pytest.mark.skipif(
64+
condition=h5py is None, reason="h5py is required for HDF5 features."
65+
)
5766
def test_hdf5_writing(interchange, run_job):
5867
# Create an instance of AnnealMaker with custom parameters
5968
import MDAnalysis

tests/openmm_md/jobs/test_base.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,13 @@ def test_add_reporters(interchange, tmp_path):
3333
assert next_dcd[5] is True # enforce periodic boundaries
3434
assert isinstance(sim.reporters[1], StateDataReporter)
3535
next_state = sim.reporters[1].describeNextReport(sim)
36-
assert next_state[0] == 50 # steps until next report
36+
37+
# steps until next report
38+
# TODO: make test more robust
39+
if isinstance(next_state, dict):
40+
assert next_state["steps"] == 50
41+
else:
42+
assert next_state[0] == 50
3743

3844

3945
def test_resolve_attr():
@@ -180,14 +186,12 @@ def do_nothing(self, sim, dir_name):
180186
report_velocities=True,
181187
)
182188

183-
with pytest.raises(RuntimeError):
184-
run_job(maker1.make(interchange))
185-
# run_job(base_job)
186-
187189
import MDAnalysis
188190
from packaging.version import Version
189191

190192
if Version(MDAnalysis.__version__) < Version("2.8.0"):
193+
with pytest.raises(RuntimeError):
194+
run_job(maker1.make(interchange))
191195
return
192196

193197
maker2 = BaseOpenMMMaker(

tests/openmm_md/test_utils.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@
1212
increment_name,
1313
)
1414

15+
"""
16+
TODO: Needs revision
17+
"""
18+
1519

1620
@pytest.mark.skip("annoying test")
1721
def test_download_xml(tmp_path: Path) -> None:

0 commit comments

Comments
 (0)