pymdkit 1.2.12__tar.gz → 1.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. {pymdkit-1.2.12/src/pymdkit.egg-info → pymdkit-1.3.0}/PKG-INFO +4 -3
  2. {pymdkit-1.2.12 → pymdkit-1.3.0}/README.md +2 -2
  3. {pymdkit-1.2.12 → pymdkit-1.3.0}/pyproject.toml +2 -1
  4. {pymdkit-1.2.12 → pymdkit-1.3.0}/src/pymdkit/__init__.py +1 -1
  5. pymdkit-1.3.0/src/pymdkit/commands/_vasp_output.py +397 -0
  6. {pymdkit-1.2.12 → pymdkit-1.3.0}/src/pymdkit/commands/nep_rmse.py +76 -63
  7. {pymdkit-1.2.12 → pymdkit-1.3.0/src/pymdkit.egg-info}/PKG-INFO +4 -3
  8. {pymdkit-1.2.12 → pymdkit-1.3.0}/src/pymdkit.egg-info/requires.txt +1 -0
  9. pymdkit-1.2.12/src/pymdkit/commands/_vasp_output.py +0 -174
  10. {pymdkit-1.2.12 → pymdkit-1.3.0}/LICENSE +0 -0
  11. {pymdkit-1.2.12 → pymdkit-1.3.0}/setup.cfg +0 -0
  12. {pymdkit-1.2.12 → pymdkit-1.3.0}/src/pymdkit/commands/__init__.py +0 -0
  13. {pymdkit-1.2.12 → pymdkit-1.3.0}/src/pymdkit/commands/_fileio.py +0 -0
  14. {pymdkit-1.2.12 → pymdkit-1.3.0}/src/pymdkit/commands/_vaspset.py +0 -0
  15. {pymdkit-1.2.12 → pymdkit-1.3.0}/src/pymdkit/commands/add_groups.py +0 -0
  16. {pymdkit-1.2.12 → pymdkit-1.3.0}/src/pymdkit/commands/compute_ehull.py +0 -0
  17. {pymdkit-1.2.12 → pymdkit-1.3.0}/src/pymdkit/commands/compute_msd_all_groups.py +0 -0
  18. {pymdkit-1.2.12 → pymdkit-1.3.0}/src/pymdkit/commands/compute_rmsd.py +0 -0
  19. {pymdkit-1.2.12 → pymdkit-1.3.0}/src/pymdkit/commands/electrostatic_energy.py +0 -0
  20. {pymdkit-1.2.12 → pymdkit-1.3.0}/src/pymdkit/commands/final_energy.py +0 -0
  21. {pymdkit-1.2.12 → pymdkit-1.3.0}/src/pymdkit/commands/gather_contcar.py +0 -0
  22. {pymdkit-1.2.12 → pymdkit-1.3.0}/src/pymdkit/commands/perturb.py +0 -0
  23. {pymdkit-1.2.12 → pymdkit-1.3.0}/src/pymdkit/commands/stable_entry.py +0 -0
  24. {pymdkit-1.2.12 → pymdkit-1.3.0}/src/pymdkit/commands/stru2xyz.py +0 -0
  25. {pymdkit-1.2.12 → pymdkit-1.3.0}/src/pymdkit/commands/submit_vasp.py +0 -0
  26. {pymdkit-1.2.12 → pymdkit-1.3.0}/src/pymdkit/commands/substitute.py +0 -0
  27. {pymdkit-1.2.12 → pymdkit-1.3.0}/src/pymdkit/commands/supercell.py +0 -0
  28. {pymdkit-1.2.12 → pymdkit-1.3.0}/src/pymdkit/commands/symmetrize.py +0 -0
  29. {pymdkit-1.2.12 → pymdkit-1.3.0}/src/pymdkit/commands/vasp2xyz.py +0 -0
  30. {pymdkit-1.2.12 → pymdkit-1.3.0}/src/pymdkit/commands/vasp_relax.py +0 -0
  31. {pymdkit-1.2.12 → pymdkit-1.3.0}/src/pymdkit/commands/vasp_static.py +0 -0
  32. {pymdkit-1.2.12 → pymdkit-1.3.0}/src/pymdkit/pymdkit_main.py +0 -0
  33. {pymdkit-1.2.12 → pymdkit-1.3.0}/src/pymdkit.egg-info/SOURCES.txt +0 -0
  34. {pymdkit-1.2.12 → pymdkit-1.3.0}/src/pymdkit.egg-info/dependency_links.txt +0 -0
  35. {pymdkit-1.2.12 → pymdkit-1.3.0}/src/pymdkit.egg-info/entry_points.txt +0 -0
  36. {pymdkit-1.2.12 → pymdkit-1.3.0}/src/pymdkit.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pymdkit
3
- Version: 1.2.12
3
+ Version: 1.3.0
4
4
  Summary: A unified command-line toolkit for atomistic / MD structure workflows.
5
5
  Author-email: Yueda Wang <ydwang0608@ustc.edu.cn>
6
6
  License-Expression: GPL-3.0-or-later
@@ -17,6 +17,7 @@ Requires-Dist: numpy
17
17
  Requires-Dist: ase
18
18
  Requires-Dist: pymatgen
19
19
  Requires-Dist: mp-api
