pymdkit 1.0.0__py3-none-any.whl
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.
- pymdkit/__init__.py +3 -0
- pymdkit/commands/__init__.py +1 -0
- pymdkit/commands/_fileio.py +96 -0
- pymdkit/commands/_vaspset.py +169 -0
- pymdkit/commands/add_groups.py +77 -0
- pymdkit/commands/compute_ehull.py +230 -0
- pymdkit/commands/compute_msd_all_groups.py +224 -0
- pymdkit/commands/compute_rmsd.py +149 -0
- pymdkit/commands/gather_contcar.py +106 -0
- pymdkit/commands/outcar2xyz.py +141 -0
- pymdkit/commands/select_candidate.py +119 -0
- pymdkit/commands/stru2xyz.py +100 -0
- pymdkit/commands/supercell.py +164 -0
- pymdkit/commands/symmetrize.py +271 -0
- pymdkit/commands/vasp_relax.py +62 -0
- pymdkit/commands/vasp_static.py +59 -0
- pymdkit/pymdkit_main.py +115 -0
- pymdkit-1.0.0.dist-info/METADATA +201 -0
- pymdkit-1.0.0.dist-info/RECORD +23 -0
- pymdkit-1.0.0.dist-info/WHEEL +5 -0
- pymdkit-1.0.0.dist-info/entry_points.txt +2 -0
- pymdkit-1.0.0.dist-info/licenses/LICENSE +674 -0
- pymdkit-1.0.0.dist-info/top_level.txt +1 -0
pymdkit/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Auto-discovered pymdkit subcommands. Drop a new module here to add a command."""
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Shared I/O helpers for commands that transform either a single structure
|
|
2
|
+
file (``-i`` / ``-o``) or every structure in a folder (``-if`` / ``-of``).
|
|
3
|
+
|
|
4
|
+
Modules whose name starts with ``_`` are skipped by the dispatcher, so this is
|
|
5
|
+
a helper, not a command.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
# File extensions we treat as structures when scanning an input folder.
|
|
14
|
+
STRUCT_EXTS = {
|
|
15
|
+
".cif", ".vasp", ".poscar", ".xyz", ".extxyz", ".pdb", ".xsf",
|
|
16
|
+
".cfg", ".gen", ".res", ".json", ".cssr", ".lmp", ".data",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def folder_sort_key(name):
|
|
21
|
+
"""Sort by the first integer in the name (so 2 precedes 10), else by name."""
|
|
22
|
+
m = re.search(r"\d+", str(name))
|
|
23
|
+
return (int(m.group()) if m else float("inf"), str(name))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def subfolders(entry="."):
|
|
27
|
+
"""Immediate sub-directories of *entry*, ordered like ehull scans them."""
|
|
28
|
+
root = Path(entry)
|
|
29
|
+
return sorted((p for p in root.iterdir() if p.is_dir()),
|
|
30
|
+
key=lambda p: folder_sort_key(p.name))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def add_io_arguments(parser, single_output_default=None,
|
|
34
|
+
single_output_help="Output file (single-file mode)."):
|
|
35
|
+
"""Register the four I/O flags shared by single-or-folder commands.
|
|
36
|
+
|
|
37
|
+
-i/--input + -o/--output -> operate on one file
|
|
38
|
+
-if/--input-folder + -of/--output-folder -> operate on a whole folder
|
|
39
|
+
"""
|
|
40
|
+
parser.add_argument("-i", "--input",
|
|
41
|
+
help="Single input structure file.")
|
|
42
|
+
parser.add_argument("-o", "--output", default=single_output_default,
|
|
43
|
+
help=single_output_help)
|
|
44
|
+
parser.add_argument("-if", "--input-folder", dest="input_folder",
|
|
45
|
+
help="Folder of input structures (batch mode).")
|
|
46
|
+
parser.add_argument("-of", "--output-folder", dest="output_folder",
|
|
47
|
+
help="Output folder for batch mode "
|
|
48
|
+
"(default: <input-folder>-out).")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def is_structure(path, exts=None):
|
|
52
|
+
"""True if *path* looks like a structure file (by extension / POSCAR name)."""
|
|
53
|
+
exts = STRUCT_EXTS if exts is None else exts
|
|
54
|
+
p = Path(path)
|
|
55
|
+
return p.is_file() and (p.suffix.lower() in exts
|
|
56
|
+
or p.name.upper().startswith("POSCAR"))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def structure_files(folder):
|
|
60
|
+
"""Return sorted structure files inside *folder* (raises if none/missing)."""
|
|
61
|
+
folder = Path(folder)
|
|
62
|
+
if not folder.is_dir():
|
|
63
|
+
raise SystemExit(f"Error: input folder not found: {folder}")
|
|
64
|
+
files = sorted(
|
|
65
|
+
p for p in folder.iterdir()
|
|
66
|
+
if p.is_file() and (p.suffix.lower() in STRUCT_EXTS
|
|
67
|
+
or p.name.upper().startswith("POSCAR"))
|
|
68
|
+
)
|
|
69
|
+
if not files:
|
|
70
|
+
raise SystemExit(f"Error: no structure files found in {folder}")
|
|
71
|
+
return files
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def io_pairs(args, batch_suffix=".xyz"):
|
|
75
|
+
"""Yield ``(input_path, output_path)`` for single or batch mode.
|
|
76
|
+
|
|
77
|
+
*batch_suffix* sets the output extension used in batch mode and when an
|
|
78
|
+
output name has to be derived in single mode.
|
|
79
|
+
"""
|
|
80
|
+
if getattr(args, "input", None):
|
|
81
|
+
out = (Path(args.output) if args.output
|
|
82
|
+
else Path(args.input).with_suffix(batch_suffix))
|
|
83
|
+
yield Path(args.input), out
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
if getattr(args, "input_folder", None):
|
|
87
|
+
in_dir = Path(args.input_folder)
|
|
88
|
+
out_dir = (Path(args.output_folder) if args.output_folder
|
|
89
|
+
else in_dir.with_name(in_dir.name + "-out"))
|
|
90
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
for f in structure_files(in_dir):
|
|
92
|
+
yield f, out_dir / (f.stem + batch_suffix)
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
raise SystemExit(
|
|
96
|
+
"Error: provide -i/--input (single file) or -if/--input-folder (batch).")
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Shared helpers for the ``vasp-relax`` and ``vasp-static`` commands.
|
|
2
|
+
|
|
3
|
+
Both commands write a pymatgen VASP input set (INCAR / POSCAR / KPOINTS /
|
|
4
|
+
POTCAR) from a structure, starting from a default INCAR dict that the user can
|
|
5
|
+
replace/extend with ``-custom-setting FILE``.
|
|
6
|
+
|
|
7
|
+
VASP jobs are always *individual* (one structure per folder):
|
|
8
|
+
|
|
9
|
+
* ``-i file`` -> inputs written into ``-o`` (default: current dir)
|
|
10
|
+
* ``-if folder`` -> one ``./<name>/`` job folder per structure, in cwd
|
|
11
|
+
* ``-it trajectory`` -> one ``./<prefix>_<i>/`` job folder per frame, in cwd
|
|
12
|
+
|
|
13
|
+
Modules whose name starts with ``_`` are skipped by the dispatcher.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import ast
|
|
20
|
+
import warnings
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
# Structure file extensions accepted in batch (-if) mode.
|
|
24
|
+
_STRUCT_EXTS = {".cif", ".vasp", ".poscar", ".xyz", ".extxyz", ".cssr", ".json"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_custom_settings(path):
|
|
28
|
+
"""Read INCAR settings from *path*.
|
|
29
|
+
|
|
30
|
+
Accepts either a Python-dict block (``custom_settings = { "ENCUT": "520", ...}``
|
|
31
|
+
-- the braces are parsed with ast.literal_eval) or simple ``KEY = VALUE``
|
|
32
|
+
lines (one per line, ``None``/blank value clears the tag).
|
|
33
|
+
"""
|
|
34
|
+
text = Path(path).read_text(encoding="utf-8")
|
|
35
|
+
|
|
36
|
+
start, end = text.find("{"), text.rfind("}")
|
|
37
|
+
if start != -1 and end != -1 and end > start:
|
|
38
|
+
try:
|
|
39
|
+
data = ast.literal_eval(text[start:end + 1])
|
|
40
|
+
except Exception as e: # noqa: BLE001
|
|
41
|
+
raise SystemExit(f"Error: could not parse dict in {path}: {e}")
|
|
42
|
+
if not isinstance(data, dict):
|
|
43
|
+
raise SystemExit(f"Error: settings in {path} is not a dict.")
|
|
44
|
+
return {str(k).upper(): v for k, v in data.items()}
|
|
45
|
+
|
|
46
|
+
# Fallback: KEY = VALUE / KEY VALUE lines.
|
|
47
|
+
data = {}
|
|
48
|
+
for line in text.splitlines():
|
|
49
|
+
line = line.split("#", 1)[0].strip()
|
|
50
|
+
if not line:
|
|
51
|
+
continue
|
|
52
|
+
if "=" in line:
|
|
53
|
+
key, val = line.split("=", 1)
|
|
54
|
+
else:
|
|
55
|
+
parts = line.split(None, 1)
|
|
56
|
+
key, val = parts[0], (parts[1] if len(parts) > 1 else "")
|
|
57
|
+
val = val.strip().strip(",").strip().strip('"').strip("'")
|
|
58
|
+
data[key.strip().upper()] = None if val in ("", "None", "none") else val
|
|
59
|
+
if not data:
|
|
60
|
+
raise SystemExit(f"Error: no settings found in {path}.")
|
|
61
|
+
return data
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def add_vasp_arguments(parser):
|
|
65
|
+
"""Register structure-input flags and the custom-settings flag."""
|
|
66
|
+
parser.formatter_class = argparse.ArgumentDefaultsHelpFormatter
|
|
67
|
+
parser.add_argument("-i", "--input",
|
|
68
|
+
help="Single input structure file (CIF/POSCAR/...).")
|
|
69
|
+
parser.add_argument("-o", "--output", default=".",
|
|
70
|
+
help="Output directory for the single structure's inputs.")
|
|
71
|
+
parser.add_argument("-if", "--input-folder", dest="input_folder",
|
|
72
|
+
help="Folder of structures; one ./<name>/ job folder is "
|
|
73
|
+
"written per file, in the current directory.")
|
|
74
|
+
parser.add_argument("-it", "--input-trajectory", dest="input_trajectory",
|
|
75
|
+
help="Multi-structure trajectory (e.g. extxyz); one "
|
|
76
|
+
"./<prefix>_<i>/ job folder is written per frame.")
|
|
77
|
+
parser.add_argument("--frame-prefix", default="frame", dest="frame_prefix",
|
|
78
|
+
help="Sub-folder name prefix in trajectory mode "
|
|
79
|
+
"(frame_1, frame_2, ...).")
|
|
80
|
+
parser.add_argument("-custom-setting", "--custom-setting", dest="custom_setting",
|
|
81
|
+
metavar="FILE",
|
|
82
|
+
help="File of INCAR settings (Python-dict or KEY=VALUE "
|
|
83
|
+
"lines) that override the command defaults.")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _structure_files(folder):
|
|
87
|
+
folder = Path(folder)
|
|
88
|
+
if not folder.is_dir():
|
|
89
|
+
raise SystemExit(f"Error: input folder not found: {folder}")
|
|
90
|
+
return sorted(
|
|
91
|
+
p for p in folder.iterdir()
|
|
92
|
+
if p.is_file() and (p.suffix.lower() in _STRUCT_EXTS
|
|
93
|
+
or p.name.upper().startswith("POSCAR"))
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _run_trajectory(args, set_cls, settings):
|
|
98
|
+
"""Write one VASP job folder per frame of a multi-structure trajectory.
|
|
99
|
+
|
|
100
|
+
Each frame i -> ./<prefix>_<i>/ containing the VASP inputs plus a per-frame
|
|
101
|
+
extxyz (so Config_type survives for a later `outcar2xyz`).
|
|
102
|
+
"""
|
|
103
|
+
from ase.io import read as ase_read, write as ase_write
|
|
104
|
+
from pymatgen.io.ase import AseAtomsAdaptor
|
|
105
|
+
|
|
106
|
+
traj = Path(args.input_trajectory)
|
|
107
|
+
if not traj.is_file():
|
|
108
|
+
raise SystemExit(f"Error: trajectory file not found: {traj}")
|
|
109
|
+
|
|
110
|
+
frames = ase_read(str(traj), index=":")
|
|
111
|
+
if not isinstance(frames, list): # single-frame file -> wrap
|
|
112
|
+
frames = [frames]
|
|
113
|
+
|
|
114
|
+
prefix = args.frame_prefix
|
|
115
|
+
written = 0
|
|
116
|
+
for i, atoms in enumerate(frames, start=1):
|
|
117
|
+
folder = Path(f"{prefix}_{i}")
|
|
118
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
ase_write(str(folder / f"{prefix}_{i}.xyz"), atoms, format="extxyz")
|
|
120
|
+
structure = AseAtomsAdaptor.get_structure(atoms)
|
|
121
|
+
set_cls(structure, user_incar_settings=settings).write_input(str(folder))
|
|
122
|
+
written += 1
|
|
123
|
+
print(f"Wrote {written} VASP job folder(s) from {traj.name} "
|
|
124
|
+
f"({prefix}_1 .. {prefix}_{written}) in the current directory.")
|
|
125
|
+
return 0 if written else 1
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def run_vasp_set(args, set_cls, default_settings):
|
|
129
|
+
"""Write *set_cls* input files for one structure (-i), a folder (-if), or a
|
|
130
|
+
trajectory (-it). Defaults can be overridden via ``-custom-setting FILE``."""
|
|
131
|
+
warnings.filterwarnings("ignore", category=UserWarning, module="pymatgen")
|
|
132
|
+
from pymatgen.core import Structure
|
|
133
|
+
|
|
134
|
+
settings = dict(default_settings)
|
|
135
|
+
if args.custom_setting:
|
|
136
|
+
settings.update(load_custom_settings(args.custom_setting))
|
|
137
|
+
|
|
138
|
+
# Trajectory mode: one job folder per frame, in cwd.
|
|
139
|
+
if getattr(args, "input_trajectory", None):
|
|
140
|
+
return _run_trajectory(args, set_cls, settings)
|
|
141
|
+
|
|
142
|
+
# Build the (structure_file, output_dir) work list.
|
|
143
|
+
if args.input:
|
|
144
|
+
jobs = [(Path(args.input), Path(args.output))]
|
|
145
|
+
elif args.input_folder:
|
|
146
|
+
files = _structure_files(args.input_folder)
|
|
147
|
+
if not files:
|
|
148
|
+
print(f"No structure files found in {args.input_folder}")
|
|
149
|
+
return 1
|
|
150
|
+
# One job folder per structure, named after the file, in the cwd.
|
|
151
|
+
jobs = [(f, Path(f.stem)) for f in files]
|
|
152
|
+
else:
|
|
153
|
+
raise SystemExit(
|
|
154
|
+
"Error: provide -i (file), -if (folder), or -it (trajectory).")
|
|
155
|
+
|
|
156
|
+
written = 0
|
|
157
|
+
for struct_file, out_dir in jobs:
|
|
158
|
+
try:
|
|
159
|
+
structure = Structure.from_file(str(struct_file))
|
|
160
|
+
except Exception as e: # noqa: BLE001 - report and continue in batch
|
|
161
|
+
print(f"{struct_file}: cannot read ({e}) -> skipping")
|
|
162
|
+
continue
|
|
163
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
164
|
+
set_cls(structure, user_incar_settings=settings).write_input(str(out_dir))
|
|
165
|
+
print(f"{struct_file} -> {out_dir}/")
|
|
166
|
+
written += 1
|
|
167
|
+
|
|
168
|
+
print(f"Done: wrote {written} VASP job folder(s).")
|
|
169
|
+
return 0 if written else 1
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Add a per-atom GPUMD `group` array to a structure based on element order.
|
|
2
|
+
|
|
3
|
+
The group index assigned to each atom is the position of its element in the
|
|
4
|
+
provided --elements list (0-based).
|
|
5
|
+
|
|
6
|
+
Single file: add-groups -i opted.cif --elements Li Y Cl -o model.xyz
|
|
7
|
+
Whole folder: add-groups -if cifs/ --elements Li Y Cl -of cifs-grouped/
|
|
8
|
+
Scan mode: add-groups --elements Li Y Cl
|
|
9
|
+
-> scans sub-folders of the current dir for a model.xyz and tags
|
|
10
|
+
it in place (overwrites that model.xyz).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
from ase.io import read, write
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
from . import _fileio
|
|
20
|
+
except ImportError: # running as a standalone script
|
|
21
|
+
import _fileio
|
|
22
|
+
|
|
23
|
+
COMMAND = "add-groups"
|
|
24
|
+
HELP = "Tag atoms with a GPUMD group index by element order."
|
|
25
|
+
|
|
26
|
+
SCAN_FILE = "model.xyz" # filename looked for in scan mode
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def add_arguments(parser):
|
|
30
|
+
_fileio.add_io_arguments(
|
|
31
|
+
parser, single_output_default="model.xyz",
|
|
32
|
+
single_output_help="Output file for single-file mode (default: model.xyz).")
|
|
33
|
+
parser.add_argument("--elements", required=True, nargs="+",
|
|
34
|
+
help="Element order; group index = position in this list.")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _tag_one(in_path, out_path, elements):
|
|
38
|
+
atoms = read(str(in_path))
|
|
39
|
+
groups = []
|
|
40
|
+
for element in atoms.get_chemical_symbols():
|
|
41
|
+
if element in elements:
|
|
42
|
+
groups.append(elements.index(element))
|
|
43
|
+
else:
|
|
44
|
+
raise SystemExit(
|
|
45
|
+
f"{in_path}: element {element!r} not in --elements list {elements}")
|
|
46
|
+
atoms.new_array("group", np.array(groups))
|
|
47
|
+
write(str(out_path), atoms)
|
|
48
|
+
print(f"{in_path} -> {out_path}")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def run(args):
|
|
52
|
+
count = 0
|
|
53
|
+
|
|
54
|
+
# Scan mode: no -i and no -if -> tag <subfolder>/model.xyz in place.
|
|
55
|
+
if not args.input and not args.input_folder:
|
|
56
|
+
for sub in _fileio.subfolders("."):
|
|
57
|
+
model = sub / SCAN_FILE
|
|
58
|
+
if model.is_file():
|
|
59
|
+
_tag_one(model, model, args.elements)
|
|
60
|
+
count += 1
|
|
61
|
+
if count == 0:
|
|
62
|
+
print(f"No {SCAN_FILE} found in any sub-folder. Nothing to do.")
|
|
63
|
+
else:
|
|
64
|
+
print(f"Done: tagged {count} {SCAN_FILE} file(s) in place.")
|
|
65
|
+
return 0
|
|
66
|
+
|
|
67
|
+
for in_path, out_path in _fileio.io_pairs(args):
|
|
68
|
+
_tag_one(in_path, out_path, args.elements)
|
|
69
|
+
count += 1
|
|
70
|
+
print(f"Done: tagged {count} structure(s).")
|
|
71
|
+
return 0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
if __name__ == "__main__":
|
|
75
|
+
_p = argparse.ArgumentParser(description=__doc__)
|
|
76
|
+
add_arguments(_p)
|
|
77
|
+
raise SystemExit(run(_p.parse_args()))
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""
|
|
2
|
+
compute_ehull.py
|
|
3
|
+
|
|
4
|
+
Compute the energy above the convex hull (E_hull) for every VASP job folder
|
|
5
|
+
found in the current directory, against a Materials Project phase diagram.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
pymdkit ehull [--entry-path .] [--mp-api-key KEY]
|
|
9
|
+
|
|
10
|
+
Behaviour:
|
|
11
|
+
* Auto-detects every sub-folder of the entry path that contains a
|
|
12
|
+
vasprun.xml (i.e. a finished VASP job).
|
|
13
|
+
* Scans those folders first to find the distinct chemical systems present
|
|
14
|
+
(a system = the set of elements in a structure, ordered by
|
|
15
|
+
electronegativity, e.g. "Li-Y-Cl"). For each distinct system it builds /
|
|
16
|
+
loads one cached MP reference file, mp_cache_<system>.json -- so a batch
|
|
17
|
+
of Li-Y-Cl jobs produces a single mp_cache_Li-Y-Cl.json, while a mix of
|
|
18
|
+
Li-Y-Cl and La-O jobs produces mp_cache_Li-Y-Cl.json AND mp_cache_La-O.json.
|
|
19
|
+
* Computes E_form and E_hull for each structure against the phase diagram of
|
|
20
|
+
its own chemical system.
|
|
21
|
+
|
|
22
|
+
Output:
|
|
23
|
+
ehull.txt - one row per parsed structure (sorted by folder index).
|
|
24
|
+
mp_cache_<system>.json - cached MP reference entries, one per system.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import argparse
|
|
30
|
+
import math
|
|
31
|
+
import re
|
|
32
|
+
import warnings
|
|
33
|
+
from collections import defaultdict
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
from monty.serialization import dumpfn, loadfn
|
|
37
|
+
from pymatgen.analysis.bond_valence import BVAnalyzer
|
|
38
|
+
from pymatgen.analysis.phase_diagram import PhaseDiagram
|
|
39
|
+
from pymatgen.entries.compatibility import MaterialsProject2020Compatibility
|
|
40
|
+
from pymatgen.io.vasp.outputs import Vasprun
|
|
41
|
+
|
|
42
|
+
warnings.filterwarnings("ignore", category=UserWarning)
|
|
43
|
+
|
|
44
|
+
COMMAND = "ehull"
|
|
45
|
+
HELP = "Compute E_hull for every VASP job folder vs Materials Project."
|
|
46
|
+
|
|
47
|
+
REPORT_FILE = "ehull.txt"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# --- helpers ---------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
def folder_index(name: str) -> int:
|
|
53
|
+
m = re.match(r"^(\d+)", name)
|
|
54
|
+
return int(m.group(1)) if m else 10 ** 9
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def chemsys_label(entry) -> str:
|
|
58
|
+
"""Distinct-system label: element symbols ordered by electronegativity,
|
|
59
|
+
joined with '-' (e.g. Li-Y-Cl, La-O). Elements without a defined
|
|
60
|
+
electronegativity sort last, then alphabetically."""
|
|
61
|
+
def keyfn(el):
|
|
62
|
+
x = el.X
|
|
63
|
+
return (1, el.symbol) if x is None or math.isnan(x) else (0, x)
|
|
64
|
+
els = sorted(entry.composition.elements, key=keyfn)
|
|
65
|
+
return "-".join(el.symbol for el in els)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# --- MP reference entries --------------------------------------------------
|
|
69
|
+
|
|
70
|
+
def load_reference_entries(elements, label, api_key):
|
|
71
|
+
cache = Path(f"mp_cache_{label}.json")
|
|
72
|
+
if cache.exists():
|
|
73
|
+
try:
|
|
74
|
+
return loadfn(str(cache))
|
|
75
|
+
except Exception as e: # noqa: BLE001 - stale/incompatible cache
|
|
76
|
+
print(f" ! {cache.name} could not be read ({type(e).__name__}); "
|
|
77
|
+
f"refetching from Materials Project.")
|
|
78
|
+
from pymatgen.ext.matproj import MPRester
|
|
79
|
+
with MPRester(api_key) as mpr:
|
|
80
|
+
entries = mpr.get_entries_in_chemsys(
|
|
81
|
+
elements, inc_structure=True,
|
|
82
|
+
additional_criteria={"thermo_types": ["GGA_GGA+U"], "is_stable": True},
|
|
83
|
+
)
|
|
84
|
+
for e in entries:
|
|
85
|
+
e.parameters.setdefault("run_type", "GGA")
|
|
86
|
+
e.parameters.setdefault("potcar_symbols", [])
|
|
87
|
+
dumpfn(entries, str(cache))
|
|
88
|
+
return entries
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def apply_mp2020_corrections(entries):
|
|
92
|
+
"""Tag entries with oxidation states, then apply MP2020 corrections."""
|
|
93
|
+
for e in entries:
|
|
94
|
+
try:
|
|
95
|
+
valences = BVAnalyzer().get_valences(e.structure)
|
|
96
|
+
e.data["oxidation_states"] = {
|
|
97
|
+
site.species.elements[0].symbol: valences[i]
|
|
98
|
+
for i, site in enumerate(e.structure)
|
|
99
|
+
}
|
|
100
|
+
except Exception:
|
|
101
|
+
guesses = e.composition.oxi_state_guesses()
|
|
102
|
+
if guesses:
|
|
103
|
+
e.data["oxidation_states"] = guesses[0]
|
|
104
|
+
compat = MaterialsProject2020Compatibility(check_potcar=False)
|
|
105
|
+
return [compat.process_entry(e) or e for e in entries]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def build_phase_diagram(refs):
|
|
109
|
+
"""One lowest-energy reference per composition; restrict to on-hull entries."""
|
|
110
|
+
by_comp = defaultdict(list)
|
|
111
|
+
for e in refs:
|
|
112
|
+
by_comp[e.composition.reduced_formula].append(e)
|
|
113
|
+
best = [min(group, key=lambda e: e.energy_per_atom) for group in by_comp.values()]
|
|
114
|
+
return PhaseDiagram(list(PhaseDiagram(best).stable_entries))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# --- per-entry analysis ----------------------------------------------------
|
|
118
|
+
|
|
119
|
+
def parse_vasp_folder(folder):
|
|
120
|
+
try:
|
|
121
|
+
vr = Vasprun(str(folder / "vasprun.xml"),
|
|
122
|
+
parse_dos=False, parse_eigen=False)
|
|
123
|
+
except Exception:
|
|
124
|
+
return None
|
|
125
|
+
entry = vr.get_computed_entry(inc_structure=True, entry_id=folder.name)
|
|
126
|
+
# MP2020Compatibility expects run_type "GGA"; VASP reports "PBE".
|
|
127
|
+
if entry.parameters.get("run_type") == "PBE":
|
|
128
|
+
entry.parameters["run_type"] = "GGA"
|
|
129
|
+
return entry, vr.final_energy
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def collect_entries(root):
|
|
133
|
+
"""Auto-detect VASP job folders (those containing a vasprun.xml)."""
|
|
134
|
+
folders = sorted(
|
|
135
|
+
(f for f in root.iterdir() if f.is_dir() and (f / "vasprun.xml").exists()),
|
|
136
|
+
key=lambda f: folder_index(f.name),
|
|
137
|
+
)
|
|
138
|
+
out = []
|
|
139
|
+
for folder in folders:
|
|
140
|
+
result = parse_vasp_folder(folder)
|
|
141
|
+
if result is not None:
|
|
142
|
+
entry, final_energy = result
|
|
143
|
+
out.append((folder.name, entry, final_energy))
|
|
144
|
+
return out
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# --- reporting -------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
def write_report(results, root, filename):
|
|
150
|
+
def fmt(val, w=10, prec=3):
|
|
151
|
+
return f"{val:>{w}.{prec}f}" if val is not None else f"{'N/A':>{w}}"
|
|
152
|
+
|
|
153
|
+
lines = [
|
|
154
|
+
"=" * 104,
|
|
155
|
+
"VASP E_hull Report",
|
|
156
|
+
"Reference: Materials Project, MP2020 corrections applied",
|
|
157
|
+
f"Structures: {len(results)} parsed",
|
|
158
|
+
"=" * 104, "",
|
|
159
|
+
f"{'Folder':<32} {'Formula':<14} {'System':<12} "
|
|
160
|
+
f"{'FinalE(eV)':>14} {'E_form':>10} {'E_hull':>10}",
|
|
161
|
+
"-" * 104,
|
|
162
|
+
]
|
|
163
|
+
for r in results:
|
|
164
|
+
lines.append(
|
|
165
|
+
f"{r['folder']:<32} {r['formula']:<14} {r['chemsys']:<12} "
|
|
166
|
+
f"{fmt(r['final_energy'], 14, 4)} "
|
|
167
|
+
f"{fmt(r['e_form'], 10)} {fmt(r['e_hull'], 10)}"
|
|
168
|
+
)
|
|
169
|
+
(root / filename).write_text("\n".join(lines) + "\n")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# --- main ------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
def add_arguments(parser):
|
|
175
|
+
parser.add_argument("--entry-path", type=Path, default=Path("."),
|
|
176
|
+
help="Directory to scan for VASP job folders (default: .).")
|
|
177
|
+
parser.add_argument("--mp-api-key", type=str, default=None,
|
|
178
|
+
help="Materials Project API key (else read from config/env).")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def run(args):
|
|
182
|
+
root = args.entry_path.resolve()
|
|
183
|
+
|
|
184
|
+
entries = collect_entries(root)
|
|
185
|
+
if not entries:
|
|
186
|
+
print("No VASP job folders (vasprun.xml) found. Nothing to do.")
|
|
187
|
+
return 0
|
|
188
|
+
|
|
189
|
+
# Group folders by their chemical system, so one MP cache is built per system.
|
|
190
|
+
groups = defaultdict(list)
|
|
191
|
+
for folder, entry, final_energy in entries:
|
|
192
|
+
groups[chemsys_label(entry)].append((folder, entry, final_energy))
|
|
193
|
+
|
|
194
|
+
print(f"Found {len(entries)} VASP job folder(s) across "
|
|
195
|
+
f"{len(groups)} chemical system(s): {', '.join(sorted(groups))}")
|
|
196
|
+
|
|
197
|
+
results = []
|
|
198
|
+
for label in sorted(groups):
|
|
199
|
+
members = groups[label]
|
|
200
|
+
elements = label.split("-")
|
|
201
|
+
print(f" [{label}] {len(members)} structure(s) -> mp_cache_{label}.json")
|
|
202
|
+
refs = load_reference_entries(elements, label, args.mp_api_key)
|
|
203
|
+
pd = build_phase_diagram(refs)
|
|
204
|
+
corrected = apply_mp2020_corrections([e for _, e, _ in members])
|
|
205
|
+
|
|
206
|
+
for (folder, _, final_energy), entry in zip(members, corrected):
|
|
207
|
+
e_form = pd.get_form_energy_per_atom(entry)
|
|
208
|
+
try:
|
|
209
|
+
e_hull = pd.get_e_above_hull(entry)
|
|
210
|
+
except ValueError:
|
|
211
|
+
e_hull = 0.0
|
|
212
|
+
results.append({
|
|
213
|
+
"folder": folder,
|
|
214
|
+
"formula": entry.composition.reduced_formula,
|
|
215
|
+
"chemsys": label,
|
|
216
|
+
"final_energy": final_energy,
|
|
217
|
+
"e_form": e_form,
|
|
218
|
+
"e_hull": e_hull,
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
results.sort(key=lambda r: folder_index(r["folder"]))
|
|
222
|
+
write_report(results, root, REPORT_FILE)
|
|
223
|
+
print(f"\nWrote {REPORT_FILE} ({len(results)} structures).")
|
|
224
|
+
return 0
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
if __name__ == "__main__":
|
|
228
|
+
_p = argparse.ArgumentParser(description=__doc__)
|
|
229
|
+
add_arguments(_p)
|
|
230
|
+
raise SystemExit(run(_p.parse_args()))
|