Skip to content

Commit 121936b

Browse files
jmmshnJaGeo
andauthored
Make testing utils importable (#1037)
* fixed pre-commit warning * moved testing utils for vasp * simplied tutorial mock vasp import * docs and name cleanup --------- Co-authored-by: J. George <[email protected]>
1 parent fed13c7 commit 121936b

File tree

5 files changed

+326
-250
lines changed

5 files changed

+326
-250
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ repos:
4141
rev: v2.3.0
4242
hooks:
4343
- id: codespell
44-
stages: [commit, commit-msg]
44+
stages: [pre-commit, commit-msg]
4545
args: [--ignore-words-list, 'titel,statics,ba,nd,te,atomate']
4646
types_or: [python, rst, markdown]
4747
- repo: https://github.com/kynan/nbstripout
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""Utilities to help with testing.
2+
3+
Some functionalities for testing in atomate2 and useful for
4+
other projects, either downstream or parallel.
5+
6+
However, if these functionalities are places in the test directory,
7+
they will not be available to other projects via direct imports.
8+
9+
This module will hold the core logic for those tests.
10+
"""

src/atomate2/utils/testing/vasp.py

Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
"""Utilities for testing VASP calculations."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
import shutil
7+
from pathlib import Path
8+
from typing import TYPE_CHECKING, Any, Final, Literal
9+
10+
from jobflow import CURRENT_JOB
11+
from monty.io import zopen
12+
from monty.os.path import zpath as monty_zpath
13+
from pymatgen.io.vasp import Incar, Kpoints, Poscar, Potcar
14+
from pymatgen.io.vasp.sets import VaspInputSet
15+
from pymatgen.util.coord import find_in_coord_list_pbc
16+
17+
import atomate2.vasp.jobs.base
18+
import atomate2.vasp.jobs.defect
19+
import atomate2.vasp.run
20+
from atomate2.vasp.sets.base import VaspInputGenerator
21+
22+
if TYPE_CHECKING:
23+
from collections.abc import Callable, Generator, Sequence
24+
25+
from pymatgen.io.vasp.inputs import VaspInput
26+
from pytest import MonkeyPatch
27+
28+
29+
logger = logging.getLogger("atomate2")
30+
31+
_VFILES: Final = ("incar", "kpoints", "potcar", "poscar")
32+
_REF_PATHS: dict[str, str | Path] = {}
33+
_FAKE_RUN_VASP_KWARGS: dict[str, dict] = {}
34+
35+
36+
def zpath(path: str | Path) -> Path:
37+
"""Return the path of a zip file.
38+
39+
Returns an existing (zipped or unzipped) file path given the unzipped
40+
version. If no path exists, returns the unmodified path.
41+
"""
42+
return Path(monty_zpath(str(path)))
43+
44+
45+
def monkeypatch_vasp(
46+
monkeypatch: MonkeyPatch, vasp_test_dir: Path, nelect: int = 12
47+
) -> Generator[Callable[[Any, Any], Any], None, None]:
48+
"""Fake VASP calculations by copying reference files.
49+
50+
This is provided as a generator and can be used as by conextmanagers and
51+
pytest.fixture.
52+
53+
It works by monkeypatching (replacing) calls to run_vasp and
54+
VaspInputSet.write_inputs with versions that will work when the vasp executables or
55+
POTCAR files are not present.
56+
57+
The primary idea is that instead of running VASP to generate the output files,
58+
reference files will be copied into the directory instead. As we do not want to
59+
test whether VASP is giving the correct output rather that the calculation inputs
60+
are generated correctly and that the outputs are parsed properly, this should be
61+
sufficient for our needs. Another potential issue is that the POTCAR files
62+
distributed with VASP are not present on the testing server due to licensing
63+
constraints. Accordingly, VaspInputSet.write_inputs will fail unless the
64+
"potcar_spec" option is set to True, in which case a POTCAR.spec file will be
65+
written instead.
66+
67+
The pytext.fixture defined with this is stored at tests/vasp/conftest.py.
68+
For examples, see the tests in tests/vasp/makers/core.py.
69+
70+
Parameters
71+
----------
72+
monkeypatch: The a MonkeyPatch object from pytest, this is meant as a place-holder
73+
For the `monkeypatch` fixture in pytest.
74+
vasp_test_dir: The root directory for the VASP tests. This is
75+
nelect: The number of electrons in a system is usually calculate using the POTCAR
76+
which we do not have direct access to during testing. So we have to patch it in.
77+
TODO: potcar_spec should have the nelect data somehow.
78+
"""
79+
80+
def mock_run_vasp(*_args, **_kwargs) -> None:
81+
name = CURRENT_JOB.job.name
82+
try:
83+
ref_path = vasp_test_dir / _REF_PATHS[name]
84+
except KeyError:
85+
raise ValueError(
86+
f"no reference directory found for job {name!r}; "
87+
f"reference paths received={_REF_PATHS}"
88+
) from None
89+
fake_run_vasp(ref_path, **_FAKE_RUN_VASP_KWARGS.get(name, {}))
90+
91+
get_input_set_orig = VaspInputGenerator.get_input_set
92+
93+
def mock_get_input_set(self: VaspInputGenerator, *_args, **_kwargs) -> VaspInput:
94+
_kwargs["potcar_spec"] = True
95+
return get_input_set_orig(self, *_args, **_kwargs)
96+
97+
def mock_nelect(*_args, **_kwargs) -> int:
98+
return nelect
99+
100+
monkeypatch.setattr(atomate2.vasp.run, "run_vasp", mock_run_vasp)
101+
monkeypatch.setattr(atomate2.vasp.jobs.base, "run_vasp", mock_run_vasp)
102+
monkeypatch.setattr(atomate2.vasp.jobs.defect, "run_vasp", mock_run_vasp)
103+
monkeypatch.setattr(VaspInputSet, "get_input_set", mock_get_input_set)
104+
monkeypatch.setattr(VaspInputSet, "nelect", mock_nelect)
105+
106+
def _run(ref_paths: dict, fake_run_vasp_kwargs: dict | None = None) -> None:
107+
_REF_PATHS.update(ref_paths)
108+
_FAKE_RUN_VASP_KWARGS.update(fake_run_vasp_kwargs or {})
109+
110+
yield _run
111+
112+
monkeypatch.undo()
113+
_REF_PATHS.clear()
114+
_FAKE_RUN_VASP_KWARGS.clear()
115+
116+
117+
def fake_run_vasp(
118+
ref_path: Path,
119+
incar_settings: Sequence[str] | None = None,
120+
incar_exclude: Sequence[str] | None = None,
121+
check_inputs: Sequence[Literal["incar", "kpoints", "poscar", "potcar"]] = _VFILES,
122+
clear_inputs: bool = True,
123+
) -> None:
124+
"""
125+
Emulate running VASP and validate VASP input files.
126+
127+
Parameters
128+
----------
129+
ref_path
130+
Path to reference directory with VASP input files in the folder named 'inputs'
131+
and output files in the folder named 'outputs'.
132+
incar_settings
133+
A list of INCAR settings to check. Defaults to None which checks all settings.
134+
Empty list or tuple means no settings will be checked.
135+
incar_exclude
136+
A list of INCAR settings to exclude from checking. Defaults to None, meaning
137+
no settings will be excluded.
138+
check_inputs
139+
A list of vasp input files to check. Supported options are "incar", "kpoints",
140+
"poscar", "potcar", "wavecar".
141+
clear_inputs
142+
Whether to clear input files before copying in the reference VASP outputs.
143+
"""
144+
logger.info("Running fake VASP.")
145+
146+
if "incar" in check_inputs:
147+
check_incar(ref_path, incar_settings, incar_exclude)
148+
149+
if "kpoints" in check_inputs:
150+
check_kpoints(ref_path)
151+
152+
if "poscar" in check_inputs:
153+
check_poscar(ref_path)
154+
155+
if "potcar" in check_inputs:
156+
check_potcar(ref_path)
157+
158+
# This is useful to check if the WAVECAR has been copied
159+
if "wavecar" in check_inputs and not Path("WAVECAR").exists():
160+
raise ValueError("WAVECAR was not correctly copied")
161+
162+
logger.info("Verified inputs successfully")
163+
164+
if clear_inputs:
165+
clear_vasp_inputs()
166+
167+
copy_vasp_outputs(ref_path)
168+
169+
# pretend to run VASP by copying pre-generated outputs from reference dir
170+
logger.info("Generated fake vasp outputs")
171+
172+
173+
def check_incar(
174+
ref_path: Path,
175+
incar_settings: Sequence[str] | None,
176+
incar_exclude: Sequence[str] | None,
177+
) -> None:
178+
"""Check that INCAR settings are consistent with the reference calculation."""
179+
user_incar = Incar.from_file(zpath("INCAR"))
180+
ref_incar_path = zpath(ref_path / "inputs" / "INCAR")
181+
ref_incar = Incar.from_file(ref_incar_path)
182+
defaults = {"ISPIN": 1, "ISMEAR": 1, "SIGMA": 0.2}
183+
184+
keys_to_check = (
185+
set(user_incar) if incar_settings is None else set(incar_settings)
186+
) - set(incar_exclude or [])
187+
for key in keys_to_check:
188+
user_val = user_incar.get(key, defaults.get(key))
189+
ref_val = ref_incar.get(key, defaults.get(key))
190+
if user_val != ref_val:
191+
raise ValueError(
192+
f"\n\nINCAR value of {key} is inconsistent: expected {ref_val}, "
193+
f"got {user_val} \nin ref file {ref_incar_path}"
194+
)
195+
196+
197+
def check_kpoints(ref_path: Path) -> None:
198+
"""Check that KPOINTS file is consistent with the reference calculation."""
199+
user_kpoints_exists = (user_kpt_path := zpath("KPOINTS")).exists()
200+
ref_kpoints_exists = (
201+
ref_kpt_path := zpath(ref_path / "inputs" / "KPOINTS")
202+
).exists()
203+
204+
if user_kpoints_exists and not ref_kpoints_exists:
205+
raise ValueError(
206+
"atomate2 generated a KPOINTS file but the reference calculation is using "
207+
"KSPACING"
208+
)
209+
if not user_kpoints_exists and ref_kpoints_exists:
210+
raise ValueError(
211+
"atomate2 is using KSPACING but the reference calculation is using "
212+
"a KPOINTS file"
213+
)
214+
if user_kpoints_exists and ref_kpoints_exists:
215+
user_kpts = Kpoints.from_file(user_kpt_path)
216+
ref_kpts = Kpoints.from_file(ref_kpt_path)
217+
if user_kpts.style != ref_kpts.style or user_kpts.num_kpts != ref_kpts.num_kpts:
218+
raise ValueError(
219+
f"\n\nKPOINTS files are inconsistent: {user_kpts.style} != "
220+
f"{ref_kpts.style} or {user_kpts.num_kpts} != {ref_kpts.num_kpts}\nin "
221+
f"ref file {ref_kpt_path}"
222+
)
223+
else:
224+
# check k-spacing
225+
user_incar = Incar.from_file(zpath("INCAR"))
226+
ref_incar_path = zpath(ref_path / "inputs" / "INCAR")
227+
ref_incar = Incar.from_file(ref_incar_path)
228+
229+
user_ksp, ref_ksp = user_incar.get("KSPACING"), ref_incar.get("KSPACING")
230+
if user_ksp != ref_ksp:
231+
raise ValueError(
232+
f"\n\nKSPACING is inconsistent: expected {ref_ksp}, got {user_ksp} "
233+
f"\nin ref file {ref_incar_path}"
234+
)
235+
236+
237+
def check_poscar(ref_path: Path) -> None:
238+
"""Check that POSCAR information is consistent with the reference calculation."""
239+
user_poscar_path = zpath("POSCAR")
240+
ref_poscar_path = zpath(ref_path / "inputs" / "POSCAR")
241+
242+
user_poscar = Poscar.from_file(user_poscar_path)
243+
ref_poscar = Poscar.from_file(ref_poscar_path)
244+
245+
user_frac_coords = user_poscar.structure.frac_coords
246+
ref_frac_coords = ref_poscar.structure.frac_coords
247+
248+
# In some cases, the ordering of sites can change when copying input files.
249+
# To account for this, we check that the sites are the same, within a tolerance,
250+
# while accounting for PBC.
251+
coord_match = [
252+
len(find_in_coord_list_pbc(ref_frac_coords, coord, atol=1e-3)) > 0
253+
for coord in user_frac_coords
254+
]
255+
if (
256+
user_poscar.natoms != ref_poscar.natoms
257+
or user_poscar.site_symbols != ref_poscar.site_symbols
258+
or not all(coord_match)
259+
):
260+
raise ValueError(
261+
f"POSCAR files are inconsistent\n\n{ref_poscar_path!s}\n{ref_poscar}"
262+
f"\n\n{user_poscar_path!s}\n{user_poscar}"
263+
)
264+
265+
266+
def check_potcar(ref_path: Path) -> None:
267+
"""Check that POTCAR information is consistent with the reference calculation."""
268+
potcars = {"reference": None, "user": None}
269+
paths = {"reference": ref_path / "inputs", "user": Path(".")}
270+
for mode, path in paths.items():
271+
if (potcar_path := zpath(path / "POTCAR")).exists():
272+
potcars[mode] = Potcar.from_file(potcar_path).symbols
273+
elif (potcar_path := zpath(path / "POTCAR.spec")).exists():
274+
with zopen(potcar_path, "rt") as f:
275+
potcars[mode] = f.read().strip().split("\n")
276+
else:
277+
raise FileNotFoundError(f"no {mode} POTCAR or POTCAR.spec file found")
278+
279+
if potcars["reference"] != potcars["user"]:
280+
raise ValueError(
281+
"POTCAR files are inconsistent: "
282+
f"{potcars['reference']} != {potcars['user']}"
283+
)
284+
285+
286+
def clear_vasp_inputs() -> None:
287+
"""Clean up VASP input files."""
288+
for vasp_file in (
289+
"INCAR",
290+
"KPOINTS",
291+
"POSCAR",
292+
"POTCAR",
293+
"CHGCAR",
294+
"OUTCAR",
295+
"vasprun.xml",
296+
"CONTCAR",
297+
):
298+
if (file_path := zpath(vasp_file)).exists():
299+
file_path.unlink()
300+
logger.info("Cleared vasp inputs")
301+
302+
303+
def copy_vasp_outputs(ref_path: Path) -> None:
304+
"""Copy VASP output files from the reference directory."""
305+
output_path = ref_path / "outputs"
306+
for output_file in output_path.iterdir():
307+
if output_file.is_file():
308+
shutil.copy(output_file, ".")

0 commit comments

Comments
 (0)