20
+ Requires-Dist: py4vasp-core
20
21
  Requires-Dist: pyxtal
21
22
  Requires-Dist: dpdata
22
23
  Dynamic: license-file
@@ -66,7 +67,7 @@ directory for job sub-folders automatically.
66
67
  | `final-energy` | List VASP job final energies from lowest to highest |
67
68
  | `gather-contcar` | Collect CONTCARs from VASP job folders into one folder, renamed `<folder>.vasp` |
68
69
  | `msd` | Diffusivity & conductivity from GPUMD MSD jobs (auto-scans `<structure>/<temp>/`) |
69
- | `nep-rmse` | Compute NEP energy/force/stress RMSE with ASCII plots and optional candidate selection |
70
+ | `nep-rmse` | Compute NEP energy/force/stress RMSE with terminal plots and optional candidate selection |
70
71
  | `perturb` | Generate perturbed structures with dpdata |
71
72
  | `vasp2xyz` | Collect SCF-converged VASP job folders (any name) into one extxyz file |
72
73
  | `submit-vasp` | Submit/resubmit VASP job folders while limiting active queue jobs |
@@ -116,7 +117,7 @@ pymdkit substitute -i Li96Ta6La11Cl72.cif -se Li2 -we none -ref La Ta
116
117
  pymdkit electrostatic-energy -i Li3YCl6.cif
117
118
  pymdkit electrostatic-energy -if Li3YCl6-all
118
119
  pymdkit nep-rmse # writes energy/force/stress train txt files, rmse_value.txt, and terminal plots
119
- pymdkit nep-rmse -select-candidate -xyz train.xyz # interactive candidate selection; writes candidate.xyz and accurate.xyz
120
+ pymdkit nep-rmse -select -xyz train.xyz # interactive candidate selection; writes candidate.xyz and accurate.xyz
120
121
  pymdkit perturb -i example.xyz -atom 0.2 -lattice 0.03 -n 100 -o example-perturb-atom-0.2-lattice-0.03.xyz
121
122
 
122
123
  pymdkit rmsd a.cif b.cif # RMSD of two files -> rmsd.txt
@@ -43,7 +43,7 @@ directory for job sub-folders automatically.
43
43
  | `final-energy` | List VASP job final energies from lowest to highest |
44
44
  | `gather-contcar` | Collect CONTCARs from VASP job folders into one folder, renamed `<folder>.vasp` |
45
45
  | `msd` | Diffusivity & conductivity from GPUMD MSD jobs (auto-scans `<structure>/<temp>/`) |
46
- | `nep-rmse` | Compute NEP energy/force/stress RMSE with ASCII plots and optional candidate selection |
46
+ | `nep-rmse` | Compute NEP energy/force/stress RMSE with terminal plots and optional candidate selection |
47
47
  | `perturb` | Generate perturbed structures with dpdata |
48
48
  | `vasp2xyz` | Collect SCF-converged VASP job folders (any name) into one extxyz file |
49
49
  | `submit-vasp` | Submit/resubmit VASP job folders while limiting active queue jobs |
@@ -93,7 +93,7 @@ pymdkit substitute -i Li96Ta6La11Cl72.cif -se Li2 -we none -ref La Ta
93
93
  pymdkit electrostatic-energy -i Li3YCl6.cif
94
94
  pymdkit electrostatic-energy -if Li3YCl6-all
95
95
  pymdkit nep-rmse # writes energy/force/stress train txt files, rmse_value.txt, and terminal plots
96
- pymdkit nep-rmse -select-candidate -xyz train.xyz # interactive candidate selection; writes candidate.xyz and accurate.xyz
96
+ pymdkit nep-rmse -select -xyz train.xyz # interactive candidate selection; writes candidate.xyz and accurate.xyz
97
97
  pymdkit perturb -i example.xyz -atom 0.2 -lattice 0.03 -n 100 -o example-perturb-atom-0.2-lattice-0.03.xyz
98
98
 
99
99
  pymdkit rmsd a.cif b.cif # RMSD of two files -> rmsd.txt
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pymdkit"
7
- version = "1.2.12"
7
+ version = "1.3.0"
8
8
  description = "A unified command-line toolkit for atomistic / MD structure workflows."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -28,6 +28,7 @@ dependencies = [
28
28
  "ase",
29
29
  "pymatgen",
30
30
  "mp-api",
31
+ "py4vasp-core",
31
32
  "pyxtal",
32
33
  "dpdata",
33
34
  ]
@@ -1,3 +1,3 @@
1
1
  """pymdkit -- a unified CLI for atomistic / MD structure workflows."""
2
2
 
