Skip to content

Commit 6686b33

Browse files
authored
Merge pull request #404 from SCM-NV/modica/vasp_timestep
Added timestep reading from OUTCAR file from VASP in order to correctly indentify the duration of a MD trajectory. Implemented task recognition through IBRION parameter inside the OUTCAR file.
2 parents f32b5f8 + e75d2e1 commit 6686b33

11 files changed

Lines changed: 10585 additions & 32 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ jobs:
8484
if [ "${{ matrix.python-version }}" = "3.8" ]; then
8585
python -m pip install rdkit==2023.9.5 --force-reinstall
8686
fi
87+
if [ "${{ matrix.python-version }}" = "3.13" ]; then
88+
python -m pip install rdkit==2026.3.2 --force-reinstall
89+
fi
8790
8891
- name: Run Black
8992
run: |

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ This changelog is effective from the 2025 releases.
1616

1717
### Changed
1818
* `view` function uses stdin mode for AMSview, reducing overhead for image creation
19+
* `vasp_output_to_ams` now reads the MD time step from `POTIM` in the OUTCAR and labels molecular-dynamics runs (`IBRION = 0`) as such, instead of using a fixed default time step
1920

2021
### Fixed
2122
* Loading job `.dill` files where the molecule contains a `pathlib.Path` (e.g. `Molecule.properties.source`)

