Skip to content

Commit cafd514

Browse files
authored
Validate CONTCAR before copying to POSCAR to prevent corruption after forced VASP termination (#416)
* Add CONTCAR validation before copying to POSCAR - Add is_valid_poscar() utility to check if POSCAR/CONTCAR files are valid and parseable before copying, preventing empty/truncated files from being used after VASP termination - Update all VASP handlers to validate CONTCAR before copying to POSCAR: fexcf, brions, zbrent, eddrmm, edddav, zheev/eddiag, DriftErrorHandler, UnconvergedErrorHandler, and StoppedRunHandler - Add tests for VaspJob.terminate() process group termination using os.killpg() to ensure all MPI workers are killed - Add tests for is_valid_poscar() covering valid, empty, missing, malformed, and truncated POSCAR files * remove dead code #416 (comment)
1 parent ea00e6c commit cafd514

4 files changed

Lines changed: 130 additions & 29 deletions

File tree

src/custodian/vasp/handlers.py

Lines changed: 20 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
from custodian.utils import backup
3636
from custodian.vasp.interpreter import VaspModder
3737
from custodian.vasp.io import load_outcar, load_vasprun
38-
from custodian.vasp.utils import increase_k_point_density
38+
from custodian.vasp.utils import increase_k_point_density, is_valid_poscar
3939

4040
__author__ = (
4141
"Shyue Ping Ong, William Davidson Richards, Anubhav Jain, Wei Chen, Stephen Dacek, Andrew Rosen, Janosh Riebesell"
@@ -226,7 +226,8 @@ def correct(self, directory="./"):
226226
# https://www.vasp.at/forum/viewtopic.php?p=14827
227227
if self.error_count["fexcf"] == 0:
228228
# First see if last ionic configuration is more stable on rerun
229-
actions.append({"file": "CONTCAR", "action": {"_file_copy": {"dest": "POSCAR"}}})
229+
if is_valid_poscar("CONTCAR", directory):
230+
actions.append({"file": "CONTCAR", "action": {"_file_copy": {"dest": "POSCAR"}}})
230231
elif self.error_count["fexcf"] == 1 and vi["INCAR"].get("IBRION", -1) == 1:
231232
# Try more stable geometry optimization method
232233
actions.append({"dict": "INCAR", "action": {"_set": {"IBRION": 2}}})
@@ -469,7 +470,8 @@ def correct(self, directory="./"):
469470

470471
if "brions" in self.errors:
471472
# Copy CONTCAR to POSCAR so we do not lose our progress.
472-
actions.append({"file": "CONTCAR", "action": {"_file_copy": {"dest": "POSCAR"}}})
473+
if is_valid_poscar("CONTCAR", directory):
474+
actions.append({"file": "CONTCAR", "action": {"_file_copy": {"dest": "POSCAR"}}})
473475

474476
# By default, increase POTIM per the VASP error message. But if that does not work,
475477
# we should try IBRION = 2 since it is less sensitive to POTIM.
@@ -499,8 +501,9 @@ def correct(self, directory="./"):
499501

500502
ediff = vi["INCAR"].get("EDIFF", 1e-4)
501503

502-
# Copy CONTCAR to POSCAR. This should always be done so we don't lose our progress.
503-
actions.append({"file": "CONTCAR", "action": {"_file_copy": {"dest": "POSCAR"}}})
504+
# Copy CONTCAR to POSCAR so we don't lose our progress.
505+
if is_valid_poscar("CONTCAR", directory):
506+
actions.append({"file": "CONTCAR", "action": {"_file_copy": {"dest": "POSCAR"}}})
504507

505508
# Tighten EDIFF per the VASP warning message. We tighten it by a factor of 10 unless
506509
# it is > 1e-6 (in which case we set it to 1e-6) or 1e-8 in which case we stop tightening
@@ -548,11 +551,7 @@ def correct(self, directory="./"):
548551
if "eddrmm" in self.errors:
549552
# RMM algorithm is not stable for this calculation
550553
# Copy CONTCAR to POSCAR if CONTCAR has already been populated.
551-
try:
552-
is_contcar = Poscar.from_file(os.path.join(directory, "CONTCAR"))
553-
except Exception:
554-
is_contcar = False
555-
if is_contcar:
554+
if is_valid_poscar("CONTCAR", directory):
556555
actions.append({"file": "CONTCAR", "action": {"_file_copy": {"dest": "POSCAR"}}})
557556
if vi["INCAR"].get("ALGO", "Normal").lower() in {"fast", "veryfast"}:
558557
actions.append({"dict": "INCAR", "action": {"_set": {"ALGO": "Normal"}}})
@@ -570,11 +569,7 @@ def correct(self, directory="./"):
570569

571570
if "edddav" in self.errors:
572571
# Copy CONTCAR to POSCAR if CONTCAR has already been populated.
573-
try:
574-
is_contcar = Poscar.from_file(os.path.join(directory, "CONTCAR"))
575-
except Exception:
576-
is_contcar = False
577-
if is_contcar:
572+
if is_valid_poscar("CONTCAR", directory):
578573
actions.append({"file": "CONTCAR", "action": {"_file_copy": {"dest": "POSCAR"}}})
579574
if vi["INCAR"].get("ICHARG", 0) < 10:
580575
actions.append({"file": "CHGCAR", "action": {"_file_delete": {"mode": "actual"}}})
@@ -619,11 +614,7 @@ def correct(self, directory="./"):
619614

620615
if self.errors & {"zheev", "eddiag"}:
621616
# Copy CONTCAR to POSCAR if CONTCAR has already been populated.
622-
try:
623-
is_contcar = Poscar.from_file(os.path.join(directory, "CONTCAR"))
624-
except Exception:
625-
is_contcar = False
626-
if is_contcar:
617+
if is_valid_poscar("CONTCAR", directory):
627618
actions.append({"file": "CONTCAR", "action": {"_file_copy": {"dest": "POSCAR"}}})
628619
if vi["INCAR"].get("ALGO", "Normal").lower() == "fast":
629620
actions.append({"dict": "INCAR", "action": {"_set": {"ALGO": "Normal"}}})
@@ -1057,8 +1048,9 @@ def correct(self, directory="./"):
10571048
incar = vi["INCAR"]
10581049
outcar = load_outcar(os.path.join(directory, "OUTCAR"))
10591050

1060-
# Move CONTCAR to POSCAR
1061-
actions.append({"file": "CONTCAR", "action": {"_file_copy": {"dest": "POSCAR"}}})
1051+
# Move CONTCAR to POSCAR if valid
1052+
if is_valid_poscar("CONTCAR", directory):
1053+
actions.append({"file": "CONTCAR", "action": {"_file_copy": {"dest": "POSCAR"}}})
10621054

10631055
# Set PREC to High so ENAUG can be used to control Augmentation Grid Size
10641056
if incar.get("PREC", "Accurate").lower() != "high":
@@ -1239,10 +1231,9 @@ def correct(self, directory="./"):
12391231
elif not v.converged_ionic:
12401232
# Just continue optimizing and let other handlers fix ionic
12411233
# optimizer parameters
1242-
actions += [
1243-
{"dict": "INCAR", "action": {"_set": {"IBRION": 1}}},
1244-
{"file": "CONTCAR", "action": {"_file_copy": {"dest": "POSCAR"}}},
1245-
]
1234+
actions.append({"dict": "INCAR", "action": {"_set": {"IBRION": 1}}})
1235+
if is_valid_poscar("CONTCAR", directory):
1236+
actions.append({"file": "CONTCAR", "action": {"_file_copy": {"dest": "POSCAR"}}})
12461237

12471238
if actions:
12481239
vi = VaspInput.from_directory(directory)
@@ -1927,7 +1918,9 @@ def correct(self, directory="./"):
19271918
i = d["Index"]
19281919
name = shutil.make_archive(os.path.join(directory, f"vasp.chk.{i}"), "gztar")
19291920

1930-
actions = [{"file": "CONTCAR", "action": {"_file_copy": {"dest": "POSCAR"}}}]
1921+
actions = []
1922+
if is_valid_poscar("CONTCAR", directory):
1923+
actions.append({"file": "CONTCAR", "action": {"_file_copy": {"dest": "POSCAR"}}})
19311924

19321925
modder = Modder(actions=[FileActions], directory=directory)
19331926
for action in actions:

src/custodian/vasp/utils.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,18 @@
22

33
from __future__ import annotations
44

5+
import logging
6+
import os
57
from typing import TYPE_CHECKING, Any
68

79
import numpy as np
8-
from pymatgen.io.vasp.inputs import Kpoints
10+
from pymatgen.io.vasp.inputs import Kpoints, Poscar
911

1012
if TYPE_CHECKING:
1113
from pymatgen.core import Structure
1214

15+
logger = logging.getLogger(__name__)
16+
1317

1418
def _estimate_num_k_points_from_kspacing(structure: Structure, kspacing: float) -> tuple[int, ...]:
1519
"""
@@ -103,3 +107,56 @@ def increase_k_point_density(
103107
mult_fac += factor
104108

105109
return new_kpoints if success else {} # type: ignore
110+
111+
112+
def is_valid_poscar(filename: str, directory: str = "./") -> bool:
113+
"""Check if a POSCAR/CONTCAR file is valid and can be parsed.
114+
115+
This is useful to verify CONTCAR is complete before copying to POSCAR,
116+
especially after terminating a VASP job which might leave incomplete files.
117+
118+
Args:
119+
filename: Name of the file (e.g., "CONTCAR", "POSCAR")
120+
directory: Directory containing the file
121+
122+
Returns:
123+
True if the file exists, is non-empty, and can be parsed as a valid
124+
VASP structure file. False otherwise.
125+
"""
126+
filepath = os.path.join(directory, filename)
127+
128+
# Check file exists
129+
if not os.path.isfile(filepath):
130+
logger.warning(f"{filename} does not exist in {directory}")
131+
return False
132+
133+
# Check file is not empty
134+
if os.path.getsize(filepath) == 0:
135+
logger.warning(f"{filename} is empty")
136+
return False
137+
138+
# Try to parse as POSCAR
139+
try:
140+
Poscar.from_file(filepath)
141+
return True
142+
except Exception as exc:
143+
logger.warning(f"{filename} could not be parsed: {exc}")
144+
return False
145+
146+
147+
def copy_contcar_to_poscar_if_valid(directory: str = "./") -> list[dict]:
148+
"""Return action to copy CONTCAR to POSCAR only if CONTCAR is valid.
149+
150+
This prevents copying incomplete CONTCAR files that may result from
151+
terminating VASP mid-write.
152+
153+
Args:
154+
directory: Directory containing CONTCAR
155+
156+
Returns:
157+
List containing the copy action if CONTCAR is valid, empty list otherwise.
158+
"""
159+
if is_valid_poscar("CONTCAR", directory):
160+
return [{"file": "CONTCAR", "action": {"_file_copy": {"dest": "POSCAR"}}}]
161+
logger.warning("CONTCAR is not valid, skipping copy to POSCAR")
162+
return []

tests/vasp/test_jobs.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,5 +350,6 @@ def test_integration_with_real_process(self) -> None:
350350

351351
# Process should be confirmed dead (terminate() waits internally)
352352
assert real_process.poll() is not None
353+
# Verify process group no longer exists
353354
with pytest.raises(ProcessLookupError):
354355
os.killpg(original_pgid, 0)

tests/vasp/test_utils.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from pymatgen.io.vasp import Kpoints
1010
from pymatgen.util.testing import MatSciTest
1111

12-
from custodian.vasp.utils import _estimate_num_k_points_from_kspacing, increase_k_point_density
12+
from custodian.vasp.utils import _estimate_num_k_points_from_kspacing, increase_k_point_density, is_valid_poscar
1313
from tests.conftest import TEST_FILES
1414

1515

@@ -41,3 +41,53 @@ def test_kpoint_density_increase(self):
4141

4242
new_kpoints = increase_k_point_density(Kpoints(), self.small_structure, force_gamma=True, min_kpoints=14)
4343
assert new_kpoints["kpoints"] == ((3, 3, 3),)
44+
45+
46+
class TestIsValidPoscar:
47+
"""Tests for is_valid_poscar utility function."""
48+
49+
def test_valid_poscar(self, tmp_path) -> None:
50+
"""Valid POSCAR file should return True."""
51+
poscar_path = tmp_path / "POSCAR"
52+
poscar_path.write_text(
53+
"""Si2
54+
1.0
55+
3.8 0.0 0.0
56+
0.0 3.8 0.0
57+
0.0 0.0 3.8
58+
Si
59+
2
60+
direct
61+
0.0 0.0 0.0
62+
0.5 0.5 0.5
63+
"""
64+
)
65+
assert is_valid_poscar("POSCAR", str(tmp_path)) is True
66+
67+
def test_empty_poscar(self, tmp_path) -> None:
68+
"""Empty POSCAR file should return False."""
69+
poscar_path = tmp_path / "CONTCAR"
70+
poscar_path.write_text("")
71+
assert is_valid_poscar("CONTCAR", str(tmp_path)) is False
72+
73+
def test_missing_poscar(self, tmp_path) -> None:
74+
"""Missing POSCAR file should return False."""
75+
assert is_valid_poscar("CONTCAR", str(tmp_path)) is False
76+
77+
def test_malformed_poscar(self, tmp_path) -> None:
78+
"""Malformed POSCAR file should return False."""
79+
poscar_path = tmp_path / "CONTCAR"
80+
poscar_path.write_text("This is not a valid POSCAR file\nGarbage data")
81+
assert is_valid_poscar("CONTCAR", str(tmp_path)) is False
82+
83+
def test_truncated_poscar(self, tmp_path) -> None:
84+
"""Truncated POSCAR (incomplete write) should return False."""
85+
poscar_path = tmp_path / "CONTCAR"
86+
# Incomplete POSCAR - cut off mid-file
87+
poscar_path.write_text(
88+
"""Si2
89+
1.0
90+
3.8 0.0 0.0
91+
"""
92+
)
93+
assert is_valid_poscar("CONTCAR", str(tmp_path)) is False

0 commit comments

Comments
 (0)