3
- __version__ = "1.2.12"
3
+ __version__ = "1.3.0"
@@ -0,0 +1,397 @@
1
+ """Shared VASP output readers.
2
+
3
+ Reader priority is always:
4
+
5
+ vaspout.h5 > vasprun.xml > OUTCAR
6
+
7
+ Commands that extract properties from VASP outputs should use this helper so
8
+ newer vaspout.h5 files are preferred consistently across pymdkit.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+ import math
15
+ from pathlib import Path
16
+
17
+
18
+ @dataclass
19
+ class VaspOutput:
20
+ folder: Path
21
+ source: str
22
+ parser: object
23
+
24
+ @property
25
+ def final_energy(self):
26
+ return getattr(self.parser, "final_energy")
27
+
28
+ @property
29
+ def final_structure(self):
30
+ return getattr(self.parser, "final_structure", None)
31
+
32
+ @property
33
+ def ionic_steps(self):
34
+ return getattr(self.parser, "ionic_steps", [])
35
+
36
+ @property
37
+ def converged_electronic(self):
38
+ return bool(getattr(self.parser, "converged_electronic", False))
39
+
40
+ @property
41
+ def converged(self):
42
+ return bool(getattr(self.parser, "converged", self.converged_electronic))
43
+
44
+ def get_computed_entry(self, inc_structure=True, entry_id=None):
45
+ if not hasattr(self.parser, "get_computed_entry"):
46
+ from pymatgen.entries.computed_entries import ComputedStructureEntry
47
+
48
+ structure = self.final_structure
49
+ if structure is None:
50
+ structure_file = None
51
+ for name in ("CONTCAR", "POSCAR"):
52
+ candidate = self.folder / name
53
+ if candidate.exists():
54
+ structure_file = candidate
55
+ break
56
+ if structure_file is None:
57
+ raise AttributeError("get_computed_entry")
58
+ structure = _read_structure(structure_file)
59
+ return ComputedStructureEntry(
60
+ structure=structure,
61
+ energy=self.final_energy,
62
+ parameters={"run_type": "GGA"},
63
+ entry_id=entry_id or self.folder.name,
64
+ )
65
+
66
+ entry = self.parser.get_computed_entry(
67
+ inc_structure=inc_structure,
68
+ entry_id=entry_id or self.folder.name,
69
+ )
70
+ if entry.parameters.get("run_type") == "PBE":
71
+ entry.parameters["run_type"] = "GGA"
72
+ return entry
73
+
74
+
75
+ def _read_structure(path):
76
+ from pymatgen.core import Structure
77
+
78
+ return Structure.from_file(str(path))
79
+
80
+
81
+ class OutcarAdapter:
82
+ """Small adapter for the limited properties available from OUTCAR alone."""
83
+
84
+ def __init__(self, path):
85
+ from pymatgen.io.vasp.outputs import Outcar
86
+
87
+ self.path = Path(path)
88
+ self.outcar = Outcar(str(path))
89
+ self._text = self.path.read_text(errors="ignore")
90
+
91
+ @property
92
+ def final_energy(self):
93
+ for attr in ("final_energy", "final_fr_energy", "final_energy_wo_entrp"):
94
+ if hasattr(self.outcar, attr):
95
+ value = getattr(self.outcar, attr)
96
+ if value is not None:
97
+ return value
98
+ raise AttributeError("final_energy")
99
+
100
+ @property
101
+ def ionic_steps(self):
102
+ return []
103
+
104
+ @property
105
+ def final_structure(self):
106
+ return None
107
+
108
+ @property
109
+ def converged_electronic(self):
110
+ markers = (
111
+ "aborting loop because EDIFF is reached",
112
+ "reached required accuracy",
113
+ )
114
+ return any(marker in self._text for marker in markers)
115
+
116
+ @property
117
+ def converged(self):
118
+ return (self.converged_electronic
119
+ and "reached required accuracy" in self._text)
120
+
121
+
122
+
123
+ def _last_scalar(value):
124
+ try:
125
+ import numpy as np
126
+ except ImportError:
127
+ np = None
128
+
129
+ if np is not None:
130
+ try:
131
+ array = np.asarray(value, dtype=float)
132
+ except (TypeError, ValueError):
133
+ return None
134
+ if array.size == 0:
135
+ return None
136
+ return float(array.reshape(-1)[-1])
137
+ if isinstance(value, (int, float)) and math.isfinite(value):
138
+ return float(value)
139
+ if isinstance(value, (list, tuple)) and value:
140
+ return _last_scalar(value[-1])
141
+ return None
142
+
143
+
144
+ def _find_scalar_quantity(data, names):
145
+ if isinstance(data, dict):
146
+ for name in names:
147
+ if name in data:
148
+ value = _last_scalar(data[name])
149
+ if value is not None:
150
+ return value
151
+ for value in data.values():
152
+ found = _find_scalar_quantity(value, names)
153
+ if found is not None:
154
+ return found
155
+ elif isinstance(data, (list, tuple)):
156
+ for value in reversed(data):
157
+ found = _find_scalar_quantity(value, names)
158
+ if found is not None:
159
+ return found
160
+ return None
161
+ def _last_numeric_array(value):
162
+ try:
163
+ import numpy as np
164
+ except ImportError:
165
+ np = None
166
+
167
+ if np is not None:
168
+ try:
169
+ array = np.asarray(value, dtype=float)
170
+ except (TypeError, ValueError):
171
+ return None
172
+ if array.size == 0:
173
+ return None
174
+ while array.ndim > 2 and array.shape[0] > 0:
175
+ array = array[-1]
176
+ if array.ndim == 0:
177
+ return float(array)
178
+ return array.tolist()
179
+
180
+ if isinstance(value, (int, float)) and math.isfinite(value):
181
+ return float(value)
182
+ if isinstance(value, (list, tuple)) and value:
183
+ return _last_numeric_array(value[-1])
184
+ return None
185
+
186
+
187
+ def _find_quantity(data, names):
188
+ if isinstance(data, dict):
189
+ for name in names:
190
+ if name in data:
191
+ value = _last_numeric_array(data[name])
192
+ if value is not None:
193
+ return value
194
+ for value in data.values():
195
+ found = _find_quantity(value, names)
196
+ if found is not None:
197
+ return found
198
+ elif isinstance(data, (list, tuple)):
199
+ for value in reversed(data):
200
+ found = _find_quantity(value, names)
201
+ if found is not None:
202
+ return found
203
+ return None
204
+
205
+
206
+
207
+ def _last_stress_array(value):
208
+ try:
209
+ import numpy as np
210
+ except ImportError:
211
+ np = None
212
+
213
+ if np is not None:
214
+ try:
215
+ array = np.asarray(value, dtype=float)
216
+ except (TypeError, ValueError):
217
+ return None
218
+ if array.size == 0:
219
+ return None
220
+ while array.ndim > 2 and array.shape[0] > 0:
221
+ array = array[-1]
222
+ if array.ndim == 2 and array.shape[1] in (6, 9):
223
+ array = array[-1]
224
+ if array.ndim == 0:
225
+ return float(array)
226
+ return array.tolist()
227
+
228
+ if isinstance(value, (int, float)) and math.isfinite(value):
229
+ return float(value)
230
+ if isinstance(value, (list, tuple)) and value:
231
+ if value and isinstance(value[0], (list, tuple)) and len(value[0]) in (6, 9):
232
+ return _last_stress_array(value[-1])
233
+ return _last_stress_array(value[-1])
234
+ return None
235
+
236
+
237
+ def _find_stress_quantity(data, names):
238
+ if isinstance(data, dict):
239
+ for name in names:
240
+ if name in data:
241
+ value = _last_stress_array(data[name])
242
+ if value is not None:
243
+ return value
244
+ for value in data.values():
245
+ found = _find_stress_quantity(value, names)
246
+ if found is not None:
247
+ return found
248
+ elif isinstance(data, (list, tuple)):
249
+ for value in reversed(data):
250
+ found = _find_stress_quantity(value, names)
251
+ if found is not None:
252
+ return found
253
+ return None
254
+ class Py4VaspAdapter:
255
+ """Adapter for reading vaspout.h5 with the official py4vasp package."""
256
+
257
+ def __init__(self, path):
258
+ import py4vasp
259
+
260
+ self.path = Path(path)
261
+ self.folder = self.path.parent
262
+ self.calculation = py4vasp.Calculation.from_path(str(self.folder))
263
+ self._final_energy = None
264
+ self._final_structure = None
265
+ self._ionic_steps = None
266
+
267
+ @property
268
+ def final_energy(self):
269
+ if self._final_energy is None:
270
+ data = self.calculation.energy.to_dict()
271
+ value = _find_scalar_quantity(data, (
272
+ "free energy TOTEN",
273
+ "free_energy",
274
+ "free energy",
275
+ "energy",
276
+ "total",
277
+ ))
278
+ if value is None:
279
+ raise AttributeError("final_energy")
280
+ self._final_energy = value
281
+ return self._final_energy
282
+
283
+ @property
284
+ def final_structure(self):
285
+ if self._final_structure is None:
286
+ from pymatgen.io.ase import AseAtomsAdaptor
287
+
288
+ structure = self.calculation.structure
289
+ atoms = None
290
+ for getter in (
291
+ lambda: structure.to_ase(),
292
+ lambda: structure[-1].to_ase(),
293
+ lambda: structure[:].to_ase()[-1],
294
+ ):
295
+ try:
296
+ atoms = getter()
297
+ break
298
+ except Exception:
299
+ continue
300
+ if atoms is None:
301
+ raise AttributeError("final_structure")
302
+ self._final_structure = AseAtomsAdaptor.get_structure(atoms)
303
+ return self._final_structure
304
+
305
+ @property
306
+ def ionic_steps(self):
307
+ if self._ionic_steps is None:
308
+ step = {}
309
+ try:
310
+ force_data = self.calculation.force.to_dict()
311
+ forces = _find_quantity(force_data, ("forces", "force"))
312
+ if forces is not None:
313
+ step["forces"] = forces
314
+ except Exception:
315
+ pass
316
+ try:
317
+ stress_data = self.calculation.stress.to_dict()
318
+ stress = _find_stress_quantity(stress_data, ("stress", "stresses"))
319
+ if stress is not None:
320
+ step["stress"] = stress
321
+ except Exception:
322
+ pass
323
+ self._ionic_steps = [step] if step else []
324
+ return self._ionic_steps
325
+
326
+ @property
327
+ def converged_electronic(self):
328
+ outcar = self.folder / "OUTCAR"
329
+ if outcar.exists():
330
+ try:
331
+ return OutcarAdapter(outcar).converged_electronic
332
+ except Exception:
333
+ pass
334
+ return True
335
+
336
+ @property
337
+ def converged(self):
338
+ outcar = self.folder / "OUTCAR"
339
+ if outcar.exists():
340
+ try:
341
+ return OutcarAdapter(outcar).converged
342
+ except Exception:
343
+ pass
344
+ return self.converged_electronic
345
+
346
+
347
+ def _parse_vaspout(path):
348
+ return Py4VaspAdapter(path)
349
+
350
+
351
+ def _parse_vasprun(path):
352
+ from pymatgen.io.vasp.outputs import Vasprun
353
+
354
+ return Vasprun(str(path), parse_dos=False, parse_eigen=False)
355
+
356
+
357
+ def _parse_outcar(path):
358
+ return OutcarAdapter(path)
359
+
360
+
361
+ def read_vasp_output(folder, require=("final_energy",)):
362
+ """Read the best available VASP output in *folder*.
363
+
364
+ Args:
365
+ folder: VASP job folder.
366
+ require: Attribute names that must be available on the parser.
367
+
368
+ Returns:
369
+ VaspOutput or None if no parser can provide the requested properties.
370
+ """
371
+ folder = Path(folder)
372
+ candidates = (
373
+ ("vaspout.h5", _parse_vaspout),
374
+ ("vasprun.xml", _parse_vasprun),
375
+ ("OUTCAR", _parse_outcar),
376
+ )
377
+ for filename, parser_fn in candidates:
378
+ path = folder / filename
379
+ if not path.exists():
380
+ continue
381
+ try:
382
+ parser = parser_fn(path)
383
+ output = VaspOutput(folder=folder, source=filename, parser=parser)
384
+ for attr in require:
385
+ value = getattr(output, attr)
386
+ if value is None:
387
+ raise AttributeError(attr)
388
+ return output
389
+ except Exception:
390
+ continue
391
+ return None
392
+
393
+
394
+ def has_vasp_output(folder):
395
+ folder = Path(folder)
396
+ return any((folder / name).exists()
397
+ for name in ("vaspout.h5", "vasprun.xml", "OUTCAR", "CONTCAR"))
@@ -1,4 +1,4 @@
1
- """Compute and ASCII-plot NEP train RMSE for energy, force, and stress."""
1
+ """Compute and terminal-plot NEP train RMSE for energy, force, and stress."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -7,7 +7,7 @@ import math
7
7
  from pathlib import Path
