Skip to content

Commit ca1b2e7

Browse files
authored
fix(vasp): skip malformed OUTCAR force frames (#1030)
1 parent d4b987b commit ca1b2e7

2 files changed

Lines changed: 127 additions & 7 deletions

File tree

dpdata/formats/vasp/outcar.py

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,10 @@ def get_outcar_block(fp, ml=False):
126126
return blk
127127

128128

129+
class _IncompleteForceTableError(ValueError):
130+
"""Indicate that a ``TOTAL-FORCE`` table ended before all atom rows."""
131+
132+
129133
def check_outputs(coord, cell, force):
130134
if len(force) == 0:
131135
raise ValueError("cannot find forces in OUTCAR block")
@@ -167,9 +171,21 @@ def _get_frames_lower(fp, fname, begin=0, step=1, ml=False, convergence_check=Tr
167171
rec_failed = []
168172
while len(blk) > 0:
169173
if cc >= begin and (cc - begin) % step == 0:
170-
coord, cell, energy, force, virial, is_converge = analyze_block(
171-
blk, ntot, nelm, ml
172-
)
174+
try:
175+
coord, cell, energy, force, virial, is_converge = analyze_block(
176+
blk, ntot, nelm, ml
177+
)
178+
except _IncompleteForceTableError as exc:
179+
# An interrupted VASP process can leave one damaged ionic
180+
# step between otherwise usable frames. Its atom arrays are
181+
# incomplete, so skip precisely that step instead of aborting
182+
# the complete trajectory.
183+
warnings.warn(
184+
f"incomplete labels in frame {cc + 1}; it is ignored: {exc}"
185+
)
186+
blk = get_outcar_block(fp, ml)
187+
cc += 1
188+
continue
173189
if energy is None:
174190
break
175191
if nwrite == 0:
@@ -267,9 +283,33 @@ def analyze_block(lines, ntot, nelm, ml=False):
267283
virial[0][2] = tmp_v[5]
268284
virial[2][0] = tmp_v[5]
269285
elif "TOTAL-FORCE" in ii and (("ML" in ii) == ml):
286+
block_coord = []
287+
block_force = []
270288
for jj in range(idx + 2, idx + 2 + ntot):
271-
tmp_l = lines[jj]
272-
info = [float(ss) for ss in tmp_l.split()]
273-
coord.append(info[:3])
274-
force.append(info[3:6])
289+
if jj >= len(lines):
290+
# VASP output can end while a force table is being
291+
# written. Do not index beyond the block or retain the
292+
# preceding subset of atoms as a complete frame.
293+
raise _IncompleteForceTableError(
294+
f"expected {ntot} atom rows, found {len(block_force)}"
295+
)
296+
fields = lines[jj].split()
297+
if len(fields) < 6:
298+
raise _IncompleteForceTableError(
299+
f"expected 6 numeric columns, got {len(fields)} in {lines[jj]!r}"
300+
)
301+
try:
302+
info = [float(ss) for ss in fields[:6]]
303+
except ValueError as exc:
304+
# A non-numeric record with at least six fields, such as
305+
# the following energy record, means the table ended
306+
# before ``ntot`` atom rows. Parse into temporary lists
307+
# so none of the incomplete table leaks into the frame.
308+
raise _IncompleteForceTableError(
309+
f"non-numeric atom row {lines[jj]!r}"
310+
) from exc
311+
block_coord.append(info[:3])
312+
block_force.append(info[3:6])
313+
coord = block_coord
314+
force = block_force
275315
return coord, cell, energy, force, virial, is_converge

tests/test_vasp_outcar.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
from __future__ import annotations
22

3+
import io
34
import unittest
5+
import warnings
46

57
import numpy as np
68
from comp_sys import CompLabeledSys, IsPBC
79
from context import dpdata
810

11+
from dpdata.formats.vasp.outcar import _get_frames_lower
912
from dpdata.utils import uniq_atom_names
1013

1114

@@ -21,6 +24,83 @@ def setUp(self):
2124
self.v_places = 4
2225

2326

27+
class TestVaspOUTCARIncompleteForceTable(unittest.TestCase):
28+
"""Regression tests for force tables cut short before all atom rows."""
29+
30+
@staticmethod
31+
def _block(force_rows, *, include_header=False, energy=-1.0):
32+
lines = []
33+
if include_header:
34+
lines.extend(
35+
[
36+
" TITEL = PAW_PBE H 15Jun2001",
37+
" NELM = 60; maximum number of electronic SC steps",
38+
" ions per type = 2",
39+
]
40+
)
41+
lines.extend(
42+
[
43+
" VOLUME and BASIS-vectors are now :",
44+
" filler",
45+
" filler",
46+
" filler",
47+
" filler",
48+
" 1.0 0.0 0.0",
49+
" 0.0 1.0 0.0",
50+
" 0.0 0.0 1.0",
51+
" POSITION TOTAL-FORCE (eV/Angst)",
52+
" -----------------------------------------------------------------------------------",
53+
*force_rows,
54+
f" free energy TOTEN = {energy:.6f} eV",
55+
]
56+
)
57+
return "\n".join(lines) + "\n"
58+
59+
def test_non_numeric_record_before_all_atoms_skips_incomplete_frame(self):
60+
# The first frame is valid, but the second table reaches its energy
61+
# record after only one of the two expected atoms. Older tests used
62+
# complete tables, so converting that non-numeric record was never
63+
# exercised.
64+
valid_rows = ["0 0 0 1 2 3", "1 1 1 4 5 6"]
65+
incomplete_rows = ["2 2 2 7 8 9"]
66+
contents = self._block(valid_rows, include_header=True) + self._block(
67+
incomplete_rows, energy=-2.0
68+
)
69+
70+
with warnings.catch_warnings(record=True) as caught:
71+
warnings.simplefilter("always")
72+
frames = _get_frames_lower(io.StringIO(contents), "OUTCAR")
73+
74+
self.assertEqual(frames[3].shape, (1, 3, 3))
75+
self.assertEqual(frames[4].shape, (1, 2, 3))
76+
self.assertEqual(frames[6].shape, (1, 2, 3))
77+
self.assertTrue(
78+
any("incomplete labels in frame 2" in str(item.message) for item in caught)
79+
)
80+
81+
def test_truncated_table_does_not_index_past_block(self):
82+
# A file truncated immediately after its first atom previously raised
83+
# IndexError while the parser blindly indexed all ``ntot`` rows.
84+
valid_rows = ["0 0 0 1 2 3", "1 1 1 4 5 6"]
85+
truncated_block = "\n".join(
86+
[
87+
" POSITION TOTAL-FORCE (eV/Angst)",
88+
" -----------------------------------------------------------------------------------",
89+
"0 0 0 1 2 3",
90+
]
91+
)
92+
contents = self._block(valid_rows, include_header=True) + truncated_block
93+
94+
with warnings.catch_warnings(record=True) as caught:
95+
warnings.simplefilter("always")
96+
frames = _get_frames_lower(io.StringIO(contents), "OUTCAR")
97+
98+
self.assertEqual(frames[4].shape, (1, 2, 3))
99+
self.assertTrue(
100+
any("expected 2 atom rows, found 1" in str(item.message) for item in caught)
101+
)
102+
103+
24104
class TestVaspOUTCARTypeMap(unittest.TestCase, CompLabeledSys, IsPBC):
25105
def setUp(self):
26106
sys0 = dpdata.LabeledSystem("poscars/OUTCAR.ch4.unconverged", fmt="vasp/outcar")

0 commit comments

Comments
 (0)