pymdkit 1.4.0__tar.gz → 1.4.1__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.
- {pymdkit-1.4.0/src/pymdkit.egg-info → pymdkit-1.4.1}/PKG-INFO +13 -2
- {pymdkit-1.4.0 → pymdkit-1.4.1}/README.md +14 -3
- {pymdkit-1.4.0 → pymdkit-1.4.1}/pyproject.toml +1 -1
- {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/__init__.py +1 -1
- pymdkit-1.4.1/src/pymdkit/commands/_extxyz.py +85 -0
- {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/_fileio.py +15 -8
- pymdkit-1.4.1/src/pymdkit/commands/_geometry.py +12 -0
- pymdkit-1.4.1/src/pymdkit/commands/_gpumd.py +172 -0
- pymdkit-1.4.1/src/pymdkit/commands/_reduction.py +165 -0
- {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/compute_ehull.py +4 -8
- {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/compute_rmsd.py +7 -17
- pymdkit-1.4.1/src/pymdkit/commands/gpumd_thermo.py +131 -0
- pymdkit-1.4.1/src/pymdkit/commands/gpumd_trjcat.py +134 -0
- {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/pca.py +1 -1
- {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/substitute.py +7 -9
- {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/symmetrize.py +4 -14
- {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/umap.py +1 -1
- {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/vasp2xyz.py +9 -17
- {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/vasp_fe.py +3 -7
- {pymdkit-1.4.0 → pymdkit-1.4.1/src/pymdkit.egg-info}/PKG-INFO +13 -2
- {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit.egg-info/SOURCES.txt +3 -0
- pymdkit-1.4.0/src/pymdkit/commands/_extxyz.py +0 -36
- pymdkit-1.4.0/src/pymdkit/commands/_reduction.py +0 -99
- pymdkit-1.4.0/src/pymdkit/commands/gpumd_thermo.py +0 -250
- {pymdkit-1.4.0 → pymdkit-1.4.1}/LICENSE +0 -0
- {pymdkit-1.4.0 → pymdkit-1.4.1}/setup.cfg +0 -0
- {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/__init__.py +0 -0
- {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/_vasp_output.py +0 -0
- {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/_vaspset.py +0 -0
- {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/chemsys_entry.py +0 -0
- {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/compute_msd_all_groups.py +0 -0
- {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/convert.py +0 -0
- {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/ewald.py +0 -0
- {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/gather_contcar.py +0 -0
- {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/gpumd_group.py +0 -0
- {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/nep_rmse.py +0 -0
- {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/perturb.py +0 -0
- {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/submit_vasp.py +0 -0
- {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/supercell.py +0 -0
- {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/trj_extract.py +0 -0
- {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/vasp_relax.py +0 -0
- {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/commands/vasp_static.py +0 -0
- {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit/pymdkit_main.py +0 -0
- {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit.egg-info/dependency_links.txt +0 -0
- {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit.egg-info/entry_points.txt +0 -0
- {pymdkit-1.4.0 → pymdkit-1.4.1}/src/pymdkit.egg-info/requires.txt +0 -0
- {pymdkit-1.4.0 → pymdkit-1.4.1}/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.4.
|
|
3
|
+
Version: 1.4.1
|
|
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
|
|
@@ -69,6 +69,7 @@ directory for job sub-folders automatically.
|
|
|
69
69
|
| `vasp-fe` | List VASP job final energies from lowest to highest |
|
|
70
70
|
| `gather-contcar` | Collect CONTCARs from VASP job folders into one folder, renamed `<folder>.vasp` |
|
|
71
71
|
| `gpumd-thermo` | Export and plot GPUMD thermo data and detect sustained equilibrium changes |
|
|
72
|
+
| `gpumd-trjcat` | Merge trajectories from GPUMD jobs with stable equilibrium thermo data |
|
|
72
73
|
| `pca` | Reduce descriptor data to two dimensions; optional FPS sampling |
|
|
73
74
|
| `trj-extract` | Extract a GPUMD XYZ trajectory by time range |
|
|
74
75
|
| `umap` | Reduce descriptor data to two dimensions with UMAP; optional FPS sampling |
|
|
@@ -125,11 +126,13 @@ pmk ewald -if Li3YCl6-all
|
|
|
125
126
|
pmk nep-rmse # writes energy/force/stress train txt files, rmse_value.txt, and terminal plots
|
|
126
127
|
pmk nep-rmse -select -xyz train.xyz # interactive candidate selection; writes candidate.xyz and accurate.xyz
|
|
127
128
|
pmk perturb -i example.xyz -atom 0.2 -lattice 0.03 -n 100 -o example-perturb-atom-0.2-lattice-0.03.xyz
|
|
128
|
-
pmk pca -i descriptor.out -o pca-descriptor.txt
|
|
129
|
+
pmk pca -i descriptor.out -o pca-descriptor.txt -it train.xyz # also split descriptors by Config_type
|
|
129
130
|
pmk pca -i descriptor.out -o pca-descriptor.txt -fps 0.01 -it train.xyz
|
|
130
131
|
pmk umap -i descriptor.out -o umap-descriptor.txt -fps 0.01 -it train.xyz
|
|
131
132
|
pmk gpumd-thermo # current job, or recursively scan job folders
|
|
132
133
|
pmk gpumd-thermo -t thermo.out -in run.in
|
|
134
|
+
pmk gpumd-trjcat # stable ./<job>/traj.xyz files -> train.xyz
|
|
135
|
+
pmk gpumd-trjcat -i traj.xyz -o train.xyz
|
|
133
136
|
pmk trj-extract -it traj.xyz -in run.in -b 100 -e 200 -o traj-100ps-200ps.xyz
|
|
134
137
|
|
|
135
138
|
pmk rmsd a.cif b.cif # RMSD of two files -> rmsd.txt
|
|
@@ -163,10 +166,14 @@ folder also keeps a `frame_N.xyz`, so `Config_type` survives for a later
|
|
|
163
166
|
|
|
164
167
|
`nep-rmse -select`, PCA FPS, and UMAP FPS copy selected extended-XYZ frame blocks directly from the input trajectory. No parser rewrites energy, stress, forces, positions, precision, or extra metadata.
|
|
165
168
|
|
|
169
|
+
When `pca` or `umap` receives `-it train.xyz`, it reads `Config_type` from every frame and writes one additional descriptor table per value, such as `pca-descriptor-P-3m1.txt` and `pca-descriptor-Pnma.txt`. With `-fps`, it also writes matching files such as `fps-0.01-pca-descriptor-P-3m1.txt` and `fps-0.01-train-P-3m1.xyz`. These files partition the single global FPS selection; FPS is not rerun independently for each `Config_type`. Frames without `Config_type` remain in the main outputs but do not receive a type-specific file.
|
|
170
|
+
|
|
166
171
|
For folder conversion, `convert -if INPUT -of OUTPUT` writes `.xyz` by default. Set another target extension with `-oe`, for example `-oe cif` or `-oe vasp`. Single-file and trajectory modes infer the target format from `-o` and `-ot`.
|
|
167
172
|
|
|
168
173
|
`gpumd-thermo` writes `temperature.txt`, `potential-energy.txt`, `pressure.txt`, `lattice-parameters.txt`, `volume.txt`, `lattice-angles.txt`, and a headless `thermo.png` in every detected GPUMD job folder. Its warning is a numerical reminder based on sustained drift or a level shift in the last `run` segment (or the last 80% when `run.in` has no `run` record); inspect the plot before deciding whether a trajectory is equilibrated.
|
|
169
174
|
|
|
175
|
+
`gpumd-trjcat` recursively scans job subfolders for `traj.xyz`, `thermo.out`, and `run.in`. Jobs with missing, unreadable, or abnormal thermo data are skipped. Stable trajectories are merged in deterministic folder order. When `<job>/model.xyz` contains `Config_type`, that value is applied to every frame from the job while all other extended-XYZ bytes remain unchanged.
|
|
176
|
+
|
|
170
177
|
Each command's full flag list is in `pmk <command> -help`.
|
|
171
178
|
|
|
172
179
|
For long VASP batch submission, run `submit-vasp` with `nohup` and `&` if you
|
|
@@ -208,8 +215,12 @@ pymdkit/
|
|
|
208
215
|
|-- pymdkit_main.py # dispatcher: discovers and runs commands
|
|
209
216
|
`-- commands/ # one module per command
|
|
210
217
|
|-- _fileio.py # shared -i/-o/-if/-of helper (not a command)
|
|
218
|
+
|-- _gpumd.py # shared GPUMD run.in and thermo analysis
|
|
219
|
+
|-- _geometry.py # shared minimum-image geometry helper
|
|
211
220
|
|-- _vaspset.py # shared VASP input-set helper (not a command)
|
|
212
221
|
|-- gpumd_group.py
|
|
222
|
+
|-- gpumd_thermo.py
|
|
223
|
+
|-- gpumd_trjcat.py
|
|
213
224
|
|-- compute_ehull.py
|
|
214
225
|
|-- compute_rmsd.py
|
|
215
226
|
|-- ewald.py
|
|
@@ -43,6 +43,7 @@ directory for job sub-folders automatically.
|
|
|
43
43
|
| `vasp-fe` | 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
|
| `gpumd-thermo` | Export and plot GPUMD thermo data and detect sustained equilibrium changes |
|
|
46
|
+
| `gpumd-trjcat` | Merge trajectories from GPUMD jobs with stable equilibrium thermo data |
|
|
46
47
|
| `pca` | Reduce descriptor data to two dimensions; optional FPS sampling |
|
|
47
48
|
| `trj-extract` | Extract a GPUMD XYZ trajectory by time range |
|
|
48
49
|
| `umap` | Reduce descriptor data to two dimensions with UMAP; optional FPS sampling |
|
|
@@ -99,11 +100,13 @@ pmk ewald -if Li3YCl6-all
|
|
|
99
100
|
pmk nep-rmse # writes energy/force/stress train txt files, rmse_value.txt, and terminal plots
|
|
100
101
|
pmk nep-rmse -select -xyz train.xyz # interactive candidate selection; writes candidate.xyz and accurate.xyz
|
|
101
102
|
pmk perturb -i example.xyz -atom 0.2 -lattice 0.03 -n 100 -o example-perturb-atom-0.2-lattice-0.03.xyz
|
|
102
|
-
pmk pca -i descriptor.out -o pca-descriptor.txt
|
|
103
|
+
pmk pca -i descriptor.out -o pca-descriptor.txt -it train.xyz # also split descriptors by Config_type
|
|
103
104
|
pmk pca -i descriptor.out -o pca-descriptor.txt -fps 0.01 -it train.xyz
|
|
104
105
|
pmk umap -i descriptor.out -o umap-descriptor.txt -fps 0.01 -it train.xyz
|
|
105
106
|
pmk gpumd-thermo # current job, or recursively scan job folders
|
|
106
107
|
pmk gpumd-thermo -t thermo.out -in run.in
|
|
108
|
+
pmk gpumd-trjcat # stable ./<job>/traj.xyz files -> train.xyz
|
|
109
|
+
pmk gpumd-trjcat -i traj.xyz -o train.xyz
|
|
107
110
|
pmk trj-extract -it traj.xyz -in run.in -b 100 -e 200 -o traj-100ps-200ps.xyz
|
|
108
111
|
|
|
109
112
|
pmk rmsd a.cif b.cif # RMSD of two files -> rmsd.txt
|
|
@@ -137,10 +140,14 @@ folder also keeps a `frame_N.xyz`, so `Config_type` survives for a later
|
|
|
137
140
|
|
|
138
141
|
`nep-rmse -select`, PCA FPS, and UMAP FPS copy selected extended-XYZ frame blocks directly from the input trajectory. No parser rewrites energy, stress, forces, positions, precision, or extra metadata.
|
|
139
142
|
|
|
143
|
+
When `pca` or `umap` receives `-it train.xyz`, it reads `Config_type` from every frame and writes one additional descriptor table per value, such as `pca-descriptor-P-3m1.txt` and `pca-descriptor-Pnma.txt`. With `-fps`, it also writes matching files such as `fps-0.01-pca-descriptor-P-3m1.txt` and `fps-0.01-train-P-3m1.xyz`. These files partition the single global FPS selection; FPS is not rerun independently for each `Config_type`. Frames without `Config_type` remain in the main outputs but do not receive a type-specific file.
|
|
144
|
+
|
|
140
145
|
For folder conversion, `convert -if INPUT -of OUTPUT` writes `.xyz` by default. Set another target extension with `-oe`, for example `-oe cif` or `-oe vasp`. Single-file and trajectory modes infer the target format from `-o` and `-ot`.
|
|
141
146
|
|
|
142
147
|
`gpumd-thermo` writes `temperature.txt`, `potential-energy.txt`, `pressure.txt`, `lattice-parameters.txt`, `volume.txt`, `lattice-angles.txt`, and a headless `thermo.png` in every detected GPUMD job folder. Its warning is a numerical reminder based on sustained drift or a level shift in the last `run` segment (or the last 80% when `run.in` has no `run` record); inspect the plot before deciding whether a trajectory is equilibrated.
|
|
143
148
|
|
|
149
|
+
`gpumd-trjcat` recursively scans job subfolders for `traj.xyz`, `thermo.out`, and `run.in`. Jobs with missing, unreadable, or abnormal thermo data are skipped. Stable trajectories are merged in deterministic folder order. When `<job>/model.xyz` contains `Config_type`, that value is applied to every frame from the job while all other extended-XYZ bytes remain unchanged.
|
|
150
|
+
|
|
144
151
|
Each command's full flag list is in `pmk <command> -help`.
|
|
145
152
|
|
|
146
153
|
For long VASP batch submission, run `submit-vasp` with `nohup` and `&` if you
|
|
@@ -181,9 +188,13 @@ pymdkit/
|
|
|
181
188
|
`-- src/pymdkit/
|
|
182
189
|
|-- pymdkit_main.py # dispatcher: discovers and runs commands
|
|
183
190
|
`-- commands/ # one module per command
|
|
184
|
-
|-- _fileio.py # shared -i/-o/-if/-of helper (not a command)
|
|
191
|
+
|-- _fileio.py # shared -i/-o/-if/-of helper (not a command)
|
|
192
|
+
|-- _gpumd.py # shared GPUMD run.in and thermo analysis
|
|
193
|
+
|-- _geometry.py # shared minimum-image geometry helper
|
|
185
194
|
|-- _vaspset.py # shared VASP input-set helper (not a command)
|
|
186
|
-
|-- gpumd_group.py
|
|
195
|
+
|-- gpumd_group.py
|
|
196
|
+
|-- gpumd_thermo.py
|
|
197
|
+
|-- gpumd_trjcat.py
|
|
187
198
|
|-- compute_ehull.py
|
|
188
199
|
|-- compute_rmsd.py
|
|
189
200
|
|-- ewald.py
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Exact, byte-preserving helpers for extended-XYZ trajectories."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
_CONFIG_TYPE_RE = re.compile(r'(?<!\S)Config_type=(?:"([^"]*)"|(\S+))')
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def read_blocks(path):
|
|
12
|
+
"""Return ``(blocks, atom_counts)`` without parsing or reformatting data."""
|
|
13
|
+
path = Path(path)
|
|
14
|
+
lines = path.read_bytes().splitlines(keepends=True)
|
|
15
|
+
blocks = []
|
|
16
|
+
atom_counts = []
|
|
17
|
+
index = 0
|
|
18
|
+
while index < len(lines):
|
|
19
|
+
if not lines[index].strip():
|
|
20
|
+
index += 1
|
|
21
|
+
continue
|
|
22
|
+
try:
|
|
23
|
+
count = int(lines[index].strip())
|
|
24
|
+
except ValueError as exc:
|
|
25
|
+
raise ValueError(
|
|
26
|
+
f"{path}: expected an atom count at line {index + 1}"
|
|
27
|
+
) from exc
|
|
28
|
+
stop = index + count + 2
|
|
29
|
+
if stop > len(lines):
|
|
30
|
+
raise ValueError(f"{path}: incomplete frame beginning at line {index + 1}")
|
|
31
|
+
blocks.append(b"".join(lines[index:stop]))
|
|
32
|
+
atom_counts.append(count)
|
|
33
|
+
index = stop
|
|
34
|
+
return blocks, atom_counts
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def write_blocks(path, blocks, indices):
|
|
38
|
+
"""Write selected frames exactly as they appeared in the source file."""
|
|
39
|
+
Path(path).write_bytes(b"".join(blocks[int(index)] for index in indices))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _comment_line(block):
|
|
43
|
+
lines = block.splitlines(keepends=True)
|
|
44
|
+
if len(lines) < 2:
|
|
45
|
+
raise ValueError("incomplete XYZ frame: missing comment line")
|
|
46
|
+
raw = lines[1]
|
|
47
|
+
if raw.endswith(b"\r\n"):
|
|
48
|
+
body, newline = raw[:-2], b"\r\n"
|
|
49
|
+
elif raw.endswith(b"\n") or raw.endswith(b"\r"):
|
|
50
|
+
body, newline = raw[:-1], raw[-1:]
|
|
51
|
+
else:
|
|
52
|
+
body, newline = raw, b""
|
|
53
|
+
return lines, body.decode("utf-8"), newline
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def config_type_from_block(block):
|
|
57
|
+
"""Return Config_type from one raw XYZ frame, if present."""
|
|
58
|
+
_, comment, _ = _comment_line(block)
|
|
59
|
+
match = _CONFIG_TYPE_RE.search(comment)
|
|
60
|
+
if not match:
|
|
61
|
+
return None
|
|
62
|
+
return match.group(1) if match.group(1) is not None else match.group(2)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def read_config_type(path):
|
|
66
|
+
"""Read Config_type from the first frame of an XYZ file, if present."""
|
|
67
|
+
blocks, _ = read_blocks(path)
|
|
68
|
+
return config_type_from_block(blocks[0]) if blocks else None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def set_config_type(block, value):
|
|
72
|
+
"""Set Config_type while preserving all other bytes in an XYZ frame."""
|
|
73
|
+
lines, comment, newline = _comment_line(block)
|
|
74
|
+
value = str(value)
|
|
75
|
+
if re.fullmatch(r'[^\s"=]+', value):
|
|
76
|
+
encoded_value = value
|
|
77
|
+
else:
|
|
78
|
+
encoded_value = '"' + value.replace('\\', '\\\\').replace('"', '\\"') + '"'
|
|
79
|
+
assignment = f"Config_type={encoded_value}"
|
|
80
|
+
if _CONFIG_TYPE_RE.search(comment):
|
|
81
|
+
comment = _CONFIG_TYPE_RE.sub(assignment, comment, count=1)
|
|
82
|
+
else:
|
|
83
|
+
comment += (" " if comment else "") + assignment
|
|
84
|
+
lines[1] = comment.encode("utf-8") + newline
|
|
85
|
+
return b"".join(lines)
|
|
@@ -13,8 +13,15 @@ from pathlib import Path
|
|
|
13
13
|
# File extensions we treat as structures when scanning an input folder.
|
|
14
14
|
STRUCT_EXTS = {
|
|
15
15
|
".cif", ".vasp", ".poscar", ".xyz", ".extxyz", ".pdb", ".xsf",
|
|
16
|
-
".cfg", ".gen", ".res", ".json", ".cssr", ".lmp", ".data",
|
|
16
|
+
".cfg", ".gen", ".res", ".json", ".cssr", ".lmp", ".data", ".contcar",
|
|
17
17
|
}
|
|
18
|
+
STRUCT_NAMES = {"POSCAR", "CONTCAR"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def leading_index(name):
|
|
22
|
+
"""Return a leading integer, placing nonnumeric names after numbered ones."""
|
|
23
|
+
match = re.match(r"^(\d+)", str(name))
|
|
24
|
+
return int(match.group(1)) if match else 10 ** 9
|
|
18
25
|
|
|
19
26
|
|
|
20
27
|
def folder_sort_key(name):
|
|
@@ -54,8 +61,12 @@ def is_structure(path, exts=None):
|
|
|
54
61
|
"""True if *path* looks like a structure file (by extension / POSCAR name)."""
|
|
55
62
|
exts = STRUCT_EXTS if exts is None else exts
|
|
56
63
|
p = Path(path)
|
|
57
|
-
|
|
58
|
-
|
|
64
|
+
upper_name = p.name.upper()
|
|
65
|
+
return p.is_file() and (
|
|
66
|
+
p.suffix.lower() in exts
|
|
67
|
+
or upper_name in STRUCT_NAMES
|
|
68
|
+
or upper_name.startswith("POSCAR")
|
|
69
|
+
)
|
|
59
70
|
|
|
60
71
|
|
|
61
72
|
def structure_files(folder):
|
|
@@ -63,11 +74,7 @@ def structure_files(folder):
|
|
|
63
74
|
folder = Path(folder)
|
|
64
75
|
if not folder.is_dir():
|
|
65
76
|
raise SystemExit(f"Error: input folder not found: {folder}")
|
|
66
|
-
files = sorted(
|
|
67
|
-
p for p in folder.iterdir()
|
|
68
|
-
if p.is_file() and (p.suffix.lower() in STRUCT_EXTS
|
|
69
|
-
or p.name.upper().startswith("POSCAR"))
|
|
70
|
-
)
|
|
77
|
+
files = sorted(p for p in folder.iterdir() if is_structure(p))
|
|
71
78
|
if not files:
|
|
72
79
|
raise SystemExit(f"Error: no structure files found in {folder}")
|
|
73
80
|
return files
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Small geometry helpers shared by structure commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def fractional_distance(a, b):
|
|
7
|
+
"""Return minimum-image Euclidean distance in fractional coordinates."""
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
difference = np.asarray(a, dtype=float) - np.asarray(b, dtype=float)
|
|
11
|
+
difference -= np.round(difference)
|
|
12
|
+
return float(np.linalg.norm(difference))
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Shared GPUMD input and thermo-analysis utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class ThermoData:
|
|
13
|
+
time: np.ndarray
|
|
14
|
+
temperature: np.ndarray
|
|
15
|
+
potential: np.ndarray
|
|
16
|
+
pressure: np.ndarray
|
|
17
|
+
lengths: np.ndarray
|
|
18
|
+
angles: np.ndarray
|
|
19
|
+
volume: np.ndarray
|
|
20
|
+
equilibrium_start: int
|
|
21
|
+
warnings: tuple[str, ...]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_run_input(path):
|
|
25
|
+
time_step = 1.0
|
|
26
|
+
dump_interval = 10
|
|
27
|
+
runs = []
|
|
28
|
+
for raw in Path(path).read_text(encoding="utf-8").splitlines():
|
|
29
|
+
tokens = raw.split("#", 1)[0].split()
|
|
30
|
+
if not tokens:
|
|
31
|
+
continue
|
|
32
|
+
try:
|
|
33
|
+
if tokens[0] == "time_step" and len(tokens) >= 2:
|
|
34
|
+
time_step = float(tokens[1])
|
|
35
|
+
elif tokens[0] == "dump_thermo" and len(tokens) >= 2:
|
|
36
|
+
dump_interval = int(tokens[1])
|
|
37
|
+
elif tokens[0] == "run" and len(tokens) >= 2:
|
|
38
|
+
runs.append((int(tokens[1]), dump_interval, time_step))
|
|
39
|
+
except ValueError as exc:
|
|
40
|
+
raise ValueError(f"invalid {tokens[0]} setting in {path}: {raw}") from exc
|
|
41
|
+
return time_step, dump_interval, runs
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def time_axis(length, time_step, dump_interval, runs):
|
|
45
|
+
expected = sum(steps // interval for steps, interval, _ in runs)
|
|
46
|
+
if runs and expected == length:
|
|
47
|
+
values = []
|
|
48
|
+
elapsed = 0.0
|
|
49
|
+
for steps, interval, step_fs in runs:
|
|
50
|
+
count = steps // interval
|
|
51
|
+
increment = step_fs * interval / 1000.0
|
|
52
|
+
values.extend(elapsed + np.arange(count) * increment)
|
|
53
|
+
elapsed += steps * step_fs / 1000.0
|
|
54
|
+
return np.asarray(values, dtype=float)
|
|
55
|
+
increment = time_step * dump_interval / 1000.0
|
|
56
|
+
return np.arange(length, dtype=float) * increment
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def equilibrium_start(length, runs):
|
|
60
|
+
if not runs:
|
|
61
|
+
return int(length * 0.2)
|
|
62
|
+
points = runs[-1][0] // runs[-1][1]
|
|
63
|
+
return max(0, length - points)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def cell_quantities(data):
|
|
67
|
+
if data.shape[1] == 12:
|
|
68
|
+
lengths = data[:, 9:12]
|
|
69
|
+
angles = np.full((len(data), 3), 90.0)
|
|
70
|
+
return lengths, angles, np.prod(lengths, axis=1)
|
|
71
|
+
if data.shape[1] != 18:
|
|
72
|
+
raise ValueError(
|
|
73
|
+
f"thermo.out has {data.shape[1]} columns; expected 12 or 18"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
vectors = data[:, 9:18].reshape(-1, 3, 3)
|
|
77
|
+
lengths = np.linalg.norm(vectors, axis=2)
|
|
78
|
+
|
|
79
|
+
def angle(first, second):
|
|
80
|
+
denominator = np.linalg.norm(first, axis=1) * np.linalg.norm(second, axis=1)
|
|
81
|
+
cosine = np.divide(
|
|
82
|
+
np.einsum("ij,ij->i", first, second), denominator,
|
|
83
|
+
out=np.zeros(len(first)), where=denominator != 0,
|
|
84
|
+
)
|
|
85
|
+
return np.degrees(np.arccos(np.clip(cosine, -1.0, 1.0)))
|
|
86
|
+
|
|
87
|
+
angles = np.column_stack([
|
|
88
|
+
angle(vectors[:, 1], vectors[:, 2]),
|
|
89
|
+
angle(vectors[:, 2], vectors[:, 0]),
|
|
90
|
+
angle(vectors[:, 0], vectors[:, 1]),
|
|
91
|
+
])
|
|
92
|
+
return lengths, angles, np.abs(np.linalg.det(vectors))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def abnormal_reason(values):
|
|
96
|
+
values = np.asarray(values, dtype=float)
|
|
97
|
+
values = values[np.isfinite(values)]
|
|
98
|
+
if len(values) < 20 or np.ptp(values) == 0:
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
count = max(3, len(values) // 5)
|
|
102
|
+
first = float(np.mean(values[:count]))
|
|
103
|
+
last = float(np.mean(values[-count:]))
|
|
104
|
+
spread = float(np.std(values))
|
|
105
|
+
scale = max(abs(float(np.median(values))), 1.0)
|
|
106
|
+
threshold = max(0.5 * spread, 0.005 * scale)
|
|
107
|
+
|
|
108
|
+
shift = last - first
|
|
109
|
+
if abs(shift) > threshold:
|
|
110
|
+
return f"level shift {shift:+.5g}"
|
|
111
|
+
|
|
112
|
+
x = np.linspace(-0.5, 0.5, len(values))
|
|
113
|
+
slope = float(np.polyfit(x, values, 1)[0])
|
|
114
|
+
if abs(slope) > threshold:
|
|
115
|
+
return f"sustained drift {slope:+.5g} over the equilibrium segment"
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def find_abnormal(series, start):
|
|
120
|
+
component_names = {
|
|
121
|
+
"pressure": ("x", "y", "z"),
|
|
122
|
+
"lattice-parameter": ("a", "b", "c"),
|
|
123
|
+
"lattice-angle": ("alpha", "beta", "gamma"),
|
|
124
|
+
}
|
|
125
|
+
warnings = []
|
|
126
|
+
for name, values in series.items():
|
|
127
|
+
array = np.asarray(values)
|
|
128
|
+
if array.ndim == 1:
|
|
129
|
+
array = array[:, None]
|
|
130
|
+
labels = component_names.get(name, tuple(str(i) for i in range(array.shape[1])))
|
|
131
|
+
if array.shape[1] == 1:
|
|
132
|
+
labels = (name,)
|
|
133
|
+
for label, column in zip(labels, array.T):
|
|
134
|
+
reason = abnormal_reason(column[start:])
|
|
135
|
+
if reason:
|
|
136
|
+
prefix = name if label == name else f"{name}-{label}"
|
|
137
|
+
warnings.append(f"{prefix}: {reason}")
|
|
138
|
+
return tuple(warnings)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def load_thermo(thermo_path, run_path):
|
|
142
|
+
thermo_path = Path(thermo_path)
|
|
143
|
+
data = np.loadtxt(thermo_path, ndmin=2)
|
|
144
|
+
if data.shape[1] not in (12, 18):
|
|
145
|
+
raise ValueError(f"{thermo_path} must contain 12 or 18 columns")
|
|
146
|
+
|
|
147
|
+
time_step, dump_interval, runs = parse_run_input(run_path)
|
|
148
|
+
time = time_axis(len(data), time_step, dump_interval, runs)
|
|
149
|
+
temperature = data[:, 0]
|
|
150
|
+
potential = data[:, 2]
|
|
151
|
+
pressure = data[:, 3:6]
|
|
152
|
+
lengths, angles, volume = cell_quantities(data)
|
|
153
|
+
start = equilibrium_start(len(data), runs)
|
|
154
|
+
warnings = find_abnormal({
|
|
155
|
+
"temperature": temperature,
|
|
156
|
+
"potential-energy": potential,
|
|
157
|
+
"pressure": pressure,
|
|
158
|
+
"lattice-parameter": lengths,
|
|
159
|
+
"volume": volume,
|
|
160
|
+
"lattice-angle": angles,
|
|
161
|
+
}, start)
|
|
162
|
+
return ThermoData(
|
|
163
|
+
time=time,
|
|
164
|
+
temperature=temperature,
|
|
165
|
+
potential=potential,
|
|
166
|
+
pressure=pressure,
|
|
167
|
+
lengths=lengths,
|
|
168
|
+
angles=angles,
|
|
169
|
+
volume=volume,
|
|
170
|
+
equilibrium_start=start,
|
|
171
|
+
warnings=warnings,
|
|
172
|
+
)
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Shared descriptor reduction and farthest-point sampling helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
from ._extxyz import config_type_from_block, read_blocks, write_blocks
|
|
12
|
+
except ImportError:
|
|
13
|
+
from _extxyz import config_type_from_block, read_blocks, write_blocks
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_descriptors(path):
|
|
17
|
+
data = np.loadtxt(path, ndmin=2)
|
|
18
|
+
if data.shape[0] == 0 or data.shape[1] == 0:
|
|
19
|
+
raise ValueError(f"descriptor file is empty: {path}")
|
|
20
|
+
if not np.all(np.isfinite(data)):
|
|
21
|
+
raise ValueError(f"descriptor file contains non-finite values: {path}")
|
|
22
|
+
return data
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def pca_2d(data):
|
|
26
|
+
centered = data - np.mean(data, axis=0, keepdims=True)
|
|
27
|
+
if len(data) == 1:
|
|
28
|
+
return np.zeros((1, 2)), np.array([0.0, 0.0])
|
|
29
|
+
_, singular, vh = np.linalg.svd(centered, full_matrices=False)
|
|
30
|
+
dimensions = min(2, vh.shape[0])
|
|
31
|
+
reduced = centered @ vh[:dimensions].T
|
|
32
|
+
if dimensions < 2:
|
|
33
|
+
reduced = np.column_stack([reduced, np.zeros(len(data))])
|
|
34
|
+
variance = singular ** 2
|
|
35
|
+
ratio = variance / variance.sum() if variance.sum() else np.zeros_like(variance)
|
|
36
|
+
explained = np.zeros(2)
|
|
37
|
+
explained[:min(2, len(ratio))] = ratio[:2]
|
|
38
|
+
return reduced, explained
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def umap_2d(data):
|
|
42
|
+
if len(data) < 3:
|
|
43
|
+
return pca_2d(data)[0]
|
|
44
|
+
import umap
|
|
45
|
+
|
|
46
|
+
neighbors = min(15, len(data) - 1)
|
|
47
|
+
reducer = umap.UMAP(
|
|
48
|
+
n_components=2,
|
|
49
|
+
n_neighbors=neighbors,
|
|
50
|
+
random_state=42,
|
|
51
|
+
transform_seed=42,
|
|
52
|
+
)
|
|
53
|
+
return reducer.fit_transform(data)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def farthest_point_sampling(points, min_distance):
|
|
57
|
+
if min_distance < 0:
|
|
58
|
+
raise ValueError("-fps must be non-negative")
|
|
59
|
+
selected = [0]
|
|
60
|
+
min_distances = np.linalg.norm(points - points[0], axis=1)
|
|
61
|
+
min_distances[0] = -np.inf
|
|
62
|
+
while True:
|
|
63
|
+
index = int(np.argmax(min_distances))
|
|
64
|
+
if min_distances[index] < min_distance:
|
|
65
|
+
break
|
|
66
|
+
selected.append(index)
|
|
67
|
+
distances = np.linalg.norm(points - points[index], axis=1)
|
|
68
|
+
min_distances = np.minimum(min_distances, distances)
|
|
69
|
+
min_distances[selected] = -np.inf
|
|
70
|
+
return np.asarray(selected, dtype=int)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def fps_label(value):
|
|
74
|
+
return format(float(value), "g")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _filename_label(config_type):
|
|
78
|
+
label = re.sub(r"[^A-Za-z0-9_.+-]+", "-", str(config_type)).strip("-.")
|
|
79
|
+
return label or "unknown"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _labeled_path(path, config_type):
|
|
83
|
+
path = Path(path)
|
|
84
|
+
return path.with_name(f"{path.stem}-{_filename_label(config_type)}{path.suffix}")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _config_groups(blocks):
|
|
88
|
+
groups = {}
|
|
89
|
+
labels = {}
|
|
90
|
+
for index, block in enumerate(blocks):
|
|
91
|
+
config_type = config_type_from_block(block)
|
|
92
|
+
if config_type is None:
|
|
93
|
+
continue
|
|
94
|
+
label = _filename_label(config_type)
|
|
95
|
+
if label in labels and labels[label] != config_type:
|
|
96
|
+
raise ValueError(
|
|
97
|
+
f"Config_type values {labels[label]!r} and {config_type!r} "
|
|
98
|
+
f"map to the same filename label {label!r}"
|
|
99
|
+
)
|
|
100
|
+
labels[label] = config_type
|
|
101
|
+
groups.setdefault(config_type, []).append(index)
|
|
102
|
+
return {name: np.asarray(indices, dtype=int) for name, indices in groups.items()}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _write_config_outputs(descriptor_path, reduced, groups, selected=None,
|
|
106
|
+
trajectory_path=None, blocks=None):
|
|
107
|
+
for config_type, group_indices in groups.items():
|
|
108
|
+
indices = group_indices
|
|
109
|
+
if selected is not None:
|
|
110
|
+
indices = np.intersect1d(selected, group_indices, assume_unique=True)
|
|
111
|
+
config_descriptor = _labeled_path(descriptor_path, config_type)
|
|
112
|
+
np.savetxt(config_descriptor, reduced[indices], fmt="%.8f")
|
|
113
|
+
outputs = [str(config_descriptor)]
|
|
114
|
+
if trajectory_path is not None:
|
|
115
|
+
config_trajectory = _labeled_path(trajectory_path, config_type)
|
|
116
|
+
write_blocks(config_trajectory, blocks, indices)
|
|
117
|
+
outputs.append(str(config_trajectory))
|
|
118
|
+
print(f"Config_type={config_type}: {len(indices)} row(s) -> "
|
|
119
|
+
+ " and ".join(outputs))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def write_reduction(output, reduced, fps=None, trajectory=None):
|
|
123
|
+
if fps is not None and trajectory is None:
|
|
124
|
+
raise ValueError("-it is required when -fps is used")
|
|
125
|
+
|
|
126
|
+
output = Path(output)
|
|
127
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
np.savetxt(output, reduced, fmt="%.8f")
|
|
129
|
+
print(f"Wrote {len(reduced)} reduced descriptor(s) to {output}")
|
|
130
|
+
|
|
131
|
+
blocks = None
|
|
132
|
+
groups = {}
|
|
133
|
+
if trajectory is not None:
|
|
134
|
+
blocks, _ = read_blocks(trajectory)
|
|
135
|
+
if len(blocks) != len(reduced):
|
|
136
|
+
raise ValueError(
|
|
137
|
+
f"{trajectory} has {len(blocks)} frame(s), but the descriptor file "
|
|
138
|
+
f"has {len(reduced)} row(s)"
|
|
139
|
+
)
|
|
140
|
+
groups = _config_groups(blocks)
|
|
141
|
+
_write_config_outputs(output, reduced, groups)
|
|
142
|
+
untyped = len(blocks) - sum(len(indices) for indices in groups.values())
|
|
143
|
+
if untyped:
|
|
144
|
+
print(f"{untyped} frame(s) have no Config_type and were not split by type.")
|
|
145
|
+
|
|
146
|
+
if fps is None:
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
selected = np.sort(farthest_point_sampling(reduced, fps))
|
|
150
|
+
label = fps_label(fps)
|
|
151
|
+
descriptor_output = output.with_name(f"fps-{label}-{output.name}")
|
|
152
|
+
trajectory = Path(trajectory)
|
|
153
|
+
trajectory_output = trajectory.with_name(f"fps-{label}-{trajectory.name}")
|
|
154
|
+
np.savetxt(descriptor_output, reduced[selected], fmt="%.8f")
|
|
155
|
+
write_blocks(trajectory_output, blocks, selected)
|
|
156
|
+
print(f"FPS distance {label}: selected {len(selected)} of {len(reduced)} structure(s)")
|
|
157
|
+
print(f"Wrote {descriptor_output} and {trajectory_output}")
|
|
158
|
+
_write_config_outputs(
|
|
159
|
+
descriptor_output,
|
|
160
|
+
reduced,
|
|
161
|
+
groups,
|
|
162
|
+
selected=selected,
|
|
163
|
+
trajectory_path=trajectory_output,
|
|
164
|
+
blocks=blocks,
|
|
165
|
+
)
|
|
@@ -4,15 +4,15 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import argparse
|
|
6
6
|
import math
|
|
7
|
-
import re
|
|
8
7
|
import warnings
|
|
9
8
|
from collections import defaultdict
|
|
10
9
|
from pathlib import Path
|
|
11
10
|
|
|
12
11
|
|
|
13
12
|
try:
|
|
14
|
-
from . import _vasp_output
|
|
13
|
+
from . import _fileio, _vasp_output
|
|
15
14
|
except ImportError: # running as a standalone script
|
|
15
|
+
import _fileio
|
|
16
16
|
import _vasp_output
|
|
17
17
|
|
|
18
18
|
warnings.filterwarnings("ignore", category=UserWarning)
|
|
@@ -22,10 +22,6 @@ HELP = "Compute E_hull for every VASP job folder."
|
|
|
22
22
|
REPORT_FILE = "ehull.txt"
|
|
23
23
|
|
|
24
24
|
|
|
25
|
-
def folder_index(name: str) -> int:
|
|
26
|
-
m = re.match(r"^(\d+)", name)
|
|
27
|
-
return int(m.group(1)) if m else 10 ** 9
|
|
28
|
-
|
|
29
25
|
|
|
30
26
|
def chemsys_label(entry) -> str:
|
|
31
27
|
def keyfn(el):
|
|
@@ -98,7 +94,7 @@ def collect_entries(root):
|
|
|
98
94
|
root = Path(root)
|
|
99
95
|
folders = sorted(
|
|
100
96
|
(f for f in root.iterdir() if f.is_dir() and _vasp_output.has_vasp_output(f)),
|
|
101
|
-
key=lambda f:
|
|
97
|
+
key=lambda f: _fileio.leading_index(f.name),
|
|
102
98
|
)
|
|
103
99
|
out = []
|
|
104
100
|
for folder in folders:
|
|
@@ -208,7 +204,7 @@ def run(args):
|
|
|
208
204
|
"e_hull": e_hull,
|
|
209
205
|
})
|
|
210
206
|
|
|
211
|
-
results.sort(key=lambda r:
|
|
207
|
+
results.sort(key=lambda r: _fileio.leading_index(r["folder"]))
|
|
212
208
|
write_report(results, root, REPORT_FILE, reference)
|
|
213
209
|
print(f"\nWrote {REPORT_FILE} ({len(results)} structures).")
|
|
214
210
|
return 0
|