8
8
 
9
9
  COMMAND = "nep-rmse"
10
- HELP = "Compute NEP energy/force/stress RMSE and draw ASCII plots."
10
+ HELP = "Compute NEP energy/force/stress RMSE and draw terminal plots."
11
11
 
12
12
 
13
13
  def add_arguments(parser: argparse.ArgumentParser) -> None:
@@ -18,10 +18,12 @@ def add_arguments(parser: argparse.ArgumentParser) -> None:
18
18
  help="NEP force_train.out file.")
19
19
  parser.add_argument("-stress-train", dest="stress_train", default="stress_train.out",
20
20
  help="NEP stress_train.out file.")
21
- parser.add_argument("-select-candidate", action="store_true",
21
+ parser.add_argument("-select", dest="select_candidate", action="store_true",
22
22
  help="Interactively select candidate structures from train.xyz.")
23
+ parser.add_argument("-select-candidate", dest="select_candidate",
24
+ action="store_true", help=argparse.SUPPRESS)
23
25
  parser.add_argument("-xyz", default="train.xyz",
24
- help="Training trajectory for -select-candidate mode.")
26
+ help="Training trajectory for -select mode.")
25
27
 
26
28
 
27
29
  def load_training(args):
@@ -101,7 +103,10 @@ def write_rmse_report(sections):
101
103
  def axis_range(*tables):