src/scm/plams/mol/molecule.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2923,7 +2923,7 @@ def from_array(self, xyz_array: Any, atom_subset: Optional[Iterable[Atom]] = Non
29232923
for at, (x, y, z) in zip(atom_subset, xyz_array):
29242924
at.coords = (x, y, z)
29252925

2926-
def __array__(self, dtype: Optional["DTypeLike"] = None) -> np.ndarray:
2926+
def __array__(self, dtype: Optional["DTypeLike"] = None, copy: Optional[bool] = None) -> np.ndarray:
29272927
"""A magic method for constructing numpy arrays.
29282928
29292929
This method ensures that passing a |Molecule| instance to numpy.array_ produces an array of Cartesian coordinates (see :meth:`.Molecule.as_array`).
@@ -2933,7 +2933,11 @@ def __array__(self, dtype: Optional["DTypeLike"] = None) -> np.ndarray:
29332933
.. _`data type`: https://docs.scipy.org/doc/numpy/reference/arrays.dtypes.html
29342934
"""
29352935
ret = self.as_array()
2936-
return ret.astype(dtype, copy=False)
2936+
if dtype is not None:
2937+
ret = ret.astype(dtype, copy=False)
2938+
if copy:
2939+
ret = ret.copy()
2940+
return ret
29372941

29382942
# ===========================================================================
29392943
# ==== File/format IO =======================================================

src/scm/plams/tools/converters.py

Lines changed: 84 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,10 @@
2929

3030
@requires_optional_package("ase")
3131
def traj_to_rkf(
32-
trajfile: str, rkftrajectoryfile: str, task: Optional[str] = None, timestep: float = 0.25
32+
trajfile: str,
33+
rkftrajectoryfile: str,
34+
task: Optional[str] = None,
35+
timestep: float = 0.25,
3336
) -> Tuple[Optional["ndarray"], Optional["Cell"]]:
3437
"""
3538
Convert ase .traj file to .rkf file. NOTE: The order of atoms (or the number of atoms) cannot change between frames!
@@ -131,7 +134,7 @@ def traj_to_rkf(
131134
else:
132135
task = "moleculardynamics"
133136
kf["General%task"] = task
134-
kf["General%user input"] = "\xFF".join([f"Task {task}", "Engine External", "EndEngine"])
137+
kf["General%user input"] = "\xff".join([f"Task {task}", "Engine External", "EndEngine"])
135138

136139
return coords, cell
137140

@@ -213,19 +216,40 @@ def _postprocess_vasp_amsrkf(kffile: str, outcar: str) -> None:
213216
userinput.append(" EndInput") # end of the Free block
214217
userinput.append("EndEngine")
215218
userinput.append(f"Task {kf['General%task']}")
216-
kf["General%user input"] = "\xFF".join(userinput)
219+
kf["General%user input"] = "\xff".join(userinput)
217220

218221
finally:
219222
kf.save()
220223

221224

225+
def _read_md_params_from_outcar(outcar_path: str, max_lines: int = 100) -> Tuple[Optional[int], Optional[float]]:
226+
"""Read (IBRION, POTIM) from the INCAR reproduced at the top of a VASP OUTCAR.
227+
Only the first `max_lines` lines are scanned. Either value is None if not found."""
228+
ibrion, potim = None, None
229+
with open(outcar_path) as f:
230+
for i, line in enumerate(f):
231+
if i >= max_lines:
232+
break
233+
if ibrion is None and "IBRION" in line:
234+
m = re.search(r"IBRION\s*=\s*(-?\d+)", line)
235+
if m:
236+
ibrion = int(m.group(1))
237+
if potim is None and "POTIM" in line:
238+
m = re.search(r"POTIM\s*=\s*([\d.]+)", line)
239+
if m:
240+
potim = float(m.group(1))
241+
if ibrion is not None and potim is not None:
242+
break
243+
return ibrion, potim
244+
245+
222246
def vasp_output_to_ams(
223247
vasp_folder: str,
224248
wdir: Optional[str] = None,
225249
overwrite: bool = False,
226250
write_engine_rkf: bool = True,
227251
task: Optional[str] = None,
228-
timestep: float = 0.25,
252+
timestep: Optional[float] = None,
229253
) -> str:
230254
"""
231255
Converts VASP output (OUTCAR, ...) to AMS output (ams.rkf, vasp.rkf)
@@ -247,10 +271,14 @@ def vasp_output_to_ams(
247271
If True, also write vasp.rkf alongside ams.rkf. The vasp.rkf file will only contain an AMSResults section (energy, gradients, stress tensor). It will not contain the DOS or the band structure.
248272
249273
task : str
250-
Which task to write to ams.rkf. If None it is auto-determined (probably set to 'geometryoptimization')
251-
252-
timestep : float
253-
If task='moleculardynamics', which timestep (in fs) between frames to write
274+
Which task to write to ams.rkf. If None it is auto-determined:
275+
'moleculardynamics' if the OUTCAR is an MD run (IBRION = 0), otherwise
276+
determined from the trajectory (singlepoint / geometryoptimization).
277+
278+
timestep : float or None
279+
Time (in fs) between frames written to ams.rkf for an MD run.
280+
If None (default), it is taken from POTIM in the OUTCAR when the run is
281+
molecular dynamics (IBRION = 0); for non-MD runs no physical timestep applies.
254282
"""
255283
if not os.path.isdir(vasp_folder):
256284
raise ValueError(f"Directory {vasp_folder} does not exist")
@@ -270,6 +298,16 @@ def vasp_output_to_ams(
270298
if os.path.exists(os.path.join(wdir, "ams.rkf")) and not overwrite:
271299
return wdir
272300

301+
# Read IBRION/POTIM from the top of the OUTCAR (the INCAR is reproduced there).
302+
# IBRION = 0 means molecular dynamics, which determines both the timestep and the task.
303+
if timestep is None or task is None:
304+
ibrion, potim = _read_md_params_from_outcar(outcar, max_lines=100)
305+
is_md = ibrion == 0
306+
if timestep is None and is_md:
307+
timestep = potim
308+
if task is None and is_md:
309+
task = "moleculardynamics"
310+
273311
# convert OUTCAR to a .traj file inside wdir
274312
trajfile = file_to_traj(outcar, os.path.join(wdir, "vasp.traj"))
275313

@@ -279,8 +317,11 @@ def vasp_output_to_ams(
279317
_remove_or_raise(kffile, overwrite)
280318
_remove_or_raise(enginefile, overwrite)
281319

320+
# traj_to_rkf always expects a float timestep; outside MD the value is irrelevant.
321+
md_timestep = 0.25 if timestep is None else timestep
322+
282323
# convert the .traj file to ams.rkf
283-
traj_to_rkf(trajfile, kffile, task=task, timestep=timestep)
324+
traj_to_rkf(trajfile, kffile, task=task, timestep=md_timestep)
284325

285326
_postprocess_vasp_amsrkf(kffile, outcar)
286327
if write_engine_rkf:
@@ -309,7 +350,7 @@ def _postprocess_qe_amsrkf(kffile: str, qe_outfile: str) -> None:
309350
" EndInput",
310351
"EndEngine",
311352
]
312-
kf["General%user input"] = "\xFF".join(userinput)
353+
kf["General%user input"] = "\xff".join(userinput)
313354

314355
finally:
315356
kf.save()
@@ -324,8 +365,15 @@ def _postprocess_gaussian_amsrkf(kffile: str, gaussian_outfile: str) -> None:
324365
kf["EngineResults%Description(1)"] = f"Standalone Gaussian. Data from {os.path.abspath(gaussian_outfile)}"
325366
kf["EngineResults%Files(1)"] = "gaussian.rkf"
326367

327-
userinput = ["!Gaussian", "Engine External", " Input", " Unknown Gaussian input", " EndInput", "EndEngine"]
328-
kf["General%user input"] = "\xFF".join(userinput)
368+
userinput = [
369+
"!Gaussian",
370+
"Engine External",
371+
" Input",
372+
" Unknown Gaussian input",
373+
" EndInput",
374+
"EndEngine",
375+
]
376+
kf["General%user input"] = "\xff".join(userinput)
329377

330378
finally:
331379
kf.save()
@@ -402,7 +450,10 @@ def text_out_file_to_ams(
402450

403451

404452
def qe_output_to_ams(
405-
qe_outfile: str, wdir: Optional[str] = None, overwrite: bool = False, write_engine_rkf: bool = True
453+
qe_outfile: str,
454+
wdir: Optional[str] = None,
455+
overwrite: bool = False,
456+
write_engine_rkf: bool = True,
406457
) -> str:
407458
"""
408459
Converts a qe .out file to ams.rkf and qe.rkf.
@@ -419,14 +470,21 @@ def qe_output_to_ams(
419470
420471
"""
421472
wdir = text_out_file_to_ams(
422-
qe_outfile, wdir, overwrite=overwrite, write_engine_rkf=write_engine_rkf, enginename="qe"
473+
qe_outfile,
474+
wdir,
475+
overwrite=overwrite,
476+
write_engine_rkf=write_engine_rkf,
477+
enginename="qe",
423478
)
424479
_postprocess_qe_amsrkf(os.path.join(wdir, "ams.rkf"), qe_outfile)
425480
return wdir
426481

427482

428483
def gaussian_output_to_ams(
429-
outfile: str, wdir: Optional[str] = None, overwrite: bool = False, write_engine_rkf: bool = True
484+
outfile: str,
485+
wdir: Optional[str] = None,
486+
overwrite: bool = False,
487+
write_engine_rkf: bool = True,
430488
) -> str:
431489
"""
432490
Converts a Gaussian .out file to ams.rkf and gaussian.rkf.
@@ -443,7 +501,11 @@ def gaussian_output_to_ams(
443501
444502
"""
445503
wdir = text_out_file_to_ams(
446-
outfile, wdir, overwrite=overwrite, write_engine_rkf=write_engine_rkf, enginename="gaussian"
504+
outfile,
505+
wdir,
506+
overwrite=overwrite,
507+
write_engine_rkf=write_engine_rkf,
508+
enginename="gaussian",
447509
)
448510
_postprocess_gaussian_amsrkf(os.path.join(wdir, "ams.rkf"), outfile)
449511
return wdir
@@ -484,7 +546,12 @@ def get_ase_atoms(
484546
pbc = ["T"] * len(cell_arr) + ["F"] * (3 - len(cell_arr))
485547
else:
486548
cell_arr = None
487-
atoms = Atoms(symbols=elements, positions=np.array(crd).reshape(-1, 3), cell=cell_arr, pbc=pbc)
549+
atoms = Atoms(
550+
symbols=elements,
551+
positions=np.array(crd).reshape(-1, 3),
552+
cell=cell_arr,
553+
pbc=pbc,
554+
)
488555
if get_results:
489556
calculator = SinglePointCalculator(atoms)
490557
atoms.set_calculator(calculator)

src/scm/plams/tools/view.py

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1230,12 +1230,7 @@ def generate_image(cls, system: Union[Molecule, "ChemicalSystem"], config: ViewC
12301230
elif _has_scm_chemsys and isinstance(system, ChemicalSystem):
12311231
regions = [system.get_regions_of_atom(at) for at in system]
12321232

1233-
try:
1234-
import matplotlib.colormaps as colormaps
1235-
except ImportError:
1236-
import matplotlib.cm as colormaps
1237-
1238-
cmap = colormaps.get_cmap("tab10")
1233+
cmap = plt.get_cmap("tab10")
12391234
color_counter = 0
12401235
region_cmap: Dict[str, colors.Colormap] = {}
12411236
for patch in ax.patches:
@@ -1252,7 +1247,7 @@ def generate_image(cls, system: Union[Molecule, "ChemicalSystem"], config: ViewC
12521247
if region in region_cmap:
12531248
region_color = region_cmap[region]
12541249
else:
1255-
region_color = cmap.colors[color_counter]
1250+
region_color = cmap.colors[color_counter] # type: ignore[attr-defined,index]
12561251
region_cmap[region] = region_color
12571252
color_counter += 1
12581253
region_patch = patches.Circle(

0 commit comments

Comments
 (0)