|
| 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