102
104
  import numpy as np
103
105
 
104
- merged = np.vstack([t for t in tables if t is not None and len(t)])
106
+ available = [t for t in tables if t is not None and len(t)]
107
+ if not available:
108
+ return -1.0, 1.0
109
+ merged = np.vstack(available)
105
110
  lo = float(np.min(merged))
106
111
  hi = float(np.max(merged))
107
112
  pad = 0.1 * (hi - lo) if hi > lo else 1.0
@@ -116,54 +121,45 @@ def metric_value(metric, vals):
116
121
  return vals["stress_GPa"], "GPa", 4
117
122
 
118
123
 
119
- def ascii_terminal_plot(metric, title, xlabel, ylabel, table,
120
- all_vals, candidate_table=None, accurate_table=None,
121
- candidate_vals=None, accurate_vals=None,
122
- width=72, height=22):
123
- xmin, xmax = axis_range(table, candidate_table, accurate_table)
124
- grid = [[" " for _ in range(width)] for _ in range(height)]
124
+ def _place_point(grid, x, y, xmin, xmax, marker):
125
+ width = len(grid[0])
126
+ height = len(grid)
127
+ if xmax == xmin:
128
+ return
129
+ col = int(round((x - xmin) / (xmax - xmin) * (width - 1)))
130
+ row = int(round((xmax - y) / (xmax - xmin) * (height - 1)))
131
+ if 0 <= row < height and 0 <= col < width:
132
+ grid[row][col] = marker
125
133
 
126
- def place(x, y, char):
127
- if xmax == xmin:
128
- return
129
- col = int(round((x - xmin) / (xmax - xmin) * (width - 1)))
130
- row = int(round((xmax - y) / (xmax - xmin) * (height - 1)))
131
- if 0 <= row < height and 0 <= col < width:
132
- grid[row][col] = char
133
-
134
- for i in range(width):
135
- x = xmin + (xmax - xmin) * i / max(width - 1, 1)
136
- place(x, x, "/")
137
134
 
135
+ def _plot_panel(table, candidate_table=None, accurate_table=None,
136
+ width=56, height=18):
137
+ xmin, xmax = axis_range(table, candidate_table, accurate_table)
138
+ grid = [[" " for _ in range(width)] for _ in range(height)]
138
139
  if candidate_table is not None or accurate_table is not None:
139
140
  if accurate_table is not None:
140
141
  for x, y in accurate_table:
141
- place(x, y, "o")
142
+ _place_point(grid, x, y, xmin, xmax, "\u25CB")
142
143
  if candidate_table is not None:
143
144
  for x, y in candidate_table:
144
- place(x, y, ".")
145
- legend = ".=candidate, o=accurate, /=DFT=NEP"
145
+ _place_point(grid, x, y, xmin, xmax, "\u25CF")
146
146
  else:
147
147
  for x, y in table:
148
- place(x, y, "o")
149
- legend = "o=all, /=DFT=NEP"
150
-
151
- value, unit, prec = metric_value(metric, all_vals)
152
- title_line = f"{title} RMSE: all {value:.{prec}f} {unit}"
153
- if candidate_vals is not None and accurate_vals is not None:
154
- cand, _, _ = metric_value(metric, candidate_vals)
155
- acc, _, _ = metric_value(metric, accurate_vals)
156
- title_line += f"; candidate {cand:.{prec}f}; accurate {acc:.{prec}f}"
157
-
158
- print(f"\n{title} RMSE plot")
159
- print(title_line)
160
- print(f"y: {ylabel}")
161
- print("+" + "-" * width + "+")
162
- for row in grid:
163
- print("|" + "".join(row) + "|")
164
- print("+" + "-" * width + "+")
165
- print(f"x: {xlabel}")
166
- print(f"range: {xmin:.6g} to {xmax:.6g}; {legend}")
148
+ _place_point(grid, x, y, xmin, xmax, "\u25CB")
149
+ return ["".join(row) for row in grid]
150
+
151
+
152
+ def _rmse_plot_title(all_vals, candidate_vals=None, accurate_vals=None):
153
+ parts = []
154
+ for metric, label in (("energy", "Energy"), ("force", "Force"), ("stress", "Stress")):
155
+ value, unit, prec = metric_value(metric, all_vals)
156
+ text = f"{label}: all {value:.{prec}f} {unit}"
157
+ if candidate_vals is not None and accurate_vals is not None:
158
+ cand, _, _ = metric_value(metric, candidate_vals)
159
+ acc, _, _ = metric_value(metric, accurate_vals)
160
+ text += f", selected {cand:.{prec}f}, unselected {acc:.{prec}f}"
161
+ parts.append(text)
162
+ return " | ".join(parts)
167
163
 
168
164
 
169
165
  def plot_rmse(all_vals, energy_txt, force_txt, stress_txt,
@@ -171,19 +167,18 @@ def plot_rmse(all_vals, energy_txt, force_txt, stress_txt,
171
167
  candidate_vals=None, accurate_vals=None):
172
168
  candidate_tables = candidate_tables or {}
173
169
  accurate_tables = accurate_tables or {}
174
- ascii_terminal_plot(
175
- "energy", "Energy", "DFT energy (eV/atom)", "NEP energy (eV/atom)",
176
- energy_txt, all_vals, candidate_tables.get("energy"), accurate_tables.get("energy"),
177
- candidate_vals, accurate_vals)
178
- ascii_terminal_plot(
179
- "force", "Force", "DFT force (eV/A)", "NEP force (eV/A)",
180
- force_txt, all_vals, candidate_tables.get("force"), accurate_tables.get("force"),
181
- candidate_vals, accurate_vals)
182
- ascii_terminal_plot(
183
- "stress", "Stress", "DFT stress (GPa)", "NEP stress (GPa)",
184
- stress_txt, all_vals, candidate_tables.get("stress"), accurate_tables.get("stress"),
185
- candidate_vals, accurate_vals)
186
-
170
+ panels = [
171
+ _plot_panel(energy_txt, candidate_tables.get("energy"), accurate_tables.get("energy")),
172
+ _plot_panel(force_txt, candidate_tables.get("force"), accurate_tables.get("force")),
173
+ _plot_panel(stress_txt, candidate_tables.get("stress"), accurate_tables.get("stress")),
174
+ ]
175
+ width = len(panels[0][0])
176
+ print("\nNEP RMSE plot")
177
+ print(_rmse_plot_title(all_vals, candidate_vals, accurate_vals))
178
+ print("\u250C" + ("\u2500" * width) + "\u252C" + ("\u2500" * width) + "\u252C" + ("\u2500" * width) + "\u2510")
179
+ for row in range(len(panels[0])):
180
+ print("\u2502" + panels[0][row] + "\u2502" + panels[1][row] + "\u2502" + panels[2][row] + "\u2502")
181
+ print("\u2514" + ("\u2500" * width) + "\u2534" + ("\u2500" * width) + "\u2534" + ("\u2500" * width) + "\u2518")
187
182
 
188
183
  def frame_atom_slices(frames):
189
184
  slices = []
@@ -203,19 +198,37 @@ def select_indices(energy_txt, proportion):
203
198
  return np.sort(order[:n_candidate]), np.sort(order[n_candidate:])
204
199
 
205
200
 
201
+ def _equal_frame_rows(table, frames, label):
202
+ if len(frames) == 0:
203
+ return []
204
+ if len(table) % len(frames) != 0:
205
+ raise ValueError(
206
+ f"{label} rows ({len(table)}) cannot be divided among "
207
+ f"{len(frames)} structures for selection mode")
208
+ rows_per_frame = len(table) // len(frames)
209
+ return [slice(i * rows_per_frame, (i + 1) * rows_per_frame)
210
+ for i in range(len(frames))]
211
+
212
+
213
+ def _rows_from_slices(slices, indices):
214
+ rows = []
215
+ for idx in indices:
216
+ rows.extend(range(slices[idx].start, slices[idx].stop))
217
+ return rows
218
+
219
+
206
220
  def subset_tables(energy_txt, force_txt, stress_txt, frames, indices):
207
221
  import numpy as np
208
222
 
209
223
  atom_slices = frame_atom_slices(frames)
210
- if len(stress_txt) != len(energy_txt):
211
- raise ValueError("stress_train rows must match energy_train rows for selection mode")
212
- force_rows = []
213
- for idx in indices:
214
- force_rows.extend(range(atom_slices[idx].start * 3, atom_slices[idx].stop * 3))
224
+ force_slices = [slice(s.start * 3, s.stop * 3) for s in atom_slices]
225
+ stress_slices = _equal_frame_rows(stress_txt, frames, "stress_train")
226
+ force_rows = _rows_from_slices(force_slices, indices)
227
+ stress_rows = _rows_from_slices(stress_slices, indices)
215
228
  return (
216
229
  energy_txt[indices],
217
230
  force_txt[force_rows] if force_rows else np.empty((0, 2)),
218
- stress_txt[indices],
231
+ stress_txt[stress_rows] if stress_rows else np.empty((0, 2)),
219
232
  )
220
233
 
221
234
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pymdkit
3
- Version: 1.2.12
3
+ Version: 1.3.0
4
4
  Summary: A unified command-line toolkit for atomistic / MD structure workflows.
5
5
  Author-email: Yueda Wang <ydwang0608@ustc.edu.cn>
6
6
  License-Expression: GPL-3.0-or-later
@@ -17,6 +17,7 @@ Requires-Dist: numpy
17
17
  Requires-Dist: ase
18
18
  Requires-Dist: pymatgen
19
19
  Requires-Dist: mp-api
20
+ Requires-Dist: py4vasp-core
20
21
  Requires-Dist: pyxtal
21
22
  Requires-Dist: dpdata
22
23
  Dynamic: license-file
@@ -66,7 +67,7 @@ directory for job sub-folders automatically.
66
67
  | `final-energy` | List VASP job final energies from lowest to highest |
67
68
  | `gather-contcar` | Collect CONTCARs from VASP job folders into one folder, renamed `<folder>.vasp` |
68
69
  | `msd` | Diffusivity & conductivity from GPUMD MSD jobs (auto-scans `<structure>/<temp>/`) |
69
- | `nep-rmse` | Compute NEP energy/force/stress RMSE with ASCII plots and optional candidate selection |
70
+ | `nep-rmse` | Compute NEP energy/force/stress RMSE with terminal plots and optional candidate selection |
70
71
  | `perturb` | Generate perturbed structures with dpdata |
71
72
  | `vasp2xyz` | Collect SCF-converged VASP job folders (any name) into one extxyz file |
72
73
  | `submit-vasp` | Submit/resubmit VASP job folders while limiting active queue jobs |
@@ -116,7 +117,7 @@ pymdkit substitute -i Li96Ta6La11Cl72.cif -se Li2 -we none -ref La Ta
116
117
  pymdkit electrostatic-energy -i Li3YCl6.cif
117
118
  pymdkit electrostatic-energy -if Li3YCl6-all
118
119
  pymdkit nep-rmse # writes energy/force/stress train txt files, rmse_value.txt, and terminal plots
119
- pymdkit nep-rmse -select-candidate -xyz train.xyz # interactive candidate selection; writes candidate.xyz and accurate.xyz
120
+ pymdkit nep-rmse -select -xyz train.xyz # interactive candidate selection; writes candidate.xyz and accurate.xyz
120
121
  pymdkit perturb -i example.xyz -atom 0.2 -lattice 0.03 -n 100 -o example-perturb-atom-0.2-lattice-0.03.xyz
121
122
 
122
123
  pymdkit rmsd a.cif b.cif # RMSD of two files -> rmsd.txt
@@ -2,5 +2,6 @@ numpy
2
2
  ase
3
3
  pymatgen
4
4
  mp-api
5
+ py4vasp-core
5
6
  pyxtal
6
7
  dpdata
@@ -1,174 +0,0 @@
1
- """Shared VASP output readers.
2
-
3
- Reader priority is always:
4
-
5
- vaspout.h5 > vasprun.xml > OUTCAR
6
-
7
- Commands that extract properties from VASP outputs should use this helper so
8
- newer vaspout.h5 files are preferred consistently across pymdkit.
9
- """
10
-
11
- from __future__ import annotations
12
-
13
- from dataclasses import dataclass
14
- from pathlib import Path
15
-
16
-
17
- @dataclass
18
- class VaspOutput:
19
- folder: Path
20
- source: str
21
- parser: object
22
-
23
- @property
24
- def final_energy(self):
25
- return getattr(self.parser, "final_energy")
26
-
27
- @property
28
- def final_structure(self):
29
- return getattr(self.parser, "final_structure", None)
30
-
31
- @property
32
- def ionic_steps(self):
33
- return getattr(self.parser, "ionic_steps", [])
34
-
35
- @property
36
- def converged_electronic(self):
37
- return bool(getattr(self.parser, "converged_electronic", False))
38
-
39
- @property
40
- def converged(self):
41
- return bool(getattr(self.parser, "converged", self.converged_electronic))
42
-
43
- def get_computed_entry(self, inc_structure=True, entry_id=None):
44
- if not hasattr(self.parser, "get_computed_entry"):
45
- from pymatgen.entries.computed_entries import ComputedStructureEntry
46
-
47
- structure_file = None
48
- for name in ("CONTCAR", "POSCAR"):
49
- candidate = self.folder / name
50
- if candidate.exists():
51
- structure_file = candidate
52
- break
53
- if structure_file is None:
54
- raise AttributeError("get_computed_entry")
55
- structure = _read_structure(structure_file)
56
- return ComputedStructureEntry(
57
- structure=structure,
58
- energy=self.final_energy,
59
- parameters={"run_type": "GGA"},
60
- entry_id=entry_id or self.folder.name,
61
- )
62
-
63
- entry = self.parser.get_computed_entry(
64
- inc_structure=inc_structure,
65
- entry_id=entry_id or self.folder.name,
66
- )
67
- if entry.parameters.get("run_type") == "PBE":
68
- entry.parameters["run_type"] = "GGA"
69
- return entry
70
-
71
-
72
- def _read_structure(path):
73
- from pymatgen.core import Structure
74
-
75
- return Structure.from_file(str(path))
76
-
77
-
78
- class OutcarAdapter:
79
- """Small adapter for the limited properties available from OUTCAR alone."""
80
-
81
- def __init__(self, path):
82
- from pymatgen.io.vasp.outputs import Outcar
83
-
84
- self.path = Path(path)
85
- self.outcar = Outcar(str(path))
86
- self._text = self.path.read_text(errors="ignore")
87
-
88
- @property
89
- def final_energy(self):
90
- for attr in ("final_energy", "final_fr_energy", "final_energy_wo_entrp"):
91
- if hasattr(self.outcar, attr):
92
- value = getattr(self.outcar, attr)
93
- if value is not None:
94
- return value
95
- raise AttributeError("final_energy")
96
-
97
- @property
98
- def ionic_steps(self):
99
- return []
100
-
101
- @property
102
- def final_structure(self):
103
- return None
104
-
105
- @property
106
- def converged_electronic(self):
107
- markers = (
108
- "aborting loop because EDIFF is reached",
109
- "reached required accuracy",
110
- )
111
- return any(marker in self._text for marker in markers)
112
-
113
- @property
114
- def converged(self):
115
- return (self.converged_electronic
116
- and "reached required accuracy" in self._text)
117
-
118
-
119
- def _parse_vaspout(path):
120
- from pymatgen.io.vasp.outputs import Vaspout
121
-
122
- try:
123
- return Vaspout(str(path), parse_dos=False, parse_eigen=False)
124
- except TypeError:
125
- return Vaspout(str(path))
126
-
127
-
128
- def _parse_vasprun(path):
129
- from pymatgen.io.vasp.outputs import Vasprun
130
-
131
- return Vasprun(str(path), parse_dos=False, parse_eigen=False)
132
-
133
-
134
- def _parse_outcar(path):
135
- return OutcarAdapter(path)
136
-
137
-
138
- def read_vasp_output(folder, require=("final_energy",)):
139
- """Read the best available VASP output in *folder*.
140
-
141
- Args:
142
- folder: VASP job folder.
143
- require: Attribute names that must be available on the parser.
144
-
145
- Returns:
146
- VaspOutput or None if no parser can provide the requested properties.
147
- """
148
- folder = Path(folder)
149
- candidates = (
150
- ("vaspout.h5", _parse_vaspout),
151
- ("vasprun.xml", _parse_vasprun),
152
- ("OUTCAR", _parse_outcar),
153
- )
154
- for filename, parser_fn in candidates:
155
- path = folder / filename
156
- if not path.exists():
157
- continue
158
- try:
159
- parser = parser_fn(path)
160
- output = VaspOutput(folder=folder, source=filename, parser=parser)
161
- for attr in require:
162
- value = getattr(output, attr)
163
- if value is None:
164
- raise AttributeError(attr)
165
- return output
166
- except Exception:
167
- continue
168
- return None
169
-
170
-
171
- def has_vasp_output(folder):
172
- folder = Path(folder)
173
- return any((folder / name).exists()
174
- for name in ("vaspout.h5", "vasprun.xml", "OUTCAR", "CONTCAR"))
File without changes
File without changes